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.filter(in_reply_to__isnull=True)
|
||||||
return query
|
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):
|
def tagged_with(self, hashtag: str | Hashtag):
|
||||||
if isinstance(hashtag, str):
|
if isinstance(hashtag, str):
|
||||||
tag_q = models.Q(hashtags__contains=hashtag)
|
tag_q = models.Q(hashtags__contains=hashtag)
|
||||||
|
@ -526,48 +548,6 @@ class Post(StatorModel):
|
||||||
hashtag=hashtag,
|
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) ###
|
### ActivityPub (outbound) ###
|
||||||
|
|
||||||
def to_ap(self) -> dict:
|
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.core.exceptions import PermissionDenied
|
||||||
from django.db import models
|
|
||||||
from django.http import Http404, JsonResponse
|
from django.http import Http404, JsonResponse
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
from django.views.decorators.vary import vary_on_headers
|
from django.views.decorators.vary import vary_on_headers
|
||||||
from django.views.generic import TemplateView, View
|
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.decorators import cache_page_by_ap_json
|
||||||
from core.ld import canonicalise
|
from core.ld import canonicalise
|
||||||
from users.decorators import identity_required
|
from users.decorators import identity_required
|
||||||
from users.models import Identity
|
|
||||||
from users.shortcuts import by_handle_or_404
|
from users.shortcuts import by_handle_or_404
|
||||||
|
|
||||||
|
|
||||||
|
@ -38,44 +37,19 @@ class Individual(TemplateView):
|
||||||
return super().get(request)
|
return super().get(request)
|
||||||
|
|
||||||
def get_context_data(self):
|
def get_context_data(self):
|
||||||
parent = None
|
ancestors, descendants = PostService(self.post_obj).context(
|
||||||
if self.post_obj.in_reply_to:
|
self.request.identity
|
||||||
try:
|
)
|
||||||
parent = Post.by_object_uri(self.post_obj.in_reply_to, fetch=True)
|
|
||||||
except Post.DoesNotExist:
|
|
||||||
pass
|
|
||||||
return {
|
return {
|
||||||
"identity": self.identity,
|
"identity": self.identity,
|
||||||
"post": self.post_obj,
|
"post": self.post_obj,
|
||||||
"interactions": PostInteraction.get_post_interactions(
|
"interactions": PostInteraction.get_post_interactions(
|
||||||
[self.post_obj],
|
[self.post_obj] + ancestors + descendants,
|
||||||
self.request.identity,
|
self.request.identity,
|
||||||
),
|
),
|
||||||
"link_original": True,
|
"link_original": True,
|
||||||
"parent": parent,
|
"ancestors": ancestors,
|
||||||
"replies": Post.objects.filter(
|
"descendants": descendants,
|
||||||
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"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def serve_object(self):
|
def serve_object(self):
|
||||||
|
@ -101,10 +75,11 @@ class Like(View):
|
||||||
post = get_object_or_404(
|
post = get_object_or_404(
|
||||||
identity.posts.prefetch_related("attachments"), pk=post_id
|
identity.posts.prefetch_related("attachments"), pk=post_id
|
||||||
)
|
)
|
||||||
|
service = PostService(post)
|
||||||
if self.undo:
|
if self.undo:
|
||||||
post.unlike_as(self.request.identity)
|
service.unlike_as(self.request.identity)
|
||||||
else:
|
else:
|
||||||
post.like_as(self.request.identity)
|
service.like_as(self.request.identity)
|
||||||
# Return either a redirect or a HTMX snippet
|
# Return either a redirect or a HTMX snippet
|
||||||
if request.htmx:
|
if request.htmx:
|
||||||
return render(
|
return render(
|
||||||
|
@ -129,10 +104,11 @@ class Boost(View):
|
||||||
def post(self, request, handle, post_id):
|
def post(self, request, handle, post_id):
|
||||||
identity = by_handle_or_404(self.request, handle, local=False)
|
identity = by_handle_or_404(self.request, handle, local=False)
|
||||||
post = get_object_or_404(identity.posts, pk=post_id)
|
post = get_object_or_404(identity.posts, pk=post_id)
|
||||||
|
service = PostService(post)
|
||||||
if self.undo:
|
if self.undo:
|
||||||
post.unboost_as(request.identity)
|
service.unboost_as(request.identity)
|
||||||
else:
|
else:
|
||||||
post.boost_as(request.identity)
|
service.boost_as(request.identity)
|
||||||
# Return either a redirect or a HTMX snippet
|
# Return either a redirect or a HTMX snippet
|
||||||
if request.htmx:
|
if request.htmx:
|
||||||
return render(
|
return render(
|
||||||
|
|
|
@ -11,6 +11,7 @@ from activities.models import (
|
||||||
PostStates,
|
PostStates,
|
||||||
TimelineEvent,
|
TimelineEvent,
|
||||||
)
|
)
|
||||||
|
from activities.services import PostService
|
||||||
from api import schemas
|
from api import schemas
|
||||||
from api.views.base import api_router
|
from api.views.base import api_router
|
||||||
from core.models import Config
|
from core.models import Config
|
||||||
|
@ -87,13 +88,10 @@ def delete_status(request, id: str):
|
||||||
@identity_required
|
@identity_required
|
||||||
def status_context(request, id: str):
|
def status_context(request, id: str):
|
||||||
post = get_object_or_404(Post, pk=id)
|
post = get_object_or_404(Post, pk=id)
|
||||||
parent = post.in_reply_to_post()
|
service = PostService(post)
|
||||||
ancestors = []
|
ancestors, descendants = service.context(request.identity)
|
||||||
if parent:
|
|
||||||
ancestors.append(parent)
|
|
||||||
descendants = list(Post.objects.filter(in_reply_to=post.object_uri)[:40])
|
|
||||||
interactions = PostInteraction.get_post_interactions(
|
interactions = PostInteraction.get_post_interactions(
|
||||||
[post] + ancestors + descendants, request.identity
|
ancestors + descendants, request.identity
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"ancestors": [p.to_mastodon_json(interactions=interactions) for p in ancestors],
|
"ancestors": [p.to_mastodon_json(interactions=interactions) for p in ancestors],
|
||||||
|
@ -107,7 +105,8 @@ def status_context(request, id: str):
|
||||||
@identity_required
|
@identity_required
|
||||||
def favourite_status(request, id: str):
|
def favourite_status(request, id: str):
|
||||||
post = get_object_or_404(Post, pk=id)
|
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)
|
interactions = PostInteraction.get_post_interactions([post], request.identity)
|
||||||
return post.to_mastodon_json(interactions=interactions)
|
return post.to_mastodon_json(interactions=interactions)
|
||||||
|
|
||||||
|
@ -116,7 +115,8 @@ def favourite_status(request, id: str):
|
||||||
@identity_required
|
@identity_required
|
||||||
def unfavourite_status(request, id: str):
|
def unfavourite_status(request, id: str):
|
||||||
post = get_object_or_404(Post, pk=id)
|
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)
|
interactions = PostInteraction.get_post_interactions([post], request.identity)
|
||||||
return post.to_mastodon_json(interactions=interactions)
|
return post.to_mastodon_json(interactions=interactions)
|
||||||
|
|
||||||
|
@ -125,7 +125,8 @@ def unfavourite_status(request, id: str):
|
||||||
@identity_required
|
@identity_required
|
||||||
def reblog_status(request, id: str):
|
def reblog_status(request, id: str):
|
||||||
post = get_object_or_404(Post, pk=id)
|
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)
|
interactions = PostInteraction.get_post_interactions([post], request.identity)
|
||||||
return post.to_mastodon_json(interactions=interactions)
|
return post.to_mastodon_json(interactions=interactions)
|
||||||
|
|
||||||
|
@ -134,6 +135,7 @@ def reblog_status(request, id: str):
|
||||||
@identity_required
|
@identity_required
|
||||||
def unreblog_status(request, id: str):
|
def unreblog_status(request, id: str):
|
||||||
post = get_object_or_404(Post, pk=id)
|
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)
|
interactions = PostInteraction.get_post_interactions([post], request.identity)
|
||||||
return post.to_mastodon_json(interactions=interactions)
|
return post.to_mastodon_json(interactions=interactions)
|
||||||
|
|
|
@ -3,11 +3,11 @@
|
||||||
{% block title %}Post by {{ post.author.html_name_or_handle }}{% endblock %}
|
{% block title %}Post by {{ post.author.html_name_or_handle }}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% if parent %}
|
{% for ancestor in ancestors reversed %}
|
||||||
{% include "activities/_post.html" with post=parent reply=True link_original=False %}
|
{% include "activities/_post.html" with post=ancestor reply=True link_original=False %}
|
||||||
{% endif %}
|
{% endfor %}
|
||||||
{% include "activities/_post.html" %}
|
{% include "activities/_post.html" %}
|
||||||
{% for reply in replies %}
|
{% for descendant in descendants %}
|
||||||
{% include "activities/_post.html" with post=reply reply=True link_original=False %}
|
{% include "activities/_post.html" with post=descendant reply=True link_original=False %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endblock %}
|
{% 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