Post types (#182)
Initial support for Posts of type other than 'Note'. Render special Post types with templates.
This commit is contained in:
parent
86bc48f3e0
commit
a408cbaa27
|
@ -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"]
|
||||
|
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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)
|
||||
|
|
|
@ -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
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
—
|
||||
{% 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>
|
|
@ -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>
|
|
@ -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)
|
|
@ -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":
|
||||
|
|
Loading…
Reference in New Issue