From 156305a16ad45d52c9a3703665e4ad7963327faa Mon Sep 17 00:00:00 2001 From: imperosol Date: Wed, 26 Mar 2025 15:06:41 +0100 Subject: [PATCH 1/5] add api endpoint to upload a sas picture --- core/utils.py | 14 +++++++ .../commands/generate_galaxy_test_data.py | 9 +---- sas/api.py | 40 ++++++++++++++++++- sas/tests/test_api.py | 27 ++++++++++++- 4 files changed, 79 insertions(+), 11 deletions(-) diff --git a/core/utils.py b/core/utils.py index 2f56cebf..d9e8f180 100644 --- a/core/utils.py +++ b/core/utils.py @@ -17,6 +17,7 @@ from datetime import date, timedelta # Image utils from io import BytesIO +from typing import Final import PIL from django.conf import settings @@ -26,6 +27,19 @@ 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. 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/sas/api.py b/sas/api.py index d9e2ad2e..2d6d64ef 100644 --- a/sas/api.py +++ b/sas/api.py @@ -1,7 +1,9 @@ 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, Query, UploadedFile +from ninja.errors import HttpError from ninja_extra import ControllerBase, api_controller, paginate, route from ninja_extra.exceptions import NotFound, PermissionDenied from ninja_extra.pagination import PageNumberPaginationExtra @@ -9,7 +11,13 @@ 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 sas.models import Album, PeoplePictureRelation, Picture from sas.schemas import ( @@ -92,6 +100,34 @@ class PicturesController(ControllerBase): .annotate(album=F("parent__name")) ) + @route.post( + "", + permissions=[CanEdit], + response={200: None, 409: dict[str, list[str]]}, + url_name="upload_picture", + ) + def upload_picture(self, album_id: Body[int], picture: UploadedFile): + 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: + raise HttpError(status_code=409, message=str(e)) from e + @route.get( "/{picture_id}/identified", permissions=[IsAuthenticated, CanView], diff --git a/sas/tests/test_api.py b/sas/tests/test_api.py index 25014e86..2570a3c6 100644 --- a/sas/tests/test_api.py +++ b/sas/tests/test_api.py @@ -1,13 +1,16 @@ +import pytest from django.conf import settings from django.core.cache import cache +from django.core.files.uploadedfile import SimpleUploadedFile from django.db import transaction -from django.test import TestCase +from django.test import Client, TestCase from django.urls import reverse from model_bakery import baker from model_bakery.recipe import Recipe from core.baker_recipes import old_subscriber_user, subscriber_user from core.models import Group, SithFile, User +from core.utils import RED_PIXEL_PNG from sas.baker_recipes import picture_recipe from sas.models import Album, PeoplePictureRelation, Picture, PictureModerationRequest @@ -241,3 +244,25 @@ class TestAlbumSearch(TestSas): # - 1 for pagination # - 1 for the actual results self.client.get(reverse("api:search-album")) + + +@pytest.mark.django_db +def test_upload_picture(client: Client): + sas = SithFile.objects.get(pk=settings.SITH_SAS_ROOT_DIR_ID) + album = baker.make(Album, is_in_sas=True, parent=sas, name="test album") + user = baker.make(User, is_superuser=True) + client.force_login(user) + img = SimpleUploadedFile( + name="img.png", content=RED_PIXEL_PNG, content_type="image/png" + ) + res = client.post( + reverse("api:upload_picture"), {"album_id": album.id, "picture": img} + ) + assert res.status_code == 200 + picture = Picture.objects.filter(parent_id=album.id).first() + assert picture is not None + assert picture.name == "img.png" + assert picture.owner == user + assert picture.file.name == "SAS/test album/img.png" + assert picture.compressed.name == ".compressed/SAS/test album/img.webp" + assert picture.thumbnail.name == ".thumbnails/SAS/test album/img.webp" From b83fbf91e16f248d5dd63c8502ac6c1207704c0f Mon Sep 17 00:00:00 2001 From: imperosol Date: Sat, 29 Mar 2025 18:19:05 +0100 Subject: [PATCH 2/5] extract album creation form into its own fragment --- sas/forms.py | 70 ++++-------- sas/templates/sas/album.jinja | 21 +++- .../sas/fragments/album_create_form.jinja | 18 +++ sas/templates/sas/main.jinja | 17 +-- sas/urls.py | 2 + sas/views.py | 105 +++++++++--------- 6 files changed, 111 insertions(+), 122 deletions(-) create mode 100644 sas/templates/sas/fragments/album_create_form.jinja 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/templates/sas/album.jinja b/sas/templates/sas/album.jinja index 6c2cbcf7..677552c1 100644 --- a/sas/templates/sas/album.jinja +++ b/sas/templates/sas/album.jinja @@ -110,13 +110,28 @@ {% if is_sas_admin %} -
+ {{ album_create_fragment }} + {% csrf_token %}
- {{ form.as_p() }} - +

