Basic post mutation

This commit is contained in:
Andrew Godwin 2022-12-11 12:37:28 -07:00
parent fc8a21fc5c
commit 20239b5cb7
14 changed files with 365 additions and 56 deletions

View File

@ -80,6 +80,12 @@ class PostStates(StateGraph):
class PostQuerySet(models.QuerySet): class PostQuerySet(models.QuerySet):
def not_hidden(self):
query = self.exclude(
state__in=[PostStates.deleted, PostStates.deleted_fanned_out]
)
return query
def public(self, include_replies: bool = False): def public(self, include_replies: bool = False):
query = self.filter( query = self.filter(
visibility__in=[ visibility__in=[
@ -103,6 +109,18 @@ class PostQuerySet(models.QuerySet):
return query.filter(in_reply_to__isnull=True) return query.filter(in_reply_to__isnull=True)
return query return query
def unlisted(self, include_replies: bool = False):
query = self.filter(
visibility__in=[
Post.Visibilities.public,
Post.Visibilities.local_only,
Post.Visibilities.unlisted,
],
)
if not include_replies:
return query.filter(in_reply_to__isnull=True)
return query
def tagged_with(self, hashtag: str | Hashtag): def tagged_with(self, hashtag: str | Hashtag):
if isinstance(hashtag, str): if isinstance(hashtag, str):
tag_q = models.Q(hashtags__contains=hashtag) tag_q = models.Q(hashtags__contains=hashtag)
@ -118,12 +136,18 @@ class PostManager(models.Manager):
def get_queryset(self): def get_queryset(self):
return PostQuerySet(self.model, using=self._db) return PostQuerySet(self.model, using=self._db)
def not_hidden(self):
return self.get_queryset().not_hidden()
def public(self, include_replies: bool = False): def public(self, include_replies: bool = False):
return self.get_queryset().public(include_replies=include_replies) return self.get_queryset().public(include_replies=include_replies)
def local_public(self, include_replies: bool = False): def local_public(self, include_replies: bool = False):
return self.get_queryset().local_public(include_replies=include_replies) return self.get_queryset().local_public(include_replies=include_replies)
def unlisted(self, include_replies: bool = False):
return self.get_queryset().unlisted(include_replies=include_replies)
def tagged_with(self, hashtag: str | Hashtag): def tagged_with(self, hashtag: str | Hashtag):
return self.get_queryset().tagged_with(hashtag=hashtag) return self.get_queryset().tagged_with(hashtag=hashtag)
@ -248,6 +272,8 @@ class Post(StatorModel):
""" """
Returns the actual Post object we're replying to, if we can find it Returns the actual Post object we're replying to, if we can find it
""" """
if self.in_reply_to is None:
return None
return ( return (
Post.objects.filter(object_uri=self.in_reply_to) Post.objects.filter(object_uri=self.in_reply_to)
.select_related("author") .select_related("author")
@ -338,6 +364,7 @@ class Post(StatorModel):
author: Identity, author: Identity,
content: str, content: str,
summary: str | None = None, summary: str | None = None,
sensitive: bool = False,
visibility: int = Visibilities.public, visibility: int = Visibilities.public,
reply_to: Optional["Post"] = None, reply_to: Optional["Post"] = None,
attachments: list | None = None, attachments: list | None = None,
@ -359,7 +386,7 @@ class Post(StatorModel):
author=author, author=author,
content=content, content=content,
summary=summary or None, summary=summary or None,
sensitive=bool(summary), sensitive=bool(summary) or sensitive,
local=True, local=True,
visibility=visibility, visibility=visibility,
hashtags=hashtags, hashtags=hashtags,
@ -424,6 +451,48 @@ class Post(StatorModel):
hashtag=hashtag, hashtag=hashtag,
) )
### Actions ###
def interact_as(self, identity, type):
from activities.models import PostInteraction, PostInteractionStates
interaction = PostInteraction.objects.get_or_create(
type=type, identity=identity, post=self
)[0]
if interaction.state in [
PostInteractionStates.undone,
PostInteractionStates.undone_fanned_out,
]:
interaction.transition_perform(PostInteractionStates.new)
def uninteract_as(self, identity, type):
from activities.models import PostInteraction, PostInteractionStates
for interaction in PostInteraction.objects.filter(
type=type, identity=identity, post=self
):
interaction.transition_perform(PostInteractionStates.undone)
def like_as(self, identity):
from activities.models import PostInteraction
self.interact_as(identity, PostInteraction.Types.like)
def unlike_as(self, identity):
from activities.models import PostInteraction
self.uninteract_as(identity, PostInteraction.Types.like)
def boost_as(self, identity):
from activities.models import PostInteraction
self.interact_as(identity, PostInteraction.Types.boost)
def unboost_as(self, identity):
from activities.models import PostInteraction
self.uninteract_as(identity, PostInteraction.Types.boost)
### ActivityPub (outbound) ### ### ActivityPub (outbound) ###
def to_ap(self) -> dict: def to_ap(self) -> dict:
@ -711,11 +780,11 @@ class Post(StatorModel):
### Mastodon API ### ### Mastodon API ###
def to_mastodon_json(self): def to_mastodon_json(self, interactions=None):
reply_parent = None reply_parent = None
if self.in_reply_to: if self.in_reply_to:
reply_parent = Post.objects.filter(object_uri=self.in_reply_to).first() reply_parent = Post.objects.filter(object_uri=self.in_reply_to).first()
return { value = {
"id": self.pk, "id": self.pk,
"uri": self.object_uri, "uri": self.object_uri,
"created_at": format_ld_date(self.published), "created_at": format_ld_date(self.published),
@ -755,3 +824,7 @@ class Post(StatorModel):
"text": self.safe_content_plain(), "text": self.safe_content_plain(),
"edited_at": format_ld_date(self.edited) if self.edited else None, "edited_at": format_ld_date(self.edited) if self.edited else None,
} }
if interactions:
value["favourited"] = self.pk in interactions.get("like", [])
value["reblogged"] = self.pk in interactions.get("boost", [])
return value

View File

@ -148,7 +148,7 @@ class TimelineEvent(models.Model):
### Mastodon Client API ### ### Mastodon Client API ###
def to_mastodon_notification_json(self): def to_mastodon_notification_json(self, interactions=None):
result = { result = {
"id": self.pk, "id": self.pk,
"created_at": format_ld_date(self.created), "created_at": format_ld_date(self.created),
@ -156,13 +156,19 @@ class TimelineEvent(models.Model):
} }
if self.type == self.Types.liked: if self.type == self.Types.liked:
result["type"] = "favourite" result["type"] = "favourite"
result["status"] = self.subject_post.to_mastodon_json() result["status"] = self.subject_post.to_mastodon_json(
interactions=interactions
)
elif self.type == self.Types.boosted: elif self.type == self.Types.boosted:
result["type"] = "reblog" result["type"] = "reblog"
result["status"] = self.subject_post.to_mastodon_json() result["status"] = self.subject_post.to_mastodon_json(
interactions=interactions
)
elif self.type == self.Types.mentioned: elif self.type == self.Types.mentioned:
result["type"] = "mention" result["type"] = "mention"
result["status"] = self.subject_post.to_mastodon_json() result["status"] = self.subject_post.to_mastodon_json(
interactions=interactions
)
elif self.type == self.Types.followed: elif self.type == self.Types.followed:
result["type"] = "follow" result["type"] = "follow"
else: else:

View File

@ -6,7 +6,7 @@ from django.utils.decorators import method_decorator
from django.views.decorators.vary import vary_on_headers from django.views.decorators.vary import vary_on_headers
from django.views.generic import TemplateView, View from django.views.generic import TemplateView, View
from activities.models import Post, PostInteraction, PostInteractionStates, PostStates from activities.models import Post, PostInteraction, PostStates
from core.decorators import cache_page_by_ap_json from core.decorators import cache_page_by_ap_json
from core.ld import canonicalise from core.ld import canonicalise
from users.decorators import identity_required from users.decorators import identity_required
@ -94,20 +94,9 @@ class Like(View):
identity.posts.prefetch_related("attachments"), pk=post_id identity.posts.prefetch_related("attachments"), pk=post_id
) )
if self.undo: if self.undo:
# Undo any likes on the post post.unlike_as(self.request.identity)
for interaction in PostInteraction.objects.filter(
type=PostInteraction.Types.like,
identity=request.identity,
post=post,
):
interaction.transition_perform(PostInteractionStates.undone)
else: else:
# Make a like on this post if we didn't already post.like_as(self.request.identity)
PostInteraction.objects.get_or_create(
type=PostInteraction.Types.like,
identity=request.identity,
post=post,
)
# Return either a redirect or a HTMX snippet # Return either a redirect or a HTMX snippet
if request.htmx: if request.htmx:
return render( return render(
@ -133,20 +122,9 @@ class Boost(View):
identity = by_handle_or_404(self.request, handle, local=False) identity = by_handle_or_404(self.request, handle, local=False)
post = get_object_or_404(identity.posts, pk=post_id) post = get_object_or_404(identity.posts, pk=post_id)
if self.undo: if self.undo:
# Undo any boosts on the post post.unboost_as(request.identity)
for interaction in PostInteraction.objects.filter(
type=PostInteraction.Types.boost,
identity=request.identity,
post=post,
):
interaction.transition_perform(PostInteractionStates.undone)
else: else:
# Make a boost on this post if we didn't already post.boost_as(request.identity)
PostInteraction.objects.get_or_create(
type=PostInteraction.Types.boost,
identity=request.identity,
post=post,
)
# Return either a redirect or a HTMX snippet # Return either a redirect or a HTMX snippet
if request.htmx: if request.htmx:
return render( return render(

View File

@ -160,3 +160,8 @@ class Relationship(Schema):
domain_blocking: bool domain_blocking: bool
endorsed: bool endorsed: bool
note: str note: str
class Context(Schema):
ancestors: list[Status]
descendants: list[Status]

View File

@ -1,8 +1,10 @@
from .accounts import * # noqa from .accounts import * # noqa
from .apps import * # noqa from .apps import * # noqa
from .base import api_router # noqa from .filters import * # noqa
from .instance import * # noqa from .instance import * # noqa
from .media import * # noqa
from .notifications import * # noqa from .notifications import * # noqa
from .oauth import * # noqa from .oauth import * # noqa
from .search import * # noqa from .search import * # noqa
from .statuses import * # noqa
from .timelines import * # noqa from .timelines import * # noqa

View File

@ -1,12 +1,11 @@
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from activities.models import Post from activities.models import Post, PostInteraction
from api import schemas from api import schemas
from api.decorators import identity_required
from api.views.base import api_router from api.views.base import api_router
from users.models import Identity from users.models import Identity
from ..decorators import identity_required
@api_router.get("/v1/accounts/verify_credentials", response=schemas.Account) @api_router.get("/v1/accounts/verify_credentials", response=schemas.Account)
@identity_required @identity_required
@ -69,7 +68,8 @@ def account_statuses(
): ):
identity = get_object_or_404(Identity, pk=id) identity = get_object_or_404(Identity, pk=id)
posts = ( posts = (
identity.posts.public() identity.posts.not_hidden()
.unlisted(include_replies=not exclude_replies)
.select_related("author") .select_related("author")
.prefetch_related("attachments") .prefetch_related("attachments")
.order_by("-created") .order_by("-created")
@ -91,4 +91,6 @@ def account_statuses(
# invert the ordering to accomodate # invert the ordering to accomodate
anchor_post = Post.objects.get(pk=min_id) anchor_post = Post.objects.get(pk=min_id)
posts = posts.filter(created__gt=anchor_post.created).order_by("created") posts = posts.filter(created__gt=anchor_post.created).order_by("created")
return [post.to_mastodon_json() for post in posts[:limit]] posts = list(posts[:limit])
interactions = PostInteraction.get_post_interactions(posts, request.identity)
return [post.to_mastodon_json(interactions=interactions) for post in posts]

9
api/views/filters.py Normal file
View File

@ -0,0 +1,9 @@
from api.views.base import api_router
from ..decorators import identity_required
@api_router.get("/v1/filters")
@identity_required
def status(request):
return []

76
api/views/media.py Normal file
View File

@ -0,0 +1,76 @@
from django.shortcuts import get_object_or_404
from ninja import File, Schema
from ninja.files import UploadedFile
from activities.models import PostAttachment, PostAttachmentStates
from api import schemas
from api.views.base import api_router
from core.files import blurhash_image, resize_image
from ..decorators import identity_required
class UploadMediaSchema(Schema):
description: str = ""
focus: str = "0,0"
@api_router.post("/v1/media", response=schemas.MediaAttachment)
@api_router.post("/v2/media", response=schemas.MediaAttachment)
@identity_required
def upload_media(
request,
file: UploadedFile = File(...),
details: UploadMediaSchema | None = None,
):
main_file = resize_image(
file,
size=(2000, 2000),
cover=False,
)
thumbnail_file = resize_image(
file,
size=(400, 225),
cover=True,
)
attachment = PostAttachment.objects.create(
blurhash=blurhash_image(thumbnail_file),
mimetype="image/webp",
width=main_file.image.width,
height=main_file.image.height,
name=details.description if details else None,
state=PostAttachmentStates.fetched,
)
attachment.file.save(
main_file.name,
main_file,
)
attachment.thumbnail.save(
thumbnail_file.name,
thumbnail_file,
)
attachment.save()
return attachment.to_mastodon_json()
@api_router.get("/v1/media/{id}", response=schemas.MediaAttachment)
@identity_required
def get_media(
request,
id: str,
):
attachment = get_object_or_404(PostAttachment, pk=id)
return attachment.to_mastodon_json()
@api_router.put("/v1/media/{id}", response=schemas.MediaAttachment)
@identity_required
def update_media(
request,
id: str,
details: UploadMediaSchema | None = None,
):
attachment = get_object_or_404(PostAttachment, pk=id)
attachment.name = details.description if details else None
attachment.save()
return attachment.to_mastodon_json()

View File

@ -1,8 +1,7 @@
from activities.models import TimelineEvent from activities.models import PostInteraction, TimelineEvent
from api import schemas
from .. import schemas from api.decorators import identity_required
from ..decorators import identity_required from api.views.base import api_router
from .base import api_router
@api_router.get("/v1/notifications", response=list[schemas.Notification]) @api_router.get("/v1/notifications", response=list[schemas.Notification])
@ -49,4 +48,9 @@ def notifications(
# invert the ordering to accomodate # invert the ordering to accomodate
anchor_event = TimelineEvent.objects.get(pk=min_id) anchor_event = TimelineEvent.objects.get(pk=min_id)
events = events.filter(created__gt=anchor_event.created).order_by("created") events = events.filter(created__gt=anchor_event.created).order_by("created")
return [event.to_mastodon_notification_json() for event in events[:limit]] events = list(events[:limit])
interactions = PostInteraction.get_event_interactions(events, request.identity)
return [
event.to_mastodon_notification_json(interactions=interactions)
for event in events
]

View File

@ -2,6 +2,7 @@ from typing import Literal
from ninja import Field from ninja import Field
from activities.models import PostInteraction
from activities.search import Searcher from activities.search import Searcher
from api import schemas from api import schemas
from api.decorators import identity_required from api.decorators import identity_required
@ -38,5 +39,11 @@ def search(
if type is None or type == "hashtag": if type is None or type == "hashtag":
result["hashtag"] = [h.to_mastodon_json() for h in search_result["hashtags"]] result["hashtag"] = [h.to_mastodon_json() for h in search_result["hashtags"]]
if type is None or type == "statuses": if type is None or type == "statuses":
result["statuses"] = [p.to_mastodon_json() for p in search_result["posts"]] interactions = PostInteraction.get_post_interactions(
search_result["posts"], request.identity
)
result["statuses"] = [
p.to_mastodon_json(interactions=interactions)
for p in search_result["posts"]
]
return result return result

139
api/views/statuses.py Normal file
View File

@ -0,0 +1,139 @@
from typing import Literal
from django.forms import ValidationError
from django.shortcuts import get_object_or_404
from ninja import Schema
from activities.models import (
Post,
PostAttachment,
PostInteraction,
PostStates,
TimelineEvent,
)
from api import schemas
from api.views.base import api_router
from core.models import Config
from ..decorators import identity_required
class PostStatusSchema(Schema):
status: str
in_reply_to_id: str | None = None
sensitive: bool = False
spoiler_text: str | None = None
visibility: Literal["public", "unlisted", "private", "direct"] = "public"
language: str | None = None
scheduled_at: str | None = None
media_ids: list[str] = []
@api_router.post("/v1/statuses", response=schemas.Status)
@identity_required
def post_status(request, details: PostStatusSchema):
# Check text length
if len(details.status) > Config.system.post_length:
raise ValidationError("Status is too long")
if len(details.status) == 0 and not details.media_ids:
raise ValidationError("Status is empty")
# Grab attachments
attachments = [get_object_or_404(PostAttachment, pk=id) for id in details.media_ids]
# Create the Post
visibility_map = {
"public": Post.Visibilities.public,
"unlisted": Post.Visibilities.unlisted,
"private": Post.Visibilities.followers,
"direct": Post.Visibilities.mentioned,
}
reply_post = None
if details.in_reply_to_id:
try:
reply_post = Post.objects.get(pk=details.in_reply_to_id)
except Post.DoesNotExist:
pass
post = Post.create_local(
author=request.identity,
content=details.status,
summary=details.spoiler_text,
sensitive=details.sensitive,
visibility=visibility_map[details.visibility],
reply_to=reply_post,
attachments=attachments,
)
# Add their own timeline event for immediate visibility
TimelineEvent.add_post(request.identity, post)
return post.to_mastodon_json()
@api_router.get("/v1/statuses/{id}", response=schemas.Status)
@identity_required
def status(request, id: str):
post = get_object_or_404(Post, pk=id)
interactions = PostInteraction.get_post_interactions([post], request.identity)
return post.to_mastodon_json(interactions=interactions)
@api_router.delete("/v1/statuses/{id}", response=schemas.Status)
@identity_required
def delete_status(request, id: str):
post = get_object_or_404(Post, pk=id)
post.transition_perform(PostStates.deleted)
TimelineEvent.objects.filter(subject_post=post, identity=request.identity).delete()
return post.to_mastodon_json()
@api_router.get("/v1/statuses/{id}/context", response=schemas.Context)
@identity_required
def status_context(request, id: str):
post = get_object_or_404(Post, pk=id)
parent = post.in_reply_to_post()
ancestors = []
if parent:
ancestors.append(parent)
descendants = list(Post.objects.filter(in_reply_to=post.object_uri)[:40])
interactions = PostInteraction.get_post_interactions(
[post] + ancestors + descendants, request.identity
)
return {
"ancestors": [p.to_mastodon_json(interactions=interactions) for p in ancestors],
"descendants": [
p.to_mastodon_json(interactions=interactions) for p in descendants
],
}
@api_router.post("/v1/statuses/{id}/favourite", response=schemas.Status)
@identity_required
def favourite_status(request, id: str):
post = get_object_or_404(Post, pk=id)
post.like_as(request.identity)
interactions = PostInteraction.get_post_interactions([post], request.identity)
return post.to_mastodon_json(interactions=interactions)
@api_router.post("/v1/statuses/{id}/unfavourite", response=schemas.Status)
@identity_required
def unfavourite_status(request, id: str):
post = get_object_or_404(Post, pk=id)
post.unlike_as(request.identity)
interactions = PostInteraction.get_post_interactions([post], request.identity)
return post.to_mastodon_json(interactions=interactions)
@api_router.post("/v1/statuses/{id}/reblog", response=schemas.Status)
@identity_required
def reblog_status(request, id: str):
post = get_object_or_404(Post, pk=id)
post.boost_as(request.identity)
interactions = PostInteraction.get_post_interactions([post], request.identity)
return post.to_mastodon_json(interactions=interactions)
@api_router.post("/v1/statuses/{id}/unreblog", response=schemas.Status)
@identity_required
def unreblog_status(request, id: str):
post = get_object_or_404(Post, pk=id)
post.unboost_as(request.identity)
interactions = PostInteraction.get_post_interactions([post], request.identity)
return post.to_mastodon_json(interactions=interactions)

View File

@ -1,4 +1,4 @@
from activities.models import Post, TimelineEvent from activities.models import Post, PostInteraction, TimelineEvent
from .. import schemas from .. import schemas
from ..decorators import identity_required from ..decorators import identity_required
@ -36,7 +36,12 @@ def home(
# invert the ordering to accomodate # invert the ordering to accomodate
anchor_post = Post.objects.get(pk=min_id) anchor_post = Post.objects.get(pk=min_id)
events = events.filter(created__gt=anchor_post.created).order_by("created") events = events.filter(created__gt=anchor_post.created).order_by("created")
return [event.subject_post.to_mastodon_json() for event in events[:limit]] events = list(events[:limit])
interactions = PostInteraction.get_event_interactions(events, request.identity)
return [
event.subject_post.to_mastodon_json(interactions=interactions)
for event in events
]
@api_router.get("/v1/timelines/public", response=list[schemas.Status]) @api_router.get("/v1/timelines/public", response=list[schemas.Status])
@ -76,7 +81,9 @@ def public(
# invert the ordering to accomodate # invert the ordering to accomodate
anchor_post = Post.objects.get(pk=min_id) anchor_post = Post.objects.get(pk=min_id)
posts = posts.filter(created__gt=anchor_post.created).order_by("created") posts = posts.filter(created__gt=anchor_post.created).order_by("created")
return [post.to_mastodon_json() for post in posts[:limit]] posts = list(posts[:limit])
interactions = PostInteraction.get_post_interactions(posts, request.identity)
return [post.to_mastodon_json(interactions=interactions) for post in posts]
@api_router.get("/v1/timelines/tag/{hashtag}", response=list[schemas.Status]) @api_router.get("/v1/timelines/tag/{hashtag}", response=list[schemas.Status])
@ -115,7 +122,9 @@ def hashtag(
# invert the ordering to accomodate # invert the ordering to accomodate
anchor_post = Post.objects.get(pk=min_id) anchor_post = Post.objects.get(pk=min_id)
posts = posts.filter(created__gt=anchor_post.created).order_by("created") posts = posts.filter(created__gt=anchor_post.created).order_by("created")
return [post.to_mastodon_json() for post in posts[:limit]] posts = list(posts[:limit])
interactions = PostInteraction.get_post_interactions(posts, request.identity)
return [post.to_mastodon_json(interactions=interactions) for post in posts]
@api_router.get("/v1/conversations", response=list[schemas.Status]) @api_router.get("/v1/conversations", response=list[schemas.Status])

View File

@ -32,8 +32,8 @@ def resize_image(
return file return file
def blurhash_image(image) -> str: def blurhash_image(file) -> str:
""" """
Returns the blurhash for an image Returns the blurhash for an image
""" """
return blurhash.encode(image, 4, 4) return blurhash.encode(file, 4, 4)

View File

@ -62,9 +62,8 @@ class ViewIdentity(ListView):
def get_queryset(self): def get_queryset(self):
return ( return (
self.identity.posts.filter( self.identity.posts.not_hidden()
visibility__in=[Post.Visibilities.public, Post.Visibilities.unlisted], .unlisted(include_replies=True)
)
.select_related("author") .select_related("author")
.prefetch_related("attachments") .prefetch_related("attachments")
.order_by("-created") .order_by("-created")