From 9067caf9a3097730324f4fcfc94927aea366f04c Mon Sep 17 00:00:00 2001 From: Andrew Godwin Date: Tue, 20 Dec 2022 09:59:06 +0000 Subject: [PATCH] Add expanded post context Fixes #120 --- activities/models/post.py | 64 ++++++------------ activities/services/__init__.py | 1 + activities/services/post.py | 94 ++++++++++++++++++++++++++ activities/views/posts.py | 52 ++++---------- api/views/statuses.py | 22 +++--- templates/activities/post.html | 10 +-- tests/activities/services/test_post.py | 37 ++++++++++ 7 files changed, 185 insertions(+), 95 deletions(-) create mode 100644 activities/services/__init__.py create mode 100644 activities/services/post.py create mode 100644 tests/activities/services/test_post.py diff --git a/activities/models/post.py b/activities/models/post.py index 374823c..dd18d87 100644 --- a/activities/models/post.py +++ b/activities/models/post.py @@ -128,6 +128,28 @@ class PostQuerySet(models.QuerySet): return query.filter(in_reply_to__isnull=True) return query + def visible_to(self, identity, include_replies: bool = False): + query = self.filter( + models.Q( + visibility__in=[ + Post.Visibilities.public, + Post.Visibilities.local_only, + Post.Visibilities.unlisted, + ] + ) + | models.Q( + visibility=Post.Visibilities.followers, + author__inbound_follows__source=identity, + ) + | models.Q( + visibility=Post.Visibilities.mentioned, + mentions=identity, + ) + ).distinct() + 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) @@ -526,48 +548,6 @@ 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: diff --git a/activities/services/__init__.py b/activities/services/__init__.py new file mode 100644 index 0000000..6e973c5 --- /dev/null +++ b/activities/services/__init__.py @@ -0,0 +1 @@ +from .post import PostService # noqa diff --git a/activities/services/post.py b/activities/services/post.py new file mode 100644 index 0000000..60ddab4 --- /dev/null +++ b/activities/services/post.py @@ -0,0 +1,94 @@ +from typing import cast + +from activities.models import Post, PostInteraction, PostInteractionStates, PostStates +from users.models import Identity + + +class PostService: + """ + High-level operations on Posts + """ + + def __init__(self, post: Post): + self.post = post + + def interact_as(self, identity: Identity, type: str): + """ + Performs an interaction on this Post + """ + interaction = PostInteraction.objects.get_or_create( + type=type, + identity=identity, + post=self.post, + )[0] + if interaction.state in [ + PostInteractionStates.undone, + PostInteractionStates.undone_fanned_out, + ]: + interaction.transition_perform(PostInteractionStates.new) + + def uninteract_as(self, identity, type): + """ + Undoes an interaction on this Post + """ + for interaction in PostInteraction.objects.filter( + type=type, + identity=identity, + post=self.post, + ): + interaction.transition_perform(PostInteractionStates.undone) + + def like_as(self, identity: Identity): + self.interact_as(identity, PostInteraction.Types.like) + + def unlike_as(self, identity: Identity): + self.uninteract_as(identity, PostInteraction.Types.like) + + def boost_as(self, identity: Identity): + self.interact_as(identity, PostInteraction.Types.boost) + + def unboost_as(self, identity: Identity): + self.uninteract_as(identity, PostInteraction.Types.boost) + + def context(self, identity: Identity | None) -> tuple[list[Post], list[Post]]: + """ + Returns ancestor/descendant information. + + Ancestors are guaranteed to be in order from closest to furthest. + Descendants are in depth-first order, starting with closest. + + If identity is provided, includes mentions/followers-only posts they + can see. Otherwise, shows unlisted and above only. + """ + num_ancestors = 10 + num_descendants = 50 + # Retrieve ancestors via parent walk + ancestors: list[Post] = [] + ancestor = self.post + while ancestor.in_reply_to and len(ancestors) < num_ancestors: + ancestor = cast(Post, ancestor.in_reply_to_post()) + if ancestor.state in [PostStates.deleted, PostStates.deleted_fanned_out]: + break + ancestors.append(ancestor) + # Retrieve descendants via breadth-first-search + descendants: list[Post] = [] + queue = [self.post] + while queue and len(descendants) < num_descendants: + node = queue.pop() + child_queryset = ( + Post.objects.not_hidden() + .filter(in_reply_to=node.object_uri) + .select_related("author", "author__domain") + .prefetch_related("emojis") + .order_by("published") + ) + if identity: + child_queryset = child_queryset.visible_to( + identity=identity, include_replies=True + ) + else: + child_queryset = child_queryset.unlisted(include_replies=True) + for child in child_queryset: + descendants.append(child) + queue.append(child) + return ancestors, descendants diff --git a/activities/views/posts.py b/activities/views/posts.py index b543535..967352e 100644 --- a/activities/views/posts.py +++ b/activities/views/posts.py @@ -1,16 +1,15 @@ from django.core.exceptions import PermissionDenied -from django.db import models from django.http import Http404, JsonResponse from django.shortcuts import get_object_or_404, redirect, render 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, PostStates +from activities.models import PostInteraction, PostStates +from activities.services import PostService from core.decorators import cache_page_by_ap_json from core.ld import canonicalise from users.decorators import identity_required -from users.models import Identity from users.shortcuts import by_handle_or_404 @@ -38,44 +37,19 @@ class Individual(TemplateView): return super().get(request) def get_context_data(self): - parent = None - if self.post_obj.in_reply_to: - try: - parent = Post.by_object_uri(self.post_obj.in_reply_to, fetch=True) - except Post.DoesNotExist: - pass + ancestors, descendants = PostService(self.post_obj).context( + self.request.identity + ) return { "identity": self.identity, "post": self.post_obj, "interactions": PostInteraction.get_post_interactions( - [self.post_obj], + [self.post_obj] + ancestors + descendants, self.request.identity, ), "link_original": True, - "parent": parent, - "replies": Post.objects.filter( - models.Q( - visibility__in=[ - Post.Visibilities.public, - Post.Visibilities.local_only, - Post.Visibilities.unlisted, - ] - ) - | models.Q( - visibility=Post.Visibilities.followers, - author__inbound_follows__source=self.identity, - ) - | models.Q( - visibility=Post.Visibilities.mentioned, - mentions=self.identity, - ), - in_reply_to=self.post_obj.object_uri, - ) - .exclude(author__restriction=Identity.Restriction.blocked) - .distinct() - .select_related("author__domain") - .prefetch_related("emojis") - .order_by("published", "created"), + "ancestors": ancestors, + "descendants": descendants, } def serve_object(self): @@ -101,10 +75,11 @@ class Like(View): post = get_object_or_404( identity.posts.prefetch_related("attachments"), pk=post_id ) + service = PostService(post) if self.undo: - post.unlike_as(self.request.identity) + service.unlike_as(self.request.identity) else: - post.like_as(self.request.identity) + service.like_as(self.request.identity) # Return either a redirect or a HTMX snippet if request.htmx: return render( @@ -129,10 +104,11 @@ class Boost(View): def post(self, request, handle, post_id): identity = by_handle_or_404(self.request, handle, local=False) post = get_object_or_404(identity.posts, pk=post_id) + service = PostService(post) if self.undo: - post.unboost_as(request.identity) + service.unboost_as(request.identity) else: - post.boost_as(request.identity) + service.boost_as(request.identity) # Return either a redirect or a HTMX snippet if request.htmx: return render( diff --git a/api/views/statuses.py b/api/views/statuses.py index 752ee65..30c1690 100644 --- a/api/views/statuses.py +++ b/api/views/statuses.py @@ -11,6 +11,7 @@ from activities.models import ( PostStates, TimelineEvent, ) +from activities.services import PostService from api import schemas from api.views.base import api_router from core.models import Config @@ -87,13 +88,10 @@ def delete_status(request, id: str): @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]) + service = PostService(post) + ancestors, descendants = service.context(request.identity) interactions = PostInteraction.get_post_interactions( - [post] + ancestors + descendants, request.identity + ancestors + descendants, request.identity ) return { "ancestors": [p.to_mastodon_json(interactions=interactions) for p in ancestors], @@ -107,7 +105,8 @@ def status_context(request, id: str): @identity_required def favourite_status(request, id: str): post = get_object_or_404(Post, pk=id) - post.like_as(request.identity) + service = PostService(post) + service.like_as(request.identity) interactions = PostInteraction.get_post_interactions([post], request.identity) return post.to_mastodon_json(interactions=interactions) @@ -116,7 +115,8 @@ def favourite_status(request, id: str): @identity_required def unfavourite_status(request, id: str): post = get_object_or_404(Post, pk=id) - post.unlike_as(request.identity) + service = PostService(post) + service.unlike_as(request.identity) interactions = PostInteraction.get_post_interactions([post], request.identity) return post.to_mastodon_json(interactions=interactions) @@ -125,7 +125,8 @@ def unfavourite_status(request, id: str): @identity_required def reblog_status(request, id: str): post = get_object_or_404(Post, pk=id) - post.boost_as(request.identity) + service = PostService(post) + service.boost_as(request.identity) interactions = PostInteraction.get_post_interactions([post], request.identity) return post.to_mastodon_json(interactions=interactions) @@ -134,6 +135,7 @@ def reblog_status(request, id: str): @identity_required def unreblog_status(request, id: str): post = get_object_or_404(Post, pk=id) - post.unboost_as(request.identity) + service = PostService(post) + service.unboost_as(request.identity) interactions = PostInteraction.get_post_interactions([post], request.identity) return post.to_mastodon_json(interactions=interactions) diff --git a/templates/activities/post.html b/templates/activities/post.html index 6205064..ef1bb96 100644 --- a/templates/activities/post.html +++ b/templates/activities/post.html @@ -3,11 +3,11 @@ {% block title %}Post by {{ post.author.html_name_or_handle }}{% endblock %} {% block content %} - {% if parent %} - {% include "activities/_post.html" with post=parent reply=True link_original=False %} - {% endif %} + {% for ancestor in ancestors reversed %} + {% include "activities/_post.html" with post=ancestor reply=True link_original=False %} + {% endfor %} {% include "activities/_post.html" %} - {% for reply in replies %} - {% include "activities/_post.html" with post=reply reply=True link_original=False %} + {% for descendant in descendants %} + {% include "activities/_post.html" with post=descendant reply=True link_original=False %} {% endfor %} {% endblock %} diff --git a/tests/activities/services/test_post.py b/tests/activities/services/test_post.py new file mode 100644 index 0000000..7069de1 --- /dev/null +++ b/tests/activities/services/test_post.py @@ -0,0 +1,37 @@ +import pytest + +from activities.models import Post +from activities.services import PostService +from users.models import Identity + + +@pytest.mark.django_db +def test_post_context(identity: Identity): + """ + Tests that post context fetching works correctly + """ + post1 = Post.create_local( + author=identity, + content="

first

", + visibility=Post.Visibilities.public, + ) + post2 = Post.create_local( + author=identity, + content="

second

", + visibility=Post.Visibilities.public, + reply_to=post1, + ) + post3 = Post.create_local( + author=identity, + content="

third

", + visibility=Post.Visibilities.public, + reply_to=post2, + ) + # Test the view from the start of thread + ancestors, descendants = PostService(post1).context(None) + assert ancestors == [] + assert descendants == [post2, post3] + # Test the view from the end of thread + ancestors, descendants = PostService(post3).context(None) + assert ancestors == [post2, post1] + assert descendants == []