Skip to content

Commit 831a32e

Browse files
committed
WIP
1 parent b20861b commit 831a32e

17 files changed

Lines changed: 426 additions & 134 deletions

File tree

core/management/commands/populate.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -693,9 +693,7 @@ def handle(self, *args, **options):
693693
# SAS
694694
for f in self.SAS_FIXTURE_PATH.glob("*"):
695695
if f.is_dir():
696-
album = Album(name=f.name)
697-
album.clean()
698-
album.save()
696+
album = Album.objects.create(name=f.name, is_moderated=True)
699697
for p in f.iterdir():
700698
file = resize_image(Image.open(p), 1000, "WEBP")
701699
pict = Picture(
@@ -709,6 +707,7 @@ def handle(self, *args, **options):
709707
pict.generate_thumbnails()
710708
pict.full_clean()
711709
pict.save()
710+
album.generate_thumbnail()
712711

713712
img_skia = Picture.objects.get(name="skia.jpg")
714713
img_sli = Picture.objects.get(name="sli.jpg")

core/models.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -883,8 +883,6 @@ def clean(self):
883883
super().clean()
884884
if "/" in self.name:
885885
raise ValidationError(_("Character '/' not authorized in name"))
886-
if self == self.parent:
887-
raise ValidationError(_("Loop in folder tree"), code="loop")
888886
if self == self.parent or (
889887
self.parent is not None and self in self.get_parent_list()
890888
):

core/tests/test_files.py

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from uuid import uuid4
66

77
import pytest
8+
from django.conf import settings
89
from django.core.cache import cache
910
from django.core.files.uploadedfile import SimpleUploadedFile, UploadedFile
1011
from django.test import Client, TestCase
@@ -17,8 +18,8 @@
1718
from core.baker_recipes import board_user, old_subscriber_user, subscriber_user
1819
from core.models import Group, QuickUploadImage, SithFile, User
1920
from core.utils import RED_PIXEL_PNG
21+
from sas.baker_recipes import picture_recipe
2022
from sas.models import Picture
21-
from sith import settings
2223

2324

2425
@pytest.mark.django_db
@@ -30,24 +31,19 @@ class TestImageAccess:
3031
lambda: baker.make(
3132
User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)]
3233
),
33-
lambda: baker.make(
34-
User, groups=[Group.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)]
35-
),
3634
],
3735
)
3836
def test_sas_image_access(self, user_factory: Callable[[], User]):
3937
"""Test that only authorized users can access the sas image."""
4038
user = user_factory()
41-
picture: SithFile = baker.make(
42-
Picture, parent=SithFile.objects.get(pk=settings.SITH_SAS_ROOT_DIR_ID)
43-
)
44-
assert picture.is_owned_by(user)
39+
picture = picture_recipe.make()
40+
assert user.can_edit(picture)
4541

4642
def test_sas_image_access_owner(self):
4743
"""Test that the owner of the image can access it."""
4844
user = baker.make(User)
49-
picture: Picture = baker.make(Picture, owner=user)
50-
assert picture.is_owned_by(user)
45+
picture = picture_recipe.make(owner=user)
46+
assert user.can_edit(picture)
5147

