Compare commits

...

4 Commits

Author SHA1 Message Date
imperosol 27e63b0d12 Automatically resize album thumbnail 2026-04-23 23:52:20 +02:00
thomas girod fdf89ea716 Merge pull request #1356 from ae-utbm/sas-parent-fix
actually fix bug where you can't select /SAS as a parent album
2026-04-22 13:06:03 +02:00
imperosol 3954f2f170 apply review comments 2026-04-22 10:59:56 +02:00
imperosol d36d672d0b actually fix bug where you can't select /SAS as a parent album 2026-04-22 00:07:39 +02:00
8 changed files with 121 additions and 99 deletions
+3 -1
View File
@@ -110,7 +110,9 @@ class Command(BaseCommand):
p.save(force_lock=True)
club_root = SithFile.objects.create(name="clubs", owner=root)
sas = SithFile.objects.create(name="SAS", owner=root, is_in_sas=True)
sas = SithFile.objects.create(
name="SAS", owner=root, id=settings.SITH_SAS_ROOT_DIR_ID
)
main_club = Club.objects.create(
id=1, name="AE", address="6 Boulevard Anatole France, 90000 Belfort"
)
+4 -2
View File
@@ -886,8 +886,10 @@ class SithFile(models.Model):
return self.get_parent_path() + "/" + self.name
def save(self, *args, **kwargs):
sas = SithFile.objects.filter(id=settings.SITH_SAS_ROOT_DIR_ID).first()
self.is_in_sas = sas in self.get_parent_list() or self == sas
sas_id = settings.SITH_SAS_ROOT_DIR_ID
self.is_in_sas = self.id == sas_id or any(
p.id == sas_id for p in self.get_parent_list()
)
adding = self._state.adding
super().save(*args, **kwargs)
if adding:
+11
View File
@@ -344,3 +344,14 @@ def test_quick_upload_image(
assert (
parsed["name"] == Path(file.name).stem[: QuickUploadImage.IMAGE_NAME_SIZE - 1]
)
@pytest.mark.django_db
def test_populated_sas_is_in_sas():
"""Test that, in the data generated by the populate command,
the SAS has value is_in_sas=True.
If it's not the case, it has no incidence in prod, but it's annoying
in dev and may cause misunderstandings.
"""
assert SithFile.objects.get(id=settings.SITH_SAS_ROOT_DIR_ID).is_in_sas
+24 -5
View File
@@ -1,16 +1,22 @@
from typing import Any
from pathlib import Path
from typing import TYPE_CHECKING, Any
from django import forms
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from PIL import Image
from core.models import User
from core.utils import resize_image
from core.views import MultipleImageField
from core.views.forms import SelectDate
from core.views.widgets.ajax_select import AutoCompleteSelectMultipleGroup
from sas.models import Album, Picture, PictureModerationRequest
from sas.widgets.ajax_select import AutoCompleteSelectAlbum
if TYPE_CHECKING:
from django.db.models.fields.files import FieldFile
class AlbumCreateForm(forms.ModelForm):
class Meta:
@@ -49,17 +55,30 @@ class AlbumEditForm(forms.ModelForm):
class Meta:
model = Album
fields = ["name", "date", "file", "parent", "edit_groups"]
widgets = {
"edit_groups": AutoCompleteSelectMultipleGroup,
}
widgets = {"edit_groups": AutoCompleteSelectMultipleGroup, "date": SelectDate}
name = forms.CharField(max_length=Album.NAME_MAX_LENGTH, label=_("file name"))
date = forms.DateField(label=_("Date"), widget=SelectDate, required=True)
recursive = forms.BooleanField(label=_("Apply rights recursively"), required=False)
parent = forms.ModelChoiceField(
Album.objects.all(), required=True, widget=AutoCompleteSelectAlbum
)
def clean_file(self):
# if a file was given in the form, resize it
f: FieldFile = self.cleaned_data["file"]
if self.errors or not f or "file" not in self.changed_data:
return f
f.file = resize_image(Image.open(f.file), 200, "WEBP")
return f
def save(self, commit=True): # noqa: FBT002
if self.instance.file:
self.instance.file.name = str(Path(self.instance.name) / "thumb.webp")
self.instance = super().save(commit=commit)
if not self.instance.file:
self.instance.generate_thumbnail()
return self.instance
class PictureModerationRequestForm(forms.ModelForm):
"""Form to create a PictureModerationRequest.
+5 -17
View File
@@ -110,7 +110,7 @@ class Picture(SasFile):
def get_absolute_url(self):
return reverse("sas:picture", kwargs={"picture_id": self.id})
def generate_thumbnails(self, *, overwrite=False):
def generate_thumbnails(self):
im = Image.open(BytesIO(self.file.read()))
with contextlib.suppress(Exception):
im = exif_auto_rotate(im)
@@ -126,10 +126,6 @@ class Picture(SasFile):
file = resize_image(im, max(im.size), extension, optimize=False)
thumb = resize_image(im, 200, "webp")
compressed = resize_image(im, 1200, "webp")
if overwrite:
self.file.delete()
self.thumbnail.delete()
self.compressed.delete()
new_extension_name = str(Path(self.name).with_suffix(".webp"))
self.file = file
self.file.name = self.name
@@ -205,13 +201,7 @@ class AlbumQuerySet(models.QuerySet):
class SASAlbumManager(models.Manager):
def get_queryset(self):
return (
super()
.get_queryset()
.filter(
Q(id=settings.SITH_SAS_ROOT_DIR_ID) | Q(is_in_sas=True, is_folder=True)
)
)
return super().get_queryset().filter(is_in_sas=True, is_folder=True)
class Album(SasFile):
@@ -251,17 +241,15 @@ class Album(SasFile):
return reverse("sas:album_preview", kwargs={"album_id": self.id})
def generate_thumbnail(self):
p = (
self.children_pictures.order_by("?").first()
or self.children_albums.exclude(file=None)
.exclude(file="")
p = self.children_pictures.order_by("?").first() or (
self.children_albums.exclude(Q(file=None) | Q(file=""))
.order_by("?")
.first()
)
if p and p.file:
image = resize_image(Image.open(BytesIO(p.file.read())), 200, "webp")
self.file = image
self.file.name = f"{self.name}/thumb.webp"
self.file.name = str(Path(self.name) / "thumb.webp")
self.save()
+4 -4
View File
@@ -2,19 +2,19 @@
<a href="{{ url('sas:album', album_id=a.id) }}">
{% if a.file %}
{% set img = a.get_download_url() %}
{% set src = a.name %}
{% set alt = 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 %}
{% set alt = picture.name %}
{% else %}
{% set img = static('core/img/sas.jpg') %}
{% set src = "sas.jpg" %}
{% set alt = "sas.jpg" %}
{% endif %}
<div
class="album{% if not a.is_moderated %} not_moderated{% endif %}"
>
<img src="{{ img }}" alt="{{ src }}" loading="lazy" />
<img src="{{ img }}" alt="{{ alt }}" loading="lazy" />
{% if not a.is_moderated %}
<div class="overlay">&nbsp;</div>
<div class="text">{% trans %}To be moderated{% endtrans %}</div>
+58 -61
View File
@@ -20,7 +20,7 @@ from django.conf import settings
from django.core.cache import cache
from django.test import Client, TestCase
from django.urls import reverse
from django.utils import timezone
from django.utils.timezone import localdate
from model_bakery import baker
from pytest_django.asserts import assertHTMLEqual, assertInHTML, assertRedirects
@@ -66,6 +66,25 @@ def test_main_page_no_form_for_regular_users(client: Client):
assert len(forms) == 0
@pytest.mark.django_db
def test_main_page_displayed_albums(client: Client):
"""Test that the right data is displayed on the SAS main page"""
sas = Album.objects.get(id=settings.SITH_SAS_ROOT_DIR_ID)
Album.objects.exclude(id=sas.id).delete()
album_a = baker.make(Album, parent=sas, is_moderated=True)
album_b = baker.make(Album, parent=album_a, is_moderated=True)
album_c = baker.make(Album, parent=sas, is_moderated=True)
baker.make(Album, parent=sas, is_moderated=False)
client.force_login(subscriber_user.make())
res = client.get(reverse("sas:main"))
# album_b is not a direct child of the SAS, so it shouldn't be displayed
# in the categories, but it should appear in the latest albums.
# album_d isn't moderated, so it shouldn't appear at all for a simple user.
# Also, the SAS itself shouldn't be listed in the albums.
assert res.context_data["latest"] == [album_c, album_b, album_a]
assert res.context_data["categories"] == [album_a, album_c]
@pytest.mark.django_db
def test_main_page_content_anonymous(client: Client):
"""Test that public users see only an incentive to login"""
@@ -91,6 +110,15 @@ def test_album_access_non_subscriber(client: Client):
assert res.status_code == 200
@pytest.mark.django_db
def test_accessing_sas_from_album_view_is_404(client: Client):
"""Test that trying to see the SAS with a regular album view isn't allowed."""
res = client.get(
reverse("sas:album", kwargs={"album_id": settings.SITH_SAS_ROOT_DIR_ID})
)
assert res.status_code == 404
@pytest.mark.django_db
class TestAlbumUpload:
@staticmethod
@@ -149,11 +177,7 @@ class TestAlbumEdit:
@pytest.mark.parametrize(
"user",
[
None,
lambda: baker.make(User),
subscriber_user.make,
],
[None, lambda: baker.make(User), subscriber_user.make],
)
def test_permission_denied(
self,
@@ -164,9 +188,10 @@ class TestAlbumEdit:
if user:
client.force_login(user())
response = client.get(reverse("sas:album_edit", kwargs={"album_id": album.pk}))
url = reverse("sas:album_edit", kwargs={"album_id": album.pk})
response = client.get(url)
assert response.status_code == 403
response = client.post(reverse("sas:album_edit", kwargs={"album_id": album.pk}))
response = client.post(url)
assert response.status_code == 403
def test_sas_root_read_only(self, client: Client, sas_root: Album):
@@ -174,13 +199,10 @@ class TestAlbumEdit:
User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)]
)
client.force_login(moderator)
response = client.get(
reverse("sas:album_edit", kwargs={"album_id": sas_root.pk})
)
url = reverse("sas:album_edit", kwargs={"album_id": sas_root.pk})
response = client.get(url)
assert response.status_code == 404
response = client.post(
reverse("sas:album_edit", kwargs={"album_id": sas_root.pk})
)
response = client.post(url)
assert response.status_code == 404
@pytest.mark.parametrize(
@@ -198,7 +220,7 @@ class TestAlbumEdit:
data = {
"name": album.name[: Album.NAME_MAX_LENGTH],
"parent": baker.make(Album, parent=album.parent, is_moderated=True).pk,
"date": timezone.now().strftime("%Y-%m-%d"),
"date": localdate().strftime("%Y-%m-%d"),
"file": "/random/path",
"edit_groups": [settings.SITH_GROUP_SAS_ADMIN_ID],
"recursive": False,
@@ -210,7 +232,7 @@ class TestAlbumEdit:
data = {
"name": album.name[: Album.NAME_MAX_LENGTH],
"parent": album.pk,
"date": timezone.now().strftime("%Y-%m-%d"),
"date": localdate().strftime("%Y-%m-%d"),
}
assert AlbumEditForm(data=data).is_valid()
@@ -223,19 +245,12 @@ class TestAlbumEdit:
payload = {
"name": album.name[: Album.NAME_MAX_LENGTH],
"parent": album.pk,
"date": timezone.now().strftime("%Y-%m-%d"),
"date": localdate().strftime("%Y-%m-%d"),
}
response = client.post(
reverse(
"sas:album_edit",
kwargs={"album_id": album.pk},
),
payload,
)
assertInHTML(
"<li>Boucle dans l'arborescence des dossiers</li>",
response.text,
reverse("sas:album_edit", kwargs={"album_id": album.pk}), payload
)
assertInHTML("<li>Boucle dans l'arborescence des dossiers</li>", response.text)
assert response.status_code == 200
@pytest.mark.parametrize(
@@ -247,57 +262,39 @@ class TestAlbumEdit:
),
],
)
@pytest.mark.parametrize(
"parent",
[
lambda: baker.make(
Album, parent_id=settings.SITH_SAS_ROOT_DIR_ID, is_moderated=True
),
lambda: Album.objects.get(id=settings.SITH_SAS_ROOT_DIR_ID),
],
)
def test_update(
self,
client: Client,
album: Album,
sas_root: Album,
user: Callable[[], User],
parent: Callable[[], Album],
):
client.force_login(user())
# Prepare a good payload
expected_redirect = reverse("sas:album", kwargs={"album_id": album.pk})
expected_date = timezone.now()
payload = {
"name": album.name[: Album.NAME_MAX_LENGTH],
"parent": baker.make(Album, parent=sas_root, is_moderated=True).pk,
"date": expected_date.strftime("%Y-%m-%d"),
"parent": parent().id,
"date": localdate().strftime("%Y-%m-%d"),
"recursive": False,
}
# Test successful update
response = client.post(
reverse(
"sas:album_edit",
kwargs={"album_id": album.pk},
),
payload,
reverse("sas:album_edit", kwargs={"album_id": album.pk}), payload
)
assertRedirects(response, expected_redirect)
updated_album = Album.objects.get(id=album.pk)
assert updated_album.name == payload["name"]
assert updated_album.parent.id == payload["parent"]
assert timezone.localdate(updated_album.date) == timezone.localdate(
expected_date
)
# Test root album can be used as parent
payload["parent"] = sas_root.pk
response = client.post(
reverse(
"sas:album_edit",
kwargs={"album_id": album.pk},
),
payload,
)
assertRedirects(response, expected_redirect)
updated_album = Album.objects.get(id=album.pk)
assert updated_album.name == payload["name"]
assert updated_album.parent.id == payload["parent"]
assert timezone.localdate(updated_album.date) == timezone.localdate(
expected_date
)
album.refresh_from_db()
assert album.name == payload["name"]
assert album.parent.id == payload["parent"]
assert localdate(album.date) == localdate()
class TestSasModeration(TestCase):
+11 -8
View File
@@ -16,6 +16,7 @@ from typing import Any
from django.conf import settings
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.core.exceptions import PermissionDenied
from django.db.models import Count, OuterRef, Subquery
from django.http import Http404, HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect
@@ -37,7 +38,7 @@ from sas.forms import (
PictureModerationRequestForm,
PictureUploadForm,
)
from sas.models import Album, AlbumQuerySet, PeoplePictureRelation, Picture
from sas.models import Album, PeoplePictureRelation, Picture
class AlbumCreateFragment(FragmentMixin, CreateView):
@@ -85,7 +86,9 @@ class SASMainView(UseFragmentsMixin, TemplateView):
kwargs["categories"] = list(
albums_qs.filter(parent_id=settings.SITH_SAS_ROOT_DIR_ID).order_by("id")
)
kwargs["latest"] = list(albums_qs.order_by("-id")[:5])
kwargs["latest"] = list(
albums_qs.exclude(id=settings.SITH_SAS_ROOT_DIR_ID).order_by("-id")[:5]
)
return kwargs
@@ -126,6 +129,9 @@ def send_thumb(request, picture_id):
class AlbumView(CanViewMixin, UseFragmentsMixin, DetailView):
model = Album
# exclude the SAS from the album accessible with this view
# the SAS can be viewed only with SASMainView
queryset = Album.objects.exclude(id=settings.SITH_SAS_ROOT_DIR_ID)
pk_url_kwarg = "album_id"
template_name = "sas/album.jinja"
@@ -147,9 +153,8 @@ class AlbumView(CanViewMixin, UseFragmentsMixin, DetailView):
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
if not request.user.can_edit(self.object):
raise PermissionDenied
FileView.handle_clipboard(request, self.object)
return HttpResponseRedirect(self.request.path)
@@ -262,13 +267,11 @@ class PictureAskRemovalView(CanViewMixin, DetailView, FormView):
class AlbumEditView(CanEditMixin, UpdateView):
model = Album
queryset = Album.objects.exclude(id=settings.SITH_SAS_ROOT_DIR_ID)
form_class = AlbumEditForm
template_name = "core/edit.jinja"
pk_url_kwarg = "album_id"
def get_queryset(self) -> AlbumQuerySet:
return super().get_queryset().exclude(id=settings.SITH_SAS_ROOT_DIR_ID)
def form_valid(self, form):
ret = super().form_valid(form)
if form.cleaned_data["recursive"]: