From 20239b5cb7455d593680b17d2d80d2a4850c524d Mon Sep 17 00:00:00 2001 From: Andrew Godwin Date: Sun, 11 Dec 2022 12:37:28 -0700 Subject: Basic post mutation --- activities/models/post.py | 79 +++++++++++++++++++- activities/models/timeline_event.py | 14 ++-- activities/views/posts.py | 32 ++------- api/schemas.py | 5 ++ api/views/__init__.py | 4 +- api/views/accounts.py | 12 ++-- api/views/filters.py | 9 +++ api/views/media.py | 76 ++++++++++++++++++++ api/views/notifications.py | 16 +++-- api/views/search.py | 9 ++- api/views/statuses.py | 139 ++++++++++++++++++++++++++++++++++++ api/views/timelines.py | 17 +++-- core/files.py | 4 +- users/views/identity.py | 5 +- 14 files changed, 365 insertions(+), 56 deletions(-) create mode 100644 api/views/filters.py create mode 100644 api/views/media.py create mode 100644 api/views/statuses.py diff --git a/activities/models/post.py b/activities/models/post.py index 16e798c..8c2ce13 100644 --- a/activities/models/post.py +++ b/activities/models/post.py @@ -80,6 +80,12 @@ class PostStates(StateGraph): class PostQuerySet(models.QuerySet): + def not_hidden(self): + query = self.exclude( + state__in=[PostStates.deleted, PostStates.deleted_fanned_out] + ) + return query + def public(self, include_replies: bool = False): query = self.filter( visibility__in=[ @@ -103,6 +109,18 @@ class PostQuerySet(models.QuerySet): return query.filter(in_reply_to__isnull=True) return query + def unlisted(self, include_replies: bool = False): + query = self.filter( + visibility__in=[ + Post.Visibilities.public, + Post.Visibilities.local_only, + Post.Visibilities.unlisted, + ], + ) + if not include_replies: + return query.filter(in_reply_to__isnull=True) + return query + def tagged_with(self, hashtag: str | Hashtag): if isinstance(hashtag, str): tag_q = models.Q(hashtags__contains=hashtag) @@ -118,12 +136,18 @@ class PostManager(models.Manager): def get_queryset(self): return PostQuerySet(self.model, using=self._db) + def not_hidden(self): + return self.get_queryset().not_hidden() + def public(self, include_replies: bool = False): return self.get_queryset().public(include_replies=include_replies) def local_public(self, include_replies: bool = False): return self.get_queryset().local_public(include_replies=include_replies) + def unlisted(self, include_replies: bool = False): + return self.get_queryset().unlisted(include_replies=include_replies) + def tagged_with(self, hashtag: str | Hashtag): return self.get_queryset().tagged_with(hashtag=hashtag) @@ -248,6 +272,8 @@ class Post(StatorModel): """ Returns the actual Post object we're replying to, if we can find it """ + if self.in_reply_to is None: + return None return ( Post.objects.filter(object_uri=self.in_reply_to) .select_related("author") @@ -338,6 +364,7 @@ class Post(StatorModel): author: Identity, content: str, summary: str | None = None, + sensitive: bool = False, visibility: int = Visibilities.public, reply_to: Optional["Post"] = None, attachments: list | None = None, @@ -359,7 +386,7 @@ class Post(StatorModel): author=author, content=content, summary=summary or None, - sensitive=bool(summary), + sensitive=bool(summary) or sensitive, local=True, visibility=visibility, hashtags=hashtags, @@ -424,6 +451,48 @@ class Post(StatorModel): hashtag=hashtag, ) + ### Actions ### + + def interact_as(self, identity, type): + from activities.models import PostInteraction, PostInteractionStates + + interaction = PostInteraction.objects.get_or_create( + type=type, identity=identity, post=self + )[0] + if interaction.state in [ + PostInteractionStates.undone, + PostInteractionStates.undone_fanned_out, + ]: + interaction.transition_perform(PostInteractionStates.new) + + def uninteract_as(self, identity, type): + from activities.models import PostInteraction, PostInteractionStates + + for interaction in PostInteraction.objects.filter( + type=type, identity=identity, post=self + ): + interaction.transition_perform(PostInteractionStates.undone) + + def like_as(self, identity): + from activities.models import PostInteraction + + self.interact_as(identity, PostInteraction.Types.like) + + def unlike_as(self, identity): + from activities.models import PostInteraction + + self.uninteract_as(identity, PostInteraction.Types.like) + + def boost_as(self, identity): + from activities.models import PostInteraction + + self.interact_as(identity, PostInteraction.Types.boost) + + def unboost_as(self, identity): + from activities.models import PostInteraction + + self.uninteract_as(identity, PostInteraction.Types.boost) + ### ActivityPub (outbound) ### def to_ap(self) -> dict: @@ -711,11 +780,11 @@ class Post(StatorModel): ### Mastodon API ### - def to_mastodon_json(self): + def to_mastodon_json(self, interactions=None): reply_parent = None if self.in_reply_to: reply_parent = Post.objects.filter(object_uri=self.in_reply_to).first() - return { + value = { "id": self.pk, "uri": self.object_uri, "created_at": format_ld_date(self.published), @@ -755,3 +824,7 @@ class Post(StatorModel): "text": self.safe_content_plain(), "edited_at": format_ld_date(self.edited) if self.edited else None, } + if interactions: + value["favourited"] = self.pk in interactions.get("like", []) + value["reblogged"] = self.pk in interactions.get("boost", []) + return value diff --git a/activities/models/timeline_event.py b/activities/models/timeline_event.py index 30d473d..16f8632 100644 --- a/activities/models/timeline_event.py +++ b/activities/models/timeline_event.py @@ -148,7 +148,7 @@ class TimelineEvent(models.Model): ### Mastodon Client API ### - def to_mastodon_notification_json(self): + def to_mastodon_notification_json(self, interactions=None): result = { "id": self.pk, "created_at": format_ld_date(self.created), @@ -156,13 +156,19 @@ class TimelineEvent(models.Model): } if self.type == self.Types.liked: result["type"] = "favourite" - result["status"] = self.subject_post.to_mastodon_json() + result["status"] = self.subject_post.to_mastodon_json( + interactions=interactions + ) elif self.type == self.Types.boosted: result["type"] = "reblog" - result["status"] = self.subject_post.to_mastodon_json() + result["status"] = self.subject_post.to_mastodon_json( + interactions=interactions + ) elif self.type == self.Types.mentioned: result["type"] = "mention" - result["status"] = self.subject_post.to_mastodon_json() + result["status"] = self.subject_post.to_mastodon_json( + interactions=interactions + ) elif self.type == self.Types.followed: result["type"] = "follow" else: diff --git a/activities/views/posts.py b/activities/views/posts.py index ccc38fc..e285c7e 100644 --- a/activities/views/posts.py +++ b/activities/views/posts.py @@ -6,7 +6,7 @@ from django.utils.decorators import method_decorator from django.views.decorators.vary import vary_on_headers from django.views.generic import TemplateView, View -from activities.models import Post, PostInteraction, PostInteractionStates, PostStates +from activities.models import Post, PostInteraction, PostStates from core.decorators import cache_page_by_ap_json from core.ld import canonicalise from users.decorators import identity_required @@ -94,20 +94,9 @@ class Like(View): identity.posts.prefetch_related("attachments"), pk=post_id ) if self.undo: - # Undo any likes on the post - for interaction in PostInteraction.objects.filter( - type=PostInteraction.Types.like, - identity=request.identity, - post=post, - ): - interaction.transition_perform(PostInteractionStates.undone) + post.unlike_as(self.request.identity) else: - # Make a like on this post if we didn't already - PostInteraction.objects.get_or_create( - type=PostInteraction.Types.like, - identity=request.identity, - post=post, - ) + post.like_as(self.request.identity) # Return either a redirect or a HTMX snippet if request.htmx: return render( @@ -133,20 +122,9 @@ class Boost(View): identity = by_handle_or_404(self.request, handle, local=False) post = get_object_or_404(identity.posts, pk=post_id) if self.undo: - # Undo any boosts on the post - for interaction in PostInteraction.objects.filter( - type=PostInteraction.Types.boost, - identity=request.identity, - post=post, - ): - interaction.transition_perform(PostInteractionStates.undone) + post.unboost_as(request.identity) else: - # Make a boost on this post if we didn't already - PostInteraction.objects.get_or_create( - type=PostInteraction.Types.boost, - identity=request.identity, - post=post, - ) + post.boost_as(request.identity) # Return either a redirect or a HTMX snippet if request.htmx: return render( diff --git a/api/schemas.py b/api/schemas.py index a8f4e45..97b8169 100644 --- a/api/schemas.py +++ b/api/schemas.py @@ -160,3 +160,8 @@ class Relationship(Schema): domain_blocking: bool endorsed: bool note: str + + +class Context(Schema): + ancestors: list[Status] + descendants: list[Status] diff --git a/api/views/__init__.py b/api/views/__init__.py index c6dc765..f95fc21 100644 --- a/api/views/__init__.py +++ b/api/views/__init__.py @@ -1,8 +1,10 @@ from .accounts import * # noqa from .apps import * # noqa -from .base import api_router # noqa +from .filters import * # noqa from .instance import * # noqa +from .media import * # noqa from .notifications import * # noqa from .oauth import * # noqa from .search import * # noqa +from .statuses import * # noqa from .timelines import * # noqa diff --git a/api/views/accounts.py b/api/views/accounts.py index 1b883e8..43ec75d 100644 --- a/api/views/accounts.py +++ b/api/views/accounts.py @@ -1,12 +1,11 @@ from django.shortcuts import get_object_or_404 -from activities.models import Post +from activities.models import Post, PostInteraction from api import schemas +from api.decorators import identity_required from api.views.base import api_router from users.models import Identity -from ..decorators import identity_required - @api_router.get("/v1/accounts/verify_credentials", response=schemas.Account) @identity_required @@ -69,7 +68,8 @@ def account_statuses( ): identity = get_object_or_404(Identity, pk=id) posts = ( - identity.posts.public() + identity.posts.not_hidden() + .unlisted(include_replies=not exclude_replies) .select_related("author") .prefetch_related("attachments") .order_by("-created") @@ -91,4 +91,6 @@ def account_statuses( # invert the ordering to accomodate anchor_post = Post.objects.get(pk=min_id) posts = posts.filter(created__gt=anchor_post.created).order_by("created") - return [post.to_mastodon_json() for post in posts[:limit]] + posts = list(posts[:limit]) + interactions = PostInteraction.get_post_interactions(posts, request.identity) + return [post.to_mastodon_json(interactions=interactions) for post in posts] diff --git a/api/views/filters.py b/api/views/filters.py new file mode 100644 index 0000000..ec82a7d --- /dev/null +++ b/api/views/filters.py @@ -0,0 +1,9 @@ +from api.views.base import api_router + +from ..decorators import identity_required + + +@api_router.get("/v1/filters") +@identity_required +def status(request): + return [] diff --git a/api/views/media.py b/api/views/media.py new file mode 100644 index 0000000..35c0650 --- /dev/null +++ b/api/views/media.py @@ -0,0 +1,76 @@ +from django.shortcuts import get_object_or_404 +from ninja import File, Schema +from ninja.files import UploadedFile + +from activities.models import PostAttachment, PostAttachmentStates +from api import schemas +from api.views.base import api_router +from core.files import blurhash_image, resize_image + +from ..decorators import identity_required + + +class UploadMediaSchema(Schema): + description: str = "" + focus: str = "0,0" + + +@api_router.post("/v1/media", response=schemas.MediaAttachment) +@api_router.post("/v2/media", response=schemas.MediaAttachment) +@identity_required +def upload_media( + request, + file: UploadedFile = File(...), + details: UploadMediaSchema | None = None, +): + main_file = resize_image( + file, + size=(2000, 2000), + cover=False, + ) + thumbnail_file = resize_image( + file, + size=(400, 225), + cover=True, + ) + attachment = PostAttachment.objects.create( + blurhash=blurhash_image(thumbnail_file), + mimetype="image/webp", + width=main_file.image.width, + height=main_file.image.height, + name=details.description if details else None, + state=PostAttachmentStates.fetched, + ) + attachment.file.save( + main_file.name, + main_file, + ) + attachment.thumbnail.save( + thumbnail_file.name, + thumbnail_file, + ) + attachment.save() + return attachment.to_mastodon_json() + + +@api_router.get("/v1/media/{id}", response=schemas.MediaAttachment) +@identity_required +def get_media( + request, + id: str, +): + attachment = get_object_or_404(PostAttachment, pk=id) + return attachment.to_mastodon_json() + + +@api_router.put("/v1/media/{id}", response=schemas.MediaAttachment) +@identity_required +def update_media( + request, + id: str, + details: UploadMediaSchema | None = None, +): + attachment = get_object_or_404(PostAttachment, pk=id) + attachment.name = details.description if details else None + attachment.save() + return attachment.to_mastodon_json() diff --git a/api/views/notifications.py b/api/views/notifications.py index 9ccda81..7b05d14 100644 --- a/api/views/notifications.py +++ b/api/views/notifications.py @@ -1,8 +1,7 @@ -from activities.models import TimelineEvent - -from .. import schemas -from ..decorators import identity_required -from .base import api_router +from activities.models import PostInteraction, TimelineEvent +from api import schemas +from api.decorators import identity_required +from api.views.base import api_router @api_router.get("/v1/notifications", response=list[schemas.Notification]) @@ -49,4 +48,9 @@ def notifications( # invert the ordering to accomodate anchor_event = TimelineEvent.objects.get(pk=min_id) events = events.filter(created__gt=anchor_event.created).order_by("created") - return [event.to_mastodon_notification_json() for event in events[:limit]] + events = list(events[:limit]) + interactions = PostInteraction.get_event_interactions(events, request.identity) + return [ + event.to_mastodon_notification_json(interactions=interactions) + for event in events + ] diff --git a/api/views/search.py b/api/views/search.py index 7735a65..bd44cd7 100644 --- a/api/views/search.py +++ b/api/views/search.py @@ -2,6 +2,7 @@ from typing import Literal from ninja import Field +from activities.models import PostInteraction from activities.search import Searcher from api import schemas from api.decorators import identity_required @@ -38,5 +39,11 @@ def search( if type is None or type == "hashtag": result["hashtag"] = [h.to_mastodon_json() for h in search_result["hashtags"]] if type is None or type == "statuses": - result["statuses"] = [p.to_mastodon_json() for p in search_result["posts"]] + interactions = PostInteraction.get_post_interactions( + search_result["posts"], request.identity + ) + result["statuses"] = [ + p.to_mastodon_json(interactions=interactions) + for p in search_result["posts"] + ] return result diff --git a/api/views/statuses.py b/api/views/statuses.py new file mode 100644 index 0000000..752ee65 --- /dev/null +++ b/api/views/statuses.py @@ -0,0 +1,139 @@ +from typing import Literal + +from django.forms import ValidationError +from django.shortcuts import get_object_or_404 +from ninja import Schema + +from activities.models import ( + Post, + PostAttachment, + PostInteraction, + PostStates, + TimelineEvent, +) +from api import schemas +from api.views.base import api_router +from core.models import Config + +from ..decorators import identity_required + + +class PostStatusSchema(Schema): + status: str + in_reply_to_id: str | None = None + sensitive: bool = False + spoiler_text: str | None = None + visibility: Literal["public", "unlisted", "private", "direct"] = "public" + language: str | None = None + scheduled_at: str | None = None + media_ids: list[str] = [] + + +@api_router.post("/v1/statuses", response=schemas.Status) +@identity_required +def post_status(request, details: PostStatusSchema): + # Check text length + if len(details.status) > Config.system.post_length: + raise ValidationError("Status is too long") + if len(details.status) == 0 and not details.media_ids: + raise ValidationError("Status is empty") + # Grab attachments + attachments = [get_object_or_404(PostAttachment, pk=id) for id in details.media_ids] + # Create the Post + visibility_map = { + "public": Post.Visibilities.public, + "unlisted": Post.Visibilities.unlisted, + "private": Post.Visibilities.followers, + "direct": Post.Visibilities.mentioned, + } + reply_post = None + if details.in_reply_to_id: + try: + reply_post = Post.objects.get(pk=details.in_reply_to_id) + except Post.DoesNotExist: + pass + post = Post.create_local( + author=request.identity, + content=details.status, + summary=details.spoiler_text, + sensitive=details.sensitive, + visibility=visibility_map[details.visibility], + reply_to=reply_post, + attachments=attachments, + ) + # Add their own timeline event for immediate visibility + TimelineEvent.add_post(request.identity, post) + return post.to_mastodon_json() + + +@api_router.get("/v1/statuses/{id}", response=schemas.Status) +@identity_required +def status(request, id: str): + post = get_object_or_404(Post, pk=id) + interactions = PostInteraction.get_post_interactions([post], request.identity) + return post.to_mastodon_json(interactions=interactions) + + +@api_router.delete("/v1/statuses/{id}", response=schemas.Status) +@identity_required +def delete_status(request, id: str): + post = get_object_or_404(Post, pk=id) + post.transition_perform(PostStates.deleted) + TimelineEvent.objects.filter(subject_post=post, identity=request.identity).delete() + return post.to_mastodon_json() + + +@api_router.get("/v1/statuses/{id}/context", response=schemas.Context) +@identity_required +def status_context(request, id: str): + post = get_object_or_404(Post, pk=id) + parent = post.in_reply_to_post() + ancestors = [] + if parent: + ancestors.append(parent) + descendants = list(Post.objects.filter(in_reply_to=post.object_uri)[:40]) + interactions = PostInteraction.get_post_interactions( + [post] + ancestors + descendants, request.identity + ) + return { + "ancestors": [p.to_mastodon_json(interactions=interactions) for p in ancestors], + "descendants": [ + p.to_mastodon_json(interactions=interactions) for p in descendants + ], + } + + +@api_router.post("/v1/statuses/{id}/favourite", response=schemas.Status) +@identity_required +def favourite_status(request, id: str): + post = get_object_or_404(Post, pk=id) + post.like_as(request.identity) + interactions = PostInteraction.get_post_interactions([post], request.identity) + return post.to_mastodon_json(interactions=interactions) + + +@api_router.post("/v1/statuses/{id}/unfavourite", response=schemas.Status) +@identity_required +def unfavourite_status(request, id: str): + post = get_object_or_404(Post, pk=id) + post.unlike_as(request.identity) + interactions = PostInteraction.get_post_interactions([post], request.identity) + return post.to_mastodon_json(interactions=interactions) + + +@api_router.post("/v1/statuses/{id}/reblog", response=schemas.Status) +@identity_required +def reblog_status(request, id: str): + post = get_object_or_404(Post, pk=id) + post.boost_as(request.identity) + interactions = PostInteraction.get_post_interactions([post], request.identity) + return post.to_mastodon_json(interactions=interactions) + + +@api_router.post("/v1/statuses/{id}/unreblog", response=schemas.Status) +@identity_required +def unreblog_status(request, id: str): + post = get_object_or_404(Post, pk=id) + post.unboost_as(request.identity) + interactions = PostInteraction.get_post_interactions([post], request.identity) + return post.to_mastodon_json(interactions=interactions) diff --git a/api/views/timelines.py b/api/views/timelines.py index d560596..84eed7a 100644 --- a/api/views/timelines.py +++ b/api/views/timelines.py @@ -1,4 +1,4 @@ -from activities.models import Post, TimelineEvent +from activities.models import Post, PostInteraction, TimelineEvent from .. import schemas from ..decorators import identity_required @@ -36,7 +36,12 @@ def home( # invert the ordering to accomodate anchor_post = Post.objects.get(pk=min_id) events = events.filter(created__gt=anchor_post.created).order_by("created") - return [event.subject_post.to_mastodon_json() for event in events[:limit]] + events = list(events[:limit]) + interactions = PostInteraction.get_event_interactions(events, request.identity) + return [ + event.subject_post.to_mastodon_json(interactions=interactions) + for event in events + ] @api_router.get("/v1/timelines/public", response=list[schemas.Status]) @@ -76,7 +81,9 @@ def public( # invert the ordering to accomodate anchor_post = Post.objects.get(pk=min_id) posts = posts.filter(created__gt=anchor_post.created).order_by("created") - return [post.to_mastodon_json() for post in posts[:limit]] + posts = list(posts[:limit]) + interactions = PostInteraction.get_post_interactions(posts, request.identity) + return [post.to_mastodon_json(interactions=interactions) for post in posts] @api_router.get("/v1/timelines/tag/{hashtag}", response=list[schemas.Status]) @@ -115,7 +122,9 @@ def hashtag( # invert the ordering to accomodate anchor_post = Post.objects.get(pk=min_id) posts = posts.filter(created__gt=anchor_post.created).order_by("created") - return [post.to_mastodon_json() for post in posts[:limit]] + posts = list(posts[:limit]) + interactions = PostInteraction.get_post_interactions(posts, request.identity) + return [post.to_mastodon_json(interactions=interactions) for post in posts] @api_router.get("/v1/conversations", response=list[schemas.Status]) diff --git a/core/files.py b/core/files.py index 4c7729d..a04cef9 100644 --- a/core/files.py +++ b/core/files.py @@ -32,8 +32,8 @@ def resize_image( return file -def blurhash_image(image) -> str: +def blurhash_image(file) -> str: """ Returns the blurhash for an image """ - return blurhash.encode(image, 4, 4) + return blurhash.encode(file, 4, 4) diff --git a/users/views/identity.py b/users/views/identity.py index fba640c..b68806b 100644 --- a/users/views/identity.py +++ b/users/views/identity.py @@ -62,9 +62,8 @@ class ViewIdentity(ListView): def get_queryset(self): return ( - self.identity.posts.filter( - visibility__in=[Post.Visibilities.public, Post.Visibilities.unlisted], - ) + self.identity.posts.not_hidden() + .unlisted(include_replies=True) .select_related("author") .prefetch_related("attachments") .order_by("-created") -- cgit v1.2.3