diff --git a/common/api.py b/common/api.py
index c9e369bd..4f80ce7c 100644
--- a/common/api.py
+++ b/common/api.py
@@ -96,5 +96,6 @@ api = NinjaAPI(
description=f"{settings.SITE_INFO['site_name']} API
Learn more",
)
-NOT_FOUND = 404, {"message": "Note not found"}
+NOT_FOUND = 404, {"message": "Not found"}
OK = 200, {"message": "OK"}
+NO_DATA = {"data": [], "count": 0, "pages": 0}
diff --git a/common/models/index.py b/common/models/index.py
index 2ff3d550..994eac7f 100644
--- a/common/models/index.py
+++ b/common/models/index.py
@@ -14,7 +14,6 @@ class QueryParser:
fields = ["sort"]
default_search_params = {
"q": "",
- "filter_by": "",
"query_by": "",
"sort_by": "",
"per_page": 20,
@@ -45,6 +44,7 @@ class QueryParser:
self.page = page
self.page_size = page_size
self.filter_by = {}
+ self.exclude_by = {}
self.query_by = []
self.sort_by = []
@@ -63,6 +63,10 @@ class QueryParser:
"""Override a specific filter"""
self.filter_by[field] = value if isinstance(value, list) else [value]
+ def exclude(self, field: str, value: list[int] | list[str] | int | str):
+ """Exclude a specific filter"""
+ self.exclude_by[field] = value if isinstance(value, list) else [value]
+
def sort(self, fields: list[str]):
"""Override the default sort fields"""
self.sort_by = fields
@@ -76,8 +80,8 @@ class QueryParser:
)
if self.page_size:
params["per_page"] = self.page_size
+ filters = []
if self.filter_by:
- filters = []
for field, values in self.filter_by.items():
if field == "_":
filters += values
@@ -88,6 +92,16 @@ class QueryParser:
else str(values[0])
)
filters.append(f"{field}:{v}")
+ if self.exclude_by:
+ for field, values in self.exclude_by.items():
+ if values:
+ v = (
+ f"[{','.join(map(str, values))}]"
+ if len(values) > 1
+ else str(values[0])
+ )
+ filters.append(f"{field}:!={v}")
+ if filters:
params["filter_by"] = " && ".join(filters)
if self.query_by:
params["query_by"] = ",".join(self.query_by)
diff --git a/journal/apis/__init__.py b/journal/apis/__init__.py
index 0cf9e977..e7f747cf 100644
--- a/journal/apis/__init__.py
+++ b/journal/apis/__init__.py
@@ -3,3 +3,4 @@ from .note import * # noqa
from .review import * # noqa
from .shelf import * # noqa
from .tag import * # noqa
+from .post import * # noqa
diff --git a/journal/apis/post.py b/journal/apis/post.py
new file mode 100644
index 00000000..b7656506
--- /dev/null
+++ b/journal/apis/post.py
@@ -0,0 +1,147 @@
+from typing import List, Literal, Union
+
+from ninja import Field, Schema
+
+from catalog.common.models import Item
+from common.api import NOT_FOUND, Result, api
+from journal.models.index import JournalIndex, JournalQueryParser
+
+
+class CustomEmoji(Schema):
+ shortcode: str
+ url: str
+ static_url: str
+ visible_in_picker: bool
+ category: str
+
+
+class AccountField(Schema):
+ name: str
+ value: str
+ verified_at: str | None = None
+
+
+class Account(Schema):
+ id: str
+ username: str
+ acct: str
+ url: str
+ display_name: str
+ note: str
+ avatar: str
+ avatar_static: str
+ header: str | None = Field(...)
+ header_static: str | None = Field(...)
+ locked: bool
+ fields: list[AccountField]
+ emojis: list[CustomEmoji]
+ bot: bool
+ group: bool
+ discoverable: bool
+ indexable: bool
+ moved: Union[None, bool, "Account"] = None
+ suspended: bool = False
+ limited: bool = False
+ created_at: str
+ # last_status_at: str | None = Field(...)
+ # statuses_count: int | None
+ # followers_count: int | None
+ # following_count: int | None
+ source: dict | None = None
+
+
+class MediaAttachment(Schema):
+ id: str
+ type: Literal["unknown", "image", "gifv", "video", "audio"]
+ url: str
+ preview_url: str
+ remote_url: str | None = None
+ meta: dict
+ description: str | None = None
+ blurhash: str | None = None
+
+
+class StatusMention(Schema):
+ id: str
+ username: str
+ url: str
+ acct: str
+
+
+class StatusTag(Schema):
+ name: str
+ url: str
+
+
+class Post(Schema):
+ id: str
+ uri: str
+ created_at: str
+ account: Account
+ content: str
+ visibility: Literal["public", "unlisted", "private", "direct"]
+ sensitive: bool
+ spoiler_text: str
+ # media_attachments: list[MediaAttachment]
+ mentions: list[StatusMention]
+ tags: list[StatusTag]
+ emojis: list[CustomEmoji]
+ reblogs_count: int
+ favourites_count: int
+ replies_count: int
+ url: str | None = Field(...)
+ in_reply_to_id: str | None = Field(...)
+ in_reply_to_account_id: str | None = Field(...)
+ # reblog: Optional["Status"] = Field(...)
+ # poll: Poll | None = Field(...)
+ # card: None = Field(...)
+ language: str | None = Field(...)
+ text: str | None = Field(...)
+ edited_at: str | None = None
+ favourited: bool = False
+ reblogged: bool = False
+ muted: bool = False
+ bookmarked: bool = False
+ pinned: bool = False
+ ext_neodb: dict | None = None
+
+
+class PaginatedPostList(Schema):
+ data: List[Post]
+ pages: int
+ count: int
+
+
+PostTypes = {"mark", "comment", "review", "collection"}
+
+
+@api.get(
+ "/item/{item_uuid}/posts/",
+ response={200: PaginatedPostList, 401: Result, 404: Result},
+ tags=["catalog"],
+)
+def list_posts_for_item(request, item_uuid: str, type: str | None = None):
+ """
+ Get posts for an item
+
+ `type` is optional, can be a comma separated list of "comment", "review", "collection", "mark"; default is "comment,review"
+ """
+ item = Item.get_by_url(item_uuid)
+ if not item:
+ return NOT_FOUND
+ types = [t for t in (type or "").split(",") if t in PostTypes]
+ q = "type:" + ",".join(types or ["comment", "review"])
+ query = JournalQueryParser(q)
+ query.filter("item_id", item.pk)
+ query.filter("visibility", 0)
+ query.exclude("owner_id", request.user.identity.ignoring)
+ r = JournalIndex.instance().search(query)
+ result = {
+ "data": [
+ p.to_mastodon_json()
+ for p in r.posts.prefetch_related("attachments", "author")
+ ],
+ "pages": r.pages,
+ "count": r.total,
+ }
+ return result
diff --git a/takahe/models.py b/takahe/models.py
index bc31f28a..7622534b 100644
--- a/takahe/models.py
+++ b/takahe/models.py
@@ -39,6 +39,15 @@ if TYPE_CHECKING:
# class Meta:
# db_table = "django_session"
+DATETIME_MS_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ"
+
+
+def format_ld_date(value: datetime.datetime) -> str:
+ # We chop the timestamp to be identical to the timestamps returned by
+ # Mastodon's API, because some clients like Toot! (for iOS) are especially
+ # picky about timestamp parsing.
+ return f"{value.strftime(DATETIME_MS_FORMAT)[:-4]}Z"
+
class Snowflake:
"""
@@ -781,6 +790,55 @@ class Identity(models.Model):
else:
return f"/proxy/identity_icon/{self.pk}/"
+ def to_mastodon_json(self, source=False):
+ # from activities.models import Emoji, Post
+
+ # missing = StaticAbsoluteUrl("img/missing.png").absolute
+
+ # metadata_value_text = (
+ # " ".join([m["value"] for m in self.metadata]) if self.metadata else ""
+ # )
+ # emojis = Emoji.emojis_from_content(
+ # f"{self.name} {self.summary} {metadata_value_text}", self.domain
+ # )
+ renderer = ContentRenderer(local=False)
+ result = {
+ "id": str(self.pk),
+ "username": self.username or "",
+ "acct": self.username if source else self.handle,
+ "url": self.absolute_profile_uri() or "",
+ "display_name": self.name or "",
+ "note": self.summary or "",
+ "avatar": settings.SITE_INFO["site_url"] + self.local_icon_url(),
+ "avatar_static": settings.SITE_INFO["site_url"] + self.local_icon_url(),
+ "header": "", # settings.SITE_INFO['site_url']+ header_image if header_image else missing,
+ "header_static": "", # settings.SITE_INFO['site_url']+header_image if header_image else missing,
+ "locked": bool(self.manually_approves_followers),
+ "fields": (
+ [
+ {
+ "name": m["name"],
+ "value": renderer.render_identity_data(m["value"], self),
+ "verified_at": None,
+ }
+ for m in self.metadata
+ ]
+ if self.metadata
+ else []
+ ),
+ "emojis": [], # [emoji.to_mastodon_json() for emoji in emojis],
+ "bot": self.actor_type.lower() in ["service", "application"],
+ "group": self.actor_type.lower() == "group",
+ "discoverable": self.discoverable,
+ "indexable": self.indexable,
+ "suspended": False,
+ "limited": False,
+ "created_at": format_ld_date(
+ self.created.replace(hour=0, minute=0, second=0, microsecond=0)
+ ),
+ }
+ return result
+
class Follow(models.Model):
"""
@@ -1308,6 +1366,103 @@ class Post(models.Model):
def safe_content_local(self):
return ContentRenderer(local=True).render_post(self.content, self)
+ def _safe_content_note(self, *, local: bool = True):
+ return ContentRenderer(local=local).render_post(self.content, self)
+
+ def safe_content_remote(self):
+ """
+ Returns the content formatted for remote consumption
+ """
+ return self._safe_content_note(local=False)
+
+ @property
+ def stats_with_defaults(self):
+ """
+ Returns the stats dict with counts of likes/etc. in it
+ """
+ return {
+ "likes": self.stats.get("likes", 0) if self.stats else 0,
+ "boosts": self.stats.get("boosts", 0) if self.stats else 0,
+ "replies": self.stats.get("replies", 0) if self.stats else 0,
+ }
+
+ def to_mastodon_json(self, interactions=None, bookmarks=None, identity=None):
+ reply_parent = None
+ if self.in_reply_to:
+ # Load the PK and author.id explicitly to prevent a SELECT on the entire author Identity
+ reply_parent = (
+ Post.objects.filter(object_uri=self.in_reply_to)
+ .only("pk", "author_id")
+ .first()
+ )
+ visibility_mapping = {
+ self.Visibilities.public: "public",
+ self.Visibilities.unlisted: "unlisted",
+ self.Visibilities.followers: "private",
+ self.Visibilities.mentioned: "direct",
+ self.Visibilities.local_only: "public",
+ }
+ language = self.language
+ if self.language == "":
+ language = None
+ value = {
+ "id": str(self.pk),
+ "uri": self.object_uri,
+ "created_at": format_ld_date(self.published),
+ "account": self.author.to_mastodon_json(),
+ "content": self.safe_content_remote(),
+ "language": language,
+ "visibility": visibility_mapping[self.visibility], # type: ignore
+ "sensitive": self.sensitive,
+ "spoiler_text": self.summary or "",
+ "media_attachments": [
+ # attachment.to_mastodon_json() for attachment in self.attachments.all()
+ ],
+ "mentions": [
+ mention.to_mastodon_mention_json() for mention in self.mentions.all()
+ ],
+ "tags": (
+ [
+ {
+ "name": tag,
+ "url": f"https://{self.author.domain.uri_domain}/tags/{tag}/",
+ }
+ for tag in self.hashtags
+ ]
+ if self.hashtags
+ else []
+ ),
+ # Filter in the list comp rather than query because the common case is no emoji in the resultset
+ # When filter is on emojis like `emojis.usable()` it causes a query that is not cached by prefetch_related
+ "emojis": [
+ emoji.to_mastodon_json()
+ for emoji in self.emojis.all()
+ if emoji.is_usable
+ ],
+ "reblogs_count": self.stats_with_defaults["boosts"],
+ "favourites_count": self.stats_with_defaults["likes"],
+ "replies_count": self.stats_with_defaults["replies"],
+ "url": self.absolute_object_uri(),
+ "in_reply_to_id": str(reply_parent.pk) if reply_parent else None,
+ "in_reply_to_account_id": (
+ str(reply_parent.author_id) if reply_parent else None
+ ),
+ "reblog": None,
+ "poll": None, # self.type_data.to_mastodon_json(self, identity) if isinstance(self.type_data, QuestionData) else None,
+ "card": None,
+ "text": self.safe_content_remote(),
+ "edited_at": format_ld_date(self.edited) if self.edited else None,
+ }
+ if isinstance(self.type_data, dict) and "object" in self.type_data:
+ value["ext_neodb"] = self.type_data["object"]
+ if interactions:
+ value["favourited"] = self.pk in interactions.get("like", [])
+ value["reblogged"] = self.pk in interactions.get("boost", [])
+ value["pinned"] = self.pk in interactions.get("pin", [])
+ if bookmarks:
+ value["bookmarked"] = self.pk in bookmarks
+ return value
+
class FanOut(models.Model):
"""