Image attachment uploads

This commit is contained in:
Andrew Godwin 2022-12-01 18:46:49 -07:00
parent a826ae18ea
commit 6f2f28a3a7
16 changed files with 418 additions and 166 deletions

View File

@ -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

View File

@ -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",
),
),
]

View File

@ -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):

View File

@ -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],
}

182
activities/views/compose.py Normal file
View File

@ -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}
)

View File

@ -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

24
core/files.py Normal file
View File

@ -0,0 +1,24 @@
import io
import blurhash
from django.core.files import File
from PIL import Image, ImageOps
def resize_image(image: File, *, size: tuple[int, int]) -> File:
"""
Resizes an image to fit insize the given size (cropping one dimension
to fit if needed)
"""
with Image.open(image) as img:
resized_image = ImageOps.fit(img, size)
new_image_bytes = io.BytesIO()
resized_image.save(new_image_bytes, format=img.format)
return File(new_image_bytes)
def blurhash_image(image) -> str:
"""
Returns the blurhash for an image
"""
return blurhash.encode(image, 4, 4)

View File

@ -17,3 +17,4 @@ sentry-sdk~=1.11.0
dj_database_url~=1.0.0
python-dotenv~=0.21.0
email-validator~=1.3.0
blurhash-python~=1.1.3

View File

