From 6f2f28a3a752cc47d9dc96bda862ed67cd75c9af Mon Sep 17 00:00:00 2001 From: Andrew Godwin Date: Thu, 1 Dec 2022 18:46:49 -0700 Subject: Image attachment uploads --- activities/admin.py | 5 + .../migrations/0003_postattachment_null_thumb.py | 54 ++++++ activities/models/post.py | 22 ++- activities/models/post_attachment.py | 43 ++++- activities/views/compose.py | 182 +++++++++++++++++++++ activities/views/posts.py | 140 +--------------- 6 files changed, 299 insertions(+), 147 deletions(-) create mode 100644 activities/migrations/0003_postattachment_null_thumb.py create mode 100644 activities/views/compose.py (limited to 'activities') diff --git a/activities/admin.py b/activities/admin.py index c4875ca..fe7b52f 100644 --- a/activities/admin.py +++ b/activities/admin.py @@ -25,6 +25,11 @@ class HashtagAdmin(admin.ModelAdmin): instance.transition_perform("outdated") +@admin.register(PostAttachment) +class PostAttachmentAdmin(admin.ModelAdmin): + list_display = ["id", "post", "created"] + + class PostAttachmentInline(admin.StackedInline): model = PostAttachment extra = 0 diff --git a/activities/migrations/0003_postattachment_null_thumb.py b/activities/migrations/0003_postattachment_null_thumb.py new file mode 100644 index 0000000..f999150 --- /dev/null +++ b/activities/migrations/0003_postattachment_null_thumb.py @@ -0,0 +1,54 @@ +# Generated by Django 4.1.3 on 2022-12-01 23:42 + +import functools + +import django.db.models.deletion +import django.utils.timezone +from django.db import migrations, models + +import core.uploads + + +class Migration(migrations.Migration): + + dependencies = [ + ("activities", "0002_hashtag"), + ] + + operations = [ + migrations.AddField( + model_name="postattachment", + name="created", + field=models.DateTimeField( + auto_now_add=True, default=django.utils.timezone.now + ), + preserve_default=False, + ), + migrations.AddField( + model_name="postattachment", + name="thumbnail", + field=models.ImageField( + blank=True, + null=True, + upload_to=functools.partial( + core.uploads.upload_namer, *("attachment_thumbnails",), **{} + ), + ), + ), + migrations.AddField( + model_name="postattachment", + name="updated", + field=models.DateTimeField(auto_now=True), + ), + migrations.AlterField( + model_name="postattachment", + name="post", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="attachments", + to="activities.post", + ), + ), + ] diff --git a/activities/models/post.py b/activities/models/post.py index eecce04..d3365e7 100644 --- a/activities/models/post.py +++ b/activities/models/post.py @@ -1,5 +1,5 @@ import re -from typing import Dict, Iterable, Optional, Set +from typing import Dict, Iterable, List, Optional, Set import httpx import urlman @@ -312,7 +312,7 @@ class Post(StatorModel): """ return ( await Post.objects.select_related("author", "author__domain") - .prefetch_related("mentions", "mentions__domain") + .prefetch_related("mentions", "mentions__domain", "attachments") .aget(pk=self.pk) ) @@ -326,6 +326,7 @@ class Post(StatorModel): summary: Optional[str] = None, visibility: int = Visibilities.public, reply_to: Optional["Post"] = None, + attachments: Optional[List] = None, ) -> "Post": with transaction.atomic(): # Find mentions in this post @@ -353,6 +354,8 @@ class Post(StatorModel): post.object_uri = post.urls.object_uri post.url = post.absolute_object_uri() post.mentions.set(mentions) + if attachments: + post.attachments.set(attachments) post.save() return post @@ -361,6 +364,7 @@ class Post(StatorModel): content: str, summary: Optional[str] = None, visibility: int = Visibilities.public, + attachments: Optional[List] = None, ): with transaction.atomic(): # Strip all HTML and apply linebreaks filter @@ -371,6 +375,7 @@ class Post(StatorModel): self.edited = timezone.now() self.hashtags = Hashtag.hashtags_from_content(content) or None self.mentions.set(self.mentions_from_content(content, self.author)) + self.attachments.set(attachments or []) self.save() @classmethod @@ -421,6 +426,7 @@ class Post(StatorModel): "as:sensitive": self.sensitive, "url": self.absolute_object_uri(), "tag": [], + "attachment": [], } if self.summary: value["summary"] = self.summary @@ -438,11 +444,13 @@ class Post(StatorModel): } ) value["cc"].append(mention.actor_uri) - # Remove tag and cc if they're empty - if not value["cc"]: - del value["cc"] - if not value["tag"]: - del value["tag"] + # Attachments + for attachment in self.attachments.all(): + value["attachment"].append(attachment.to_ap()) + # Remove fields if they're empty + for field in ["cc", "tag", "attachment"]: + if not value[field]: + del value[field] return value def to_create_ap(self): diff --git a/activities/models/post_attachment.py b/activities/models/post_attachment.py index 6ccea08..7feaba5 100644 --- a/activities/models/post_attachment.py +++ b/activities/models/post_attachment.py @@ -27,15 +27,24 @@ class PostAttachment(StatorModel): "activities.post", on_delete=models.CASCADE, related_name="attachments", + blank=True, + null=True, ) state = StateField(graph=PostAttachmentStates) mimetype = models.CharField(max_length=200) - # File may not be populated if it's remote and not cached on our side yet + # Files may not be populated if it's remote and not cached on our side yet file = models.FileField( - upload_to=partial(upload_namer, "attachments"), null=True, blank=True + upload_to=partial(upload_namer, "attachments"), + null=True, + blank=True, + ) + thumbnail = models.ImageField( + upload_to=partial(upload_namer, "attachment_thumbnails"), + null=True, + blank=True, ) remote_url = models.CharField(max_length=500, null=True, blank=True) @@ -49,6 +58,9 @@ class PostAttachment(StatorModel): focal_y = models.IntegerField(null=True, blank=True) blurhash = models.TextField(null=True, blank=True) + created = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) + def is_image(self): return self.mimetype in [ "image/apng", @@ -58,3 +70,30 @@ class PostAttachment(StatorModel): "image/png", "image/webp", ] + + def thumbnail_url(self): + if self.thumbnail: + return self.thumbnail.url + elif self.file: + return self.file.url + else: + return self.remote_url + + def full_url(self): + if self.file: + return self.file.url + else: + return self.remote_url + + ### ActivityPub ### + + def to_ap(self): + return { + "url": self.file.url, + "name": self.name, + "type": "Document", + "width": self.width, + "height": self.height, + "mediaType": self.mimetype, + "http://joinmastodon.org/ns#focalPoint": [0.5, 0.5], + } diff --git a/activities/views/compose.py b/activities/views/compose.py new file mode 100644 index 0000000..58dc7fc --- /dev/null +++ b/activities/views/compose.py @@ -0,0 +1,182 @@ +from django import forms +from django.core.exceptions import PermissionDenied +from django.shortcuts import get_object_or_404, redirect, render +from django.utils.decorators import method_decorator +from django.views.generic import FormView + +from activities.models import ( + Post, + PostAttachment, + PostAttachmentStates, + PostStates, + TimelineEvent, +) +from core.files import blurhash_image, resize_image +from core.html import html_to_plaintext +from core.models import Config +from users.decorators import identity_required + + +@method_decorator(identity_required, name="dispatch") +class Compose(FormView): + + template_name = "activities/compose.html" + + class form_class(forms.Form): + text = forms.CharField( + widget=forms.Textarea( + attrs={ + "placeholder": "What's on your mind?", + }, + ) + ) + visibility = forms.ChoiceField( + choices=[ + (Post.Visibilities.public, "Public"), + (Post.Visibilities.local_only, "Local Only"), + (Post.Visibilities.unlisted, "Unlisted"), + (Post.Visibilities.followers, "Followers & Mentioned Only"), + (Post.Visibilities.mentioned, "Mentioned Only"), + ], + ) + content_warning = forms.CharField( + required=False, + label=Config.lazy_system_value("content_warning_text"), + widget=forms.TextInput( + attrs={ + "placeholder": Config.lazy_system_value("content_warning_text"), + }, + ), + help_text="Optional - Post will be hidden behind this text until clicked", + ) + reply_to = forms.CharField(widget=forms.HiddenInput(), required=False) + + def clean_text(self): + text = self.cleaned_data.get("text") + if not text: + return text + length = len(text) + if length > Config.system.post_length: + raise forms.ValidationError( + f"Maximum post length is {Config.system.post_length} characters (you have {length})" + ) + return text + + def get_initial(self): + initial = super().get_initial() + if self.post_obj: + initial.update( + { + "reply_to": self.reply_to.pk if self.reply_to else "", + "visibility": self.post_obj.visibility, + "text": html_to_plaintext(self.post_obj.content), + "content_warning": self.post_obj.summary, + } + ) + else: + initial[ + "visibility" + ] = self.request.identity.config_identity.default_post_visibility + if self.reply_to: + initial["reply_to"] = self.reply_to.pk + if self.reply_to.visibility == Post.Visibilities.public: + initial["visibility"] = Post.Visibilities.unlisted + else: + initial["visibility"] = self.reply_to.visibility + initial["text"] = f"@{self.reply_to.author.handle} " + return initial + + def form_valid(self, form): + # Gather any attachment objects now, they're not in the form proper + attachments = [] + if "attachment" in self.request.POST: + attachments = PostAttachment.objects.filter( + pk__in=self.request.POST.getlist("attachment", []) + ) + # Dispatch based on edit or not + if self.post_obj: + self.post_obj.edit_local( + content=form.cleaned_data["text"], + summary=form.cleaned_data.get("content_warning"), + visibility=form.cleaned_data["visibility"], + attachments=attachments, + ) + self.post_obj.transition_perform(PostStates.edited) + else: + post = Post.create_local( + author=self.request.identity, + content=form.cleaned_data["text"], + summary=form.cleaned_data.get("content_warning"), + visibility=form.cleaned_data["visibility"], + reply_to=self.reply_to, + attachments=attachments, + ) + # Add their own timeline event for immediate visibility + TimelineEvent.add_post(self.request.identity, post) + return redirect("/") + + def dispatch(self, request, handle=None, post_id=None, *args, **kwargs): + self.post_obj = None + if handle and post_id: + # Make sure the request identity owns the post! + if handle != request.identity.handle: + raise PermissionDenied("Post author is not requestor") + + self.post_obj = get_object_or_404(request.identity.posts, pk=post_id) + + # Grab the reply-to post info now + self.reply_to = None + reply_to_id = request.POST.get("reply_to") or request.GET.get("reply_to") + if reply_to_id: + try: + self.reply_to = Post.objects.get(pk=reply_to_id) + except Post.DoesNotExist: + pass + # Keep going with normal rendering + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["reply_to"] = self.reply_to + if self.post_obj: + context["post"] = self.post_obj + return context + + +@method_decorator(identity_required, name="dispatch") +class ImageUpload(FormView): + """ + Handles image upload - returns a new input type hidden to embed in + the main form that references an orphaned PostAttachment + """ + + template_name = "activities/_image_upload.html" + + class form_class(forms.Form): + image = forms.ImageField() + description = forms.CharField(required=False) + + def form_valid(self, form): + # Make a PostAttachment + thumbnail_file = resize_image(form.cleaned_data["image"], size=(400, 225)) + attachment = PostAttachment.objects.create( + blurhash=blurhash_image(thumbnail_file), + mimetype=form.cleaned_data["image"].image.get_format_mimetype(), + width=form.cleaned_data["image"].image.width, + height=form.cleaned_data["image"].image.height, + name=form.cleaned_data.get("description"), + state=PostAttachmentStates.fetched, + ) + attachment.file.save( + form.cleaned_data["image"].name, + form.cleaned_data["image"], + ) + attachment.thumbnail.save( + form.cleaned_data["image"].name, + thumbnail_file, + ) + attachment.save() + # Return the response, with a hidden input plus a note + return render( + self.request, "activities/_image_uploaded.html", {"attachment": attachment} + ) diff --git a/activities/views/posts.py b/activities/views/posts.py index 6dbfc8e..8b0ff33 100644 --- a/activities/views/posts.py +++ b/activities/views/posts.py @@ -1,21 +1,12 @@ -from django import forms from django.core.exceptions import PermissionDenied from django.db import models from django.http import JsonResponse from django.shortcuts import get_object_or_404, redirect, render from django.utils.decorators import method_decorator -from django.views.generic import FormView, TemplateView, View +from django.views.generic import TemplateView, View -from activities.models import ( - Post, - PostInteraction, - PostInteractionStates, - PostStates, - TimelineEvent, -) -from core.html import html_to_plaintext +from activities.models import Post, PostInteraction, PostInteractionStates, PostStates from core.ld import canonicalise -from core.models import Config from users.decorators import identity_required from users.shortcuts import by_handle_or_404 @@ -188,130 +179,3 @@ class Delete(TemplateView): def post(self, request): self.post_obj.transition_perform(PostStates.deleted) return redirect("/") - - -@method_decorator(identity_required, name="dispatch") -class Compose(FormView): - - template_name = "activities/compose.html" - - class form_class(forms.Form): - id = forms.IntegerField( - required=False, - widget=forms.HiddenInput(), - ) - - text = forms.CharField( - widget=forms.Textarea( - attrs={ - "placeholder": "What's on your mind?", - }, - ) - ) - visibility = forms.ChoiceField( - choices=[ - (Post.Visibilities.public, "Public"), - (Post.Visibilities.local_only, "Local Only"), - (Post.Visibilities.unlisted, "Unlisted"), - (Post.Visibilities.followers, "Followers & Mentioned Only"), - (Post.Visibilities.mentioned, "Mentioned Only"), - ], - ) - content_warning = forms.CharField( - required=False, - label=Config.lazy_system_value("content_warning_text"), - widget=forms.TextInput( - attrs={ - "placeholder": Config.lazy_system_value("content_warning_text"), - }, - ), - help_text="Optional - Post will be hidden behind this text until clicked", - ) - reply_to = forms.CharField(widget=forms.HiddenInput(), required=False) - - def clean_text(self): - text = self.cleaned_data.get("text") - if not text: - return text - length = len(text) - if length > Config.system.post_length: - raise forms.ValidationError( - f"Maximum post length is {Config.system.post_length} characters (you have {length})" - ) - return text - - def get_initial(self): - initial = super().get_initial() - if self.post_obj: - initial.update( - { - "id": self.post_obj.id, - "reply_to": self.reply_to.pk if self.reply_to else "", - "visibility": self.post_obj.visibility, - "text": html_to_plaintext(self.post_obj.content), - "content_warning": self.post_obj.summary, - } - ) - else: - initial[ - "visibility" - ] = self.request.identity.config_identity.default_post_visibility - if self.reply_to: - initial["reply_to"] = self.reply_to.pk - if self.reply_to.visibility == Post.Visibilities.public: - initial["visibility"] = Post.Visibilities.unlisted - else: - initial["visibility"] = self.reply_to.visibility - initial["text"] = f"@{self.reply_to.author.handle} " - return initial - - def form_valid(self, form): - post_id = form.cleaned_data.get("id") - if post_id: - post = get_object_or_404(self.request.identity.posts, pk=post_id) - post.edit_local( - content=form.cleaned_data["text"], - summary=form.cleaned_data.get("content_warning"), - visibility=form.cleaned_data["visibility"], - ) - - # Should there be a timeline event for edits? - # E.g. "@user edited #123" - - post.transition_perform(PostStates.edited) - else: - post = Post.create_local( - author=self.request.identity, - content=form.cleaned_data["text"], - summary=form.cleaned_data.get("content_warning"), - visibility=form.cleaned_data["visibility"], - reply_to=self.reply_to, - ) - # Add their own timeline event for immediate visibility - TimelineEvent.add_post(self.request.identity, post) - return redirect("/") - - def dispatch(self, request, handle=None, post_id=None, *args, **kwargs): - self.post_obj = None - if handle and post_id: - # Make sure the request identity owns the post! - if handle != request.identity.handle: - raise PermissionDenied("Post author is not requestor") - - self.post_obj = get_object_or_404(request.identity.posts, pk=post_id) - - # Grab the reply-to post info now - self.reply_to = None - reply_to_id = request.POST.get("reply_to") or request.GET.get("reply_to") - if reply_to_id: - try: - self.reply_to = Post.objects.get(pk=reply_to_id) - except Post.DoesNotExist: - pass - # Keep going with normal rendering - return super().dispatch(request, *args, **kwargs) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context["reply_to"] = self.reply_to - return context -- cgit v1.2.3