mirror of
https://github.com/jointakahe/takahe.git
synced 2024-11-25 16:51:00 +00:00
parent
e28294c81a
commit
9067caf9a3
7 changed files with 185 additions and 95 deletions
|
@ -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:
|
||||
|
|
1
activities/services/__init__.py
Normal file
1
activities/services/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
from .post import PostService # noqa
|
94
activities/services/post.py
Normal file
94
activities/services/post.py
Normal file
|
@ -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
|
|
@ -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(
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 %}
|
||||
|
|
37
tests/activities/services/test_post.py
Normal file
37
tests/activities/services/test_post.py
Normal file
|
@ -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="<p>first</p>",
|
||||
visibility=Post.Visibilities.public,
|
||||
)
|
||||
post2 = Post.create_local(
|
||||
author=identity,
|
||||
content="<p>second</p>",
|
||||
visibility=Post.Visibilities.public,
|
||||
reply_to=post1,
|
||||
)
|
||||
post3 = Post.create_local(
|
||||
author=identity,
|
||||
content="<p>third</p>",
|
||||
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 == []
|
Loading…
Reference in a new issue