API: get posts for an item

This commit is contained in:
mein Name 2025-01-22 08:49:43 -05:00 committed by Henri Dickson
parent be80f9df2b
commit ce715926e2
5 changed files with 321 additions and 3 deletions

View file

@ -96,5 +96,6 @@ api = NinjaAPI(
description=f"{settings.SITE_INFO['site_name']} API <hr/><a href='{settings.SITE_INFO['site_url']}'>Learn more</a>", description=f"{settings.SITE_INFO['site_name']} API <hr/><a href='{settings.SITE_INFO['site_url']}'>Learn more</a>",
) )
NOT_FOUND = 404, {"message": "Note not found"} NOT_FOUND = 404, {"message": "Not found"}
OK = 200, {"message": "OK"} OK = 200, {"message": "OK"}
NO_DATA = {"data": [], "count": 0, "pages": 0}

View file

@ -14,7 +14,6 @@ class QueryParser:
fields = ["sort"] fields = ["sort"]
default_search_params = { default_search_params = {
"q": "", "q": "",
"filter_by": "",
"query_by": "", "query_by": "",
"sort_by": "", "sort_by": "",
"per_page": 20, "per_page": 20,
@ -45,6 +44,7 @@ class QueryParser:
self.page = page self.page = page
self.page_size = page_size self.page_size = page_size
self.filter_by = {} self.filter_by = {}
self.exclude_by = {}
self.query_by = [] self.query_by = []
self.sort_by = [] self.sort_by = []
@ -63,6 +63,10 @@ class QueryParser:
"""Override a specific filter""" """Override a specific filter"""
self.filter_by[field] = value if isinstance(value, list) else [value] 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]): def sort(self, fields: list[str]):
"""Override the default sort fields""" """Override the default sort fields"""
self.sort_by = fields self.sort_by = fields
@ -76,8 +80,8 @@ class QueryParser:
) )
if self.page_size: if self.page_size:
params["per_page"] = self.page_size params["per_page"] = self.page_size
if self.filter_by:
filters = [] filters = []
if self.filter_by:
for field, values in self.filter_by.items(): for field, values in self.filter_by.items():
if field == "_": if field == "_":
filters += values filters += values
@ -88,6 +92,16 @@ class QueryParser:
else str(values[0]) else str(values[0])
) )
filters.append(f"{field}:{v}") 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) params["filter_by"] = " && ".join(filters)
if self.query_by: if self.query_by:
params["query_by"] = ",".join(self.query_by) params["query_by"] = ",".join(self.query_by)

View file

@ -3,3 +3,4 @@ from .note import * # noqa
from .review import * # noqa from .review import * # noqa
from .shelf import * # noqa from .shelf import * # noqa
from .tag import * # noqa from .tag import * # noqa
from .post import * # noqa

147
journal/apis/post.py Normal file
View file

@ -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

View file

@ -39,6 +39,15 @@ if TYPE_CHECKING:
# class Meta: # class Meta:
# db_table = "django_session" # 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: class Snowflake:
""" """
@ -781,6 +790,55 @@ class Identity(models.Model):
else: else:
return f"/proxy/identity_icon/{self.pk}/" 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): class Follow(models.Model):
""" """
@ -1308,6 +1366,103 @@ class Post(models.Model):
def safe_content_local(self): def safe_content_local(self):
return ContentRenderer(local=True).render_post(self.content, 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): class FanOut(models.Model):
""" """