summaryrefslogtreecommitdiffstats
path: root/activities/models
diff options
context:
space:
mode:
Diffstat (limited to 'activities/models')
-rw-r--r--activities/models/__init__.py2
-rw-r--r--activities/models/post.py161
-rw-r--r--activities/models/timeline_event.py85
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]