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") instance.transition_perform("outdated")
@admin.register(PostAttachment)
class PostAttachmentAdmin(admin.ModelAdmin):
list_display = ["id", "post", "created"]
class PostAttachmentInline(admin.StackedInline): class PostAttachmentInline(admin.StackedInline):
model = PostAttachment model = PostAttachment
extra = 0 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 import re
from typing import Dict, Iterable, Optional, Set from typing import Dict, Iterable, List, Optional, Set
import httpx import httpx
import urlman import urlman
@ -312,7 +312,7 @@ class Post(StatorModel):
""" """
return ( return (
await Post.objects.select_related("author", "author__domain") await Post.objects.select_related("author", "author__domain")
.prefetch_related("mentions", "mentions__domain") .prefetch_related("mentions", "mentions__domain", "attachments")
.aget(pk=self.pk) .aget(pk=self.pk)
) )
@ -326,6 +326,7 @@ class Post(StatorModel):
summary: Optional[str] = None, summary: Optional[str] = None,
visibility: int = Visibilities.public, visibility: int = Visibilities.public,
reply_to: Optional["Post"] = None, reply_to: Optional["Post"] = None,
attachments: Optional[List] = None,
) -> "Post": ) -> "Post":
with transaction.atomic(): with transaction.atomic():
# Find mentions in this post # Find mentions in this post
@ -353,6 +354,8 @@ class Post(StatorModel):
post.object_uri = post.urls.object_uri post.object_uri = post.urls.object_uri
post.url = post.absolute_object_uri() post.url = post.absolute_object_uri()
post.mentions.set(mentions) post.mentions.set(mentions)
if attachments:
post.attachments.set(attachments)
post.save() post.save()
return post return post
@ -361,6 +364,7 @@ class Post(StatorModel):
content: str, content: str,
summary: Optional[str] = None, summary: Optional[str] = None,
visibility: int = Visibilities.public, visibility: int = Visibilities.public,
attachments: Optional[List] = None,
): ):
with transaction.atomic(): with transaction.atomic():
# Strip all HTML and apply linebreaks filter # Strip all HTML and apply linebreaks filter
@ -371,6 +375,7 @@ class Post(StatorModel):
self.edited = timezone.now() self.edited = timezone.now()
self.hashtags = Hashtag.hashtags_from_content(content) or None self.hashtags = Hashtag.hashtags_from_content(content) or None
self.mentions.set(self.mentions_from_content(content, self.author)) self.mentions.set(self.mentions_from_content(content, self.author))
self.attachments.set(attachments or [])
self.save() self.save()
@classmethod @classmethod
@ -421,6 +426,7 @@ class Post(StatorModel):
"as:sensitive": self.sensitive, "as:sensitive": self.sensitive,
"url": self.absolute_object_uri(), "url": self.absolute_object_uri(),
"tag": [], "tag": [],
"attachment": [],
} }
if self.summary: if self.summary:
value["summary"] = self.summary value["summary"] = self.summary
@ -438,11 +444,13 @@ class Post(StatorModel):
} }
) )
value["cc"].append(mention.actor_uri) value["cc"].append(mention.actor_uri)
# Remove tag and cc if they're empty # Attachments
if not value["cc"]: for attachment in self.attachments.all():
del value["cc"] value["attachment"].append(attachment.to_ap())
if not value["tag"]: # Remove fields if they're empty
del value["tag"] for field in ["cc", "tag", "attachment"]:
if not value[field]:
del value[field]
return value return value
def to_create_ap(self): def to_create_ap(self):

View file

