mirror of
				https://github.com/ae-utbm/sith.git
				synced 2025-10-31 09:03:06 +00:00 
			
		
		
		
	WIP
This commit is contained in:
		
							
								
								
									
										62
									
								
								sas/api.py
									
									
									
									
									
								
							
							
						
						
									
										62
									
								
								sas/api.py
									
									
									
									
									
								
							| @@ -2,8 +2,10 @@ from typing import Any, Literal | ||||
|  | ||||
| from django.conf import settings | ||||
| from django.core.exceptions import ValidationError | ||||
| 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.security import SessionAuth | ||||
| from ninja_extra import ControllerBase, api_controller, paginate, route | ||||
| from ninja_extra.exceptions import NotFound, PermissionDenied | ||||
| @@ -17,11 +19,12 @@ from 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, | ||||
| @@ -29,6 +32,7 @@ from sas.schemas import ( | ||||
|     AlbumSchema, | ||||
|     IdentifiedUserSchema, | ||||
|     ModerationRequestSchema, | ||||
|     MoveAlbumSchema, | ||||
|     PictureFilterSchema, | ||||
|     PictureSchema, | ||||
| ) | ||||
| @@ -71,6 +75,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): | ||||
| @@ -117,27 +163,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", | ||||
|   | ||||
| @@ -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.""" | ||||
|   | ||||
							
								
								
									
										121
									
								
								sas/models.py
									
									
									
									
									
								
							
							
						
						
									
										121
									
								
								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}" | ||||
|  | ||||
|  | ||||
| @@ -83,10 +90,15 @@ class SasFile(models.Model): | ||||
|  | ||||
|     @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: | ||||
| @@ -118,17 +130,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 | ||||
| @@ -143,18 +144,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) | ||||
|  | ||||
| @@ -164,7 +169,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 | ||||
|             ) | ||||
|         ] | ||||
|  | ||||
| @@ -186,14 +193,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() | ||||
|  | ||||
|  | ||||
| @@ -222,8 +277,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, | ||||
| @@ -257,14 +312,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}) | ||||
|  | ||||
| @@ -296,10 +354,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: | ||||
|   | ||||
| @@ -56,7 +56,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): | ||||
|   | ||||
| @@ -125,3 +125,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(); | ||||
| //   } | ||||
| // }); | ||||
|   | ||||
| @@ -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, | ||||
|           }); | ||||
|         }), | ||||
|   | ||||
| @@ -40,17 +40,17 @@ | ||||
|         </div> | ||||
|       </div> | ||||
|  | ||||
|       {% if clipboard %} | ||||
|         <div class="clipboard"> | ||||
|           {% trans %}Clipboard: {% endtrans %} | ||||
|           <ul> | ||||
|             {% for f in clipboard %} | ||||
|               <li>{{ f.get_full_path() }}</li> | ||||
|             {% endfor %} | ||||
|           </ul> | ||||
|           <input name="clear" type="submit" value="{% trans %}Clear clipboard{% endtrans %}"> | ||||
|         </div> | ||||
|       {% endif %} | ||||
| {#      {% if clipboard %}#} | ||||
| {#        <div class="clipboard">#} | ||||
| {#          {% trans %}Clipboard: {% endtrans %}#} | ||||
| {#          <ul>#} | ||||
| {#            {% for f in clipboard["albums"] %}#} | ||||
| {#              <li>{{ f.get_full_path() }}</li>#} | ||||
| {#            {% endfor %}#} | ||||
| {#          </ul>#} | ||||
| {#          <input name="clear" type="submit" value="{% trans %}Clear clipboard{% endtrans %}">#} | ||||
| {#        </div>#} | ||||
| {#      {% endif %}#} | ||||
|   {% endif %} | ||||
|  | ||||
|   {% if show_albums %} | ||||
| @@ -73,8 +73,8 @@ | ||||
|                 <div class="text">{% trans %}To be moderated{% endtrans %}</div> | ||||
|               </template> | ||||
|             </div> | ||||
|             {% if is_sas_admin %} | ||||
|               <input type="checkbox" name="file_list" :value="album.id"> | ||||
|             {% if edit_mode %} | ||||
|               <input type="checkbox" name="album_list" :value="album.id"> | ||||
|             {% endif %} | ||||
|           </a> | ||||
|         </template> | ||||
| @@ -100,7 +100,7 @@ | ||||
|             </template> | ||||
|           </div> | ||||
|           {% if is_sas_admin %} | ||||
|             <input type="checkbox" name="file_list" :value="picture.id"> | ||||
|             <input type="checkbox" name="picture_list" :value="picture.id"> | ||||
|           {% endif %} | ||||
|         </a> | ||||
|       </template> | ||||
| @@ -120,9 +120,9 @@ | ||||
|       {% csrf_token %} | ||||
|       <div class="inputs"> | ||||
|         <p> | ||||
|           <label for="{{ upload_form.images.id_for_label }}">{{ upload_form.images.label }} :</label> | ||||
|           {{ upload_form.images|add_attr("x-ref=pictures") }} | ||||
|           <span class="helptext">{{ upload_form.images.help_text }}</span> | ||||
|           <label for="{{ form.images.id_for_label }}">{{ form.images.label }} :</label> | ||||
|           {{ form.images|add_attr("x-ref=pictures") }} | ||||
|           <span class="helptext">{{ form.images.help_text }}</span> | ||||
|         </p> | ||||
|         <input type="submit" value="{% trans %}Upload{% endtrans %}" /> | ||||
|         <progress x-ref="progress" x-show="sending"></progress> | ||||
|   | ||||
| @@ -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 %} | ||||
|     <div | ||||
|       class="album{% if not a.is_moderated %} not_moderated{% endif %}" | ||||
|     > | ||||
|     <div class="album{% if not a.is_moderated %} not_moderated{% endif %}"> | ||||
|       <img src="{{ img }}" alt="{{ src }}" loading="lazy" /> | ||||
|       {% if not a.is_moderated %} | ||||
|         <div class="overlay"> </div> | ||||
|   | ||||
| @@ -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() | ||||
|   | ||||
							
								
								
									
										67
									
								
								sas/views.py
									
									
									
									
									
								
							
							
						
						
									
										67
									
								
								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) | ||||
|   | ||||
		Reference in New Issue
	
	Block a user