5248
@pytest.mark.parametrize(
5349
"user_factory",
@@ -63,7 +59,7 @@ def test_sas_image_access_forbidden(self, user_factory: Callable[[], User]):
6359
user = user_factory()
6460
owner = baker.make(User)
6561
picture: Picture = baker.make(Picture, owner=owner)
66-
assert not picture.is_owned_by(user)
62+
assert not user.can_edit(picture)
6763

6864

6965
@pytest.mark.django_db

core/utils.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,20 +12,23 @@
1212
# OR WITHIN THE LOCAL FILE "LICENSE"
1313
#
1414
#
15-
15+
from dataclasses import dataclass
1616
from datetime import date, timedelta
1717

1818
# Image utils
1919
from io import BytesIO
20-
from typing import Final, Unpack
20+
from typing import Any, Final, Unpack
2121

2222
import PIL
2323
from django.conf import settings
2424
from django.core.files.base import ContentFile
2525
from django.core.files.uploadedfile import UploadedFile
2626
from django.db import models
27+
from django.forms import BaseForm
2728
from django.http import Http404, HttpRequest
2829
from django.shortcuts import get_list_or_404
30+
from django.template.loader import render_to_string
31+
from django.utils.safestring import SafeString
2932
from django.utils.timezone import localdate
3033
from PIL import ExifTags
3134
from PIL.Image import Image, Resampling
@@ -44,6 +47,21 @@
4447
"""
4548

4649

50+
@dataclass
51+
class FormFragmentTemplateData[T: BaseForm]:
52+
"""Dataclass used to pre-render form fragments"""
53+
54+
form: T
55+
template: str
56+
context: dict[str, Any]
57+
58+
def render(self, request: HttpRequest) -> SafeString:
59+
# Request is needed for csrf_tokens
60+
return render_to_string(
61+
self.template, context={"form": self.form, **self.context}, request=request
62+
)
63+
64+
4765
def get_start_of_semester(today: date | None = None) -> date:
4866
"""Return the date of the start of the semester of the given date.
4967
If no date is given, return the start date of the current semester.

docs/tutorial/groups.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,3 +263,35 @@ avec un unique champ permettant de sélectionner des groupes.
263263
Par défaut, seuls les utilisateurs avec la permission
264264
`auth.change_permission` auront accès à ce formulaire
265265
(donc, normalement, uniquement les utilisateurs Root).
266+
267+
```mermaid
268+
sequenceDiagram
269+
participant A as Utilisateur
270+
participant B as ReverseProxy
271+
participant C as MarkdownImage
272+
participant D as Model
273+
274+
A->>B: GET /page/foo
275+
B->>C: GET /page/foo
276+
C-->>B: La page, avec les urls
277+
B-->>A: La page, avec les urls
278+
alt image publique
279+
A->>B: GET markdown/public/2025/img.webp
280+
B-->>A: img.webp
281+
end
282+
alt image privée
283+
A->>B: GET markdown_image/{id}
284+
B->>C: GET markdown_image/{id}
285+
C->>D: user.can_view(image)
286+
alt l'utilisateur a le droit de voir l'image
287+
D-->>C: True
288+
C-->>B: 200 (avec le X-Accel-Redirect)
289+
B-->>A: img.webp
290+
end
291+
alt l'utilisateur n'a pas le droit de l'image
292+
D-->>C: False
293+
C-->>B: 403
294+
B-->>A: 403
295+
end
296+
end
297+
```

galaxy/management/commands/generate_galaxy_test_data.py

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,6 @@
3535
from sas.models import Album, PeoplePictureRelation, Picture
3636
from subscription.models import Subscription
3737

38-
RED_PIXEL_PNG: Final[bytes] = (
39-
b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52"
40-
b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90\x77\x53"
41-
b"\xde\x00\x00\x00\x0c\x49\x44\x41\x54\x08\xd7\x63\xf8\xcf\xc0\x00"
42-
b"\x00\x03\x01\x01\x00\x18\xdd\x8d\xb0\x00\x00\x00\x00\x49\x45\x4e"
43-
b"\x44\xae\x42\x60\x82"
44-
)
45-
4638
USER_PACK_SIZE: Final[int] = 1000
4739

4840

sas/api.py

Lines changed: 53 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22

33
from django.conf import settings
44
from django.core.exceptions import ValidationError
5+
from django.shortcuts import get_list_or_404
56
from django.urls import reverse
6-
from ninja import Body, File, Query
7+
from ninja import Body, Query, UploadedFile
8+
from ninja.errors import HttpError
79
from ninja.security import SessionAuth
810
from ninja_extra import ControllerBase, api_controller, paginate, route
911
from ninja_extra.exceptions import NotFound, PermissionDenied
@@ -16,18 +18,20 @@
1618
CanAccessLookup,
1719
CanEdit,
1820
CanView,
21+
HasPerm,
1922
IsInGroup,
2023
IsRoot,
2124
)
2225
from core.models import Notification, User
23-
from core.schemas import UploadedImage
26+
from core.utils import get_list_exact_or_404
2427
from sas.models import Album, PeoplePictureRelation, Picture
2528
from sas.schemas import (
2629
AlbumAutocompleteSchema,
2730
AlbumFilterSchema,
2831
AlbumSchema,
2932
IdentifiedUserSchema,
3033
ModerationRequestSchema,
34+
MoveAlbumSchema,
3135
PictureFilterSchema,
3236
PictureSchema,
3337
)
@@ -69,6 +73,48 @@ def autocomplete_album(self, filters: Query[AlbumFilterSchema]):
6973
Album.objects.viewable_by(self.context.request.user).order_by("-date")
7074
)
7175

