From d6219118498527379d7ac8d116e256bc1b232934 Mon Sep 17 00:00:00 2001 From: imperosol Date: Thu, 23 Apr 2026 23:52:20 +0200 Subject: [PATCH] Automatically resize album thumbnail --- sas/forms.py | 45 ++++++++++++++++++++++++++++++---- sas/models.py | 22 ++++++----------- sas/templates/sas/macros.jinja | 8 +++--- sas/views.py | 8 +++--- 4 files changed, 55 insertions(+), 28 deletions(-) diff --git a/sas/forms.py b/sas/forms.py index a478ce43..bde2c29c 100644 --- a/sas/forms.py +++ b/sas/forms.py @@ -1,16 +1,23 @@ -from typing import Any +import copy +from pathlib import Path +from typing import TYPE_CHECKING, Any from django import forms from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ +from PIL import Image from core.models import User +from core.utils import resize_image from core.views import MultipleImageField from core.views.forms import SelectDate from core.views.widgets.ajax_select import AutoCompleteSelectMultipleGroup from sas.models import Album, Picture, PictureModerationRequest from sas.widgets.ajax_select import AutoCompleteSelectAlbum +if TYPE_CHECKING: + from django.db.models.fields.files import FieldFile + class AlbumCreateForm(forms.ModelForm): class Meta: @@ -49,17 +56,45 @@ class AlbumEditForm(forms.ModelForm): class Meta: model = Album fields = ["name", "date", "file", "parent", "edit_groups"] - widgets = { - "edit_groups": AutoCompleteSelectMultipleGroup, - } + widgets = {"edit_groups": AutoCompleteSelectMultipleGroup, "date": SelectDate} name = forms.CharField(max_length=Album.NAME_MAX_LENGTH, label=_("file name")) - date = forms.DateField(label=_("Date"), widget=SelectDate, required=True) recursive = forms.BooleanField(label=_("Apply rights recursively"), required=False) parent = forms.ModelChoiceField( Album.objects.all(), required=True, widget=AutoCompleteSelectAlbum ) + def clean_file(self): + # if a file was given in the form, resize it + f: FieldFile = self.cleaned_data["file"] + if self.errors or not f or "file" not in self.changed_data: + return f + f.file = resize_image(Image.open(f.file), 200, "WEBP") + return f + + def save(self, commit=True): # noqa: FBT002 + initial_file = copy.copy(self.initial["file"]) + if not self.cleaned_data["file"]: + # if no file is in the form, it can mean either : + # - there was a file initially, but the deletion box was checked + # - there was no file initially, and there still isn't + # in both cases, we procedurally generate the thumbnail + self.instance.generate_thumbnail() + elif "file" in self.changed_data: + self.instance.file.name = str(Path(self.instance.name) / "thumb.webp") + res = super().save(commit=commit) + if initial_file and ( + not self.instance.file or initial_file.path != self.instance.file.path + ): + # The initial file must be removed from storage + # AFTER the new one has been dealt with, + # in order to be sure that django will generate a different filename. + # Otherwise, the client cache wouldn't be properly busted. + # Even if there was no file, this operation cannot fail, because django + # will just silently return in that case. + initial_file.delete(save=False) + return res + class PictureModerationRequestForm(forms.ModelForm): """Form to create a PictureModerationRequest. diff --git a/sas/models.py b/sas/models.py index 64f6c15b..d4981839 100644 --- a/sas/models.py +++ b/sas/models.py @@ -22,6 +22,7 @@ from typing import ClassVar, Self from django.conf import settings from django.core.cache import cache +from django.core.files.base import ContentFile from django.db import models from django.db.models import Exists, OuterRef, Q from django.urls import reverse @@ -110,7 +111,7 @@ class Picture(SasFile): def get_absolute_url(self): return reverse("sas:picture", kwargs={"picture_id": self.id}) - def generate_thumbnails(self, *, overwrite=False): + def generate_thumbnails(self): im = Image.open(BytesIO(self.file.read())) with contextlib.suppress(Exception): im = exif_auto_rotate(im) @@ -126,10 +127,6 @@ class Picture(SasFile): file = resize_image(im, max(im.size), extension, optimize=False) thumb = resize_image(im, 200, "webp") compressed = resize_image(im, 1200, "webp") - if overwrite: - self.file.delete() - self.thumbnail.delete() - self.compressed.delete() new_extension_name = str(Path(self.name).with_suffix(".webp")) self.file = file self.file.name = self.name @@ -245,17 +242,12 @@ class Album(SasFile): return reverse("sas:album_preview", kwargs={"album_id": self.id}) def generate_thumbnail(self): - p = ( - self.children_pictures.order_by("?").first() - or self.children_albums.exclude(file=None) - .exclude(file="") - .order_by("?") - .first() - ) - if p and p.file: - image = resize_image(Image.open(BytesIO(p.file.read())), 200, "webp") + p = self.children_pictures.order_by("?").first() + if p and p.thumbnail: + image = ContentFile( + name=str(Path(self.name) / "thumb.webp"), content=p.thumbnail.read() + ) self.file = image - self.file.name = f"{self.name}/thumb.webp" self.save() diff --git a/sas/templates/sas/macros.jinja b/sas/templates/sas/macros.jinja index 36f76584..e0c21bce 100644 --- a/sas/templates/sas/macros.jinja +++ b/sas/templates/sas/macros.jinja @@ -2,19 +2,19 @@ {% if a.file %} {% set img = a.get_download_url() %} - {% set src = a.name %} + {% set alt = a.name %} {% elif a.children.filter(is_folder=False, is_moderated=True).exists() %} {% set picture = a.children.filter(is_folder=False).first().as_picture %} {% set img = picture.get_download_thumb_url() %} - {% set src = picture.name %} + {% set alt = picture.name %} {% else %} {% set img = static('core/img/sas.jpg') %} - {% set src = "sas.jpg" %} + {% set alt = "sas.jpg" %} {% endif %}
- {{ src }} + {{ alt }} {% if not a.is_moderated %}
 
{% trans %}To be moderated{% endtrans %}
diff --git a/sas/views.py b/sas/views.py index 160182e4..7e7f0ba2 100644 --- a/sas/views.py +++ b/sas/views.py @@ -16,6 +16,7 @@ from typing import Any from django.conf import settings from django.contrib.auth.mixins import PermissionRequiredMixin +from django.core.exceptions import PermissionDenied from django.db.models import Count, OuterRef, Subquery from django.http import Http404, HttpResponseRedirect from django.shortcuts import get_object_or_404, redirect @@ -152,10 +153,9 @@ class AlbumView(CanViewMixin, UseFragmentsMixin, DetailView): def post(self, request, *args, **kwargs): self.object = self.get_object() - if not self.object.file: - self.object.generate_thumbnail() - if request.user.can_edit(self.object): # Handle the copy-paste functions - FileView.handle_clipboard(request, self.object) + if not request.user.can_edit(self.object): + raise PermissionDenied + FileView.handle_clipboard(request, self.object) return HttpResponseRedirect(self.request.path) def get_fragment_data(self) -> dict[str, dict[str, Any]]: