summaryrefslogtreecommitdiffstats
path: root/activities
diff options
context:
space:
mode:
authorAndrew Godwin2022-12-01 18:46:49 -0700
committerAndrew Godwin2022-12-01 18:47:04 -0700
commit6f2f28a3a752cc47d9dc96bda862ed67cd75c9af (patch)
tree1165843b5c207d9e50028bf48db399df20cf7552 /activities
parenta826ae18ea41e44614ce20d5caad9425d76aa60d (diff)
downloadtakahe-6f2f28a3a752cc47d9dc96bda862ed67cd75c9af.tar.gz
takahe-6f2f28a3a752cc47d9dc96bda862ed67cd75c9af.tar.bz2
takahe-6f2f28a3a752cc47d9dc96bda862ed67cd75c9af.zip
Image attachment uploads
Diffstat (limited to 'activities')
-rw-r--r--activities/admin.py5
-rw-r--r--activities/migrations/0003_postattachment_null_thumb.py54
-rw-r--r--activities/models/post.py22
-rw-r--r--activities/models/post_attachment.py43
-rw-r--r--activities/views/compose.py182
-rw-r--r--activities/views/posts.py140
6 files changed, 299 insertions, 147 deletions
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