diff options
Diffstat (limited to 'activities/models')
-rw-r--r-- | activities/models/__init__.py | 2 | ||||
-rw-r--r-- | activities/models/post.py | 161 | ||||
-rw-r--r-- | activities/models/timeline_event.py | 85 |
3 files changed, 248 insertions, 0 deletions
diff --git a/activities/models/__init__.py b/activities/models/__init__.py new file mode 100644 index 0000000..b74075f --- /dev/null +++ b/activities/models/__init__.py @@ -0,0 +1,2 @@ +from .post import Post # noqa +from .timeline_event import TimelineEvent # noqa diff --git a/activities/models/post.py b/activities/models/post.py new file mode 100644 index 0000000..f4d159f --- /dev/null +++ b/activities/models/post.py @@ -0,0 +1,161 @@ +import urlman +from django.db import models + +from activities.models.timeline_event import TimelineEvent +from core.html import sanitize_post +from stator.models import State, StateField, StateGraph, StatorModel +from users.models.follow import Follow +from users.models.identity import Identity + + +class PostStates(StateGraph): + new = State(try_interval=300) + fanned_out = State() + + new.transitions_to(fanned_out) + + @classmethod + async def handle_new(cls, instance: "Post"): + """ + Creates all needed fan-out objects for a new Post. + """ + pass + + +class Post(StatorModel): + """ + A post (status, toot) that is either local or remote. + """ + + class Visibilities(models.IntegerChoices): + public = 0 + unlisted = 1 + followers = 2 + mentioned = 3 + + # The author (attributedTo) of the post + author = models.ForeignKey( + "users.Identity", + on_delete=models.PROTECT, + related_name="posts", + ) + + # The state the post is in + state = StateField(PostStates) + + # If it is our post or not + local = models.BooleanField() + + # The canonical object ID + object_uri = models.CharField(max_length=500, blank=True, null=True) + + # Who should be able to see this Post + visibility = models.IntegerField( + choices=Visibilities.choices, + default=Visibilities.public, + ) + + # The main (HTML) content + content = models.TextField() + + # If the contents of the post are sensitive, and the summary (content + # warning) to show if it is + sensitive = models.BooleanField(default=False) + summary = models.TextField(blank=True, null=True) + + # The public, web URL of this Post on the original server + url = models.CharField(max_length=500, blank=True, null=True) + + # The Post it is replying to as an AP ID URI + # (as otherwise we'd have to pull entire threads to use IDs) + in_reply_to = models.CharField(max_length=500, blank=True, null=True) + + # The identities the post is directly to (who can see it if not public) + to = models.ManyToManyField( + "users.Identity", + related_name="posts_to", + blank=True, + ) + + # The identities mentioned in the post + mentions = models.ManyToManyField( + "users.Identity", + related_name="posts_mentioning", + blank=True, + ) + + created = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) + + class urls(urlman.Urls): + view = "{self.identity.urls.view}posts/{self.id}/" + + def __str__(self): + return f"{self.author} #{self.id}" + + @property + def safe_content(self): + return sanitize_post(self.content) + + ### Local creation ### + + @classmethod + def create_local(cls, author: Identity, content: str) -> "Post": + post = cls.objects.create( + author=author, + content=content, + local=True, + ) + post.object_uri = post.author.actor_uri + f"posts/{post.id}/" + post.url = post.object_uri + post.save() + return post + + ### ActivityPub (outgoing) ### + + ### ActivityPub (incoming) ### + + @classmethod + def by_ap(cls, data, create=False) -> "Post": + """ + Retrieves a Post instance by its ActivityPub JSON object. + + Optionally creates one if it's not present. + Raises KeyError if it's not found and create is False. + """ + # Do we have one with the right ID? + try: + return cls.objects.get(object_uri=data["id"]) + except cls.DoesNotExist: + if create: + # Resolve the author + author = Identity.by_actor_uri(data["attributedTo"], create=create) + return cls.objects.create( + author=author, + content=sanitize_post(data["content"]), + summary=data.get("summary", None), + sensitive=data.get("as:sensitive", False), + url=data.get("url", None), + local=False, + # TODO: to + # TODO: mentions + # TODO: visibility + ) + else: + raise KeyError(f"No post with ID {data['id']}", data) + + @classmethod + def handle_create_ap(cls, data): + """ + Handles an incoming create request + """ + # Ensure the Create actor is the Post's attributedTo + if data["actor"] != data["object"]["attributedTo"]: + raise ValueError("Create actor does not match its Post object", data) + # Create it + post = cls.by_ap(data["object"], create=True) + # Make timeline events as appropriate + for follow in Follow.objects.filter(target=post.author, source__local=True): + TimelineEvent.add_post(follow.source, post) + # Force it into fanned_out as it's not ours + post.transition_perform(PostStates.fanned_out) diff --git a/activities/models/timeline_event.py b/activities/models/timeline_event.py new file mode 100644 index 0000000..43fc458 --- /dev/null +++ b/activities/models/timeline_event.py @@ -0,0 +1,85 @@ +from django.db import models + + +class TimelineEvent(models.Model): + """ + Something that has happened to an identity that we want them to see on one + or more timelines, like posts, likes and follows. + """ + + class Types(models.TextChoices): + post = "post" + mention = "mention" + like = "like" + follow = "follow" + boost = "boost" + + # The user this event is for + identity = models.ForeignKey( + "users.Identity", + on_delete=models.CASCADE, + related_name="timeline_events", + ) + + # What type of event it is + type = models.CharField(max_length=100, choices=Types.choices) + + # The subject of the event (which is used depends on the type) + subject_post = models.ForeignKey( + "activities.Post", + on_delete=models.CASCADE, + blank=True, + null=True, + related_name="timeline_events_about_us", + ) + subject_identity = models.ForeignKey( + "users.Identity", + on_delete=models.CASCADE, + blank=True, + null=True, + related_name="timeline_events_about_us", + ) + + created = models.DateTimeField(auto_now_add=True) + + class Meta: + index_together = [ + # This relies on a DB that can use left subsets of indexes + ("identity", "type", "subject_post", "subject_identity"), + ("identity", "type", "subject_identity"), + ] + + ### Alternate constructors ### + + @classmethod + def add_follow(cls, identity, source_identity): + """ + Adds a follow to the timeline if it's not there already + """ + return cls.objects.get_or_create( + identity=identity, + type=cls.Types.follow, + subject_identity=source_identity, + )[0] + + @classmethod + def add_post(cls, identity, post): + """ + Adds a post to the timeline if it's not there already + """ + return cls.objects.get_or_create( + identity=identity, + type=cls.Types.post, + subject_post=post, + )[0] + + @classmethod + def add_like(cls, identity, post): + """ + Adds a like to the timeline if it's not there already + """ + return cls.objects.get_or_create( + identity=identity, + type=cls.Types.like, + subject_post=post, + )[0] |