mirror of
https://github.com/ae-utbm/sith.git
synced 2025-06-08 20:25:21 +00:00
WIP
This commit is contained in:
parent
854e297eb4
commit
e1952570bb
@ -715,9 +715,7 @@ Welcome to the wiki page!
|
|||||||
# SAS
|
# SAS
|
||||||
for f in self.SAS_FIXTURE_PATH.glob("*"):
|
for f in self.SAS_FIXTURE_PATH.glob("*"):
|
||||||
if f.is_dir():
|
if f.is_dir():
|
||||||
album = Album(name=f.name)
|
album = Album.objects.create(name=f.name, is_moderated=True)
|
||||||
album.clean()
|
|
||||||
album.save()
|
|
||||||
for p in f.iterdir():
|
for p in f.iterdir():
|
||||||
file = resize_image(Image.open(p), 1000, "WEBP")
|
file = resize_image(Image.open(p), 1000, "WEBP")
|
||||||
pict = Picture(
|
pict = Picture(
|
||||||
@ -731,6 +729,7 @@ Welcome to the wiki page!
|
|||||||
pict.generate_thumbnails()
|
pict.generate_thumbnails()
|
||||||
pict.full_clean()
|
pict.full_clean()
|
||||||
pict.save()
|
pict.save()
|
||||||
|
album.generate_thumbnail()
|
||||||
|
|
||||||
img_skia = Picture.objects.get(name="skia.jpg")
|
img_skia = Picture.objects.get(name="skia.jpg")
|
||||||
img_sli = Picture.objects.get(name="sli.jpg")
|
img_sli = Picture.objects.get(name="sli.jpg")
|
||||||
|
@ -908,8 +908,6 @@ class SithFile(models.Model):
|
|||||||
super().clean()
|
super().clean()
|
||||||
if "/" in self.name:
|
if "/" in self.name:
|
||||||
raise ValidationError(_("Character '/' not authorized in 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 (
|
if self == self.parent or (
|
||||||
self.parent is not None and self in self.get_parent_list()
|
self.parent is not None and self in self.get_parent_list()
|
||||||
):
|
):
|
||||||
|
@ -5,6 +5,7 @@ from typing import Callable
|
|||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from django.conf import settings
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.core.files.uploadedfile import SimpleUploadedFile, UploadedFile
|
from django.core.files.uploadedfile import SimpleUploadedFile, UploadedFile
|
||||||
from django.test import Client, TestCase
|
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.baker_recipes import board_user, old_subscriber_user, subscriber_user
|
||||||
from core.models import Group, QuickUploadImage, SithFile, User
|
from core.models import Group, QuickUploadImage, SithFile, User
|
||||||
from core.utils import RED_PIXEL_PNG
|
from core.utils import RED_PIXEL_PNG
|
||||||
|
from sas.baker_recipes import picture_recipe
|
||||||
from sas.models import Picture
|
from sas.models import Picture
|
||||||
from sith import settings
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@ -30,24 +31,19 @@ class TestImageAccess:
|
|||||||
lambda: baker.make(
|
lambda: baker.make(
|
||||||
User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)]
|
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]):
|
def test_sas_image_access(self, user_factory: Callable[[], User]):
|
||||||
"""Test that only authorized users can access the sas image."""
|
"""Test that only authorized users can access the sas image."""
|
||||||
user = user_factory()
|
user = user_factory()
|
||||||
picture: SithFile = baker.make(
|
picture = picture_recipe.make()
|
||||||
Picture, parent=SithFile.objects.get(pk=settings.SITH_SAS_ROOT_DIR_ID)
|
assert user.can_edit(picture)
|
||||||
)
|
|
||||||
assert picture.is_owned_by(user)
|
|
||||||
|
|
||||||
def test_sas_image_access_owner(self):
|
def test_sas_image_access_owner(self):
|
||||||
"""Test that the owner of the image can access it."""
|
"""Test that the owner of the image can access it."""
|
||||||
user = baker.make(User)
|
user = baker.make(User)
|
||||||
picture: Picture = baker.make(Picture, owner=user)
|
picture = picture_recipe.make(owner=user)
|
||||||
assert picture.is_owned_by(user)
|
assert user.can_edit(picture)
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"user_factory",
|
"user_factory",
|
||||||
@ -63,7 +59,7 @@ class TestImageAccess:
|
|||||||
user = user_factory()
|
user = user_factory()
|
||||||
owner = baker.make(User)
|
owner = baker.make(User)
|
||||||
picture: Picture = baker.make(Picture, owner=owner)
|
picture: Picture = baker.make(Picture, owner=owner)
|
||||||
assert not picture.is_owned_by(user)
|
assert not user.can_edit(picture)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
|
@ -12,20 +12,23 @@
|
|||||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
|
from dataclasses import dataclass
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
|
|
||||||
# Image utils
|
# Image utils
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from typing import Final, Unpack
|
from typing import Any, Final, Unpack
|
||||||
|
|
||||||
import PIL
|
import PIL
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
from django.core.files.uploadedfile import UploadedFile
|
from django.core.files.uploadedfile import UploadedFile
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.forms import BaseForm
|
||||||
from django.http import Http404, HttpRequest
|
from django.http import Http404, HttpRequest
|
||||||
from django.shortcuts import get_list_or_404
|
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 django.utils.timezone import localdate
|
||||||
from PIL import ExifTags
|
from PIL import ExifTags
|
||||||
from PIL.Image import Image, Resampling
|
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:
|
def get_start_of_semester(today: date | None = None) -> date:
|
||||||
"""Return the date of the start of the semester of the given 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.
|
If no date is given, return the start date of the current semester.
|
||||||
|
@ -263,3 +263,35 @@ avec un unique champ permettant de sélectionner des groupes.
|
|||||||
Par défaut, seuls les utilisateurs avec la permission
|
Par défaut, seuls les utilisateurs avec la permission
|
||||||
`auth.change_permission` auront accès à ce formulaire
|
`auth.change_permission` auront accès à ce formulaire
|
||||||
(donc, normalement, uniquement les utilisateurs Root).
|
(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
|
||||||
|
```
|
||||||
|
@ -35,14 +35,6 @@ from core.utils import RED_PIXEL_PNG
|
|||||||
from sas.models import Album, PeoplePictureRelation, Picture
|
from sas.models import Album, PeoplePictureRelation, Picture
|
||||||
from subscription.models import Subscription
|
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
|
USER_PACK_SIZE: Final[int] = 1000
|
||||||
|
|
||||||
|
|
||||||
|
62
sas/api.py
62
sas/api.py
@ -3,8 +3,10 @@ from typing import Any, Literal
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db.models import F
|
from django.db.models import F
|
||||||
|
from django.shortcuts import get_list_or_404
|
||||||
from django.urls import reverse
|
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 import ControllerBase, api_controller, paginate, route
|
||||||
from ninja_extra.exceptions import NotFound, PermissionDenied
|
from ninja_extra.exceptions import NotFound, PermissionDenied
|
||||||
from ninja_extra.pagination import PageNumberPaginationExtra
|
from ninja_extra.pagination import PageNumberPaginationExtra
|
||||||
@ -16,11 +18,12 @@ from core.auth.api_permissions import (
|
|||||||
CanAccessLookup,
|
CanAccessLookup,
|
||||||
CanEdit,
|
CanEdit,
|
||||||
CanView,
|
CanView,
|
||||||
|
HasPerm,
|
||||||
IsInGroup,
|
IsInGroup,
|
||||||
IsRoot,
|
IsRoot,
|
||||||
)
|
)
|
||||||
from core.models import Notification, User
|
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.models import Album, PeoplePictureRelation, Picture
|
||||||
from sas.schemas import (
|
from sas.schemas import (
|
||||||
AlbumAutocompleteSchema,
|
AlbumAutocompleteSchema,
|
||||||
@ -28,6 +31,7 @@ from sas.schemas import (
|
|||||||
AlbumSchema,
|
AlbumSchema,
|
||||||
IdentifiedUserSchema,
|
IdentifiedUserSchema,
|
||||||
ModerationRequestSchema,
|
ModerationRequestSchema,
|
||||||
|
MoveAlbumSchema,
|
||||||
PictureFilterSchema,
|
PictureFilterSchema,
|
||||||
PictureSchema,
|
PictureSchema,
|
||||||
)
|
)
|
||||||
@ -69,6 +73,48 @@ class AlbumController(ControllerBase):
|
|||||||
Album.objects.viewable_by(self.context.request.user).order_by("-date")
|
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")
|
@api_controller("/sas/picture")
|
||||||
class PicturesController(ControllerBase):
|
class PicturesController(ControllerBase):
|
||||||
@ -116,27 +162,25 @@ class PicturesController(ControllerBase):
|
|||||||
},
|
},
|
||||||
url_name="upload_picture",
|
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)
|
album = self.get_object_or_exception(Album, pk=album_id)
|
||||||
user = self.context.request.user
|
user = self.context.request.user
|
||||||
self_moderate = user.has_perm("sas.moderate_sasfile")
|
self_moderate = user.has_perm("sas.moderate_sasfile")
|
||||||
new = Picture(
|
new = Picture(
|
||||||
parent=album,
|
parent=album,
|
||||||
name=picture.name,
|
name=picture.name,
|
||||||
file=picture,
|
original=picture,
|
||||||
owner=user,
|
owner=user,
|
||||||
is_moderated=self_moderate,
|
is_moderated=self_moderate,
|
||||||
is_folder=False,
|
|
||||||
mime_type=picture.content_type,
|
|
||||||
)
|
)
|
||||||
if self_moderate:
|
if self_moderate:
|
||||||
new.moderator = user
|
new.moderator = user
|
||||||
|
new.generate_thumbnails()
|
||||||
try:
|
try:
|
||||||
new.generate_thumbnails()
|
|
||||||
new.full_clean()
|
new.full_clean()
|
||||||
new.save()
|
|
||||||
except ValidationError as e:
|
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(
|
@route.get(
|
||||||
"/{picture_id}/identified",
|
"/{picture_id}/identified",
|
||||||
|
@ -1,12 +1,35 @@
|
|||||||
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
from model_bakery import seq
|
from model_bakery import seq
|
||||||
from model_bakery.recipe import Recipe
|
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 "))
|
album_recipe = Recipe(
|
||||||
"""A SAS Picture fixture.
|
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
|
picture_recipe = Recipe(
|
||||||
to explicitly set the parent album, or it won't work
|
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."""
|
||||||
|
126
sas/models.py
126
sas/models.py
@ -17,12 +17,16 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import contextlib
|
import contextlib
|
||||||
from io import BytesIO
|
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.conf import settings
|
||||||
from django.core.cache import cache
|
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 import models
|
||||||
from django.db.models import Exists, OuterRef, Q
|
from django.db.models import Exists, OuterRef, Q
|
||||||
|
from django.db.models.deletion import Collector
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
@ -32,6 +36,9 @@ from PIL import Image
|
|||||||
from core.models import Group, Notification, User
|
from core.models import Group, Notification, User
|
||||||
from core.utils import exif_auto_rotate, resize_image
|
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):
|
def get_directory(instance: SasFile, filename: str):
|
||||||
return f"./{instance.parent_path}/{filename}"
|
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):
|
def get_thumbnail_directory(instance: SasFile, filename: str):
|
||||||
if isinstance(instance, Album):
|
if isinstance(instance, Album):
|
||||||
name, extension = filename.rsplit(".", 1)
|
_, extension = filename.rsplit(".", 1)
|
||||||
filename = f"{name}/thumb.{extension}"
|
filename = f"{instance.name}/thumb.{extension}"
|
||||||
return f"./.thumbnails/{instance.parent_path}/{filename}"
|
return f"./.thumbnails/{instance.parent_path}/{filename}"
|
||||||
|
|
||||||
|
|
||||||
@ -57,6 +64,7 @@ class SasFile(models.Model):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
abstract = True
|
abstract = True
|
||||||
|
permissions = [("moderate_sasfile", "Can moderate SAS albums and SAS pictures")]
|
||||||
|
|
||||||
def can_be_viewed_by(self, user):
|
def can_be_viewed_by(self, user):
|
||||||
if user.is_anonymous:
|
if user.is_anonymous:
|
||||||
@ -74,15 +82,20 @@ class SasFile(models.Model):
|
|||||||
cache.set(cache_key, viewable, timeout=10)
|
cache.set(cache_key, viewable, timeout=10)
|
||||||
return self.id in viewable
|
return self.id in viewable
|
||||||
|
|
||||||
def can_be_edited_by(self, user):
|
def can_be_edited_by(self, user: User):
|
||||||
return user.is_root or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID)
|
return user.has_perm(f"sas.change_{self._meta.model_name}")
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def parent_path(self) -> str:
|
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]])
|
return "/".join(["SAS", *[p.name for p in self.parent_list]])
|
||||||
|
|
||||||
@cached_property
|
@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 = []
|
parents = []
|
||||||
current = self.parent
|
current = self.parent
|
||||||
while current is not None:
|
while current is not None:
|
||||||
@ -114,17 +127,6 @@ class AlbumQuerySet(models.QuerySet):
|
|||||||
Exists(Picture.objects.filter(parent_id=OuterRef("pk")).viewable_by(user))
|
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):
|
class Album(SasFile):
|
||||||
NAME_MAX_LENGTH: ClassVar[int] = 50
|
NAME_MAX_LENGTH: ClassVar[int] = 50
|
||||||
@ -139,18 +141,22 @@ class Album(SasFile):
|
|||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
)
|
)
|
||||||
thumbnail = models.FileField(
|
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(
|
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(
|
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 = models.DateField(
|
||||||
_("event date"),
|
_("event date"),
|
||||||
help_text=_("The date on which the photos in this album were taken"),
|
help_text=_("The date on which the photos in this album were taken"),
|
||||||
default=timezone.localdate,
|
default=timezone.localdate,
|
||||||
|
blank=True,
|
||||||
)
|
)
|
||||||
is_moderated = models.BooleanField(_("is moderated"), default=False)
|
is_moderated = models.BooleanField(_("is moderated"), default=False)
|
||||||
|
|
||||||
@ -160,7 +166,9 @@ class Album(SasFile):
|
|||||||
verbose_name = _("album")
|
verbose_name = _("album")
|
||||||
constraints = [
|
constraints = [
|
||||||
models.UniqueConstraint(
|
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):
|
def get_absolute_url(self):
|
||||||
return reverse("sas:album", kwargs={"album_id": self.id})
|
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):
|
def get_download_url(self):
|
||||||
return reverse("sas:album_preview", kwargs={"album_id": self.id})
|
return reverse("sas:album_preview", kwargs={"album_id": self.id})
|
||||||
|
|
||||||
def generate_thumbnail(self):
|
def generate_thumbnail(self):
|
||||||
p = self.pictures.order_by("?").first() or self.children.order_by("?").first()
|
p = (
|
||||||
if p and p.thumbnail:
|
self.pictures.exclude(thumbnail="").order_by("?").first()
|
||||||
self.thumbnail = p.thumbnail
|
or self.children.exclude(thumbnail="").order_by("?").first()
|
||||||
self.thumbnail.name = f"{self.name}/thumb.webp"
|
)
|
||||||
|
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()
|
self.save()
|
||||||
|
|
||||||
|
|
||||||
@ -218,8 +274,8 @@ class Picture(SasFile):
|
|||||||
thumbnail = models.FileField(
|
thumbnail = models.FileField(
|
||||||
upload_to=get_thumbnail_directory,
|
upload_to=get_thumbnail_directory,
|
||||||
verbose_name=_("thumbnail"),
|
verbose_name=_("thumbnail"),
|
||||||
unique=True,
|
|
||||||
max_length=256,
|
max_length=256,
|
||||||
|
unique=True,
|
||||||
)
|
)
|
||||||
original = models.FileField(
|
original = models.FileField(
|
||||||
upload_to=get_directory,
|
upload_to=get_directory,
|
||||||
@ -253,14 +309,17 @@ class Picture(SasFile):
|
|||||||
|
|
||||||
objects = PictureQuerySet.as_manager()
|
objects = PictureQuerySet.as_manager()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("picture")
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["name", "parent"], name="sas_picture_unique_per_album"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
|
||||||
if self._state.adding:
|
|
||||||
self.generate_thumbnails()
|
|
||||||
super().save(*args, **kwargs)
|
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse("sas:picture", kwargs={"picture_id": self.id})
|
return reverse("sas:picture", kwargs={"picture_id": self.id})
|
||||||
|
|
||||||
@ -292,10 +351,11 @@ class Picture(SasFile):
|
|||||||
# - photographers usually already optimize their images
|
# - photographers usually already optimize their images
|
||||||
thumb = resize_image(im, 200, "webp")
|
thumb = resize_image(im, 200, "webp")
|
||||||
compressed = resize_image(im, 1200, "webp")
|
compressed = resize_image(im, 1200, "webp")
|
||||||
|
new_extension_name = str(Path(self.original.name).with_suffix(".webp"))
|
||||||
self.thumbnail = thumb
|
self.thumbnail = thumb
|
||||||
self.thumbnail.name = self.name
|
self.thumbnail.name = new_extension_name
|
||||||
self.compressed = compressed
|
self.compressed = compressed
|
||||||
self.compressed.name = self.name
|
self.compressed.name = new_extension_name
|
||||||
|
|
||||||
def rotate(self, degree):
|
def rotate(self, degree):
|
||||||
for field in self.original, self.compressed, self.thumbnail:
|
for field in self.original, self.compressed, self.thumbnail:
|
||||||
|
@ -50,7 +50,12 @@ class AlbumAutocompleteSchema(ModelSchema):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def resolve_path(obj: Album) -> str:
|
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):
|
class PictureFilterSchema(FilterSchema):
|
||||||
|
@ -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();
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
@ -30,10 +30,10 @@ document.addEventListener("alpine:init", () => {
|
|||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
this.pictures.map((p: PictureSchema) => {
|
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), {
|
return zipWriter.add(imgName, new HttpReader(p.full_size_url), {
|
||||||
level: 9,
|
level: 9,
|
||||||
lastModDate: new Date(p.date),
|
lastModDate: new Date(p.created_at),
|
||||||
onstart: incrementProgressBar,
|
onstart: incrementProgressBar,
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
@ -40,17 +40,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if clipboard %}
|
{# {% if clipboard %}#}
|
||||||
<div class="clipboard">
|
{# <div class="clipboard">#}
|
||||||
{% trans %}Clipboard: {% endtrans %}
|
{# {% trans %}Clipboard: {% endtrans %}#}
|
||||||
<ul>
|
{# <ul>#}
|
||||||
{% for f in clipboard %}
|
{# {% for f in clipboard["albums"] %}#}
|
||||||
<li>{{ f.get_full_path() }}</li>
|
{# <li>{{ f.get_full_path() }}</li>#}
|
||||||
{% endfor %}
|
{# {% endfor %}#}
|
||||||
</ul>
|
{# </ul>#}
|
||||||
<input name="clear" type="submit" value="{% trans %}Clear clipboard{% endtrans %}">
|
{# <input name="clear" type="submit" value="{% trans %}Clear clipboard{% endtrans %}">#}
|
||||||
</div>
|
{# </div>#}
|
||||||
{% endif %}
|
{# {% endif %}#}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if show_albums %}
|
{% if show_albums %}
|
||||||
@ -73,8 +73,8 @@
|
|||||||
<div class="text">{% trans %}To be moderated{% endtrans %}</div>
|
<div class="text">{% trans %}To be moderated{% endtrans %}</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
{% if is_sas_admin %}
|
{% if edit_mode %}
|
||||||
<input type="checkbox" name="file_list" :value="album.id">
|
<input type="checkbox" name="album_list" :value="album.id">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</a>
|
</a>
|
||||||
</template>
|
</template>
|
||||||
@ -100,7 +100,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
{% if is_sas_admin %}
|
{% if is_sas_admin %}
|
||||||
<input type="checkbox" name="file_list" :value="picture.id">
|
<input type="checkbox" name="picture_list" :value="picture.id">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</a>
|
</a>
|
||||||
</template>
|
</template>
|
||||||
@ -120,9 +120,9 @@
|
|||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<div class="inputs">
|
<div class="inputs">
|
||||||
<p>
|
<p>
|
||||||
<label for="{{ upload_form.images.id_for_label }}">{{ upload_form.images.label }} :</label>
|
<label for="{{ form.images.id_for_label }}">{{ form.images.label }} :</label>
|
||||||
{{ upload_form.images|add_attr("x-ref=pictures") }}
|
{{ form.images|add_attr("x-ref=pictures") }}
|
||||||
<span class="helptext">{{ upload_form.images.help_text }}</span>
|
<span class="helptext">{{ form.images.help_text }}</span>
|
||||||
</p>
|
</p>
|
||||||
<input type="submit" value="{% trans %}Upload{% endtrans %}" />
|
<input type="submit" value="{% trans %}Upload{% endtrans %}" />
|
||||||
<progress x-ref="progress" x-show="sending"></progress>
|
<progress x-ref="progress" x-show="sending"></progress>
|
||||||
|
@ -3,17 +3,11 @@
|
|||||||
{% if a.thumbnail %}
|
{% if a.thumbnail %}
|
||||||
{% set img = a.get_download_url() %}
|
{% set img = a.get_download_url() %}
|
||||||
{% set src = a.name %}
|
{% 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 %}
|
{% else %}
|
||||||
{% set img = static('core/img/sas.jpg') %}
|
{% set img = static('core/img/sas.jpg') %}
|
||||||
{% set src = "sas.jpg" %}
|
{% set src = "sas.jpg" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div
|
<div class="album{% if not a.is_moderated %} not_moderated{% endif %}">
|
||||||
class="album{% if not a.is_moderated %} not_moderated{% endif %}"
|
|
||||||
>
|
|
||||||
<img src="{{ img }}" alt="{{ src }}" loading="lazy" />
|
<img src="{{ img }}" alt="{{ src }}" loading="lazy" />
|
||||||
{% if not a.is_moderated %}
|
{% if not a.is_moderated %}
|
||||||
<div class="overlay"> </div>
|
<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.baker_recipes import old_subscriber_user, subscriber_user
|
||||||
from core.models import User
|
from core.models import User
|
||||||
from sas.baker_recipes import picture_recipe
|
from sas.baker_recipes import album_recipe, picture_recipe
|
||||||
from sas.models import Picture
|
from sas.models import Album, Picture
|
||||||
|
|
||||||
|
|
||||||
class TestPictureQuerySet(TestCase):
|
class TestPictureQuerySet(TestCase):
|
||||||
@ -44,3 +44,22 @@ class TestPictureQuerySet(TestCase):
|
|||||||
user.pictures.create(picture=self.pictures[1]) # moderated
|
user.pictures.create(picture=self.pictures[1]) # moderated
|
||||||
pictures = list(Picture.objects.viewable_by(user))
|
pictures = list(Picture.objects.viewable_by(user))
|
||||||
assert pictures == [self.pictures[1]]
|
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.urls import reverse
|
||||||
from django.utils.safestring import SafeString
|
from django.utils.safestring import SafeString
|
||||||
from django.views.generic import CreateView, DetailView, TemplateView
|
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.auth.mixins import CanEditMixin, CanViewMixin
|
||||||
from core.models import SithFile, User
|
from core.models import SithFile, User
|
||||||
from core.views import UseFragmentsMixin
|
from core.views import FileView, UseFragmentsMixin
|
||||||
from core.views.files import FileView, send_raw_file
|
from core.views.files import send_raw_file
|
||||||
from core.views.mixins import FragmentMixin, FragmentRenderer
|
from core.views.mixins import FragmentMixin, FragmentRenderer
|
||||||
from core.views.user import UserTabsMixin
|
from core.views.user import UserTabsMixin
|
||||||
from sas.forms import (
|
from sas.forms import (
|
||||||
@ -63,6 +63,7 @@ class AlbumCreateFragment(FragmentMixin, CreateView):
|
|||||||
|
|
||||||
|
|
||||||
class SASMainView(UseFragmentsMixin, TemplateView):
|
class SASMainView(UseFragmentsMixin, TemplateView):
|
||||||
|
form_class = AlbumCreateForm
|
||||||
template_name = "sas/main.jinja"
|
template_name = "sas/main.jinja"
|
||||||
|
|
||||||
def get_fragments(self) -> dict[str, FragmentRenderer]:
|
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)
|
root_user = User.objects.get(pk=settings.SITH_ROOT_USER_ID)
|
||||||
return {"album_create_fragment": {"owner": root_user}}
|
return {"album_create_fragment": {"owner": root_user}}
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def dispatch(self, request, *args, **kwargs):
|
||||||
self.form = self.get_form()
|
if request.method == "POST" and not self.request.user.has_perm("sas.add_album"):
|
||||||
root = User.objects.filter(username="root").first()
|
raise PermissionDenied
|
||||||
if request.user.is_authenticated and request.user.is_in_group(
|
return super().dispatch(request, *args, **kwargs)
|
||||||
pk=settings.SITH_GROUP_SAS_ADMIN_ID
|
|
||||||
):
|
def get_form(self, form_class=None):
|
||||||
if self.form.is_valid():
|
if not self.request.user.has_perm("sas.add_album"):
|
||||||
self.form.process(parent=None, owner=root, files=[], automodere=True)
|
return None
|
||||||
if self.form.is_valid():
|
return super().get_form(form_class)
|
||||||
return super().form_valid(self.form)
|
|
||||||
else:
|
def get_form_kwargs(self):
|
||||||
self.form.add_error(None, _("You do not have the permission to do that"))
|
return super().get_form_kwargs() | {
|
||||||
return self.form_invalid(self.form)
|
"owner": User.objects.get(pk=settings.SITH_ROOT_USER_ID),
|
||||||
|
"parent": None,
|
||||||
|
}
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
kwargs = super().get_context_data(**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["categories"] = list(albums_qs.filter(parent=None).order_by("id"))
|
||||||
kwargs["latest"] = list(albums_qs.order_by("-id")[:5])
|
kwargs["latest"] = list(albums_qs.order_by("-id")[:5])
|
||||||
return kwargs
|
return kwargs
|
||||||
@ -149,10 +152,11 @@ def send_thumb(request, picture_id):
|
|||||||
return send_raw_file(Path(picture.thumbnail.path))
|
return send_raw_file(Path(picture.thumbnail.path))
|
||||||
|
|
||||||
|
|
||||||
class AlbumView(CanViewMixin, UseFragmentsMixin, DetailView):
|
class AlbumView(CanViewMixin, UseFragmentsMixin, FormMixin, DetailView):
|
||||||
model = Album
|
model = Album
|
||||||
pk_url_kwarg = "album_id"
|
pk_url_kwarg = "album_id"
|
||||||
template_name = "sas/album.jinja"
|
template_name = "sas/album.jinja"
|
||||||
|
form_class = PictureUploadForm
|
||||||
|
|
||||||
def get_fragments(self) -> dict[str, FragmentRenderer]:
|
def get_fragments(self) -> dict[str, FragmentRenderer]:
|
||||||
return {
|
return {
|
||||||
@ -167,27 +171,32 @@ class AlbumView(CanViewMixin, UseFragmentsMixin, DetailView):
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise Http404 from e
|
raise Http404 from e
|
||||||
if "clipboard" not in request.session:
|
if "clipboard" not in request.session:
|
||||||
request.session["clipboard"] = []
|
request.session["clipboard"] = {"albums": [], "pictures": []}
|
||||||
return super().dispatch(request, *args, **kwargs)
|
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):
|
def post(self, request, *args, **kwargs):
|
||||||
self.object = self.get_object()
|
self.object = self.get_object()
|
||||||
if not self.object.file:
|
form = self.get_form()
|
||||||
self.object.generate_thumbnail()
|
if not form:
|
||||||
if request.user.can_edit(self.object): # Handle the copy-paste functions
|
# the form is reserved for users that can edit this album.
|
||||||
FileView.handle_clipboard(request, self.object)
|
# If there is no form, it means the user has no right to do a POST
|
||||||
return HttpResponseRedirect(self.request.path)
|
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]]:
|
def get_fragment_data(self) -> dict[str, dict[str, Any]]:
|
||||||
return {"album_create_fragment": {"owner": self.request.user}}
|
return {"album_create_fragment": {"owner": self.request.user}}
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
kwargs = super().get_context_data(**kwargs)
|
kwargs = super().get_context_data(**kwargs)
|
||||||
if ids := self.request.session.get("clipboard", None):
|
kwargs["clipboard"] = {}
|
||||||
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"] = (
|
kwargs["show_albums"] = (
|
||||||
Album.objects.viewable_by(self.request.user)
|
Album.objects.viewable_by(self.request.user)
|
||||||
.filter(parent_id=self.object.id)
|
.filter(parent_id=self.object.id)
|
||||||
|
@ -182,13 +182,12 @@ class OpenApi:
|
|||||||
path[action]["operationId"] = "_".join(
|
path[action]["operationId"] = "_".join(
|
||||||
desc["operationId"].split("_")[:-1]
|
desc["operationId"].split("_")[:-1]
|
||||||
)
|
)
|
||||||
|
|
||||||
schema = str(schema)
|
schema = str(schema)
|
||||||
|
|
||||||
if old_hash == sha1(schema.encode("utf-8")).hexdigest():
|
if old_hash == sha1(schema.encode("utf-8")).hexdigest():
|
||||||
logging.getLogger("django").info("✨ Api did not change, nothing to do ✨")
|
logging.getLogger("django").info("✨ Api did not change, nothing to do ✨")
|
||||||
return
|
return
|
||||||
|
|
||||||
with open(out, "w") as f:
|
out.write_text(schema)
|
||||||
_ = f.write(schema)
|
|
||||||
|
|
||||||
return subprocess.Popen(["npm", "run", "openapi"])
|
return subprocess.Popen(["npm", "run", "openapi"])
|
||||||
|
Loading…
x
Reference in New Issue
Block a user