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