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): """