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):
def not_hidden(self):
query = self.exclude(
state__in=[PostStates.deleted, PostStates.deleted_fanned_out]
)
return query
def public(self, include_replies: bool = False):
query = self.filter(
visibility__in=[
@ -103,6 +109,18 @@ class PostQuerySet(models.QuerySet):
return query.filter(in_reply_to__isnull=True)
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):
if isinstance(hashtag, str):
tag_q = models.Q(hashtags__contains=hashtag)
@ -118,12 +136,18 @@ class PostManager(models.Manager):
def get_queryset(self):
return PostQuerySet(self.model, using=self._db)
def not_hidden(self):
return self.get_queryset().not_hidden()
def public(self, include_replies: bool = False):
return self.get_queryset().public(include_replies=include_replies)
def local_public(self, include_replies: bool = False):
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):
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
"""
if self.in_reply_to is None:
return None
return (
Post.objects.filter(object_uri=self.in_reply_to)
.select_related("author")
@ -338,6 +364,7 @@ class Post(StatorModel):
author: Identity,
content: str,
summary: str | None = None,
sensitive: bool = False,
visibility: int = Visibilities.public,
reply_to: Optional["Post"] = None,
attachments: list | None = None,
@ -359,7 +386,7 @@ class Post(StatorModel):
author=author,
content=content,
summary=summary or None,
sensitive=bool(summary),
sensitive=bool(summary) or sensitive,
local=True,
visibility=visibility,
hashtags=hashtags,
@ -424,6 +451,48 @@ class Post(StatorModel):
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) ###
def to_ap(self) -> dict:
@ -711,11 +780,11 @@ class Post(StatorModel):
### Mastodon API ###
def to_mastodon_json(self):
def to_mastodon_json(self, interactions=None):
reply_parent = None
if self.in_reply_to:
reply_parent = Post.objects.filter(object_uri=self.in_reply_to).first()
return {
value = {
"id": self.pk,
"uri": self.object_uri,
"created_at": format_ld_date(self.published),
@ -755,3 +824,7 @@ class Post(StatorModel):
"text": self.safe_content_plain(),
"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 ###
def to_mastodon_notification_json(self):
def to_mastodon_notification_json(self, interactions=None):
result = {
"id": self.pk,
"created_at": format_ld_date(self.created),
@ -156,13 +156,19 @@ class TimelineEvent(models.Model):
}
if self.type == self.Types.liked:
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:
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:
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:
result["type"] = "follow"
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.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.ld import canonicalise
from users.decorators import identity_required
@ -94,20 +94,9 @@ class Like(View):
identity.posts.prefetch_related("attachments"), pk=post_id
)
if self.undo:
# Undo any likes on the post
for interaction in PostInteraction.objects.filter(
type=PostInteraction.Types.like,
identity=request.identity,
post=post,
):
interaction.transition_perform(PostInteractionStates.undone)
post.unlike_as(self.request.identity)
else:
# Make a like on this post if we didn't already
PostInteraction.objects.get_or_create(
type=PostInteraction.Types.like,
identity=request.identity,
post=post,
)
post.like_as(self.request.identity)
# Return either a redirect or a HTMX snippet
if request.htmx:
return render(
@ -133,20 +122,9 @@ class Boost(View):
identity = by_handle_or_404(self.request, handle, local=False)
post = get_object_or_404(identity.posts, pk=post_id)
if self.undo:
# Undo any boosts on the post
for interaction in PostInteraction.objects.filter(
type=PostInteraction.Types.boost,
identity=request.identity,
post=post,
):
interaction.transition_perform(PostInteractionStates.undone)
post.unboost_as(request.identity)
else:
# Make a boost on this post if we didn't already
PostInteraction.objects.get_or_create(
type=PostInteraction.Types.boost,
identity=request.identity,
post=post,
)
post.boost_as(request.identity)
# Return either a redirect or a HTMX snippet
if request.htmx:
return render(

View file

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

View file

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

View file

@ -1,12 +1,11 @@
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.decorators import identity_required
from api.views.base import api_router
from users.models import Identity
from ..decorators import identity_required
@api_router.get("/v1/accounts/verify_credentials", response=schemas.Account)
@identity_required
@ -69,7 +68,8 @@ def account_statuses(
):
identity = get_object_or_404(Identity, pk=id)
posts = (
identity.posts.public()
identity.posts.not_hidden()
.unlisted(include_replies=not exclude_replies)
.select_related("author")
.prefetch_related("attachments")
.order_by("-created")
@ -91,4 +91,6 @@ def account_statuses(
# invert the ordering to accomodate
anchor_post = Post.objects.get(pk=min_id)
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 .. import schemas
from ..decorators import identity_required
from .base import api_router
from activities.models import PostInteraction, TimelineEvent
from api import schemas
from api.decorators import identity_required
from api.views.base import api_router
@api_router.get("/v1/notifications", response=list[schemas.Notification])
@ -49,4 +48,9 @@ def notifications(
# invert the ordering to accomodate
anchor_event = TimelineEvent.objects.get(pk=min_id)
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 activities.models import PostInteraction
from activities.search import Searcher
from api import schemas
from api.decorators import identity_required
@ -38,5 +39,11 @@ def search(
if type is None or type == "hashtag":
result["hashtag"] = [h.to_mastodon_json() for h in search_result["hashtags"]]
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

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 ..decorators import identity_required
@ -36,7 +36,12 @@ def home(
# invert the ordering to accomodate
anchor_post = Post.objects.get(pk=min_id)
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])
@ -76,7 +81,9 @@ def public(
# invert the ordering to accomodate
anchor_post = Post.objects.get(pk=min_id)
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])
@ -115,7 +122,9 @@ def hashtag(
# invert the ordering to accomodate
anchor_post = Post.objects.get(pk=min_id)
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])

View file

@ -32,8 +32,8 @@ def resize_image(
return file
def blurhash_image(image) -> str:
def blurhash_image(file) -> str:
"""
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):
return (
self.identity.posts.filter(
visibility__in=[Post.Visibilities.public, Post.Visibilities.unlisted],
)
self.identity.posts.not_hidden()
.unlisted(include_replies=True)
.select_related("author")
.prefetch_related("attachments")
.order_by("-created")