summaryrefslogtreecommitdiffstats
path: root/activities
diff options
context:
space:
mode:
Diffstat (limited to 'activities')
-rw-r--r--activities/__init__.py0
-rw-r--r--activities/admin.py15
-rw-r--r--activities/apps.py6
-rw-r--r--activities/migrations/0001_initial.py155
-rw-r--r--activities/migrations/__init__.py0
-rw-r--r--activities/models/__init__.py2
-rw-r--r--activities/models/post.py161
-rw-r--r--activities/models/timeline_event.py85
-rw-r--r--activities/views/__init__.py0
-rw-r--r--activities/views/home.py42
10 files changed, 466 insertions, 0 deletions
diff --git a/activities/__init__.py b/activities/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/activities/__init__.py
diff --git a/activities/admin.py b/activities/admin.py
new file mode 100644
index 0000000..8b27951
--- /dev/null
+++ b/activities/admin.py
@@ -0,0 +1,15 @@
+from django.contrib import admin
+
+from activities.models import Post, TimelineEvent
+
+
+@admin.register(Post)
+class PostAdmin(admin.ModelAdmin):
+ list_display = ["id", "author", "created"]
+ raw_id_fields = ["to", "mentions"]
+
+
+@admin.register(TimelineEvent)
+class TimelineEventAdmin(admin.ModelAdmin):
+ list_display = ["id", "identity", "created", "type"]
+ raw_id_fields = ["identity", "subject_post", "subject_identity"]
diff --git a/activities/apps.py b/activities/apps.py
new file mode 100644
index 0000000..cffeee3
--- /dev/null
+++ b/activities/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class ActivitiesConfig(AppConfig):
+ default_auto_field = "django.db.models.BigAutoField"
+ name = "activities"
diff --git a/activities/migrations/0001_initial.py b/activities/migrations/0001_initial.py
new file mode 100644
index 0000000..a97146d
--- /dev/null
+++ b/activities/migrations/0001_initial.py
@@ -0,0 +1,155 @@
+# Generated by Django 4.1.3 on 2022-11-11 20:02
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+import activities.models.post
+import stator.models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ("users", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="Post",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("state_ready", models.BooleanField(default=False)),
+ ("state_changed", models.DateTimeField(auto_now_add=True)),
+ ("state_attempted", models.DateTimeField(blank=True, null=True)),
+ ("state_locked_until", models.DateTimeField(blank=True, null=True)),
+ (
+ "state",
+ stator.models.StateField(
+ choices=[("new", "new"), ("fanned_out", "fanned_out")],
+ default="new",
+ graph=activities.models.post.PostStates,
+ max_length=100,
+ ),
+ ),
+ ("local", models.BooleanField()),
+ ("object_uri", models.CharField(blank=True, max_length=500, null=True)),
+ (
+ "visibility",
+ models.IntegerField(
+ choices=[
+ (0, "Public"),
+ (1, "Unlisted"),
+ (2, "Followers"),
+ (3, "Mentioned"),
+ ],
+ default=0,
+ ),
+ ),
+ ("content", models.TextField()),
+ ("sensitive", models.BooleanField(default=False)),
+ ("summary", models.TextField(blank=True, null=True)),
+ ("url", models.CharField(blank=True, max_length=500, null=True)),
+ (
+ "in_reply_to",
+ models.CharField(blank=True, max_length=500, null=True),
+ ),
+ ("created", models.DateTimeField(auto_now_add=True)),
+ ("updated", models.DateTimeField(auto_now=True)),
+ (
+ "author",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="statuses",
+ to="users.identity",
+ ),
+ ),
+ (
+ "mentions",
+ models.ManyToManyField(
+ related_name="posts_mentioning", to="users.identity"
+ ),
+ ),
+ (
+ "to",
+ models.ManyToManyField(
+ related_name="posts_to", to="users.identity"
+ ),
+ ),
+ ],
+ options={
+ "abstract": False,
+ },
+ ),
+ migrations.CreateModel(
+ name="TimelineEvent",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "type",
+ models.CharField(
+ choices=[
+ ("post", "Post"),
+ ("mention", "Mention"),
+ ("like", "Like"),
+ ("follow", "Follow"),
+ ("boost", "Boost"),
+ ],
+ max_length=100,
+ ),
+ ),
+ ("created", models.DateTimeField(auto_now_add=True)),
+ (
+ "identity",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="timeline_events",
+ to="users.identity",
+ ),
+ ),
+ (
+ "subject_identity",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="timeline_events_about_us",
+ to="users.identity",
+ ),
+ ),
+ (
+ "subject_post",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="timeline_events_about_us",
+ to="activities.post",
+ ),
+ ),
+ ],
+ options={
+ "index_together": {
+ ("identity", "type", "subject_post", "subject_identity"),
+ ("identity", "type", "subject_identity"),
+ },
+ },
+ ),
+ ]
diff --git a/activities/migrations/__init__.py b/activities/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/activities/migrations/__init__.py
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]
diff --git a/activities/views/__init__.py b/activities/views/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/activities/views/__init__.py
diff --git a/activities/views/home.py b/activities/views/home.py
new file mode 100644
index 0000000..867856d
--- /dev/null
+++ b/activities/views/home.py
@@ -0,0 +1,42 @@
+from django import forms
+from django.shortcuts import redirect
+from django.template.defaultfilters import linebreaks_filter
+from django.utils.decorators import method_decorator
+from django.views.generic import FormView
+
+from activities.models import Post, TimelineEvent
+from core.forms import FormHelper
+from users.decorators import identity_required
+
+
+@method_decorator(identity_required, name="dispatch")
+class Home(FormView):
+
+ template_name = "activities/home.html"
+
+ class form_class(forms.Form):
+ text = forms.CharField()
+
+ helper = FormHelper(submit_text="Post")
+
+ def get_context_data(self):
+ context = super().get_context_data()
+ context.update(
+ {
+ "timeline_posts": [
+ te.subject_post
+ for te in TimelineEvent.objects.filter(
+ identity=self.request.identity,
+ type=TimelineEvent.Types.post,
+ ).order_by("-created")[:100]
+ ],
+ }
+ )
+ return context
+
+ def form_valid(self, form):
+ Post.create_local(
+ author=self.request.identity,
+ content=linebreaks_filter(form.cleaned_data["text"]),
+ )
+ return redirect(".")