@ -27,15 +27,24 @@ class PostAttachment(StatorModel):
"activities.post", "activities.post",
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name="attachments", related_name="attachments",
blank=True,
null=True,
) )
state = StateField(graph=PostAttachmentStates) state = StateField(graph=PostAttachmentStates)
mimetype = models.CharField(max_length=200) 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( 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) 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) focal_y = models.IntegerField(null=True, blank=True)
blurhash = models.TextField(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): def is_image(self):
return self.mimetype in [ return self.mimetype in [
"image/apng", "image/apng",
@ -58,3 +70,30 @@ class PostAttachment(StatorModel):
"image/png", "image/png",
"image/webp", "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.core.exceptions import PermissionDenied
from django.db import models from django.db import models
from django.http import JsonResponse from django.http import JsonResponse
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.utils.decorators import method_decorator 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 ( from activities.models import Post, PostInteraction, PostInteractionStates, PostStates
Post,
PostInteraction,
PostInteractionStates,
PostStates,
TimelineEvent,
)
from core.html import html_to_plaintext
from core.ld import canonicalise from core.ld import canonicalise
from core.models import Config
from users.decorators import identity_required from users.decorators import identity_required
from users.shortcuts import by_handle_or_404 from users.shortcuts import by_handle_or_404
@ -188,130 +179,3 @@ class Delete(TemplateView):
def post(self, request): def post(self, request):
self.post_obj.transition_perform(PostStates.deleted) self.post_obj.transition_perform(PostStates.deleted)
return redirect("/") 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 dj_database_url~=1.0.0
python-dotenv~=0.21.0 python-dotenv~=0.21.0
email-validator~=1.3.0 email-validator~=1.3.0
blurhash-python~=1.1.3

View file

@ -586,6 +586,25 @@ form img.preview {
align-self: center; 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 { form .buttons {
text-align: right; text-align: right;
margin: -20px 0 15px 0; margin: -20px 0 15px 0;
@ -595,6 +614,15 @@ form p+.buttons {
margin-top: 0; margin-top: 0;
} }
form .button.add-image {
margin: 10px 0 10px 0;
}
form progress {
display: none;
width: 100%;
}
.right-column form .buttons { .right-column form .buttons {
margin: 5px 10px 5px 0; margin: 5px 10px 5px 0;
} }
@ -1062,7 +1090,8 @@ table.metadata td.name {
cursor: pointer; cursor: pointer;
} }
.copied, .copied:hover { .copied,
.copied:hover {
color: var(--color-highlight); color: var(--color-highlight);
transition: 0.2s; 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.urls import path, re_path
from django.views.static import serve 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 core import views as core
from stator import views as stator from stator import views as stator
from users.views import activitypub, admin, auth, follows, identity, settings from users.views import activitypub, admin, auth, follows, identity, settings
@ -120,14 +120,19 @@ urlpatterns = [
path("@<handle>/inbox/", activitypub.Inbox.as_view()), path("@<handle>/inbox/", activitypub.Inbox.as_view()),
path("@<handle>/action/", identity.ActionIdentity.as_view()), path("@<handle>/action/", identity.ActionIdentity.as_view()),
# Posts # 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>/", posts.Individual.as_view()),
path("@<handle>/posts/<int:post_id>/like/", posts.Like.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>/unlike/", posts.Like.as_view(undo=True)),
path("@<handle>/posts/<int:post_id>/boost/", posts.Boost.as_view()), 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>/unboost/", posts.Boost.as_view(undo=True)),
path("@<handle>/posts/<int:post_id>/delete/", posts.Delete.as_view()), 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 # Authentication
path("auth/login/", auth.Login.as_view(), name="login"), path("auth/login/", auth.Login.as_view(), name="login"),
path("auth/logout/", auth.Logout.as_view(), name="logout"), 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"> <div class="attachments">
{% for attachment in post.attachments.all %} {% for attachment in post.attachments.all %}
{% if attachment.is_image %} {% 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 %} {% endif %}
{% endfor %} {% endfor %}
</div> </div>

View file

@ -17,8 +17,24 @@
{% include "forms/_field.html" with field=form.content_warning %} {% include "forms/_field.html" with field=form.content_warning %}
{% include "forms/_field.html" with field=form.visibility %} {% include "forms/_field.html" with field=form.visibility %}
</fieldset> </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"> <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> </div>
</form> </form>
{% endblock %} {% endblock %}

View file

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

View file

@ -1,12 +1,10 @@
import io
from django import forms from django import forms
from django.core.files import File from django.core.files import File
from django.shortcuts import redirect from django.shortcuts import redirect
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.generic import FormView from django.views.generic import FormView
from PIL import Image, ImageOps
from core.files import resize_image
from users.decorators import identity_required from users.decorators import identity_required
@ -51,13 +49,6 @@ class ProfilePage(FormView):
"discoverable": identity.discoverable, "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): def form_valid(self, form):
# Update basic info # Update basic info
identity = self.request.identity identity = self.request.identity
@ -70,12 +61,12 @@ class ProfilePage(FormView):
if isinstance(icon, File): if isinstance(icon, File):
identity.icon.save( identity.icon.save(
icon.name, icon.name,
self.resize_image(icon, size=(400, 400)), resize_image(icon, size=(400, 400)),
) )
if isinstance(image, File): if isinstance(image, File):
identity.image.save( identity.image.save(
image.name, image.name,
self.resize_image(image, size=(1500, 500)), resize_image(image, size=(1500, 500)),
) )
identity.save() identity.save()
return redirect(".") return redirect(".")