76+
@route.patch("/parent", permissions=[IsAuthenticated])
77+
def change_album_parent(self, payload: list[MoveAlbumSchema]):
78+
"""Change parents of albums
79+
80+
Note:
81+
For this operation to work, the user must be authorized
82+
to edit both the moved albums and their new parent.
83+
"""
84+
user: User = self.context.request.user
85+
albums: list[Album] = get_list_exact_or_404(
86+
Album, pk__in={a.id for a in payload}
87+
)
88+
if not user.has_perm("sas.change_album"):
89+
unauthorized = [a.id for a in albums if not user.can_edit(a)]
90+
raise PermissionDenied(
91+
f"You can't move the following albums : {unauthorized}"
92+
)
93+
parents: list[Album] = get_list_exact_or_404(
94+
Album, pk__in={a.new_parent_id for a in payload}
95+
)
96+
if not user.has_perm("sas.change_album"):
97+
unauthorized = [a.id for a in parents if not user.can_edit(a)]
98+
raise PermissionDenied(
99+
f"You can't move to the following albums : {unauthorized}"
100+
)
101+
id_to_new_parent = {i.id: i.new_parent_id for i in payload}
102+
for album in albums:
103+
album.parent_id = id_to_new_parent[album.id]
104+
# known caveat : moving an album won't move it's thumbnail.
105+
# E.g. if the album foo/bar is moved to foo/baz,
106+
# the thumbnail will still be foo/bar/thumb.webp
107+
# This has no impact for the end user
108+
# and doing otherwise would be hard for us to implement,
109+
# because we would then have to manage rollbacks on fail.
110+
Album.objects.bulk_update(albums, fields=["parent_id"])
111+
112+
@route.delete("", permissions=[HasPerm("sas.delete_album")])
113+
def delete_album(self, album_ids: list[int]):
114+
# known caveat : deleting an album doesn't delete the pictures on the disk.
115+
# It's a db only operation.
116+
albums: list[Album] = get_list_or_404(Album, pk__in=album_ids)
117+
72118

73119
@api_controller("/sas/picture")
74120
class PicturesController(ControllerBase):
@@ -110,27 +156,25 @@ def fetch_pictures(self, filters: Query[PictureFilterSchema]):
110156
},
111157
url_name="upload_picture",
112158
)
113-
def upload_picture(self, album_id: Body[int], picture: File[UploadedImage]):
159+
def upload_picture(self, album_id: Body[int], picture: UploadedFile):
114160
album = self.get_object_or_exception(Album, pk=album_id)
115161
user = self.context.request.user
116162
self_moderate = user.has_perm("sas.moderate_sasfile")
117163
new = Picture(
118164
parent=album,
119165
name=picture.name,
120-
file=picture,
166+
original=picture,
121167
owner=user,
122168
is_moderated=self_moderate,
123-
is_folder=False,
124-
mime_type=picture.content_type,
125169
)
126170
if self_moderate:
127171
new.moderator = user
172+
new.generate_thumbnails()
128173
try:
129-
new.generate_thumbnails()
130174
new.full_clean()
131-
new.save()
132175
except ValidationError as e:
133-
return self.create_response({"detail": dict(e)}, status_code=409)
176+
raise HttpError(status_code=409, message=str(e)) from e
177+
new.save()
134178

135179
@route.get(
136180
"/{picture_id}/identified",

sas/baker_recipes.py

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,35 @@
1+
from django.core.files.uploadedfile import SimpleUploadedFile
12
from model_bakery import seq
23
from model_bakery.recipe import Recipe
34

4-
from sas.models import Picture
5+
from core.utils import RED_PIXEL_PNG
6+
from sas.models import Album, Picture
57

6-
picture_recipe = Recipe(Picture, is_moderated=True, name=seq("Picture "))
7-
"""A SAS Picture fixture.
8+
album_recipe = Recipe(
9+
Album,
10+
name=seq("Album "),
11+
thumbnail=SimpleUploadedFile(
12+
name="thumb.webp", content=b"", content_type="image/webp"
13+
),
14+
)
815

9-
Warnings:
10-
If you don't `bulk_create` this, you need
11-
to explicitly set the parent album, or it won't work
12-
"""
16+
17+
picture_recipe = Recipe(
18+
Picture,
19+
is_moderated=True,
20+
name=seq("Picture "),
21+
original=SimpleUploadedFile(
22+
# compressed and thumbnail are generated on save (except if bulk creating).
23+
# For this step no to fail, original must be a valid image.
24+
name="img.png",
25+
content=RED_PIXEL_PNG,
26+
content_type="image/png",
27+
),
28+
compressed=SimpleUploadedFile(
29+
name="img.webp", content=b"", content_type="image/webp"
30+
),
31+
thumbnail=SimpleUploadedFile(
32+
name="img.webp", content=b"", content_type="image/webp"
33+
),
34+
)
35+
"""A SAS Picture fixture."""

0 commit comments

Comments
 (0)