summaryrefslogtreecommitdiffstats
path: root/activities/models
diff options
context:
space:
mode:
authorAndrew Godwin2022-11-15 18:30:30 -0700
committerAndrew Godwin2022-11-15 15:30:32 -0700
commit20e63023bb0d3c7e4cb36b91b73e79f51889cc90 (patch)
tree96c99139f03550e35902440cd321290bc47f8f0f /activities/models
parent4aa92744aea6097ffb784ca7de6bd95cc599988d (diff)
downloadtakahe-20e63023bb0d3c7e4cb36b91b73e79f51889cc90.tar.gz
takahe-20e63023bb0d3c7e4cb36b91b73e79f51889cc90.tar.bz2
takahe-20e63023bb0d3c7e4cb36b91b73e79f51889cc90.zip
Get outbound likes/boosts and their undos working
Diffstat (limited to 'activities/models')
-rw-r--r--activities/models/__init__.py6
-rw-r--r--activities/models/fan_out.py35
-rw-r--r--activities/models/post.py50
-rw-r--r--activities/models/post_interaction.py112
-rw-r--r--activities/models/timeline_event.py17
5 files changed, 195 insertions, 25 deletions
diff --git a/activities/models/__init__.py b/activities/models/__init__.py
index a0680ad..48ba879 100644
--- a/activities/models/__init__.py
+++ b/activities/models/__init__.py
@@ -1,4 +1,4 @@
-from .fan_out import FanOut # noqa
-from .post import Post # noqa
-from .post_interaction import PostInteraction # noqa
+from .fan_out import FanOut, FanOutStates # noqa
+from .post import Post, PostStates # noqa
+from .post_interaction import PostInteraction, PostInteractionStates # noqa
from .timeline_event import TimelineEvent # noqa
diff --git a/activities/models/fan_out.py b/activities/models/fan_out.py
index dbe86c0..771be19 100644
--- a/activities/models/fan_out.py
+++ b/activities/models/fan_out.py
@@ -38,6 +38,40 @@ class FanOutStates(StateGraph):
key_id=post.author.public_key_id,
)
return cls.sent
+ # Handle boosts/likes
+ elif fan_out.type == FanOut.Types.interaction:
+ interaction = await fan_out.subject_post_interaction.afetch_full()
+ if fan_out.identity.local:
+ # Make a timeline event directly
+ await sync_to_async(TimelineEvent.add_post_interaction)(
+ identity=fan_out.identity,
+ interaction=interaction,
+ )
+ else:
+ # Send it to the remote inbox
+ await HttpSignature.signed_request(
+ uri=fan_out.identity.inbox_uri,
+ body=canonicalise(interaction.to_ap()),
+ private_key=interaction.identity.private_key,
+ key_id=interaction.identity.public_key_id,
+ )
+ # 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:
+ # Delete any local timeline events
+ await sync_to_async(TimelineEvent.delete_post_interaction)(
+ identity=fan_out.identity,
+ interaction=interaction,
+ )
+ else:
+ # Send an undo to the remote inbox
+ await HttpSignature.signed_request(
+ uri=fan_out.identity.inbox_uri,
+ body=canonicalise(interaction.to_undo_ap()),
+ private_key=interaction.identity.private_key,
+ key_id=interaction.identity.public_key_id,
+ )
else:
raise ValueError(f"Cannot fan out with type {fan_out.type}")
@@ -50,6 +84,7 @@ class FanOut(StatorModel):
class Types(models.TextChoices):
post = "post"
interaction = "interaction"
+ undo_interaction = "undo_interaction"
state = StateField(FanOutStates)
diff --git a/activities/models/post.py b/activities/models/post.py
index 74b335b..22e6412 100644
--- a/activities/models/post.py
+++ b/activities/models/post.py
@@ -2,7 +2,7 @@ from typing import Dict, Optional
import httpx
import urlman
-from django.db import models
+from django.db import models, transaction
from django.utils import timezone
from activities.models.fan_out import FanOut
@@ -99,7 +99,12 @@ class Post(StatorModel):
class urls(urlman.Urls):
view = "{self.author.urls.view}posts/{self.id}/"
- object_uri = "{self.author.urls.actor}posts/{self.id}/"
+ view_nice = "{self.author.urls.view_nice}posts/{self.id}/"
+ object_uri = "{self.author.actor_uri}posts/{self.id}/"
+ action_like = "{view}like/"
+ action_unlike = "{view}unlike/"
+ action_boost = "{view}boost/"
+ action_unboost = "{view}unboost/"
def get_scheme(self, url):
return "https"
@@ -130,16 +135,17 @@ class Post(StatorModel):
def create_local(
cls, author: Identity, content: str, summary: Optional[str] = None
) -> "Post":
- post = cls.objects.create(
- author=author,
- content=content,
- summary=summary or None,
- sensitive=bool(summary),
- local=True,
- )
- post.object_uri = post.author.actor_uri + f"posts/{post.id}/"
- post.url = post.object_uri
- post.save()
+ with transaction.atomic():
+ post = cls.objects.create(
+ author=author,
+ content=content,
+ summary=summary or None,
+ sensitive=bool(summary),
+ local=True,
+ )
+ post.object_uri = post.urls.object_uri
+ post.url = post.urls.view_nice
+ post.save()
return post
### ActivityPub (outbound) ###
@@ -179,7 +185,7 @@ class Post(StatorModel):
"content": self.safe_content,
"to": "as:Public",
"as:sensitive": self.sensitive,
- "url": self.urls.view.full(), # type: ignore
+ "url": self.urls.view_nice if self.local else self.url,
}
if self.summary:
value["summary"] = self.summary
@@ -257,7 +263,7 @@ class Post(StatorModel):
create=True,
update=True,
)
- raise ValueError(f"Cannot find Post with URI {object_uri}")
+ raise cls.DoesNotExist(f"Cannot find Post with URI {object_uri}")
@classmethod
def handle_create_ap(cls, data):
@@ -275,6 +281,22 @@ class Post(StatorModel):
# Force it into fanned_out as it's not ours
post.transition_perform(PostStates.fanned_out)
+ @classmethod
+ def handle_delete_ap(cls, data):
+ """
+ Handles an incoming create request
+ """
+ # Find our post by ID if we have one
+ try:
+ post = cls.by_object_uri(data["object"]["id"])
+ except cls.DoesNotExist:
+ # It's already been deleted
+ return
+ # Ensure the actor on the request authored the post
+ if not post.author.actor_uri == data["actor"]:
+ raise ValueError("Actor on delete does not match object")
+ post.delete()
+
def debug_fetch(self):
"""
Fetches the Post from its original URL again and updates us with it
diff --git a/activities/models/post_interaction.py b/activities/models/post_interaction.py
index 151ab45..ea95cdf 100644
--- a/activities/models/post_interaction.py
+++ b/activities/models/post_interaction.py
@@ -14,9 +14,13 @@ from users.models.identity import Identity
class PostInteractionStates(StateGraph):
new = State(try_interval=300)
- fanned_out = State()
+ fanned_out = State(externally_progressed=True)
+ undone = State(try_interval=300)
+ undone_fanned_out = State()
new.transitions_to(fanned_out)
+ fanned_out.transitions_to(undone)
+ undone.transitions_to(undone_fanned_out)
@classmethod
async def handle_new(cls, instance: "PostInteraction"):
@@ -31,26 +35,74 @@ class PostInteractionStates(StateGraph):
):
if follow.source.local or follow.target.local:
await FanOut.objects.acreate(
- identity_id=follow.source_id,
type=FanOut.Types.interaction,
- subject_post=interaction,
+ identity_id=follow.source_id,
+ subject_post=interaction.post,
+ subject_post_interaction=interaction,
)
# Like: send a copy to the original post author only
elif interaction.type == interaction.Types.like:
await FanOut.objects.acreate(
- identity_id=interaction.post.author_id,
type=FanOut.Types.interaction,
- subject_post=interaction,
+ identity_id=interaction.post.author_id,
+ subject_post=interaction.post,
+ subject_post_interaction=interaction,
)
else:
raise ValueError("Cannot fan out unknown type")
- # And one for themselves if they're local
- if interaction.identity.local:
+ # And one for themselves if they're local and it's a boost
+ if (
+ interaction.type == PostInteraction.Types.boost
+ and interaction.identity.local
+ ):
await FanOut.objects.acreate(
identity_id=interaction.identity_id,
type=FanOut.Types.interaction,
- subject_post=interaction,
+ subject_post=interaction.post,
+ subject_post_interaction=interaction,
+ )
+ return cls.fanned_out
+
+ @classmethod
+ async def handle_undone(cls, instance: "PostInteraction"):
+ """
+ Creates all needed fan-out objects to undo a PostInteraction.
+ """
+ interaction = await instance.afetch_full()
+ # Undo Boost: send a copy to all people who follow this user
+ if interaction.type == interaction.Types.boost:
+ async for follow in interaction.identity.inbound_follows.select_related(
+ "source", "target"
+ ):
+ if follow.source.local or follow.target.local:
+ await FanOut.objects.acreate(
+ type=FanOut.Types.undo_interaction,
+ identity_id=follow.source_id,
+ subject_post=interaction.post,
+ subject_post_interaction=interaction,
+ )
+ # Undo Like: send a copy to the original post author only
+ elif interaction.type == interaction.Types.like:
+ await FanOut.objects.acreate(
+ type=FanOut.Types.undo_interaction,
+ identity_id=interaction.post.author_id,
+ subject_post=interaction.post,
+ subject_post_interaction=interaction,
+ )
+ else:
+ raise ValueError("Cannot fan out unknown type")
+ # And one for themselves if they're local and it's a boost
+ if (
+ interaction.type == PostInteraction.Types.boost
+ and interaction.identity.local
+ ):
+ await FanOut.objects.acreate(
+ identity_id=interaction.identity_id,
+ type=FanOut.Types.undo_interaction,
+ subject_post=interaction.post,
+ subject_post_interaction=interaction,
)
+ return cls.undone_fanned_out
class PostInteraction(StatorModel):
@@ -95,6 +147,35 @@ class PostInteraction(StatorModel):
class Meta:
index_together = [["type", "identity", "post"]]
+ ### Display helpers ###
+
+ @classmethod
+ def get_post_interactions(cls, posts, identity):
+ """
+ Returns a dict of {interaction_type: set(post_ids)} for all the posts
+ and the given identity, for use in templates.
+ """
+ # Bulk-fetch any interactions
+ ids_with_interaction_type = cls.objects.filter(
+ identity=identity,
+ post_id__in=[post.pk for post in posts],
+ type__in=[cls.Types.like, cls.Types.boost],
+ state__in=[PostInteractionStates.new, PostInteractionStates.fanned_out],
+ ).values_list("post_id", "type")
+ # Make it into the return dict
+ result = {}
+ for post_id, interaction_type in ids_with_interaction_type:
+ result.setdefault(interaction_type, set()).add(post_id)
+ return result
+
+ @classmethod
+ def get_event_interactions(cls, events, identity):
+ """
+ Returns a dict of {interaction_type: set(post_ids)} for all the posts
+ within the events and the given identity, for use in templates.
+ """
+ return cls.get_post_interactions([e.subject_post for e in events], identity)
+
### Async helpers ###
async def afetch_full(self):
@@ -111,6 +192,9 @@ class PostInteraction(StatorModel):
"""
Returns the AP JSON for this object
"""
+ # Create an object URI if we don't have one
+ if self.object_uri is None:
+ self.object_uri = self.identity.actor_uri + f"#{self.type}/{self.id}"
if self.type == self.Types.boost:
value = {
"type": "Announce",
@@ -132,6 +216,18 @@ class PostInteraction(StatorModel):
raise ValueError("Cannot turn into AP")
return value
+ def to_undo_ap(self) -> Dict:
+ """
+ Returns the AP JSON to undo this object
+ """
+ object = self.to_ap()
+ return {
+ "id": object["id"] + "/undo",
+ "type": "Undo",
+ "actor": self.identity.actor_uri,
+ "object": object,
+ }
+
### ActivityPub (inbound) ###
@classmethod
diff --git a/activities/models/timeline_event.py b/activities/models/timeline_event.py
index 6dba32c..29dec19 100644
--- a/activities/models/timeline_event.py
+++ b/activities/models/timeline_event.py
@@ -114,3 +114,20 @@ class TimelineEvent(models.Model):
subject_identity_id=interaction.identity_id,
subject_post_interaction=interaction,
)[0]
+
+ @classmethod
+ def delete_post_interaction(cls, identity, interaction):
+ if interaction.type == interaction.Types.like:
+ cls.objects.filter(
+ identity=identity,
+ type=cls.Types.liked,
+ subject_post_id=interaction.post_id,
+ subject_identity_id=interaction.identity_id,
+ ).delete()
+ elif interaction.type == interaction.Types.boost:
+ cls.objects.filter(
+ identity=identity,
+ type__in=[cls.Types.boosted, cls.Types.boost],
+ subject_post_id=interaction.post_id,
+ subject_identity_id=interaction.identity_id,
+ ).delete()