From a408cbaa276aa281aa041e69cd74891027f6212a Mon Sep 17 00:00:00 2001 From: Michael Manfre Date: Sun, 18 Dec 2022 11:09:25 -0500 Subject: [PATCH] Post types (#182) Initial support for Posts of type other than 'Note'. Render special Post types with templates. --- activities/admin.py | 4 +- .../0005_post_type_post_type_data.py | 36 +++++++ activities/models/post.py | 96 ++++++++++++++++--- activities/models/post_types.py | 69 +++++++++++++ static/css/style.css | 30 ++++++ templates/activities/_type_question.html | 22 +++++ templates/activities/_type_unknown.html | 5 + tests/activities/models/test_post_types.py | 69 +++++++++++++ users/models/inbox_message.py | 18 ++-- 9 files changed, 330 insertions(+), 19 deletions(-) create mode 100644 activities/migrations/0005_post_type_post_type_data.py create mode 100644 activities/models/post_types.py create mode 100644 templates/activities/_type_question.html create mode 100644 templates/activities/_type_unknown.html create mode 100644 tests/activities/models/test_post_types.py diff --git a/activities/admin.py b/activities/admin.py index ba8858b..023c0e3 100644 --- a/activities/admin.py +++ b/activities/admin.py @@ -104,8 +104,8 @@ class PostAttachmentInline(admin.StackedInline): @admin.register(Post) class PostAdmin(admin.ModelAdmin): - list_display = ["id", "state", "author", "created"] - list_filter = ("local", "visibility", "state", "created") + list_display = ["id", "type", "author", "state", "created"] + list_filter = ("type", "local", "visibility", "state", "created") raw_id_fields = ["to", "mentions", "author", "emojis"] actions = ["force_fetch", "reparse_hashtags"] search_fields = ["content"] diff --git a/activities/migrations/0005_post_type_post_type_data.py b/activities/migrations/0005_post_type_post_type_data.py new file mode 100644 index 0000000..ca53677 --- /dev/null +++ b/activities/migrations/0005_post_type_post_type_data.py @@ -0,0 +1,36 @@ +# Generated by Django 4.1.4 on 2022-12-16 02:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("activities", "0004_emoji_post_emojis"), + ] + + operations = [ + migrations.AddField( + model_name="post", + name="type", + field=models.CharField( + choices=[ + ("Article", "Article"), + ("Audio", "Audio"), + ("Event", "Event"), + ("Image", "Image"), + ("Note", "Note"), + ("Page", "Page"), + ("Question", "Question"), + ("Video", "Video"), + ], + default="Note", + max_length=20, + ), + ), + migrations.AddField( + model_name="post", + name="type_data", + field=models.JSONField(blank=True, null=True), + ), + ] diff --git a/activities/models/post.py b/activities/models/post.py index 6bd5040..63f618e 100644 --- a/activities/models/post.py +++ b/activities/models/post.py @@ -8,6 +8,7 @@ from asgiref.sync import async_to_sync, sync_to_async from django.conf import settings from django.contrib.postgres.indexes import GinIndex from django.db import models, transaction +from django.template import loader from django.template.defaultfilters import linebreaks_filter from django.utils import timezone from django.utils.safestring import mark_safe @@ -15,6 +16,11 @@ from django.utils.safestring import mark_safe from activities.models.emoji import Emoji from activities.models.fan_out import FanOut from activities.models.hashtag import Hashtag +from activities.models.post_types import ( + PostTypeData, + PostTypeDataDecoder, + PostTypeDataEncoder, +) from activities.templatetags.emoji_tags import imageify_emojis from core.html import sanitize_post, strip_html from core.ld import canonicalise, format_ld_date, get_list, parse_ld_date @@ -166,6 +172,16 @@ class Post(StatorModel): followers = 2 mentioned = 3 + class Types(models.TextChoices): + article = "Article" + audio = "Audio" + event = "Event" + image = "Image" + note = "Note" + page = "Page" + question = "Question" + video = "Video" + # The author (attributedTo) of the post author = models.ForeignKey( "users.Identity", @@ -191,6 +207,15 @@ class Post(StatorModel): # The main (HTML) content content = models.TextField() + type = models.CharField( + max_length=20, + choices=Types.choices, + default=Types.note, + ) + type_data = models.JSONField( + blank=True, null=True, encoder=PostTypeDataEncoder, decoder=PostTypeDataDecoder + ) + # If the contents of the post are sensitive, and the summary (content # warning) to show if it is sensitive = models.BooleanField(default=False) @@ -292,6 +317,8 @@ class Post(StatorModel): ain_reply_to_post = sync_to_async(in_reply_to_post) ### Content cleanup and extraction ### + def clean_type_data(self, value): + PostTypeData.parse_obj(value) mention_regex = re.compile( r"(^|[^\w\d\-_])@([\w\d\-_]+(?:@[\w\d\-_]+\.[\w\d\-_\.]+)?)" @@ -333,25 +360,55 @@ class Post(StatorModel): return mark_safe(self.mention_regex.sub(replacer, content)) + def _safe_content_note(self, *, local: bool = True): + content = Hashtag.linkify_hashtags( + self.linkify_mentions(sanitize_post(self.content), local=local), + domain=self.author.domain, + ) + if local: + content = imageify_emojis(content, self.author.domain) + return content + + # def _safe_content_question(self, *, local: bool = True): + # context = { + # "post": self, + # "typed_data": PostTypeData(self.type_data), + # } + # return loader.render_to_string("activities/_type_question.html", context) + + def _safe_content_typed(self, *, local: bool = True): + context = { + "post": self, + "sanitized_content": self._safe_content_note(local=local), + "local_display": local, + } + return loader.render_to_string( + ( + f"activities/_type_{self.type.lower()}.html", + "activities/_type_unknown.html", + ), + context, + ) + + def safe_content(self, *, local: bool = True): + func = getattr( + self, f"_safe_content_{self.type.lower()}", self._safe_content_typed + ) + if callable(func): + return func(local=local) + return self._safe_content_note(local=local) # fallback + def safe_content_local(self): """ Returns the content formatted for local display """ - return imageify_emojis( - Hashtag.linkify_hashtags( - self.linkify_mentions(sanitize_post(self.content), local=True) - ), - self.author.domain, - ) + return self.safe_content(local=True) def safe_content_remote(self): """ Returns the content formatted for remote consumption """ - return Hashtag.linkify_hashtags( - self.linkify_mentions(sanitize_post(self.content)), - domain=self.author.domain, - ) + return self.safe_content(local=False) def safe_content_plain(self): """ @@ -521,7 +578,7 @@ class Post(StatorModel): value = { "to": "Public", "cc": [], - "type": "Note", + "type": self.type, "id": self.object_uri, "published": format_ld_date(self.published), "attributedTo": self.author.actor_uri, @@ -531,6 +588,18 @@ class Post(StatorModel): "tag": [], "attachment": [], } + if self.type == Post.Types.question and self.type_data: + value[self.type_data.mode] = [ + { + "name": option.name, + "type": option.type, + "replies": {"type": "Collection", "totalItems": option.votes}, + } + for option in self.type_data.options + ] + value["toot:votersCount"] = self.type_data.voter_count + if self.type_data.end_time: + value["endTime"] = format_ld_date(self.type_data.end_time) if self.summary: value["summary"] = self.summary if self.in_reply_to: @@ -660,11 +729,16 @@ class Post(StatorModel): author=author, content=data["content"], local=False, + type=data["type"], ) created = True else: raise cls.DoesNotExist(f"No post with ID {data['id']}", data) if update or created: + post.type = data["type"] + if post.type in (cls.Types.article, cls.Types.question): + type_data = PostTypeData(__root__=data).__root__ + post.type_data = type_data.dict() post.content = data["content"] post.summary = data.get("summary") post.sensitive = data.get("sensitive", False) diff --git a/activities/models/post_types.py b/activities/models/post_types.py new file mode 100644 index 0000000..3450df9 --- /dev/null +++ b/activities/models/post_types.py @@ -0,0 +1,69 @@ +import json +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, Field + + +class BasePostDataType(BaseModel): + pass + + +class QuestionOption(BaseModel): + name: str + type: Literal["Note"] = "Note" + votes: int = 0 + + +class QuestionData(BasePostDataType): + type: Literal["Question"] + mode: Literal["oneOf", "anyOf"] | None = None + options: list[QuestionOption] | None + voter_count: int = Field(alias="http://joinmastodon.org/ns#votersCount", default=0) + end_time: datetime | None = Field(alias="endTime") + + class Config: + extra = "ignore" + allow_population_by_field_name = True + + def __init__(self, **data) -> None: + if "mode" not in data: + data["mode"] = "anyOf" if "anyOf" in data else "oneOf" + if "options" not in data: + options = data.pop("anyOf", None) + if not options: + options = data.pop("oneOf", None) + data["options"] = options + super().__init__(**data) + + +class ArticleData(BasePostDataType): + type: Literal["Article"] + attributed_to: str | None = Field(...) + + class Config: + extra = "ignore" + + +PostDataType = QuestionData | ArticleData + + +class PostTypeData(BaseModel): + __root__: PostDataType = Field(discriminator="type") + + +class PostTypeDataEncoder(json.JSONEncoder): + def default(self, obj): + if isinstance(obj, BasePostDataType): + return obj.dict() + elif isinstance(obj, datetime): + return obj.isoformat() + return json.JSONEncoder.default(self, obj) + + +class PostTypeDataDecoder(json.JSONDecoder): + def decode(self, *args, **kwargs): + s = super().decode(*args, **kwargs) + if isinstance(s, dict): + return PostTypeData.parse_obj(s).__root__ + return s diff --git a/static/css/style.css b/static/css/style.css index 68efbdc..749b7b6 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1087,6 +1087,36 @@ table.metadata td .emoji { text-decoration: underline; } +/* Special Type Posts */ +.post .notice a:hover { + text-decoration: underline; +} + +.post .poll h3 small { + font-weight: lighter; + font-size: small; +} +.post .poll ul { + list-style: none; +} + +.post .poll li { + padding: 6px 0; + line-height: 18px; +} +.poll-number { + display: inline-block; + width: 45px; +} +.poll-footer { + padding: 6px 0 6px; + font-size: 12px; +} +.post .poll ul li .votes { + margin-left: 10px; + font-size: small; +} + .boost-banner, .mention-banner, .follow-banner, diff --git a/templates/activities/_type_question.html b/templates/activities/_type_question.html new file mode 100644 index 0000000..1b7211a --- /dev/null +++ b/templates/activities/_type_question.html @@ -0,0 +1,22 @@ +{% load activity_tags %} + +{{ sanitized_content }} + +
+

Options: {% if post.type_data.mode == "oneOf" %}(choose one){% endif %}

+{% for item in post.type_data.options %} +{% if forloop.first %}{% endif %} +{% endfor %} + +
diff --git a/templates/activities/_type_unknown.html b/templates/activities/_type_unknown.html new file mode 100644 index 0000000..70d2a0c --- /dev/null +++ b/templates/activities/_type_unknown.html @@ -0,0 +1,5 @@ +{{ sanitized_content }} + + + Takahe has limited support for this type: See Original {{ post.type }} + diff --git a/tests/activities/models/test_post_types.py b/tests/activities/models/test_post_types.py new file mode 100644 index 0000000..857d495 --- /dev/null +++ b/tests/activities/models/test_post_types.py @@ -0,0 +1,69 @@ +import pytest + +from activities.models import Post +from activities.models.post_types import QuestionData +from core.ld import canonicalise + + +@pytest.mark.django_db +def test_question_post(config_system, identity, remote_identity): + data = { + "cc": [], + "id": "https://fosstodon.org/users/manfre/statuses/109519951621804608/activity", + "to": identity.absolute_profile_uri(), + "type": "Create", + "actor": "https://fosstodon.org/users/manfre", + "object": { + "cc": [], + "id": "https://fosstodon.org/users/manfre/statuses/109519951621804608", + "to": identity.absolute_profile_uri(), + "tag": [], + "url": "https://fosstodon.org/@manfre/109519951621804608", + "type": "Question", + "oneOf": [ + { + "name": "Option 1", + "type": "Note", + "replies": {"type": "Collection", "totalItems": 0}, + }, + { + "name": "Option 2", + "type": "Note", + "replies": {"type": "Collection", "totalItems": 0}, + }, + ], + "content": '

This is a poll :python:

@mike

', + "endTime": "2022-12-18T22:03:59Z", + "replies": { + "id": "https://fosstodon.org/users/manfre/statuses/109519951621804608/replies", + "type": "Collection", + "first": { + "next": "https://fosstodon.org/users/manfre/statuses/109519951621804608/replies?only_other_accounts=true&page=true", + "type": "CollectionPage", + "items": [], + "partOf": "https://fosstodon.org/users/manfre/statuses/109519951621804608/replies", + }, + }, + "published": "2022-12-15T22:03:59Z", + "attachment": [], + "contentMap": { + "en": '

This is a poll :python:

@mike

' + }, + "as:sensitive": False, + "attributedTo": "https://fosstodon.org/users/manfre", + "http://ostatus.org#atomUri": "https://fosstodon.org/users/manfre/statuses/109519951621804608", + "http://ostatus.org#conversation": "tag:fosstodon.org,2022-12-15:objectId=69494364:objectType=Conversation", + "http://joinmastodon.org/ns#votersCount": 0, + }, + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + ], + "published": "2022-12-15T22:03:59Z", + } + + post = Post.by_ap( + data=canonicalise(data["object"], include_security=True), create=True + ) + assert post.type == Post.Types.question + QuestionData.parse_obj(post.type_data) diff --git a/users/models/inbox_message.py b/users/models/inbox_message.py index d6d4f43..dda8138 100644 --- a/users/models/inbox_message.py +++ b/users/models/inbox_message.py @@ -32,9 +32,12 @@ class InboxMessageStates(StateGraph): case "question": pass # Drop for now case unknown: - raise ValueError( - f"Cannot handle activity of type create.{unknown}" - ) + if unknown in Post.Types.names: + await sync_to_async(Post.handle_create_ap)(instance.message) + else: + raise ValueError( + f"Cannot handle activity of type create.{unknown}" + ) case "update": match instance.message_object_type: case "note": @@ -44,9 +47,12 @@ class InboxMessageStates(StateGraph): case "question": pass # Drop for now case unknown: - raise ValueError( - f"Cannot handle activity of type update.{unknown}" - ) + if unknown in Post.Types.names: + await sync_to_async(Post.handle_update_ap)(instance.message) + else: + raise ValueError( + f"Cannot handle activity of type update.{unknown}" + ) case "accept": match instance.message_object_type: case "follow":