diff --git a/core/management/commands/populate.py b/core/management/commands/populate.py index f7843989..0e41cc93 100644 --- a/core/management/commands/populate.py +++ b/core/management/commands/populate.py @@ -740,8 +740,9 @@ Welcome to the wiki page! size=file.size, ) pict.file.name = p.name - pict.clean() + pict.full_clean() pict.generate_thumbnails() + pict.save() img_skia = Picture.objects.get(name="skia.jpg") img_sli = Picture.objects.get(name="sli.jpg") diff --git a/core/schemas.py b/core/schemas.py index f4080c90..64494ed8 100644 --- a/core/schemas.py +++ b/core/schemas.py @@ -1,16 +1,29 @@ from pathlib import Path -from typing import Annotated +from typing import Annotated, Any from annotated_types import MinLen from django.contrib.staticfiles.storage import staticfiles_storage from django.db.models import Q from django.urls import reverse from django.utils.text import slugify +from django.utils.translation import gettext as _ from haystack.query import SearchQuerySet -from ninja import FilterSchema, ModelSchema, Schema +from ninja import FilterSchema, ModelSchema, Schema, UploadedFile from pydantic import AliasChoices, Field +from pydantic_core.core_schema import ValidationInfo from core.models import Group, SithFile, User +from core.utils import is_image + + +class UploadedImage(UploadedFile): + @classmethod + def _validate(cls, v: Any, info: ValidationInfo) -> Any: + super()._validate(v, info) + if not is_image(v): + msg = _("This file is not a valid image") + raise ValueError(msg) + return v class SimpleUserSchema(ModelSchema): diff --git a/core/utils.py b/core/utils.py index 2f56cebf..c54aad81 100644 --- a/core/utils.py +++ b/core/utils.py @@ -17,15 +17,30 @@ from datetime import date, timedelta # Image utils from io import BytesIO +from typing import Final import PIL from django.conf import settings from django.core.files.base import ContentFile +from django.core.files.uploadedfile import UploadedFile from django.http import HttpRequest from django.utils.timezone import localdate from PIL import ExifTags from PIL.Image import Image, Resampling +RED_PIXEL_PNG: Final[bytes] = ( + b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52" + b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90\x77\x53" + b"\xde\x00\x00\x00\x0c\x49\x44\x41\x54\x08\xd7\x63\xf8\xcf\xc0\x00" + b"\x00\x03\x01\x01\x00\x18\xdd\x8d\xb0\x00\x00\x00\x00\x49\x45\x4e" + b"\x44\xae\x42\x60\x82" +) +"""A single red pixel, in PNG format. + +Can be used in tests and in dev, when there is a need +to generate a dummy image that is considered valid nonetheless +""" + def get_start_of_semester(today: date | None = None) -> date: """Return the date of the start of the semester of the given date. @@ -97,6 +112,18 @@ def get_semester_code(d: date | None = None) -> str: return "P" + str(start.year)[-2:] +def is_image(file: UploadedFile): + try: + im = PIL.Image.open(file.file) + im.verify() + # go back to the start of the file, without closing it. + # Otherwise, further checks on django side will fail + file.seek(0) + except PIL.UnidentifiedImageError: + return False + return True + + def resize_image( im: Image, edge: int, img_format: str, *, optimize: bool = True ) -> ContentFile: diff --git a/galaxy/management/commands/generate_galaxy_test_data.py b/galaxy/management/commands/generate_galaxy_test_data.py index 0c5614d6..966697a2 100644 --- a/galaxy/management/commands/generate_galaxy_test_data.py +++ b/galaxy/management/commands/generate_galaxy_test_data.py @@ -32,17 +32,10 @@ from django.utils import timezone from club.models import Club, Membership from core.models import Group, Page, SithFile, User +from core.utils import RED_PIXEL_PNG from sas.models import Album, PeoplePictureRelation, Picture from subscription.models import Subscription -RED_PIXEL_PNG: Final[bytes] = ( - b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52" - b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90\x77\x53" - b"\xde\x00\x00\x00\x0c\x49\x44\x41\x54\x08\xd7\x63\xf8\xcf\xc0\x00" - b"\x00\x03\x01\x01\x00\x18\xdd\x8d\xb0\x00\x00\x00\x00\x49\x45\x4e" - b"\x44\xae\x42\x60\x82" -) - USER_PACK_SIZE: Final[int] = 1000 diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 1e944299..249eee49 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-04-06 15:54+0200\n" +"POT-Creation-Date: 2025-04-08 16:20+0200\n" "PO-Revision-Date: 2016-07-18\n" "Last-Translator: Maréchal \n" @@ -116,7 +116,7 @@ msgstr "Vous ne pouvez pas ajouter deux fois le même utilisateur" msgid "You should specify a role" msgstr "Vous devez choisir un rôle" -#: club/forms.py sas/views.py +#: club/forms.py sas/forms.py msgid "You do not have the permission to do that" msgstr "Vous n'avez pas la permission de faire cela" @@ -1047,7 +1047,7 @@ msgid "Posters - edit" msgstr "Affiche - modifier" #: com/templates/com/poster_list.jinja com/templates/com/screen_list.jinja -#: sas/templates/sas/main.jinja +#: sas/templates/sas/fragments/album_create_form.jinja msgid "Create" msgstr "Créer" @@ -1644,6 +1644,10 @@ msgstr "étiquette" msgid "operation type" msgstr "type d'opération" +#: core/schemas.py +msgid "This file is not a valid image" +msgstr "Ce fichier n'est pas une image valide" + #: core/templates/core/403.jinja msgid "403, Forbidden" msgstr "403, Non autorisé" @@ -2729,7 +2733,7 @@ msgstr "Ajouter un nouveau dossier" msgid "Error creating folder %(folder_name)s: %(msg)s" msgstr "Erreur de création du dossier %(folder_name)s : %(msg)s" -#: core/views/files.py core/views/forms.py sas/forms.py +#: core/views/files.py core/views/forms.py #, python-format msgid "Error uploading file %(file_name)s: %(msg)s" msgstr "Erreur d'envoi du fichier %(file_name)s : %(msg)s" @@ -4715,11 +4719,6 @@ msgstr "Ajouter un nouvel album" msgid "Upload images" msgstr "Envoyer les images" -#: sas/forms.py -#, python-format -msgid "Error creating album %(album)s: %(msg)s" -msgstr "Erreur de création de l'album %(album)s : %(msg)s" - #: sas/forms.py msgid "You already requested moderation for this picture." msgstr "Vous avez déjà déposé une demande de retrait pour cette photo." diff --git a/sas/api.py b/sas/api.py index d9e2ad2e..175e44c0 100644 --- a/sas/api.py +++ b/sas/api.py @@ -1,7 +1,10 @@ +from typing import Any, Literal + from django.conf import settings +from django.core.exceptions import ValidationError from django.db.models import F from django.urls import reverse -from ninja import Query +from ninja import Body, File, Query from ninja_extra import ControllerBase, api_controller, paginate, route from ninja_extra.exceptions import NotFound, PermissionDenied from ninja_extra.pagination import PageNumberPaginationExtra @@ -9,8 +12,15 @@ from ninja_extra.permissions import IsAuthenticated from ninja_extra.schemas import PaginatedResponseSchema from pydantic import NonNegativeInt -from core.auth.api_permissions import CanAccessLookup, CanView, IsInGroup, IsRoot +from core.auth.api_permissions import ( + CanAccessLookup, + CanEdit, + CanView, + IsInGroup, + IsRoot, +) from core.models import Notification, User +from core.schemas import UploadedImage from sas.models import Album, PeoplePictureRelation, Picture from sas.schemas import ( AlbumAutocompleteSchema, @@ -92,6 +102,38 @@ class PicturesController(ControllerBase): .annotate(album=F("parent__name")) ) + @route.post( + "", + permissions=[CanEdit], + response={ + 200: None, + 409: dict[Literal["detail"], dict[str, list[str]]], + 422: dict[Literal["detail"], list[dict[str, Any]]], + }, + url_name="upload_picture", + ) + def upload_picture(self, album_id: Body[int], picture: File[UploadedImage]): + album = self.get_object_or_exception(Album, pk=album_id) + user = self.context.request.user + self_moderate = user.has_perm("sas.moderate_sasfile") + new = Picture( + parent=album, + name=picture.name, + file=picture, + owner=user, + is_moderated=self_moderate, + is_folder=False, + mime_type=picture.content_type, + ) + if self_moderate: + new.moderator = user + try: + new.generate_thumbnails() + new.full_clean() + new.save() + except ValidationError as e: + return self.create_response({"detail": dict(e)}, status_code=409) + @route.get( "/{picture_id}/identified", permissions=[IsAuthenticated, CanView], diff --git a/sas/forms.py b/sas/forms.py index d987aaf1..71dedd7d 100644 --- a/sas/forms.py +++ b/sas/forms.py @@ -1,6 +1,7 @@ from typing import Any from django import forms +from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ from core.models import User @@ -11,55 +12,28 @@ from sas.models import Album, Picture, PictureModerationRequest from sas.widgets.ajax_select import AutoCompleteSelectAlbum -class SASForm(forms.Form): - album_name = forms.CharField( - label=_("Add a new album"), max_length=Album.NAME_MAX_LENGTH, required=False - ) - images = MultipleImageField( - label=_("Upload images"), - required=False, - ) +class AlbumCreateForm(forms.ModelForm): + class Meta: + model = Album + fields = ["name", "parent"] + labels = {"name": _("Add a new album")} + widgets = {"parent": forms.HiddenInput} - def process(self, parent, owner, files, *, automodere=False): - try: - if self.cleaned_data["album_name"] != "": - album = Album( - parent=parent, - name=self.cleaned_data["album_name"], - owner=owner, - is_moderated=automodere, - ) - album.clean() - album.save() - except Exception as e: - self.add_error( - None, - _("Error creating album %(album)s: %(msg)s") - % {"album": self.cleaned_data["album_name"], "msg": repr(e)}, - ) - for f in files: - new_file = Picture( - parent=parent, - name=f.name, - file=f, - owner=owner, - mime_type=f.content_type, - size=f.size, - is_folder=False, - is_moderated=automodere, - ) - if automodere: - new_file.moderator = owner - try: - new_file.clean() - new_file.generate_thumbnails() - new_file.save() - except Exception as e: - self.add_error( - None, - _("Error uploading file %(file_name)s: %(msg)s") - % {"file_name": f, "msg": repr(e)}, - ) + def __init__(self, *args, owner: User, **kwargs): + super().__init__(*args, **kwargs) + self.instance.owner = owner + if owner.has_perm("sas.moderate_sasfile"): + self.instance.is_moderated = True + self.instance.moderator = owner + + def clean(self): + if not self.instance.owner.can_edit(self.instance.parent): + raise ValidationError(_("You do not have the permission to do that")) + return super().clean() + + +class PictureUploadForm(forms.Form): + images = MultipleImageField(label=_("Upload images"), required=False) class PictureEditForm(forms.ModelForm): diff --git a/sas/models.py b/sas/models.py index e2b8867a..4f3ff21e 100644 --- a/sas/models.py +++ b/sas/models.py @@ -134,7 +134,6 @@ class Picture(SasFile): self.thumbnail.name = new_extension_name self.compressed = compressed self.compressed.name = new_extension_name - self.save() def rotate(self, degree): for attr in ["file", "compressed", "thumbnail"]: @@ -235,6 +234,8 @@ class Album(SasFile): return Album.objects.filter(parent=self) def get_absolute_url(self): + if self.id == settings.SITH_SAS_ROOT_DIR_ID: + return reverse("sas:main") return reverse("sas:album", kwargs={"album_id": self.id}) def get_download_url(self): diff --git a/sas/static/bundled/sas/album-index.ts b/sas/static/bundled/sas/album-index.ts index 6dda1ce9..32f0f02f 100644 --- a/sas/static/bundled/sas/album-index.ts +++ b/sas/static/bundled/sas/album-index.ts @@ -5,8 +5,10 @@ import { type AlbumSchema, type PictureSchema, type PicturesFetchPicturesData, + type PicturesUploadPictureErrors, albumFetchAlbum, picturesFetchPictures, + picturesUploadPicture, } from "#openapi"; interface AlbumPicturesConfig { @@ -78,4 +80,49 @@ document.addEventListener("alpine:init", () => { this.loading = false; }, })); + + Alpine.data("pictureUpload", (albumId: number) => ({ + errors: [] as string[], + pictures: [], + sending: false, + progress: null as HTMLProgressElement, + + init() { + this.progress = this.$refs.progress; + }, + + async sendPictures() { + const input = this.$refs.pictures as HTMLInputElement; + const files = input.files; + this.errors = []; + this.progress.value = 0; + this.progress.max = files.length; + this.sending = true; + for (const file of files) { + await this.sendPicture(file); + } + this.sending = false; + // This should trigger a reload of the pictures of the `picture` Alpine data + this.$dispatch("pictures-upload-done"); + }, + + async sendPicture(file: File) { + const res = await picturesUploadPicture({ + // biome-ignore lint/style/useNamingConvention: api is snake_case + body: { album_id: albumId, picture: file }, + }); + if (!res.response.ok) { + let msg = ""; + if (res.response.status === 422) { + msg = (res.error as PicturesUploadPictureErrors[422]).detail + .map((err: Record<"ctx", Record<"error", string>>) => err.ctx.error) + .join(" ; "); + } else { + msg = Object.values(res.error.detail).join(" ; "); + } + this.errors.push(`${file.name} : ${msg}`); + } + this.progress.value += 1; + }, + })); }); diff --git a/sas/templates/sas/album.jinja b/sas/templates/sas/album.jinja index 6c2cbcf7..18cd6f21 100644 --- a/sas/templates/sas/album.jinja +++ b/sas/templates/sas/album.jinja @@ -73,7 +73,7 @@
{% trans %}To be moderated{% endtrans %}
- {% if edit_mode %} + {% if is_sas_admin %} {% endif %} @@ -86,7 +86,7 @@

{% trans %}Pictures{% endtrans %}


{{ download_button(_("Download album")) }} -
+