API: get posts for an item
This commit is contained in:
parent
be80f9df2b
commit
ce715926e2
5 changed files with 321 additions and 3 deletions
|
@ -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}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
147
journal/apis/post.py
Normal 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
|
155
takahe/models.py
155
takahe/models.py
|
@ -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):
|
||||||
"""
|
"""
|
||||||
|
|
Loading…
Add table
Reference in a new issue