diff --git a/core/management/commands/populate.py b/core/management/commands/populate.py index 69077199..a010848a 100644 --- a/core/management/commands/populate.py +++ b/core/management/commands/populate.py @@ -715,9 +715,7 @@ Welcome to the wiki page! # SAS for f in self.SAS_FIXTURE_PATH.glob("*"): if f.is_dir(): - album = Album(name=f.name) - album.clean() - album.save() + album = Album.objects.create(name=f.name, is_moderated=True) for p in f.iterdir(): file = resize_image(Image.open(p), 1000, "WEBP") pict = Picture( @@ -731,6 +729,7 @@ Welcome to the wiki page! pict.generate_thumbnails() pict.full_clean() pict.save() + album.generate_thumbnail() img_skia = Picture.objects.get(name="skia.jpg") img_sli = Picture.objects.get(name="sli.jpg") diff --git a/core/models.py b/core/models.py index eb8a5c1f..1737bb98 100644 --- a/core/models.py +++ b/core/models.py @@ -908,8 +908,6 @@ class SithFile(models.Model): super().clean() if "/" in self.name: raise ValidationError(_("Character '/' not authorized in name")) - if self == self.parent: - raise ValidationError(_("Loop in folder tree"), code="loop") if self == self.parent or ( self.parent is not None and self in self.get_parent_list() ): diff --git a/core/tests/test_files.py b/core/tests/test_files.py index 525de3dd..50872f04 100644 --- a/core/tests/test_files.py +++ b/core/tests/test_files.py @@ -5,6 +5,7 @@ from typing import Callable from uuid import uuid4 import pytest +from django.conf import settings from django.core.cache import cache from django.core.files.uploadedfile import SimpleUploadedFile, UploadedFile from django.test import Client, TestCase @@ -17,8 +18,8 @@ from pytest_django.asserts import assertNumQueries from core.baker_recipes import board_user, old_subscriber_user, subscriber_user from core.models import Group, QuickUploadImage, SithFile, User from core.utils import RED_PIXEL_PNG +from sas.baker_recipes import picture_recipe from sas.models import Picture -from sith import settings @pytest.mark.django_db @@ -30,24 +31,19 @@ class TestImageAccess: lambda: baker.make( User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)] ), - lambda: baker.make( - User, groups=[Group.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)] - ), ], ) def test_sas_image_access(self, user_factory: Callable[[], User]): """Test that only authorized users can access the sas image.""" user = user_factory() - picture: SithFile = baker.make( - Picture, parent=SithFile.objects.get(pk=settings.SITH_SAS_ROOT_DIR_ID) - ) - assert picture.is_owned_by(user) + picture = picture_recipe.make() + assert user.can_edit(picture) def test_sas_image_access_owner(self): """Test that the owner of the image can access it.""" user = baker.make(User) - picture: Picture = baker.make(Picture, owner=user) - assert picture.is_owned_by(user) + picture = picture_recipe.make(owner=user) + assert user.can_edit(picture) @pytest.mark.parametrize( "user_factory", @@ -63,7 +59,7 @@ class TestImageAccess: user = user_factory() owner = baker.make(User) picture: Picture = baker.make(Picture, owner=owner) - assert not picture.is_owned_by(user) + assert not user.can_edit(picture) @pytest.mark.django_db diff --git a/core/utils.py b/core/utils.py index bf4e8ec2..60d1bbfd 100644 --- a/core/utils.py +++ b/core/utils.py @@ -12,20 +12,23 @@ # OR WITHIN THE LOCAL FILE "LICENSE" # # - +from dataclasses import dataclass from datetime import date, timedelta # Image utils from io import BytesIO -from typing import Final, Unpack +from typing import Any, Final, Unpack import PIL from django.conf import settings from django.core.files.base import ContentFile from django.core.files.uploadedfile import UploadedFile from django.db import models +from django.forms import BaseForm from django.http import Http404, HttpRequest from django.shortcuts import get_list_or_404 +from django.template.loader import render_to_string +from django.utils.safestring import SafeString from django.utils.timezone import localdate from PIL import ExifTags from PIL.Image import Image, Resampling @@ -44,6 +47,21 @@ to generate a dummy image that is considered valid nonetheless """ +@dataclass +class FormFragmentTemplateData[T: BaseForm]: + """Dataclass used to pre-render form fragments""" + + form: T + template: str + context: dict[str, Any] + + def render(self, request: HttpRequest) -> SafeString: + # Request is needed for csrf_tokens + return render_to_string( + self.template, context={"form": self.form, **self.context}, request=request + ) + + def get_start_of_semester(today: date | None = None) -> date: """Return the date of the start of the semester of the given date. If no date is given, return the start date of the current semester. diff --git a/docs/tutorial/groups.md b/docs/tutorial/groups.md index 2c67b3f0..9a4bf7d1 100644 --- a/docs/tutorial/groups.md +++ b/docs/tutorial/groups.md @@ -263,3 +263,35 @@ avec un unique champ permettant de sélectionner des groupes. Par défaut, seuls les utilisateurs avec la permission `auth.change_permission` auront accès à ce formulaire (donc, normalement, uniquement les utilisateurs Root). + +```mermaid +sequenceDiagram + participant A as Utilisateur + participant B as ReverseProxy + participant C as MarkdownImage + participant D as Model + + A->>B: GET /page/foo + B->>C: GET /page/foo + C-->>B: La page, avec les urls + B-->>A: La page, avec les urls + alt image publique + A->>B: GET markdown/public/2025/img.webp + B-->>A: img.webp + end + alt image privée + A->>B: GET markdown_image/{id} + B->>C: GET markdown_image/{id} + C->>D: user.can_view(image) + alt l'utilisateur a le droit de voir l'image + D-->>C: True + C-->>B: 200 (avec le X-Accel-Redirect) + B-->>A: img.webp + end + alt l'utilisateur n'a pas le droit de l'image + D-->>C: False + C-->>B: 403 + B-->>A: 403 + end + end +``` diff --git a/galaxy/management/commands/generate_galaxy_test_data.py b/galaxy/management/commands/generate_galaxy_test_data.py index b567d4b3..8438ad90 100644 --- a/galaxy/management/commands/generate_galaxy_test_data.py +++ b/galaxy/management/commands/generate_galaxy_test_data.py @@ -35,14 +35,6 @@ 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 29d302c0..67d8f23d 100644 --- a/sas/api.py +++ b/sas/api.py @@ -3,8 +3,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.shortcuts import get_list_or_404 from django.urls import reverse -from ninja import Body, File, 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 @@ -16,11 +18,12 @@ from core.auth.api_permissions import ( CanAccessLookup, CanEdit, CanView, + HasPerm, IsInGroup, IsRoot, ) from core.models import Notification, User -from core.schemas import UploadedImage +from core.utils import get_list_exact_or_404 from sas.models import Album, PeoplePictureRelation, Picture from sas.schemas import ( AlbumAutocompleteSchema, @@ -28,6 +31,7 @@ from sas.schemas import ( AlbumSchema, IdentifiedUserSchema, ModerationRequestSchema, + MoveAlbumSchema, PictureFilterSchema, PictureSchema, ) @@ -69,6 +73,48 @@ class AlbumController(ControllerBase): Album.objects.viewable_by(self.context.request.user).order_by("-date") ) + @route.patch("/parent", permissions=[IsAuthenticated]) + def change_album_parent(self, payload: list[MoveAlbumSchema]): + """Change parents of albums + + Note: + For this operation to work, the user must be authorized + to edit both the moved albums and their new parent. + """ + user: User = self.context.request.user + albums: list[Album] = get_list_exact_or_404( + Album, pk__in={a.id for a in payload} + ) + if not user.has_perm("sas.change_album"): + unauthorized = [a.id for a in albums if not user.can_edit(a)] + raise PermissionDenied( + f"You can't move the following albums : {unauthorized}" + ) + parents: list[Album] = get_list_exact_or_404( + Album, pk__in={a.new_parent_id for a in payload} + ) + if not user.has_perm("sas.change_album"): + unauthorized = [a.id for a in parents if not user.can_edit(a)] + raise PermissionDenied( + f"You can't move to the following albums : {unauthorized}" + ) + id_to_new_parent = {i.id: i.new_parent_id for i in payload} + for album in albums: + album.parent_id = id_to_new_parent[album.id] + # known caveat : moving an album won't move it's thumbnail. + # E.g. if the album foo/bar is moved to foo/baz, + # the thumbnail will still be foo/bar/thumb.webp + # This has no impact for the end user + # and doing otherwise would be hard for us to implement, + # because we would then have to manage rollbacks on fail. + Album.objects.bulk_update(albums, fields=["parent_id"]) + + @route.delete("", permissions=[HasPerm("sas.delete_album")]) + def delete_album(self, album_ids: list[int]): + # known caveat : deleting an album doesn't delete the pictures on the disk. + # It's a db only operation. + albums: list[Album] = get_list_or_404(Album, pk__in=album_ids) + @api_controller("/sas/picture") class PicturesController(ControllerBase): @@ -116,27 +162,25 @@ class PicturesController(ControllerBase): }, url_name="upload_picture", ) - def upload_picture(self, album_id: Body[int], picture: File[UploadedImage]): + 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, + original=picture, owner=user, is_moderated=self_moderate, - is_folder=False, - mime_type=picture.content_type, ) if self_moderate: new.moderator = user + new.generate_thumbnails() try: - new.generate_thumbnails() new.full_clean() - new.save() except ValidationError as e: - return self.create_response({"detail": dict(e)}, status_code=409) + raise HttpError(status_code=409, message=str(e)) from e + new.save() @route.get( "/{picture_id}/identified", diff --git a/sas/baker_recipes.py b/sas/baker_recipes.py index 974b0aec..3cdbe772 100644 --- a/sas/baker_recipes.py +++ b/sas/baker_recipes.py @@ -1,12 +1,35 @@ +from django.core.files.uploadedfile import SimpleUploadedFile from model_bakery import seq from model_bakery.recipe import Recipe -from sas.models import Picture +from core.utils import RED_PIXEL_PNG +from sas.models import Album, Picture -picture_recipe = Recipe(Picture, is_moderated=True, name=seq("Picture ")) -"""A SAS Picture fixture. +album_recipe = Recipe( + Album, + name=seq("Album "), + thumbnail=SimpleUploadedFile( + name="thumb.webp", content=b"", content_type="image/webp" + ), +) -Warnings: - If you don't `bulk_create` this, you need - to explicitly set the parent album, or it won't work -""" + +picture_recipe = Recipe( + Picture, + is_moderated=True, + name=seq("Picture "), + original=SimpleUploadedFile( + # compressed and thumbnail are generated on save (except if bulk creating). + # For this step no to fail, original must be a valid image. + name="img.png", + content=RED_PIXEL_PNG, + content_type="image/png", + ), + compressed=SimpleUploadedFile( + name="img.webp", content=b"", content_type="image/webp" + ), + thumbnail=SimpleUploadedFile( + name="img.webp", content=b"", content_type="image/webp" + ), +) +"""A SAS Picture fixture.""" diff --git a/sas/models.py b/sas/models.py index 27b6343e..3663ec27 100644 --- a/sas/models.py +++ b/sas/models.py @@ -17,12 +17,16 @@ from __future__ import annotations import contextlib from io import BytesIO -from typing import ClassVar, Self +from pathlib import Path +from typing import TYPE_CHECKING, ClassVar, Self from django.conf import settings from django.core.cache import cache +from django.core.exceptions import ValidationError +from django.core.files.base import ContentFile from django.db import models from django.db.models import Exists, OuterRef, Q +from django.db.models.deletion import Collector from django.urls import reverse from django.utils import timezone from django.utils.functional import cached_property @@ -32,6 +36,9 @@ from PIL import Image from core.models import Group, Notification, User from core.utils import exif_auto_rotate, resize_image +if TYPE_CHECKING: + from django.db.models.fields.files import FieldFile + def get_directory(instance: SasFile, filename: str): return f"./{instance.parent_path}/{filename}" @@ -43,8 +50,8 @@ def get_compressed_directory(instance: SasFile, filename: str): def get_thumbnail_directory(instance: SasFile, filename: str): if isinstance(instance, Album): - name, extension = filename.rsplit(".", 1) - filename = f"{name}/thumb.{extension}" + _, extension = filename.rsplit(".", 1) + filename = f"{instance.name}/thumb.{extension}" return f"./.thumbnails/{instance.parent_path}/{filename}" @@ -57,6 +64,7 @@ class SasFile(models.Model): class Meta: abstract = True + permissions = [("moderate_sasfile", "Can moderate SAS albums and SAS pictures")] def can_be_viewed_by(self, user): if user.is_anonymous: @@ -74,15 +82,20 @@ class SasFile(models.Model): cache.set(cache_key, viewable, timeout=10) return self.id in viewable - def can_be_edited_by(self, user): - return user.is_root or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID) + def can_be_edited_by(self, user: User): + return user.has_perm(f"sas.change_{self._meta.model_name}") @cached_property def parent_path(self) -> str: + """The parent location in the SAS album tree (e.g. `SAS/foo/bar`).""" return "/".join(["SAS", *[p.name for p in self.parent_list]]) @cached_property - def parent_list(self) -> list[Self]: + def parent_list(self) -> list[Album]: + """The ancestors of this SAS object. + + The result is ordered from the direct parent to the farthest one. + """ parents = [] current = self.parent while current is not None: @@ -114,17 +127,6 @@ class AlbumQuerySet(models.QuerySet): Exists(Picture.objects.filter(parent_id=OuterRef("pk")).viewable_by(user)) ) - def annotate_is_moderated(self) -> Self: - # an album is moderated if it has at least one moderated photo - # if there is no photo at all, the album isn't considered as non-moderated - # (it's just empty) - return self.annotate( - is_moderated=Exists( - Picture.objects.filter(parent=OuterRef("pk"), is_moderated=True) - ) - | ~Exists(Picture.objects.filter(parent=OuterRef("pk"))) - ) - class Album(SasFile): NAME_MAX_LENGTH: ClassVar[int] = 50 @@ -139,18 +141,22 @@ class Album(SasFile): on_delete=models.CASCADE, ) thumbnail = models.FileField( - upload_to=get_thumbnail_directory, verbose_name=_("thumbnail"), max_length=256 + upload_to=get_thumbnail_directory, + verbose_name=_("thumbnail"), + max_length=256, + blank=True, ) view_groups = models.ManyToManyField( - Group, related_name="viewable_albums", verbose_name=_("view groups") + Group, related_name="viewable_albums", verbose_name=_("view groups"), blank=True ) edit_groups = models.ManyToManyField( - Group, related_name="editable_albums", verbose_name=_("edit groups") + Group, related_name="editable_albums", verbose_name=_("edit groups"), blank=True ) event_date = models.DateField( _("event date"), help_text=_("The date on which the photos in this album were taken"), default=timezone.localdate, + blank=True, ) is_moderated = models.BooleanField(_("is moderated"), default=False) @@ -160,7 +166,9 @@ class Album(SasFile): verbose_name = _("album") constraints = [ models.UniqueConstraint( - fields=["name", "parent"], name="unique_album_name_if_same_parent" + fields=["name", "parent"], + name="unique_album_name_if_same_parent", + # TODO : add `nulls_distinct=True` after upgrading to django>=5.0 ) ] @@ -182,14 +190,62 @@ class Album(SasFile): def get_absolute_url(self): return reverse("sas:album", kwargs={"album_id": self.id}) + def clean(self): + super().clean() + if "/" in self.name: + raise ValidationError(_("Character '/' not authorized in name")) + if self.parent_id is not None and ( + self.id == self.parent_id or self in self.parent_list + ): + raise ValidationError(_("Loop in album tree"), code="loop") + if self.thumbnail: + try: + Image.open(BytesIO(self.thumbnail.read())) + except Image.UnidentifiedImageError as e: + raise ValidationError(_("This is not a valid album thumbnail")) from e + + def delete(self, *args, **kwargs): + """Delete the album, all of its children and all linked disk files""" + collector = Collector(using="default") + collector.collect([self]) + albums: set[Album] = collector.data[Album] + pictures: set[Picture] = collector.data[Picture] + files: list[FieldFile] = [ + *[a.thumbnail for a in albums], + *[p.thumbnail for p in pictures], + *[p.compressed for p in pictures], + *[p.original for p in pictures], + ] + # `bool(f)` checks that the file actually exists on the disk + files = [f for f in files if bool(f)] + folders = {Path(f.path).parent for f in files} + res = super().delete(*args, **kwargs) + # once the model instances have been deleted, + # delete the actual files. + for file in files: + # save=False ensures that django doesn't recreate the db record, + # which would make the whole deletion pointless + # cf. https://docs.djangoproject.com/en/stable/ref/models/fields/#django.db.models.fields.files.FieldFile.delete + file.delete(save=False) + for folder in folders: + # now that the files are deleted, remove the empty folders + if folder.is_dir() and next(folder.iterdir(), None) is None: + folder.rmdir() + return res + def get_download_url(self): return reverse("sas:album_preview", kwargs={"album_id": self.id}) def generate_thumbnail(self): - p = self.pictures.order_by("?").first() or self.children.order_by("?").first() - if p and p.thumbnail: - self.thumbnail = p.thumbnail - self.thumbnail.name = f"{self.name}/thumb.webp" + p = ( + self.pictures.exclude(thumbnail="").order_by("?").first() + or self.children.exclude(thumbnail="").order_by("?").first() + ) + if p: + # The file is loaded into memory to duplicate it. + # It may not be the most efficient way, but thumbnails are + # usually quite small, so it's still ok + self.thumbnail = ContentFile(p.thumbnail.read(), name="thumb.webp") self.save() @@ -218,8 +274,8 @@ class Picture(SasFile): thumbnail = models.FileField( upload_to=get_thumbnail_directory, verbose_name=_("thumbnail"), - unique=True, max_length=256, + unique=True, ) original = models.FileField( upload_to=get_directory, @@ -253,14 +309,17 @@ class Picture(SasFile): objects = PictureQuerySet.as_manager() + class Meta: + verbose_name = _("picture") + constraints = [ + models.UniqueConstraint( + fields=["name", "parent"], name="sas_picture_unique_per_album" + ) + ] + def __str__(self): return self.name - def save(self, *args, **kwargs): - if self._state.adding: - self.generate_thumbnails() - super().save(*args, **kwargs) - def get_absolute_url(self): return reverse("sas:picture", kwargs={"picture_id": self.id}) @@ -292,10 +351,11 @@ class Picture(SasFile): # - photographers usually already optimize their images thumb = resize_image(im, 200, "webp") compressed = resize_image(im, 1200, "webp") + new_extension_name = str(Path(self.original.name).with_suffix(".webp")) self.thumbnail = thumb - self.thumbnail.name = self.name + self.thumbnail.name = new_extension_name self.compressed = compressed - self.compressed.name = self.name + self.compressed.name = new_extension_name def rotate(self, degree): for field in self.original, self.compressed, self.thumbnail: diff --git a/sas/schemas.py b/sas/schemas.py index bcb2ab05..4bd7622c 100644 --- a/sas/schemas.py +++ b/sas/schemas.py @@ -50,7 +50,12 @@ class AlbumAutocompleteSchema(ModelSchema): @staticmethod def resolve_path(obj: Album) -> str: - return str(Path(obj.get_parent_path()) / obj.name) + return str(Path(obj.parent_path) / obj.name) + + +class MoveAlbumSchema(Schema): + id: int + new_parent_id: int class PictureFilterSchema(FilterSchema): diff --git a/sas/static/bundled/sas/album-index.ts b/sas/static/bundled/sas/album-index.ts index 32f0f02f..bf715146 100644 --- a/sas/static/bundled/sas/album-index.ts +++ b/sas/static/bundled/sas/album-index.ts @@ -126,3 +126,108 @@ document.addEventListener("alpine:init", () => { }, })); }); + +// Todo: migrate to alpine.js if we have some time +// $("form#upload_form").submit(function (event) { +// const formData = new FormData($(this)[0]); +// +// if (!formData.get("album_name") && !formData.get("images").name) return false; +// +// if (!formData.get("images").name) { +// return true; +// } +// +// event.preventDefault(); +// +// let errorList = this.querySelector("#upload_form ul.errorlist.nonfield"); +// if (errorList === null) { +// errorList = document.createElement("ul"); +// errorList.classList.add("errorlist", "nonfield"); +// this.insertBefore(errorList, this.firstElementChild); +// } +// +// while (errorList.childElementCount > 0) +// errorList.removeChild(errorList.firstElementChild); +// +// let progress = this.querySelector("progress"); +// if (progress === null) { +// progress = document.createElement("progress"); +// progress.value = 0; +// const p = document.createElement("p"); +// p.appendChild(progress); +// this.insertBefore(p, this.lastElementChild); +// } +// +// let dataHolder; +// +// if (formData.get("album_name")) { +// dataHolder = new FormData(); +// dataHolder.set("csrfmiddlewaretoken", "{{ csrf_token }}"); +// dataHolder.set("album_name", formData.get("album_name")); +// $.ajax({ +// method: "POST", +// url: "{{ url('sas:album_upload', album_id=object.id) }}", +// data: dataHolder, +// processData: false, +// contentType: false, +// success: onSuccess, +// }); +// } +// +// const images = formData.getAll("images"); +// const imagesCount = images.length; +// let completeCount = 0; +// +// const poolSize = 1; +// const imagePool = []; +// +// while (images.length > 0 && imagePool.length < poolSize) { +// const image = images.shift(); +// imagePool.push(image); +// sendImage(image); +// } +// +// function sendImage(image) { +// dataHolder = new FormData(); +// dataHolder.set("csrfmiddlewaretoken", "{{ csrf_token }}"); +// dataHolder.set("images", image); +// +// $.ajax({ +// method: "POST", +// url: "{{ url('sas:album_upload', album_id=object.id) }}", +// data: dataHolder, +// processData: false, +// contentType: false, +// }) +// .fail(onSuccess.bind(undefined, image)) +// .done(onSuccess.bind(undefined, image)) +// .always(next.bind(undefined, image)); +// } +// +// function next(image, _, __) { +// const index = imagePool.indexOf(image); +// const nextImage = images.shift(); +// +// if (index !== -1) { +// imagePool.splice(index, 1); +// } +// +// if (nextImage) { +// imagePool.push(nextImage); +// sendImage(nextImage); +// } +// } +// +// function onSuccess(image, data, _, __) { +// let errors = []; +// +// if ($(data.responseText).find(".errorlist.nonfield")[0]) +// errors = Array.from($(data.responseText).find(".errorlist.nonfield")[0].children); +// +// while (errors.length > 0) errorList.appendChild(errors.shift()); +// +// progress.value = ++completeCount / imagesCount; +// if (progress.value === 1 && errorList.children.length === 0) +// document.location.reload(); +// } +// }); diff --git a/sas/static/bundled/sas/pictures-download-index.ts b/sas/static/bundled/sas/pictures-download-index.ts index 21ee9989..abaecbd2 100644 --- a/sas/static/bundled/sas/pictures-download-index.ts +++ b/sas/static/bundled/sas/pictures-download-index.ts @@ -30,10 +30,10 @@ document.addEventListener("alpine:init", () => { await Promise.all( this.pictures.map((p: PictureSchema) => { - const imgName = `${p.album}/IMG_${p.date.replace(/[:\-]/g, "_")}${p.name.slice(p.name.lastIndexOf("."))}`; + const imgName = `${p.album}/IMG_${p.created_at.replace(/[:\-]/g, "_")}${p.name.slice(p.name.lastIndexOf("."))}`; return zipWriter.add(imgName, new HttpReader(p.full_size_url), { level: 9, - lastModDate: new Date(p.date), + lastModDate: new Date(p.created_at), onstart: incrementProgressBar, }); }), diff --git a/sas/templates/sas/album.jinja b/sas/templates/sas/album.jinja index 306baa9e..3ba5a4d2 100644 --- a/sas/templates/sas/album.jinja +++ b/sas/templates/sas/album.jinja @@ -40,17 +40,17 @@ - {% if clipboard %} -
- {% trans %}Clipboard: {% endtrans %} - - -
- {% endif %} +{# {% if clipboard %}#} +{#
#} +{# {% trans %}Clipboard: {% endtrans %}#} +{# #} +{# #} +{#
#} +{# {% endif %}#} {% endif %} {% if show_albums %} @@ -73,8 +73,8 @@
{% trans %}To be moderated{% endtrans %}
- {% if is_sas_admin %} - + {% if edit_mode %} + {% endif %} @@ -100,7 +100,7 @@ {% if is_sas_admin %} - + {% endif %} @@ -120,9 +120,9 @@ {% csrf_token %}

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

diff --git a/sas/templates/sas/macros.jinja b/sas/templates/sas/macros.jinja index dacd1e05..9905d783 100644 --- a/sas/templates/sas/macros.jinja +++ b/sas/templates/sas/macros.jinja @@ -3,17 +3,11 @@ {% if a.thumbnail %} {% set img = a.get_download_url() %} {% set src = 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 %} {% else %} {% set img = static('core/img/sas.jpg') %} {% set src = "sas.jpg" %} {% endif %} -
+
{{ src }} {% if not a.is_moderated %}
 
diff --git a/sas/tests/test_model.py b/sas/tests/test_model.py index dd2299f1..4882e442 100644 --- a/sas/tests/test_model.py +++ b/sas/tests/test_model.py @@ -3,8 +3,8 @@ from model_bakery import baker from core.baker_recipes import old_subscriber_user, subscriber_user from core.models import User -from sas.baker_recipes import picture_recipe -from sas.models import Picture +from sas.baker_recipes import album_recipe, picture_recipe +from sas.models import Album, Picture class TestPictureQuerySet(TestCase): @@ -44,3 +44,22 @@ class TestPictureQuerySet(TestCase): user.pictures.create(picture=self.pictures[1]) # moderated pictures = list(Picture.objects.viewable_by(user)) assert pictures == [self.pictures[1]] + + +class TestDeleteAlbum(TestCase): + def setUp(cls): + cls.album: Album = album_recipe.make() + cls.album_pictures = picture_recipe.make(parent=cls.album, _quantity=5) + cls.sub_album = album_recipe.make(parent=cls.album) + cls.sub_album_pictures = picture_recipe.make(parent=cls.sub_album, _quantity=5) + + def test_delete(self): + album_ids = [self.album.id, self.sub_album.id] + picture_ids = [ + *[p.id for p in self.album_pictures], + *[p.id for p in self.sub_album_pictures], + ] + self.album.delete() + # assert not p.exists() + assert not Album.objects.filter(id__in=album_ids).exists() + assert not Picture.objects.filter(id__in=picture_ids).exists() diff --git a/sas/views.py b/sas/views.py index 2ba8990a..b41d3bee 100644 --- a/sas/views.py +++ b/sas/views.py @@ -22,12 +22,12 @@ from django.shortcuts import get_object_or_404 from django.urls import reverse from django.utils.safestring import SafeString from django.views.generic import CreateView, DetailView, TemplateView -from django.views.generic.edit import FormView, UpdateView +from django.views.generic.edit import FormMixin, FormView, UpdateView from core.auth.mixins import CanEditMixin, CanViewMixin from core.models import SithFile, User -from core.views import UseFragmentsMixin -from core.views.files import FileView, send_raw_file +from core.views import FileView, UseFragmentsMixin +from core.views.files import send_raw_file from core.views.mixins import FragmentMixin, FragmentRenderer from core.views.user import UserTabsMixin from sas.forms import ( @@ -63,6 +63,7 @@ class AlbumCreateFragment(FragmentMixin, CreateView): class SASMainView(UseFragmentsMixin, TemplateView): + form_class = AlbumCreateForm template_name = "sas/main.jinja" def get_fragments(self) -> dict[str, FragmentRenderer]: @@ -79,23 +80,25 @@ class SASMainView(UseFragmentsMixin, TemplateView): root_user = User.objects.get(pk=settings.SITH_ROOT_USER_ID) return {"album_create_fragment": {"owner": root_user}} - def post(self, request, *args, **kwargs): - self.form = self.get_form() - 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=None, owner=root, 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 dispatch(self, request, *args, **kwargs): + if request.method == "POST" and not self.request.user.has_perm("sas.add_album"): + raise PermissionDenied + return super().dispatch(request, *args, **kwargs) + + def get_form(self, form_class=None): + if not self.request.user.has_perm("sas.add_album"): + return None + return super().get_form(form_class) + + def get_form_kwargs(self): + return super().get_form_kwargs() | { + "owner": User.objects.get(pk=settings.SITH_ROOT_USER_ID), + "parent": None, + } def get_context_data(self, **kwargs): kwargs = super().get_context_data(**kwargs) - albums_qs = Album.objects.annotate_is_moderated().viewable_by(self.request.user) + albums_qs = Album.objects.viewable_by(self.request.user) kwargs["categories"] = list(albums_qs.filter(parent=None).order_by("id")) kwargs["latest"] = list(albums_qs.order_by("-id")[:5]) return kwargs @@ -149,10 +152,11 @@ def send_thumb(request, picture_id): return send_raw_file(Path(picture.thumbnail.path)) -class AlbumView(CanViewMixin, UseFragmentsMixin, DetailView): +class AlbumView(CanViewMixin, UseFragmentsMixin, FormMixin, DetailView): model = Album pk_url_kwarg = "album_id" template_name = "sas/album.jinja" + form_class = PictureUploadForm def get_fragments(self) -> dict[str, FragmentRenderer]: return { @@ -167,27 +171,32 @@ class AlbumView(CanViewMixin, UseFragmentsMixin, DetailView): except ValueError as e: raise Http404 from e if "clipboard" not in request.session: - request.session["clipboard"] = [] + request.session["clipboard"] = {"albums": [], "pictures": []} return super().dispatch(request, *args, **kwargs) + def get_form(self, *args, **kwargs): + if not self.request.user.can_edit(self.object): + return None + return super().get_form(*args, **kwargs) + 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) - return HttpResponseRedirect(self.request.path) + form = self.get_form() + if not form: + # the form is reserved for users that can edit this album. + # If there is no form, it means the user has no right to do a POST + raise PermissionDenied + FileView.handle_clipboard(self.request, self.object) + if not form.is_valid(): + return self.form_invalid(form) + return self.form_valid(form) 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) - 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["clipboard"] = {} kwargs["show_albums"] = ( Album.objects.viewable_by(self.request.user) .filter(parent_id=self.object.id) diff --git a/staticfiles/processors.py b/staticfiles/processors.py index 5f44731b..886dbc9e 100644 --- a/staticfiles/processors.py +++ b/staticfiles/processors.py @@ -182,13 +182,12 @@ class OpenApi: path[action]["operationId"] = "_".join( desc["operationId"].split("_")[:-1] ) + schema = str(schema) if old_hash == sha1(schema.encode("utf-8")).hexdigest(): logging.getLogger("django").info("✨ Api did not change, nothing to do ✨") return - with open(out, "w") as f: - _ = f.write(schema) - + out.write_text(schema) return subprocess.Popen(["npm", "run", "openapi"])