Post types (#182)

Initial support for Posts of type other than 'Note'. Render special Post types with templates.
This commit is contained in:
Michael Manfre 2022-12-18 11:09:25 -05:00 committed by GitHub
parent 86bc48f3e0
commit a408cbaa27
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 330 additions and 19 deletions

View file

@ -104,8 +104,8 @@ class PostAttachmentInline(admin.StackedInline):
@admin.register(Post) @admin.register(Post)
class PostAdmin(admin.ModelAdmin): class PostAdmin(admin.ModelAdmin):
list_display = ["id", "state", "author", "created"] list_display = ["id", "type", "author", "state", "created"]
list_filter = ("local", "visibility", "state", "created") list_filter = ("type", "local", "visibility", "state", "created")
raw_id_fields = ["to", "mentions", "author", "emojis"] raw_id_fields = ["to", "mentions", "author", "emojis"]
actions = ["force_fetch", "reparse_hashtags"] actions = ["force_fetch", "reparse_hashtags"]
search_fields = ["content"] search_fields = ["content"]

View file

@ -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),
),
]

View file

@ -8,6 +8,7 @@ from asgiref.sync import async_to_sync, sync_to_async
from django.conf import settings from django.conf import settings
from django.contrib.postgres.indexes import GinIndex from django.contrib.postgres.indexes import GinIndex
from django.db import models, transaction from django.db import models, transaction
from django.template import loader
from django.template.defaultfilters import linebreaks_filter from django.template.defaultfilters import linebreaks_filter
from django.utils import timezone from django.utils import timezone
from django.utils.safestring import mark_safe 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.emoji import Emoji
from activities.models.fan_out import FanOut from activities.models.fan_out import FanOut
from activities.models.hashtag import Hashtag from activities.models.hashtag import Hashtag
from activities.models.post_types import (
PostTypeData,
PostTypeDataDecoder,
PostTypeDataEncoder,
)
from activities.templatetags.emoji_tags import imageify_emojis from activities.templatetags.emoji_tags import imageify_emojis
from core.html import sanitize_post, strip_html from core.html import sanitize_post, strip_html
from core.ld import canonicalise, format_ld_date, get_list, parse_ld_date from core.ld import canonicalise, format_ld_date, get_list, parse_ld_date
@ -166,6 +172,16 @@ class Post(StatorModel):
followers = 2 followers = 2
mentioned = 3 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 # The author (attributedTo) of the post
author = models.ForeignKey( author = models.ForeignKey(
"users.Identity", "users.Identity",
@ -191,6 +207,15 @@ class Post(StatorModel):
# The main (HTML) content # The main (HTML) content
content = models.TextField() 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 # If the contents of the post are sensitive, and the summary (content
# warning) to show if it is # warning) to show if it is
sensitive = models.BooleanField(default=False) sensitive = models.BooleanField(default=False)
@ -292,6 +317,8 @@ class Post(StatorModel):
ain_reply_to_post = sync_to_async(in_reply_to_post) ain_reply_to_post = sync_to_async(in_reply_to_post)
### Content cleanup and extraction ### ### Content cleanup and extraction ###
def clean_type_data(self, value):
PostTypeData.parse_obj(value)
mention_regex = re.compile( mention_regex = re.compile(
r"(^|[^\w\d\-_])@([\w\d\-_]+(?:@[\w\d\-_]+\.[\w\d\-_\.]+)?)" 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)) 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): def safe_content_local(self):
""" """
Returns the content formatted for local display Returns the content formatted for local display
""" """
return imageify_emojis( return self.safe_content(local=True)
Hashtag.linkify_hashtags(
self.linkify_mentions(sanitize_post(self.content), local=True)
),
self.author.domain,
)
def safe_content_remote(self): def safe_content_remote(self):
""" """
Returns the content formatted for remote consumption Returns the content formatted for remote consumption
""" """
return Hashtag.linkify_hashtags( return self.safe_content(local=False)
self.linkify_mentions(sanitize_post(self.content)),
domain=self.author.domain,
)
def safe_content_plain(self): def safe_content_plain(self):
""" """
@ -521,7 +578,7 @@ class Post(StatorModel):
value = { value = {
"to": "Public", "to": "Public",
"cc": [], "cc": [],
"type": "Note", "type": self.type,
"id": self.object_uri, "id": self.object_uri,
"published": format_ld_date(self.published), "published": format_ld_date(self.published),
"attributedTo": self.author.actor_uri, "attributedTo": self.author.actor_uri,
@ -531,6 +588,18 @@ class Post(StatorModel):
"tag": [], "tag": [],
"attachment": [], "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: if self.summary:
value["summary"] = self.summary value["summary"] = self.summary
if self.in_reply_to: if self.in_reply_to:
@ -660,11 +729,16 @@ class Post(StatorModel):
author=author, author=author,
content=data["content"], content=data["content"],
local=False, local=False,
type=data["type"],
) )
created = True created = True
else: else:
raise cls.DoesNotExist(f"No post with ID {data['id']}", data) raise cls.DoesNotExist(f"No post with ID {data['id']}", data)
if update or created: 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.content = data["content"]
post.summary = data.get("summary") post.summary = data.get("summary")
post.sensitive = data.get("sensitive", False) post.sensitive = data.get("sensitive", False)

View file

@ -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

View file

@ -1087,6 +1087,36 @@ table.metadata td .emoji {
text-decoration: underline; 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, .boost-banner,
.mention-banner, .mention-banner,
.follow-banner, .follow-banner,

View file

@ -0,0 +1,22 @@
{% load activity_tags %}
{{ sanitized_content }}
<div class="poll">
<h3 style="display: none;">Options: {% if post.type_data.mode == "oneOf" %}<small>(choose one)</small>{% endif %}</h3>
{% for item in post.type_data.options %}
{% if forloop.first %}<ul>{% endif %}{% widthratio item.votes post.type_data.voter_count 100 as item_percent %}
<li><label class="poll-option">
<input style="display:none" name="vote-options" type="{% if post.type_data.mode == "oneOf" %}radio{% else %}checkbox{% endif %}" value="0">
<span class="poll-number" title="{{ item.votes }} votes">{{ item_percent }}%</span>
<span class="poll-option-text">{{ item.name }}</span>
</label>
{% if forloop.last %}</ul>{% endif %}
{% endfor %}
<div class="poll-footer">
<span class="vote-total">{{ post.type_data.voter_count }} people</span>
&mdash;
{% if post.type_data.end_time %}<span class="vote-end">{{ post.type_data.end_time|timedeltashort }}</span>{% endif %}
<span>Polls are currently display only</span>
</div>
</div>

View file

@ -0,0 +1,5 @@
{{ sanitized_content }}
<small class="notice" x-post-type-data="{{ post.type_data|escape }}">
Takahe has limited support for this type: <a href="{{ post.url }}">See Original {{ post.type }}</a>
</small>

View file

@ -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": '<p>This is a poll :python: </p><p><span class="h-card"><a href="https://ehakat.manfre.net/@mike/" class="u-url mention">@<span>mike</span></a></span></p>',
"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": '<p>This is a poll :python: </p><p><span class="h-card"><a href="https://ehakat.manfre.net/@mike/" class="u-url mention">@<span>mike</span></a></span></p>'
},
"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)

View file

@ -32,9 +32,12 @@ class InboxMessageStates(StateGraph):
case "question": case "question":
pass # Drop for now pass # Drop for now
case unknown: case unknown:
raise ValueError( if unknown in Post.Types.names:
f"Cannot handle activity of type create.{unknown}" await sync_to_async(Post.handle_create_ap)(instance.message)
) else:
raise ValueError(
f"Cannot handle activity of type create.{unknown}"
)
case "update": case "update":
match instance.message_object_type: match instance.message_object_type:
case "note": case "note":
@ -44,9 +47,12 @@ class InboxMessageStates(StateGraph):
case "question": case "question":
pass # Drop for now pass # Drop for now
case unknown: case unknown:
raise ValueError( if unknown in Post.Types.names:
f"Cannot handle activity of type update.{unknown}" await sync_to_async(Post.handle_update_ap)(instance.message)
) else:
raise ValueError(
f"Cannot handle activity of type update.{unknown}"
)
case "accept": case "accept":
match instance.message_object_type: match instance.message_object_type:
case "follow": case "follow":