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)
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"]

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.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)

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;
}
/* 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,

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":
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":