From 6c7ddedd342553b53dd98c8de9cbe9e8e2e8cd7c Mon Sep 17 00:00:00 2001 From: Michael Manfre Date: Sun, 27 Nov 2022 13:09:46 -0500 Subject: Post editing --- activities/models/fan_out.py | 86 ++++++++++++++++++++++++++++++-------------- activities/models/post.py | 40 +++++++++++++++++++++ activities/views/posts.py | 85 ++++++++++++++++++++++++++++++------------- 3 files changed, 160 insertions(+), 51 deletions(-) (limited to 'activities') diff --git a/activities/models/fan_out.py b/activities/models/fan_out.py index 64df929..a86e30a 100644 --- a/activities/models/fan_out.py +++ b/activities/models/fan_out.py @@ -17,11 +17,15 @@ class FanOutStates(StateGraph): """ Sends the fan-out to the right inbox. """ + LOCAL_IDENTITY = True + REMOTE_IDENTITY = False + fan_out = await instance.afetch_full() - # Handle Posts - if fan_out.type == FanOut.Types.post: - post = await fan_out.subject_post.afetch_full() - if fan_out.identity.local: + + match (fan_out.type, fan_out.identity.local): + # Handle creating/updating local posts + case (FanOut.Types.post | FanOut.Types.post_edited, LOCAL_IDENTITY): + post = await fan_out.subject_post.afetch_full() # Make a timeline event directly # If it's a reply, we only add it if we follow at least one # of the people mentioned. @@ -44,63 +48,91 @@ class FanOutStates(StateGraph): identity=fan_out.identity, post=post, ) - else: + + # Handle sending remote posts create + case (FanOut.Types.post, REMOTE_IDENTITY): + post = await fan_out.subject_post.afetch_full() # Sign it and send it await post.author.signed_request( method="post", uri=fan_out.identity.inbox_uri, body=canonicalise(post.to_create_ap()), ) - # Handle deleting posts - elif fan_out.type == FanOut.Types.post_deleted: - post = await fan_out.subject_post.afetch_full() - if fan_out.identity.local: - # Remove all timeline events mentioning it - await TimelineEvent.objects.filter( - identity=fan_out.identity, - subject_post=post, - ).adelete() - else: + + # Handle sending remote posts update + case (FanOut.Types.post_edited, REMOTE_IDENTITY): + post = await fan_out.subject_post.afetch_full() + # Sign it and send it + await post.author.signed_request( + method="post", + uri=fan_out.identity.inbox_uri, + body=canonicalise(post.to_update_ap()), + ) + + # Handle deleting local posts + case (FanOut.Types.post_deleted, LOCAL_IDENTITY): + post = await fan_out.subject_post.afetch_full() + if fan_out.identity.local: + # Remove all timeline events mentioning it + await TimelineEvent.objects.filter( + identity=fan_out.identity, + subject_post=post, + ).adelete() + + # Handle sending remote post deletes + case (FanOut.Types.post_deleted, REMOTE_IDENTITY): + post = await fan_out.subject_post.afetch_full() # Send it to the remote inbox await post.author.signed_request( method="post", uri=fan_out.identity.inbox_uri, body=canonicalise(post.to_delete_ap()), ) - # Handle boosts/likes - elif fan_out.type == FanOut.Types.interaction: - interaction = await fan_out.subject_post_interaction.afetch_full() - if fan_out.identity.local: + + # Handle local boosts/likes + case (FanOut.Types.interaction, LOCAL_IDENTITY): + interaction = await fan_out.subject_post_interaction.afetch_full() # Make a timeline event directly await sync_to_async(TimelineEvent.add_post_interaction)( identity=fan_out.identity, interaction=interaction, ) - else: + + # Handle sending remote boosts/likes + case (FanOut.Types.interaction, REMOTE_IDENTITY): + interaction = await fan_out.subject_post_interaction.afetch_full() # Send it to the remote inbox await interaction.identity.signed_request( method="post", uri=fan_out.identity.inbox_uri, body=canonicalise(interaction.to_ap()), ) - # Handle undoing boosts/likes - elif fan_out.type == FanOut.Types.undo_interaction: - interaction = await fan_out.subject_post_interaction.afetch_full() - if fan_out.identity.local: + + # Handle undoing local boosts/likes + case (FanOut.Types.undo_interaction, LOCAL_IDENTITY): # noqa:F841 + interaction = await fan_out.subject_post_interaction.afetch_full() + # Delete any local timeline events await sync_to_async(TimelineEvent.delete_post_interaction)( identity=fan_out.identity, interaction=interaction, ) - else: + + # Handle sending remote undoing boosts/likes + case (FanOut.Types.undo_interaction, REMOTE_IDENTITY): # noqa:F841 + interaction = await fan_out.subject_post_interaction.afetch_full() # Send an undo to the remote inbox await interaction.identity.signed_request( method="post", uri=fan_out.identity.inbox_uri, body=canonicalise(interaction.to_undo_ap()), ) - else: - raise ValueError(f"Cannot fan out with type {fan_out.type}") + + case _: + raise ValueError( + f"Cannot fan out with type {fan_out.type} local={fan_out.identity.local}" + ) + return cls.sent diff --git a/activities/models/post.py b/activities/models/post.py index f75c526..23194b3 100644 --- a/activities/models/post.py +++ b/activities/models/post.py @@ -22,9 +22,17 @@ class PostStates(StateGraph): deleted = State(try_interval=300) deleted_fanned_out = State() + edited = State(try_interval=300) + edited_fanned_out = State(externally_progressed=True) + new.transitions_to(fanned_out) fanned_out.transitions_to(deleted) + fanned_out.transitions_to(edited) + deleted.transitions_to(deleted_fanned_out) + edited.transitions_to(edited_fanned_out) + edited_fanned_out.transitions_to(edited) + edited_fanned_out.transitions_to(deleted) @classmethod async def handle_new(cls, instance: "Post"): @@ -56,6 +64,21 @@ class PostStates(StateGraph): ) return cls.deleted_fanned_out + @classmethod + async def handle_edited(cls, instance: "Post"): + """ + Creates all needed fan-out objects for an edited Post. + """ + post = await instance.afetch_full() + # Fan out to each target + for follow in await post.aget_targets(): + await FanOut.objects.acreate( + identity=follow, + type=FanOut.Types.post_edited, + subject_post=post, + ) + return cls.edited_fanned_out + class Post(StatorModel): """ @@ -140,6 +163,7 @@ class Post(StatorModel): action_boost = "{view}boost/" action_unboost = "{view}unboost/" action_delete = "{view}delete/" + action_edit = "{view}edit/" action_reply = "/compose/?reply_to={self.id}" def get_scheme(self, url): @@ -305,6 +329,8 @@ class Post(StatorModel): value["summary"] = self.summary if self.in_reply_to: value["inReplyTo"] = self.in_reply_to + if self.edited: + value["updated"] = format_ld_date(self.edited) # Mentions for mention in self.mentions.all(): value["tag"].append( @@ -336,6 +362,20 @@ class Post(StatorModel): "object": object, } + def to_update_ap(self): + """ + Returns the AP JSON to update this object + """ + object = self.to_ap() + return { + "to": object["to"], + "cc": object.get("cc", []), + "type": "Update", + "id": self.object_uri + "#update", + "actor": self.author.actor_uri, + "object": object, + } + def to_delete_ap(self): """ Returns the AP JSON to create this object diff --git a/activities/views/posts.py b/activities/views/posts.py index 59b1f56..5d7b0c9 100644 --- a/activities/views/posts.py +++ b/activities/views/posts.py @@ -1,6 +1,8 @@ from django import forms -from django.http import Http404, JsonResponse +from django.core.exceptions import PermissionDenied +from django.http import JsonResponse from django.shortcuts import get_object_or_404, redirect, render +from django.utils import timezone from django.utils.decorators import method_decorator from django.views.generic import FormView, TemplateView, View @@ -143,11 +145,11 @@ class Delete(TemplateView): template_name = "activities/post_delete.html" def dispatch(self, request, handle, post_id): + # Make sure the request identity owns the post! + if handle != request.identity.handle: + raise PermissionDenied("Post author is not requestor") self.identity = by_handle_or_404(self.request, handle, local=False) self.post_obj = get_object_or_404(self.identity.posts, pk=post_id) - # Make sure the request identity owns the post! - if self.post_obj.author != request.identity: - raise Http404("Post author is not requestor") return super().dispatch(request) def get_context_data(self): @@ -164,6 +166,10 @@ class Compose(FormView): template_name = "activities/compose.html" class form_class(forms.Form): + id = forms.IntegerField( + required=False, + widget=forms.HiddenInput(), + ) text = forms.CharField( widget=forms.Textarea( @@ -206,33 +212,64 @@ class Compose(FormView): def get_initial(self): initial = super().get_initial() - initial[ - "visibility" - ] = self.request.identity.config_identity.default_post_visibility - if self.reply_to: - initial["reply_to"] = self.reply_to.pk - initial["visibility"] = self.reply_to.visibility - initial["text"] = f"@{self.reply_to.author.handle} " + if self.post_obj: + initial.update( + { + "id": self.post_obj.id, + "reply_to": self.reply_to.pk if self.reply_to else "", + "visibility": self.post_obj.visibility, + "text": self.post_obj.content, + "content_warning": self.post_obj.summary, + } + ) + else: + initial[ + "visibility" + ] = self.request.identity.config_identity.default_post_visibility + if self.reply_to: + initial["reply_to"] = self.reply_to.pk + initial["visibility"] = self.reply_to.visibility + initial["text"] = f"@{self.reply_to.author.handle} " return initial def form_valid(self, form): - post = Post.create_local( - author=self.request.identity, - content=form.cleaned_data["text"], - summary=form.cleaned_data.get("content_warning"), - visibility=form.cleaned_data["visibility"], - reply_to=self.reply_to, - ) - # Add their own timeline event for immediate visibility - TimelineEvent.add_post(self.request.identity, post) + post_id = form.cleaned_data.get("id") + if post_id: + post = get_object_or_404(self.request.identity.posts, pk=post_id) + post.edited = timezone.now() + post.content = form.cleaned_data["text"] + post.summary = form.cleaned_data.get("content_warning") + post.visibility = form.cleaned_data["visibility"] + post.save() + + # Should there be a timeline event for edits? + # E.g. "@user edited #123" + + post.transition_perform(PostStates.edited) + else: + post = Post.create_local( + author=self.request.identity, + content=form.cleaned_data["text"], + summary=form.cleaned_data.get("content_warning"), + visibility=form.cleaned_data["visibility"], + reply_to=self.reply_to, + ) + # Add their own timeline event for immediate visibility + TimelineEvent.add_post(self.request.identity, post) return redirect("/") - def dispatch(self, request, *args, **kwargs): + def dispatch(self, request, handle=None, post_id=None, *args, **kwargs): + self.post_obj = None + if handle and post_id: + # Make sure the request identity owns the post! + if handle != request.identity.handle: + raise PermissionDenied("Post author is not requestor") + + self.post_obj = get_object_or_404(request.identity.posts, pk=post_id) + # Grab the reply-to post info now self.reply_to = None - reply_to_id = self.request.POST.get("reply_to") or self.request.GET.get( - "reply_to" - ) + reply_to_id = request.POST.get("reply_to") or request.GET.get("reply_to") if reply_to_id: try: self.reply_to = Post.objects.get(pk=reply_to_id) -- cgit v1.2.3