From feb5d9b74fa1e8454eaaf29afae3643c6d7c81f1 Mon Sep 17 00:00:00 2001 From: Andrew Godwin Date: Fri, 11 Nov 2022 22:02:43 -0700 Subject: Got up to incoming posts working --- activities/__init__.py | 0 activities/admin.py | 15 ++++ activities/apps.py | 6 ++ activities/migrations/0001_initial.py | 155 ++++++++++++++++++++++++++++++++ activities/migrations/__init__.py | 0 activities/models/__init__.py | 2 + activities/models/post.py | 161 ++++++++++++++++++++++++++++++++++ activities/models/timeline_event.py | 85 ++++++++++++++++++ activities/views/__init__.py | 0 activities/views/home.py | 42 +++++++++ 10 files changed, 466 insertions(+) create mode 100644 activities/__init__.py create mode 100644 activities/admin.py create mode 100644 activities/apps.py create mode 100644 activities/migrations/0001_initial.py create mode 100644 activities/migrations/__init__.py create mode 100644 activities/models/__init__.py create mode 100644 activities/models/post.py create mode 100644 activities/models/timeline_event.py create mode 100644 activities/views/__init__.py create mode 100644 activities/views/home.py (limited to 'activities') diff --git a/activities/__init__.py b/activities/__init__.py new file mode 100644 index 0000000..e69de29 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 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 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(".") -- cgit v1.2.3