Post editing
This commit is contained in:
		
							parent
							
								
									263af996d8
								
							
						
					
					
						commit
						6c7ddedd34
					
				@ -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
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
 | 
			
		||||
@ -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)
 | 
			
		||||
 | 
			
		||||
@ -4,5 +4,6 @@ black==22.10.0
 | 
			
		||||
flake8==5.0.4
 | 
			
		||||
isort==5.10.1
 | 
			
		||||
mock~=4.0.3
 | 
			
		||||
pytest-asyncio~=0.20.2
 | 
			
		||||
pytest-django~=4.5.2
 | 
			
		||||
pytest-httpx~=0.21
 | 
			
		||||
 | 
			
		||||
@ -768,11 +768,17 @@ h1.identity small {
 | 
			
		||||
    content: "HIDE";
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.post .edited {
 | 
			
		||||
    margin-left: 64px;
 | 
			
		||||
    font-weight: lighter;
 | 
			
		||||
    color: var(--color-text-duller);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.post .content {
 | 
			
		||||
    margin-left: 64px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.post.mini .content {
 | 
			
		||||
.post.mini .content, .post.mini .edited {
 | 
			
		||||
    margin-left: 0px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -104,6 +104,9 @@ class State:
 | 
			
		||||
    def __repr__(self):
 | 
			
		||||
        return f"<State {self.name}>"
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return self.name
 | 
			
		||||
 | 
			
		||||
    def __eq__(self, other):
 | 
			
		||||
        if isinstance(other, State):
 | 
			
		||||
            return self is other
 | 
			
		||||
 | 
			
		||||
@ -52,28 +52,11 @@ class StatorRunner:
 | 
			
		||||
                    Config.system = await Config.aload_system()
 | 
			
		||||
                    print(f"{self.handled} tasks processed so far")
 | 
			
		||||
                    print("Running cleaning and scheduling")
 | 
			
		||||
                    for model in self.models:
 | 
			
		||||
                        asyncio.create_task(model.atransition_clean_locks())
 | 
			
		||||
                        asyncio.create_task(model.atransition_schedule_due())
 | 
			
		||||
                    self.last_clean = time.monotonic()
 | 
			
		||||
                # Calculate space left for tasks
 | 
			
		||||
                    await self.run_cleanup()
 | 
			
		||||
 | 
			
		||||
                self.remove_completed_tasks()
 | 
			
		||||
                space_remaining = self.concurrency - len(self.tasks)
 | 
			
		||||
                # Fetch new tasks
 | 
			
		||||
                for model in self.models:
 | 
			
		||||
                    if space_remaining > 0:
 | 
			
		||||
                        for instance in await model.atransition_get_with_lock(
 | 
			
		||||
                            number=min(space_remaining, self.concurrency_per_model),
 | 
			
		||||
                            lock_expiry=(
 | 
			
		||||
                                timezone.now()
 | 
			
		||||
                                + datetime.timedelta(seconds=self.lock_expiry)
 | 
			
		||||
                            ),
 | 
			
		||||
                        ):
 | 
			
		||||
                            self.tasks.append(
 | 
			
		||||
                                asyncio.create_task(self.run_transition(instance))
 | 
			
		||||
                            )
 | 
			
		||||
                            self.handled += 1
 | 
			
		||||
                            space_remaining -= 1
 | 
			
		||||
                await self.fetch_and_process_tasks()
 | 
			
		||||
 | 
			
		||||
                # Are we in limited run mode?
 | 
			
		||||
                if self.run_for and (time.monotonic() - self.started) > self.run_for:
 | 
			
		||||
                    break
 | 
			
		||||
@ -92,6 +75,33 @@ class StatorRunner:
 | 
			
		||||
        print("Complete")
 | 
			
		||||
        return self.handled
 | 
			
		||||
 | 
			
		||||
    async def run_cleanup(self):
 | 
			
		||||
        """
 | 
			
		||||
        Do any transition cleanup tasks
 | 
			
		||||
        """
 | 
			
		||||
        for model in self.models:
 | 
			
		||||
            asyncio.create_task(model.atransition_clean_locks())
 | 
			
		||||
            asyncio.create_task(model.atransition_schedule_due())
 | 
			
		||||
        self.last_clean = time.monotonic()
 | 
			
		||||
 | 
			
		||||
    async def fetch_and_process_tasks(self):
 | 
			
		||||
        # Calculate space left for tasks
 | 
			
		||||
        space_remaining = self.concurrency - len(self.tasks)
 | 
			
		||||
        # Fetch new tasks
 | 
			
		||||
        for model in self.models:
 | 
			
		||||
            if space_remaining > 0:
 | 
			
		||||
                for instance in await model.atransition_get_with_lock(
 | 
			
		||||
                    number=min(space_remaining, self.concurrency_per_model),
 | 
			
		||||
                    lock_expiry=(
 | 
			
		||||
                        timezone.now() + datetime.timedelta(seconds=self.lock_expiry)
 | 
			
		||||
                    ),
 | 
			
		||||
                ):
 | 
			
		||||
                    self.tasks.append(
 | 
			
		||||
                        asyncio.create_task(self.run_transition(instance))
 | 
			
		||||
                    )
 | 
			
		||||
                    self.handled += 1
 | 
			
		||||
                    space_remaining -= 1
 | 
			
		||||
 | 
			
		||||
    async def run_transition(self, instance: StatorModel):
 | 
			
		||||
        """
 | 
			
		||||
        Wrapper for atransition_attempt with fallback error handling
 | 
			
		||||
 | 
			
		||||
@ -30,6 +30,11 @@ TAKAHE_ENV_FILE = os.environ.get(
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
TAKAHE_ENV_FILE = os.environ.get(
 | 
			
		||||
    "TAKAHE_ENV_FILE", "test.env" if "pytest" in sys.modules else ".env"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Settings(BaseSettings):
 | 
			
		||||
    """
 | 
			
		||||
    Pydantic-powered settings, to provide consistent error messages, strong
 | 
			
		||||
 | 
			
		||||
@ -106,6 +106,7 @@ urlpatterns = [
 | 
			
		||||
    path("@<handle>/posts/<int:post_id>/boost/", posts.Boost.as_view()),
 | 
			
		||||
    path("@<handle>/posts/<int:post_id>/unboost/", posts.Boost.as_view(undo=True)),
 | 
			
		||||
    path("@<handle>/posts/<int:post_id>/delete/", posts.Delete.as_view()),
 | 
			
		||||
    path("@<handle>/posts/<int:post_id>/edit/", posts.Compose.as_view()),
 | 
			
		||||
    # Authentication
 | 
			
		||||
    path("auth/login/", auth.Login.as_view(), name="login"),
 | 
			
		||||
    path("auth/logout/", auth.Logout.as_view(), name="logout"),
 | 
			
		||||
 | 
			
		||||
@ -18,13 +18,11 @@
 | 
			
		||||
        {% elif post.visibility == 4 %}
 | 
			
		||||
        <i class="visibility fa-solid fa-link-slash" title="Local Only"></i>
 | 
			
		||||
        {% endif %}
 | 
			
		||||
        <a href="{{ post.url }}">
 | 
			
		||||
            {% if post.published %}
 | 
			
		||||
                {{ post.published | timedeltashort }}
 | 
			
		||||
            {% else %}
 | 
			
		||||
                {{ post.created | timedeltashort }}
 | 
			
		||||
            {% endif %}
 | 
			
		||||
        </a>
 | 
			
		||||
        {% if post.published %}
 | 
			
		||||
            <a href="{{ post.url }}" title="{{ post.published }}">{{ post.published | timedeltashort }}</a>
 | 
			
		||||
        {% else %}
 | 
			
		||||
            <a href="{{ post.url }}" title="{{ post.created }}">{{ post.created | timedeltashort }}</a>
 | 
			
		||||
        {% endif %}
 | 
			
		||||
    </time>
 | 
			
		||||
 | 
			
		||||
    {% if request.identity %}
 | 
			
		||||
@ -32,14 +30,19 @@
 | 
			
		||||
        {% include "activities/_reply.html" %}
 | 
			
		||||
        {% include "activities/_like.html" %}
 | 
			
		||||
        {% include "activities/_boost.html" %}
 | 
			
		||||
        {% if post.author == request.identity %}
 | 
			
		||||
        <a title="Menu" class="menu" _="on click toggle .enabled on the next <menu/>">
 | 
			
		||||
            <i class="fa-solid fa-caret-down"></i>
 | 
			
		||||
        </a>
 | 
			
		||||
        <menu>
 | 
			
		||||
            <a href="{{ post.urls.action_edit }}">
 | 
			
		||||
                <i class="fa-solid fa-pen-to-square"></i> Edit
 | 
			
		||||
            </a>
 | 
			
		||||
            <a href="{{ post.urls.action_delete }}">
 | 
			
		||||
                <i class="fa-solid fa-trash"></i> Delete
 | 
			
		||||
            </a>
 | 
			
		||||
        </menu>
 | 
			
		||||
        {% endif %}
 | 
			
		||||
    </div>
 | 
			
		||||
    {% endif %}
 | 
			
		||||
 | 
			
		||||
@ -57,6 +60,12 @@
 | 
			
		||||
        {{ post.safe_content_local }}
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    {% if post.edited %}
 | 
			
		||||
    <div class="edited" title="{{ post.edited }}">
 | 
			
		||||
        <small>Edited {{ post.edited | timedeltashort }} ago</small>
 | 
			
		||||
    </div>
 | 
			
		||||
    {% endif %}
 | 
			
		||||
 | 
			
		||||
    {% if post.attachments.exists %}
 | 
			
		||||
        <div class="attachments">
 | 
			
		||||
            {% for attachment in post.attachments.all %}
 | 
			
		||||
 | 
			
		||||
@ -12,12 +12,13 @@
 | 
			
		||||
                {% include "activities/_mini_post.html" with post=reply_to %}
 | 
			
		||||
            {% endif %}
 | 
			
		||||
            {{ form.reply_to }}
 | 
			
		||||
            {{ form.id }}
 | 
			
		||||
            {% include "forms/_field.html" with field=form.text %}
 | 
			
		||||
            {% include "forms/_field.html" with field=form.content_warning %}
 | 
			
		||||
            {% include "forms/_field.html" with field=form.visibility %}
 | 
			
		||||
        </fieldset>
 | 
			
		||||
        <div class="buttons">
 | 
			
		||||
            <button>{% if config_identity.toot_mode %}Toot!{% else %}Post{% endif %}</button>
 | 
			
		||||
            <button>{% if form.id %}Edit{% elif config_identity.toot_mode %}Toot!{% else %}Post{% endif %}</button>
 | 
			
		||||
        </div>
 | 
			
		||||
    </form>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
@ -1,7 +1,10 @@
 | 
			
		||||
import asyncio
 | 
			
		||||
 | 
			
		||||
import pytest
 | 
			
		||||
from asgiref.sync import async_to_sync
 | 
			
		||||
from pytest_httpx import HTTPXMock
 | 
			
		||||
 | 
			
		||||
from activities.models import Post
 | 
			
		||||
from activities.models import Post, PostStates
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@pytest.mark.django_db
 | 
			
		||||
@ -112,3 +115,45 @@ def test_linkify_mentions_local(identity, remote_identity):
 | 
			
		||||
        local=True,
 | 
			
		||||
    )
 | 
			
		||||
    assert post.safe_content_local() == "<p>@test@example.com, welcome!</p>"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
async def stator_process_tasks(stator):
 | 
			
		||||
    """
 | 
			
		||||
    Guarded wrapper to simply async_to_sync and ensure all stator tasks are
 | 
			
		||||
    run to completion without blocking indefinitely.
 | 
			
		||||
    """
 | 
			
		||||
    await asyncio.wait_for(stator.fetch_and_process_tasks(), timeout=1)
 | 
			
		||||
    for _ in range(100):
 | 
			
		||||
        if not stator.tasks:
 | 
			
		||||
            break
 | 
			
		||||
        stator.remove_completed_tasks()
 | 
			
		||||
        await asyncio.sleep(0.01)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@pytest.mark.django_db
 | 
			
		||||
def test_post_transitions(identity, stator_runner):
 | 
			
		||||
 | 
			
		||||
    # Create post
 | 
			
		||||
    post = Post.objects.create(
 | 
			
		||||
        content="<p>Hello!</p>",
 | 
			
		||||
        author=identity,
 | 
			
		||||
        local=False,
 | 
			
		||||
        visibility=Post.Visibilities.mentioned,
 | 
			
		||||
    )
 | 
			
		||||
    # Test: | --> new --> fanned_out
 | 
			
		||||
    assert post.state == str(PostStates.new)
 | 
			
		||||
    async_to_sync(stator_process_tasks)(stator_runner)
 | 
			
		||||
    post = Post.objects.get(id=post.id)
 | 
			
		||||
    assert post.state == str(PostStates.fanned_out)
 | 
			
		||||
 | 
			
		||||
    # Test: fanned_out --> (forced) edited --> edited_fanned_out
 | 
			
		||||
    Post.transition_perform(post, PostStates.edited)
 | 
			
		||||
    async_to_sync(stator_process_tasks)(stator_runner)
 | 
			
		||||
    post = Post.objects.get(id=post.id)
 | 
			
		||||
    assert post.state == str(PostStates.edited_fanned_out)
 | 
			
		||||
 | 
			
		||||
    # Test: edited_fanned_out --> (forced) deleted --> deleted_fanned_out
 | 
			
		||||
    Post.transition_perform(post, PostStates.deleted)
 | 
			
		||||
    async_to_sync(stator_process_tasks)(stator_runner)
 | 
			
		||||
    post = Post.objects.get(id=post.id)
 | 
			
		||||
    assert post.state == str(PostStates.deleted_fanned_out)
 | 
			
		||||
 | 
			
		||||
@ -2,8 +2,10 @@ import re
 | 
			
		||||
 | 
			
		||||
import mock
 | 
			
		||||
import pytest
 | 
			
		||||
from django.core.exceptions import PermissionDenied
 | 
			
		||||
 | 
			
		||||
from activities.views.posts import Compose
 | 
			
		||||
from activities.models import Post
 | 
			
		||||
from activities.views.posts import Compose, Delete
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@pytest.mark.django_db
 | 
			
		||||
@ -22,3 +24,43 @@ def test_content_warning_text(identity, user, rf, config_system):
 | 
			
		||||
        assert re.search(
 | 
			
		||||
            r"<label.*>\s*Content Summary\s*</label>", content, flags=re.MULTILINE
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@pytest.mark.django_db
 | 
			
		||||
def test_post_delete_security(identity, user, rf, other_identity):
 | 
			
		||||
    # Create post
 | 
			
		||||
    other_post = Post.objects.create(
 | 
			
		||||
        content="<p>OTHER POST!</p>",
 | 
			
		||||
        author=other_identity,
 | 
			
		||||
        local=True,
 | 
			
		||||
        visibility=Post.Visibilities.public,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    request = rf.post(other_post.get_absolute_url() + "delete/")
 | 
			
		||||
    request.user = user
 | 
			
		||||
    request.identity = identity
 | 
			
		||||
 | 
			
		||||
    view = Delete.as_view()
 | 
			
		||||
    with pytest.raises(PermissionDenied) as ex:
 | 
			
		||||
        view(request, handle=other_identity.handle.lstrip("@"), post_id=other_post.id)
 | 
			
		||||
    assert str(ex.value) == "Post author is not requestor"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@pytest.mark.django_db
 | 
			
		||||
def test_post_edit_security(identity, user, rf, other_identity):
 | 
			
		||||
    # Create post
 | 
			
		||||
    other_post = Post.objects.create(
 | 
			
		||||
        content="<p>OTHER POST!</p>",
 | 
			
		||||
        author=other_identity,
 | 
			
		||||
        local=True,
 | 
			
		||||
        visibility=Post.Visibilities.public,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    request = rf.get(other_post.get_absolute_url() + "edit/")
 | 
			
		||||
    request.user = user
 | 
			
		||||
    request.identity = identity
 | 
			
		||||
 | 
			
		||||
    view = Compose.as_view()
 | 
			
		||||
    with pytest.raises(PermissionDenied) as ex:
 | 
			
		||||
        view(request, handle=other_identity.handle.lstrip("@"), post_id=other_post.id)
 | 
			
		||||
    assert str(ex.value) == "Post author is not requestor"
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,9 @@
 | 
			
		||||
import time
 | 
			
		||||
 | 
			
		||||
import pytest
 | 
			
		||||
 | 
			
		||||
from core.models import Config
 | 
			
		||||
from stator.runner import StatorModel, StatorRunner
 | 
			
		||||
from users.models import Domain, Identity, User
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -120,3 +123,26 @@ def remote_identity() -> Identity:
 | 
			
		||||
        name="Test Remote User",
 | 
			
		||||
        local=False,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@pytest.fixture
 | 
			
		||||
def stator_runner(config_system) -> StatorRunner:
 | 
			
		||||
    """
 | 
			
		||||
    Return an initialized StatorRunner for tests that need state transitioning
 | 
			
		||||
    to happen.
 | 
			
		||||
 | 
			
		||||
    Example:
 | 
			
		||||
        # Do some tasks with state side effects
 | 
			
		||||
        async_to_sync(stator_runner.fetch_and_process_tasks)()
 | 
			
		||||
    """
 | 
			
		||||
    runner = StatorRunner(
 | 
			
		||||
        StatorModel.subclasses,
 | 
			
		||||
        concurrency=100,
 | 
			
		||||
        schedule_interval=30,
 | 
			
		||||
    )
 | 
			
		||||
    runner.handled = 0
 | 
			
		||||
    runner.started = time.monotonic()
 | 
			
		||||
    runner.last_clean = time.monotonic() - runner.schedule_interval
 | 
			
		||||
    runner.tasks = []
 | 
			
		||||
 | 
			
		||||
    return runner
 | 
			
		||||
 | 
			
		||||
		Reference in New Issue
	
	Block a user