+ + {{ upload_form.images|add_attr("x-ref=pictures") }} + {{ upload_form.images.help_text }} +

+
+
    + +
{% endif %} diff --git a/sas/templates/sas/fragments/album_create_form.jinja b/sas/templates/sas/fragments/album_create_form.jinja new file mode 100644 index 00000000..4369bff6 --- /dev/null +++ b/sas/templates/sas/fragments/album_create_form.jinja @@ -0,0 +1,18 @@ +
+ {% csrf_token %} +
+
+ + {{ form.name }} +
+ {{ form.parent }} + +
+ {{ form.non_field_errors() }} + {{ form.name.errors }} +
\ No newline at end of file diff --git a/sas/templates/sas/main.jinja b/sas/templates/sas/main.jinja index 98dc9f87..5fa82a45 100644 --- a/sas/templates/sas/main.jinja +++ b/sas/templates/sas/main.jinja @@ -61,23 +61,8 @@ {% if is_sas_admin %} -
- -
- {% csrf_token %} - -
-
- - {{ form.album_name }} -
- -
- - {{ form.non_field_errors() }} - {{ form.album_name.errors }} -
+ {{ album_create_fragment }} {% endif %} {% endif %} diff --git a/sas/urls.py b/sas/urls.py index 5fb57ccf..9a5435d2 100644 --- a/sas/urls.py +++ b/sas/urls.py @@ -16,6 +16,7 @@ from django.urls import path from sas.views import ( + AlbumCreateFragment, AlbumEditView, AlbumUploadView, AlbumView, @@ -59,4 +60,5 @@ urlpatterns = [ path( "user//pictures/", UserPicturesView.as_view(), name="user_pictures" ), + path("fragment/album-create", AlbumCreateFragment.as_view(), name="album_create"), ] diff --git a/sas/views.py b/sas/views.py index 74a34816..799258a8 100644 --- a/sas/views.py +++ b/sas/views.py @@ -36,28 +36,40 @@ from sas.forms import ( from sas.models import Album, Picture -class SASMainView(FormView): - form_class = SASForm - template_name = "sas/main.jinja" - success_url = reverse_lazy("sas:main") +class AlbumCreateFragment(FragmentMixin, CreateView): + model = Album + form_class = AlbumCreateForm + template_name = "sas/fragments/album_create_form.jinja" + reload_on_redirect = True - def post(self, request, *args, **kwargs): - self.form = self.get_form() - parent = SithFile.objects.filter(id=settings.SITH_SAS_ROOT_DIR_ID).first() - files = request.FILES.getlist("images") - root = User.objects.filter(username="root").first() - if request.user.is_authenticated and request.user.is_in_group( - pk=settings.SITH_GROUP_SAS_ADMIN_ID - ): - if self.form.is_valid(): - self.form.process( - parent=parent, owner=root, files=files, automodere=True - ) - if self.form.is_valid(): - return super().form_valid(self.form) - else: - self.form.add_error(None, _("You do not have the permission to do that")) - return self.form_invalid(self.form) + def get_form_kwargs(self): + return super().get_form_kwargs() | {"owner": self.request.user} + + def render_fragment( + self, request, owner: User | None = None, **kwargs + ) -> SafeString: + self.object = None + self.owner = owner or self.request.user + return super().render_fragment(request, **kwargs) + + def get_success_url(self): + parent = self.object.parent + parent.__class__ = Album + return parent.get_absolute_url() + + +class SASMainView(UseFragmentsMixin, TemplateView): + template_name = "sas/main.jinja" + + def get_fragments(self) -> dict[str, FragmentRenderer]: + form_init = {"parent": SithFile.objects.get(id=settings.SITH_SAS_ROOT_DIR_ID)} + return { + "album_create_fragment": AlbumCreateFragment.as_fragment(initial=form_init) + } + + def get_fragment_data(self) -> dict[str, dict[str, Any]]: + root_user = User.objects.get(pk=settings.SITH_ROOT_USER_ID) + return {"album_create_fragment": {"owner": root_user}} def get_context_data(self, **kwargs): kwargs = super().get_context_data(**kwargs) @@ -130,62 +142,45 @@ class AlbumUploadView(CanViewMixin, DetailView, FormMixin): return HttpResponse(str(self.form.errors), status=200) return HttpResponse(str(self.form.errors), status=500) - -class AlbumView(CanViewMixin, DetailView, FormMixin): +class AlbumView(CanViewMixin, UseFragmentsMixin, DetailView): model = Album - form_class = SASForm pk_url_kwarg = "album_id" template_name = "sas/album.jinja" + def get_fragments(self) -> dict[str, FragmentRenderer]: + return { + "album_create_fragment": AlbumCreateFragment.as_fragment( + initial={"parent": self.object} + ) + } + def dispatch(self, request, *args, **kwargs): try: self.asked_page = int(request.GET.get("page", 1)) except ValueError as e: raise Http404 from e - return super().dispatch(request, *args, **kwargs) - - def get(self, request, *args, **kwargs): - self.form = self.get_form() if "clipboard" not in request.session: request.session["clipboard"] = [] - return super().get(request, *args, **kwargs) + return super().dispatch(request, *args, **kwargs) def post(self, request, *args, **kwargs): self.object = self.get_object() if not self.object.file: self.object.generate_thumbnail() - self.form = self.get_form() - if "clipboard" not in request.session: - request.session["clipboard"] = [] if request.user.can_edit(self.object): # Handle the copy-paste functions FileView.handle_clipboard(request, self.object) - parent = SithFile.objects.filter(id=self.object.id).first() - files = request.FILES.getlist("images") - if request.user.is_authenticated and request.user.is_subscribed: - if self.form.is_valid(): - self.form.process( - parent=parent, - owner=request.user, - files=files, - automodere=request.user.is_in_group( - pk=settings.SITH_GROUP_SAS_ADMIN_ID - ), - ) - if self.form.is_valid(): - return super().form_valid(self.form) - else: - self.form.add_error(None, _("You do not have the permission to do that")) - return self.form_invalid(self.form) + return HttpResponseRedirect(self.request.path) - def get_success_url(self): - return reverse("sas:album", kwargs={"album_id": self.object.id}) + def get_fragment_data(self) -> dict[str, dict[str, Any]]: + return {"album_create_fragment": {"owner": self.request.user}} def get_context_data(self, **kwargs): kwargs = super().get_context_data(**kwargs) - kwargs["form"] = self.form - kwargs["clipboard"] = SithFile.objects.filter( - id__in=self.request.session["clipboard"] - ) + if ids := self.request.session.get("clipboard", None): + kwargs["clipboard"] = SithFile.objects.filter(id__in=ids) + kwargs["upload_form"] = PictureUploadForm() + # if True, the albums will be fetched with a request to the API + # if False, the section won't be displayed at all kwargs["show_albums"] = ( Album.objects.viewable_by(self.request.user) .filter(parent_id=self.object.id) From 13f417ba307d3620511f61861b14e47524295c69 Mon Sep 17 00:00:00 2001 From: imperosol Date: Sat, 29 Mar 2025 18:19:58 +0100 Subject: [PATCH 3/5] Use Alpine and the API for SAS picture upload --- core/management/commands/populate.py | 3 +- sas/models.py | 3 +- sas/static/bundled/sas/album-index.ts | 37 ++++++++ sas/templates/sas/album.jinja | 116 +------------------------- sas/urls.py | 4 - sas/views.py | 41 ++------- 6 files changed, 52 insertions(+), 152 deletions(-) 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/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..b2a706d5 100644 --- a/sas/static/bundled/sas/album-index.ts +++ b/sas/static/bundled/sas/album-index.ts @@ -7,6 +7,7 @@ import { type PicturesFetchPicturesData, albumFetchAlbum, picturesFetchPictures, + picturesUploadPicture, } from "#openapi"; interface AlbumPicturesConfig { @@ -78,4 +79,40 @@ 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.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) { + this.errors.push(`${file.name} : ${res.error.detail}`); + } + this.progress.value += 1; + }, + })); }); diff --git a/sas/templates/sas/album.jinja b/sas/templates/sas/album.jinja index 677552c1..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")) }} -
+