@ -586,6 +586,25 @@ form img.preview {
align-self: center;
}
form .uploaded-image {
margin: 0 0 10px 0;
overflow: hidden;
}
form .uploaded-image img {
max-width: 200px;
max-height: 200px;
float: left;
}
form .uploaded-image p {
margin-left: 220px;
}
form .uploaded-image .buttons {
margin-left: 220px;
}
form .buttons {
text-align: right;
margin: -20px 0 15px 0;
@ -595,6 +614,15 @@ form p+.buttons {
margin-top: 0;
}
form .button.add-image {
margin: 10px 0 10px 0;
}
form progress {
display: none;
width: 100%;
}
.right-column form .buttons {
margin: 5px 10px 5px 0;
}
@ -1062,7 +1090,8 @@ table.metadata td.name {
cursor: pointer;
}
.copied, .copied:hover {
.copied,
.copied:hover {
color: var(--color-highlight);
transition: 0.2s;
}

View File

@ -3,7 +3,7 @@ from django.contrib import admin as djadmin
from django.urls import path, re_path
from django.views.static import serve
from activities.views import explore, posts, search, timelines
from activities.views import compose, explore, posts, search, timelines
from core import views as core
from stator import views as stator
from users.views import activitypub, admin, auth, follows, identity, settings
@ -120,14 +120,19 @@ urlpatterns = [
path("@<handle>/inbox/", activitypub.Inbox.as_view()),
path("@<handle>/action/", identity.ActionIdentity.as_view()),
# Posts
path("compose/", posts.Compose.as_view(), name="compose"),
path("compose/", compose.Compose.as_view(), name="compose"),
path(
"compose/image_upload/",
compose.ImageUpload.as_view(),
name="compose_image_upload",
),
path("@<handle>/posts/<int:post_id>/", posts.Individual.as_view()),
path("@<handle>/posts/<int:post_id>/like/", posts.Like.as_view()),
path("@<handle>/posts/<int:post_id>/unlike/", posts.Like.as_view(undo=True)),
path("@<handle>/posts/<int:post_id>/boost/", posts.Boost.as_view()),
path("@<handle>/posts/<int:post_id>/unboost/", posts.Boost.as_view(undo=True)),
path("@<handle>/posts/<int:post_id>/delete/", posts.Delete.as_view()),
path("@<handle>/posts/<int:post_id>/edit/", posts.Compose.as_view()),
path("@<handle>/posts/<int:post_id>/edit/", compose.Compose.as_view()),
# Authentication
path("auth/login/", auth.Login.as_view(), name="login"),
path("auth/logout/", auth.Logout.as_view(), name="logout"),

View File

@ -0,0 +1,15 @@
<form
hx-encoding='multipart/form-data'
hx-post='{% url "compose_image_upload" %}'
hx-target="this"
hx-swap="outerHTML"
_="on htmx:xhr:progress(loaded, total)
set #attachmentProgress.value to (loaded/total)*100">
{% csrf_token %}
{% include "forms/_field.html" with field=form.image %}
{% include "forms/_field.html" with field=form.description %}
<div class="buttons">
<button _="on click show #attachmentProgress with display:block then hide me">Upload</button>
<progress id="attachmentProgress" value="0" max="100"></progress>
</div>
</form>

View File

@ -0,0 +1,19 @@
<div class="uploaded-image">
<input type="hidden" name="attachment" value="{{ attachment.pk }}">
<img src="{{ attachment.thumbnail_url }}">
<p>
{{ attachment.name|default:"(no description)" }}
</p>
<div class="buttons">
<a class="button delete left" _="on click remove closest .uploaded_image">Remove</a>
</div>
</div>
{% if request.htmx %}
<a class="button add-image"
hx-get='{% url "compose_image_upload" %}'
hx-target="this"
hx-swap="outerHTML"
_="on load if beep! length of beep! <.uploaded-image/> > 3 then hide me">
Add Image
</a>
{% endif %}

View File

@ -73,7 +73,7 @@
<div class="attachments">
{% for attachment in post.attachments.all %}
{% if attachment.is_image %}
<a href="{{ attachment.remote_url }}" class="image"><img src="{{ attachment.remote_url }}" title="{{ attachment.name }}"></a>
<a href="{{ attachment.full_url }}" class="image"><img src="{{ attachment.thumbnail_url }}" title="{{ attachment.name }}"></a>
{% endif %}
{% endfor %}
</div>

View File

@ -17,8 +17,24 @@
{% include "forms/_field.html" with field=form.content_warning %}
{% include "forms/_field.html" with field=form.visibility %}
</fieldset>
<fieldset>
<legend>Images</legend>
{% if post %}
{% for attachment in post.attachments.all %}
{% include "activities/_image_uploaded.html" %}
{% endfor %}
{% endif %}
{% if not post or post.attachments.count < 4 %}
<a class="button add-image"
hx-get='{% url "compose_image_upload" %}'
hx-target="this"
hx-swap="outerHTML">
Add Image
</a>
{% endif %}
</fieldset>
<div class="buttons">
<button>{% if form.id.value %}Edit{% elif config_identity.toot_mode %}Toot!{% else %}Post{% endif %}</button>
<button>{% if post %}Save Edits{% elif config_identity.toot_mode %}Toot!{% else %}Post{% endif %}</button>
</div>
</form>
{% endblock %}

View File

@ -17,7 +17,7 @@
{% endif %}
{{ field }}
</div>
{% if field.field.widget.input_type == "file" %}
{% if field.field.widget.input_type == "file" and field.value%}
<img class="preview" src="{{ field.value }}">
{% endif %}
</div>

View File

@ -1,12 +1,10 @@
import io
from django import forms
from django.core.files import File
from django.shortcuts import redirect
from django.utils.decorators import method_decorator
from django.views.generic import FormView
from PIL import Image, ImageOps
from core.files import resize_image
from users.decorators import identity_required
@ -51,13 +49,6 @@ class ProfilePage(FormView):
"discoverable": identity.discoverable,
}
def resize_image(self, image: File, *, size: tuple[int, int]) -> File:
with Image.open(image) as img:
resized_image = ImageOps.fit(img, size)
new_image_bytes = io.BytesIO()
resized_image.save(new_image_bytes, format=img.format)
return File(new_image_bytes)
def form_valid(self, form):
# Update basic info
identity = self.request.identity
@ -70,12 +61,12 @@ class ProfilePage(FormView):
if isinstance(icon, File):
identity.icon.save(
icon.name,
self.resize_image(icon, size=(400, 400)),
resize_image(icon, size=(400, 400)),
)
if isinstance(image, File):
identity.image.save(
image.name,
self.resize_image(image, size=(1500, 500)),
resize_image(image, size=(1500, 500)),
)
identity.save()
return redirect(".")