From 5c17337595d2f3d8c410683c54899afb0a984db4 Mon Sep 17 00:00:00 2001 From: imperosol Date: Thu, 30 Apr 2026 18:40:46 +0200 Subject: [PATCH 01/24] update CI --- .github/actions/setup_project/action.yml | 38 ++++++++++-------------- .github/workflows/ci.yml | 8 ++--- .github/workflows/deploy.yml | 4 +-- .github/workflows/deploy_docs.yml | 4 +-- .github/workflows/taiste.yml | 4 +-- 5 files changed, 24 insertions(+), 34 deletions(-) diff --git a/.github/actions/setup_project/action.yml b/.github/actions/setup_project/action.yml index 0ec83445..720d36bc 100644 --- a/.github/actions/setup_project/action.yml +++ b/.github/actions/setup_project/action.yml @@ -12,7 +12,7 @@ runs: steps: - name: Install apt packages if: ${{ inputs.full == 'true' }} - uses: awalsh128/cache-apt-pkgs-action@v1.4.3 + uses: awalsh128/cache-apt-pkgs-action@v1.6.0 with: packages: gettext version: 1.0 # increment to reset cache @@ -23,26 +23,29 @@ runs: with: redis-version: "7.x" - - name: Install uv - uses: astral-sh/setup-uv@v5 - with: - version: "0.5.14" - enable-cache: true - cache-dependency-glob: "uv.lock" - - name: "Set up Python" - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version-file: ".python-version" - - name: Restore cached virtualenv - uses: actions/cache/restore@v4 + - name: Install uv + uses: astral-sh/setup-uv@v8.1.0 + with: + version: "0.11.8" + enable-cache: false + cache-dependency-glob: "uv.lock" + + - name: Restore cached virtualenv + uses: actions/cache@v5 with: - key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('pyproject.toml') }}-${{ env.CACHE_SUFFIX }} path: .venv + key: uv-${{ runner.os }}-${{ hashFiles('uv.lock') }} + restore-keys: | + uv-${{ runner.os }}-${{ hashFiles('uv.lock') }} + uv-${{ runner.os }} - name: Install dependencies - run: uv sync + run: uv sync --locked shell: bash - name: Install Xapian @@ -50,15 +53,6 @@ runs: run: uv run ./manage.py install_xapian shell: bash - # compiling xapian accounts for almost the entirety of the virtualenv setup, - # so we save the virtual environment only on workflows where it has been installed - - name: Save cached virtualenv - if: ${{ inputs.full == 'true' }} - uses: actions/cache/save@v4 - with: - key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('pyproject.toml') }}-${{ env.CACHE_SUFFIX }} - path: .venv - - name: Compile gettext messages if: ${{ inputs.full == 'true' }} run: uv run ./manage.py compilemessages diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8919d04c..e92bf82e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,8 +18,8 @@ jobs: name: Launch pre-commits checks (ruff) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 with: python-version-file: ".python-version" - uses: pre-commit/action@v3.0.1 @@ -35,7 +35,7 @@ jobs: pytest-mark: [not slow] steps: - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - uses: ./.github/actions/setup_project with: full: true @@ -49,7 +49,7 @@ jobs: uv run coverage report uv run coverage html - name: Archive code coverage results - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: coverage-report-${{ matrix.pytest-mark }} path: coverage_report diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 8df48378..d46feb8a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -14,7 +14,7 @@ jobs: steps: - name: SSH Remote Commands - uses: appleboy/ssh-action@v1.1.0 + uses: appleboy/ssh-action@v1.2.5 with: # Proxy proxy_host : ${{secrets.PROXY_HOST}} @@ -29,8 +29,6 @@ jobs: username : ${{secrets.USER}} key: ${{secrets.KEY}} - script_stop: true - # See https://github.com/ae-utbm/sith/wiki/GitHub-Actions#deployment-action script: | cd ${{secrets.SITH_PATH}} diff --git a/.github/workflows/deploy_docs.yml b/.github/workflows/deploy_docs.yml index 236917a3..cae93e7b 100644 --- a/.github/workflows/deploy_docs.yml +++ b/.github/workflows/deploy_docs.yml @@ -9,10 +9,10 @@ jobs: deploy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: ./.github/actions/setup_project - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV - - uses: actions/cache@v3 + - uses: actions/cache@v5 with: key: mkdocs-material-${{ env.cache_id }} path: .cache diff --git a/.github/workflows/taiste.yml b/.github/workflows/taiste.yml index 729e051c..9a49c8f3 100644 --- a/.github/workflows/taiste.yml +++ b/.github/workflows/taiste.yml @@ -13,7 +13,7 @@ jobs: steps: - name: SSH Remote Commands - uses: appleboy/ssh-action@v1.1.0 + uses: appleboy/ssh-action@v1.2.5 with: # Proxy proxy_host : ${{secrets.PROXY_HOST}} @@ -28,8 +28,6 @@ jobs: username : ${{secrets.USER}} key: ${{secrets.KEY}} - script_stop: true - # See https://github.com/ae-utbm/sith/wiki/GitHub-Actions#deployment-action script: | cd ${{secrets.SITH_PATH}} From f19b3056efde00df9db5f8fae91b685baf17644a Mon Sep 17 00:00:00 2001 From: Sli Date: Sat, 25 Apr 2026 01:05:38 +0200 Subject: [PATCH 02/24] Fix notifications on messages containing quotes --- core/templates/core/base/notifications.jinja | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/core/templates/core/base/notifications.jinja b/core/templates/core/base/notifications.jinja index 89fb7aad..030b2d4e 100644 --- a/core/templates/core/base/notifications.jinja +++ b/core/templates/core/base/notifications.jinja @@ -1,13 +1,13 @@
@@ -157,7 +172,7 @@ @keyup.right.window="currentPicture = nextPicture" @click="currentPicture = nextPicture" > - {% trans %}Previous picture{% endtrans %} + {% trans %}Previous picture{% endtrans %}
From 441a0160250e94a0f67708c82a9bd81e0fd43ab2 Mon Sep 17 00:00:00 2001 From: imperosol Date: Fri, 1 May 2026 18:00:22 +0200 Subject: [PATCH 04/24] refactor `Picture.generate_thumbnails` --- core/management/commands/populate.py | 3 +- core/utils.py | 17 ------- sas/api.py | 3 +- sas/models.py | 71 +++++++++++++++------------- 4 files changed, 40 insertions(+), 54 deletions(-) diff --git a/core/management/commands/populate.py b/core/management/commands/populate.py index a326b8ed..22541841 100644 --- a/core/management/commands/populate.py +++ b/core/management/commands/populate.py @@ -622,8 +622,7 @@ class Command(BaseCommand): ) pict.file.name = p.name pict.full_clean() - pict.generate_thumbnails() - pict.save() + pict.generate_thumbnails(save=True) img_skia = Picture.objects.get(name="skia.jpg") img_sli = Picture.objects.get(name="sli.jpg") diff --git a/core/utils.py b/core/utils.py index 0e284768..9fb7adc8 100644 --- a/core/utils.py +++ b/core/utils.py @@ -25,7 +25,6 @@ from django.core.files.base import ContentFile from django.core.files.uploadedfile import UploadedFile from django.http import HttpRequest from django.utils.timezone import localdate -from PIL import ExifTags from PIL.Image import Image, Resampling RED_PIXEL_PNG: Final[bytes] = ( @@ -178,22 +177,6 @@ def resize_image_explicit( return ContentFile(content.getvalue()) -def exif_auto_rotate(image): - for orientation in ExifTags.TAGS: - if ExifTags.TAGS[orientation] == "Orientation": - break - exif = dict(image._getexif().items()) - - if exif[orientation] == 3: - image = image.rotate(180, expand=True) - elif exif[orientation] == 6: - image = image.rotate(270, expand=True) - elif exif[orientation] == 8: - image = image.rotate(90, expand=True) - - return image - - def get_client_ip(request: HttpRequest) -> str | None: headers = ( "X_FORWARDED_FOR", # Common header for proxies diff --git a/sas/api.py b/sas/api.py index 62fe1e5d..61ea73dc 100644 --- a/sas/api.py +++ b/sas/api.py @@ -126,9 +126,8 @@ class PicturesController(ControllerBase): if self_moderate: new.moderator = user try: - new.generate_thumbnails() new.full_clean() - new.save() + new.generate_thumbnails(save=True) except ValidationError as e: return self.create_response({"detail": dict(e)}, status_code=409) diff --git a/sas/models.py b/sas/models.py index b5de50ab..0ce74eb9 100644 --- a/sas/models.py +++ b/sas/models.py @@ -15,7 +15,6 @@ from __future__ import annotations -import contextlib from io import BytesIO from pathlib import Path from typing import ClassVar, Self @@ -30,7 +29,7 @@ from django.utils.translation import gettext_lazy as _ from PIL import Image from core.models import Notification, SithFile, User -from core.utils import exif_auto_rotate, resize_image +from core.utils import resize_image class SasFile(SithFile): @@ -123,45 +122,51 @@ class Picture(SasFile): def get_absolute_url(self): return reverse("sas:picture", kwargs={"picture_id": self.id}) - def generate_thumbnails(self): - im = Image.open(BytesIO(self.file.read())) - with contextlib.suppress(Exception): - im = exif_auto_rotate(im) + def generate_thumbnails( + self, *, img: Image.Image | None = None, save: bool = False + ): + """Generate the thumbnail and the compressed version of this picture. + + Args: + img: if given, this will be used to generate + all three images (file, compressed, thumbnail). + Else, `self.file` will be used + save: if True, save the instance in database. + """ + img = img or Image.open(self.file) + extension = self.mime_type.split("/")[-1] + previous_files = [ + f.name for f in (self.file, self.thumbnail, self.compressed) if f + ] # convert the compressed image and the thumbnail into webp # The original image keeps its original type, because it's not # meant to be shown on the website, but rather to keep the real image - # for less frequent cases (like downloading the pictures of an user) - extension = self.mime_type.split("/")[-1] + # for less frequent cases (like downloading the pictures of a user) # the HD version of the image doesn't need to be optimized, because : # - it isn't frequently queried - # - optimizing large images takes a lot time, which greatly hinders the UX + # - optimizing large images takes a lot of time, which greatly hinders the UX # - photographers usually already optimize their images - file = resize_image(im, max(im.size), extension, optimize=False) - thumb = resize_image(im, 200, "webp") - compressed = resize_image(im, 1200, "webp") new_extension_name = str(Path(self.name).with_suffix(".webp")) - self.file = file - self.file.name = self.name - self.thumbnail = thumb - self.thumbnail.name = new_extension_name - self.compressed = compressed - self.compressed.name = new_extension_name + file = resize_image(img, max(img.size), extension, optimize=False) + self.file.save(self.name, file, save=False) + thumbnail = resize_image(img, 200, "webp") + self.thumbnail.save(new_extension_name, thumbnail, save=False) + compressed = resize_image(img, 1200, "webp") + self.compressed.save(new_extension_name, compressed, save=save) + # once the new images have been saved, delete the previous ones. + # The deletion of old files is done after, so that if anything goes + # during the whole process, no data will be lost. + for filename in previous_files: + self.file.storage.delete(filename) - def rotate(self, degree): - for attr in ["file", "compressed", "thumbnail"]: - name = self.__getattribute__(attr).name - with open(settings.MEDIA_ROOT / name, "r+b") as file: - if file: - im = Image.open(BytesIO(file.read())) - file.seek(0) - im = im.rotate(degree, expand=True) - im.save( - fp=file, - format=self.mime_type.split("/")[-1].upper(), - quality=90, - optimize=True, - progressive=True, - ) + def rotate(self, degree: int | float): + """Rotate this picture and update its thumbnails accordingly. + + Args: + degree: the rotation angle, in degree, counter-clockwise + """ + img = Image.open(self.file).rotate(degree) + self.generate_thumbnails(img=img, save=True) def get_next(self): if self.is_moderated: From 399a3813f0e0a02c1e33028f2de73f8e03d12f52 Mon Sep 17 00:00:00 2001 From: imperosol Date: Fri, 1 May 2026 18:54:13 +0200 Subject: [PATCH 05/24] feat: rotate pictures with API+AlpineJS --- sas/api.py | 13 ++++ sas/static/bundled/sas/user/pictures-index.ts | 2 +- sas/static/bundled/sas/viewer-index.ts | 49 ++++++++++--- sas/static/sas/css/picture.scss | 47 ++++++------ sas/tests/test_views.py | 71 ++++++++++++++++++- sas/views.py | 8 --- 6 files changed, 144 insertions(+), 46 deletions(-) diff --git a/sas/api.py b/sas/api.py index 61ea73dc..a09914a7 100644 --- a/sas/api.py +++ b/sas/api.py @@ -176,6 +176,19 @@ class PicturesController(ControllerBase): def delete_picture(self, picture_id: int): self.get_object_or_exception(Picture, pk=picture_id).delete() + @route.post( + "/{picture_id}/rotate/{direction}", + permissions=[IsSasAdmin], + response=PictureSchema, + url_name="rotate_picture", + ) + def rotate_picture(self, picture_id: int, direction: Literal["left", "right"]): + """Rotate the given picture and returns its edited data.""" + angle = 90 if direction == "left" else 270 + picture = self.get_object_or_exception(Picture, pk=picture_id) + picture.rotate(angle) + return picture + @route.patch( "/{picture_id}/moderation", permissions=[IsSasAdmin], diff --git a/sas/static/bundled/sas/user/pictures-index.ts b/sas/static/bundled/sas/user/pictures-index.ts index d4c75f17..32615bf9 100644 --- a/sas/static/bundled/sas/user/pictures-index.ts +++ b/sas/static/bundled/sas/user/pictures-index.ts @@ -1,4 +1,4 @@ -import { paginated } from "#core:utils/api.ts"; +import { paginated } from "#core:utils/api"; import { type PictureSchema, type PicturesFetchPicturesData, diff --git a/sas/static/bundled/sas/viewer-index.ts b/sas/static/bundled/sas/viewer-index.ts index 3573970f..863de3a0 100644 --- a/sas/static/bundled/sas/viewer-index.ts +++ b/sas/static/bundled/sas/viewer-index.ts @@ -1,7 +1,7 @@ import type TomSelect from "tom-select"; -import type { UserAjaxSelect } from "#core:core/components/ajax-select-index.ts"; -import { paginated } from "#core:utils/api.ts"; -import { History } from "#core:utils/history.ts"; +import type { UserAjaxSelect } from "#core:core/components/ajax-select-index"; +import { paginated } from "#core:utils/api"; +import { History } from "#core:utils/history"; import { type IdentifiedUserSchema, type ModerationRequestSchema, @@ -14,6 +14,7 @@ import { picturesFetchPictures, picturesIdentifyUsers, picturesModeratePicture, + picturesRotatePicture, type UserProfileSchema, usersidentifiedDeleteRelation, } from "#openapi"; @@ -28,18 +29,32 @@ class PictureWithIdentifications { identificationsLoading = false; moderationLoading = false; id: number; - // biome-ignore lint/style/useNamingConvention: api is in snake_case - compressed_url: string; + compressedUrl: string = ""; + thumbUrl: string = ""; + fullSizeUrl: string = ""; moderationRequests: ModerationRequestSchema[] = null; constructor(picture: PictureSchema) { Object.assign(this, picture); + this.compressedUrl = picture.compressed_url; + this.thumbUrl = picture.thumb_url; + this.fullSizeUrl = picture.full_size_url; } static fromPicture(picture: PictureSchema): PictureWithIdentifications { return new PictureWithIdentifications(picture); } + rebuildUrls(date: Date) { + const buildUrl = (url: string) => { + const base = url.split("?", 1)[0]; + return `${base}?date=${date.getTime().toString()}`; + }; + this.compressedUrl = buildUrl(this.compressedUrl); + this.thumbUrl = buildUrl(this.thumbUrl); + this.fullSizeUrl = buildUrl(this.fullSizeUrl); + } + /** * If not already done, fetch the users identified on this picture and * populate the identifications field @@ -82,12 +97,25 @@ class PictureWithIdentifications { this.moderationLoading = false; } + async rotate(direction: "left" | "right") { + this.imageLoading = true; + const res = await picturesRotatePicture({ + // biome-ignore lint/style/useNamingConvention: api is snake case + path: { picture_id: this.id, direction: direction }, + }); + // urls returned by the api include a timestamp for cache busting + this.fullSizeUrl = res.data.full_size_url; + this.compressedUrl = res.data.compressed_url; + this.thumbUrl = res.data.thumb_url; + this.imageLoading = false; + } + /** * Preload the photo and the identifications */ async preload(): Promise { const img = new Image(); - img.src = this.compressed_url; + img.src = this.compressedUrl; if (!img.complete) { this.imageLoading = true; img.addEventListener("load", () => { @@ -140,7 +168,8 @@ document.addEventListener("alpine:init", () => { // biome-ignore lint/style/useNamingConvention: api is in snake_case full_size_url: "", owner: "", - date: new Date(), + // biome-ignore lint/style/useNamingConvention: api is in snake_case + created_at: new Date(), identifications: [] as IdentifiedUserSchema[], }, /** @@ -291,10 +320,8 @@ document.addEventListener("alpine:init", () => { async submitIdentification(): Promise { const widget: TomSelect = this.selector.widget; await picturesIdentifyUsers({ - path: { - // biome-ignore lint/style/useNamingConvention: api is in snake_case - picture_id: this.currentPicture.id, - }, + // biome-ignore lint/style/useNamingConvention: api is in snake_case + path: { picture_id: this.currentPicture.id }, body: widget.items.map((i: string) => Number.parseInt(i, 10)), }); // refresh the identified users list diff --git a/sas/static/sas/css/picture.scss b/sas/static/sas/css/picture.scss index 03103a25..4ed49eea 100644 --- a/sas/static/sas/css/picture.scss +++ b/sas/static/sas/css/picture.scss @@ -235,37 +235,34 @@ >.tools { flex: 1; + .btn { + background-color: $primary-neutral-light-color; + display: flex; + justify-content: center; + align-items: center; + padding: 0; + color: black; + width: 40px; + height: 40px; + font-size: 20px; - >div>div { - >a.btn { - background-color: $primary-neutral-light-color; - display: flex; - justify-content: center; - align-items: center; - padding: 0; - color: black; - width: 40px; - height: 40px; - font-size: 20px; - - &:hover { - background-color: #aaa; - } + &:hover { + background-color: #aaa; } + } - >a.text.danger { - color: red; + a.text.danger { + color: red; - &:hover { - color: darkred; - } + &:hover { + color: darkred; } + } - &.buttons { - display: flex; - flex-direction: row; - gap: 5px; - } + .buttons { + display: flex; + flex-direction: row; + gap: 5px; } } } \ No newline at end of file diff --git a/sas/tests/test_views.py b/sas/tests/test_views.py index 57a69750..aa57a0b5 100644 --- a/sas/tests/test_views.py +++ b/sas/tests/test_views.py @@ -12,19 +12,23 @@ # OR WITHIN THE LOCAL FILE "LICENSE" # # -from typing import Callable +from typing import Callable, Literal +from unittest.mock import patch import pytest from bs4 import BeautifulSoup from django.conf import settings from django.core.cache import cache +from django.core.files.base import ContentFile from django.test import Client, TestCase from django.urls import reverse from model_bakery import baker +from PIL import Image from pytest_django.asserts import assertHTMLEqual, assertInHTML, assertRedirects from core.baker_recipes import old_subscriber_user, subscriber_user from core.models import Group, User +from core.utils import RED_PIXEL_PNG from sas.baker_recipes import picture_recipe from sas.models import Album, Picture @@ -162,6 +166,71 @@ class TestAlbumUpload: assert not album.children.exists() +@pytest.mark.django_db +class TestPictureRotation: + @pytest.fixture + def picture(self) -> Picture: + return picture_recipe.make( + parent_id=settings.SITH_SAS_ROOT_DIR_ID, + file=ContentFile(name="foo.png", content=RED_PIXEL_PNG), + compressed=ContentFile(name="foo.png", content=RED_PIXEL_PNG), + thumbnail=ContentFile(name="foo.png", content=RED_PIXEL_PNG), + ) + + @pytest.mark.parametrize( + "user", + [ + None, + lambda: baker.make(User), + subscriber_user.make, + old_subscriber_user.make, + ], + ) + def test_permission_denied( + self, client: Client, picture: Picture, user: Callable[[], User] | None + ): + if user: + client.force_login(user()) + + url = reverse( + "api:rotate_picture", kwargs={"picture_id": picture.id, "direction": "left"} + ) + response = client.post(url) + assert response.status_code == 403 if user else 401 + + @pytest.mark.parametrize( + "user", + [ + lambda: baker.make(User, is_superuser=True), + lambda: baker.make( + User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)] + ), + ], + ) + @pytest.mark.parametrize(("direction", "angle"), [("left", 90), ("right", 270)]) + def test_rotation( + self, + client: Client, + picture: Picture, + user: Callable[[], User], + direction: Literal["left", "right"], + angle: Literal[90, 270], + ): + client.force_login(user()) + url = reverse( + "api:rotate_picture", + kwargs={"picture_id": picture.id, "direction": direction}, + ) + with ( + patch.object(Image.Image, "rotate") as mocked_rotate, + patch.object(Picture, "generate_thumbnails") as mocked_thumb, + ): + response = client.post(url) + assert response.status_code == 200 + mocked_rotate.assert_called_once_with(angle) + mocked_thumb.assert_called_once() + + class TestSasModeration(TestCase): @classmethod def setUpTestData(cls): diff --git a/sas/views.py b/sas/views.py index 7e7f0ba2..5e5e5825 100644 --- a/sas/views.py +++ b/sas/views.py @@ -97,14 +97,6 @@ class PictureView(CanViewMixin, DetailView): pk_url_kwarg = "picture_id" template_name = "sas/picture.jinja" - def get(self, request, *args, **kwargs): - self.object = self.get_object() - if "rotate_right" in request.GET: - self.object.rotate(270) - if "rotate_left" in request.GET: - self.object.rotate(90) - return super().get(request, *args, **kwargs) - def get_context_data(self, **kwargs): return super().get_context_data(**kwargs) | { "album": Album.objects.get(children=self.object) From 22e6c09c367793aeeef7fab87e7f111e60a8241c Mon Sep 17 00:00:00 2001 From: imperosol Date: Fri, 1 May 2026 23:20:25 +0200 Subject: [PATCH 06/24] remove dead code --- sas/models.py | 31 ------------------------------- 1 file changed, 31 deletions(-) diff --git a/sas/models.py b/sas/models.py index 0ce74eb9..da4f1773 100644 --- a/sas/models.py +++ b/sas/models.py @@ -91,13 +91,6 @@ class Picture(SasFile): objects = SASPictureManager.from_queryset(PictureQuerySet)() - @property - def is_vertical(self): - with open(settings.MEDIA_ROOT / self.file.name, "rb") as f: - im = Image.open(BytesIO(f.read())) - (w, h) = im.size - return (w / h) < 1 - def get_download_url(self): return reverse( "sas:download", @@ -168,30 +161,6 @@ class Picture(SasFile): img = Image.open(self.file).rotate(degree) self.generate_thumbnails(img=img, save=True) - def get_next(self): - if self.is_moderated: - pictures_qs = self.parent.children.filter( - is_moderated=True, - asked_for_removal=False, - is_folder=False, - id__gt=self.id, - ) - else: - pictures_qs = Picture.objects.filter(id__gt=self.id, is_moderated=False) - return pictures_qs.order_by("id").first() - - def get_previous(self): - if self.is_moderated: - pictures_qs = self.parent.children.filter( - is_moderated=True, - asked_for_removal=False, - is_folder=False, - id__lt=self.id, - ) - else: - pictures_qs = Picture.objects.filter(id__lt=self.id, is_moderated=False) - return pictures_qs.order_by("-id").first() - class AlbumQuerySet(models.QuerySet): def viewable_by(self, user: User) -> Self: From 381f1ac829b9101de388087413ebcb1dd23637ee Mon Sep 17 00:00:00 2001 From: imperosol Date: Sat, 2 May 2026 16:40:07 +0200 Subject: [PATCH 07/24] refactor: use rolldown builtin `inject` --- package-lock.json | 64 ----------------------------------------------- package.json | 1 - vite.config.mts | 13 +++------- 3 files changed, 3 insertions(+), 75 deletions(-) diff --git a/package-lock.json b/package-lock.json index fa30ac06..649ebdbf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,7 +43,6 @@ "@babel/preset-env": "^7.29.2", "@biomejs/biome": "^2.4.13", "@hey-api/openapi-ts": "^0.94.5", - "@rollup/plugin-inject": "^5.0.5", "@types/alpinejs": "^3.13.11", "@types/cytoscape-cxtmenu": "^3.4.5", "@types/cytoscape-klay": "^3.1.5", @@ -2383,52 +2382,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@rollup/plugin-inject": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/@rollup/plugin-inject/-/plugin-inject-5.0.5.tgz", - "integrity": "sha512-2+DEJbNBoPROPkgTDNe8/1YXWcqxbN5DTjASVIOx8HS+pITXushyNiBV56RB08zuptzz8gT3YfkqriTBVycepg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rollup/pluginutils": "^5.0.1", - "estree-walker": "^2.0.2", - "magic-string": "^0.30.3" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/pluginutils": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", - "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "estree-walker": "^2.0.2", - "picomatch": "^4.0.2" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, "node_modules/@sentry-internal/browser-utils": { "version": "10.51.0", "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.51.0.tgz", @@ -3378,13 +3331,6 @@ "node": ">=6" } }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true, - "license": "MIT" - }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -4075,16 +4021,6 @@ "yallist": "^3.0.2" } }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, "node_modules/marked": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz", diff --git a/package.json b/package.json index 1451d9de..61ae7bfa 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,6 @@ "@babel/preset-env": "^7.29.2", "@biomejs/biome": "^2.4.13", "@hey-api/openapi-ts": "^0.94.5", - "@rollup/plugin-inject": "^5.0.5", "@types/alpinejs": "^3.13.11", "@types/cytoscape-cxtmenu": "^3.4.5", "@types/cytoscape-klay": "^3.1.5", diff --git a/vite.config.mts b/vite.config.mts index 433df0b0..14f6d8a9 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -1,5 +1,4 @@ import { parse, resolve } from "node:path"; -import inject from "@rollup/plugin-inject"; import { glob } from "glob"; import { visualizer } from "rollup-plugin-visualizer"; import { @@ -81,14 +80,8 @@ export default defineConfig((config: UserConfig) => { resolve: { alias: getAliases(), }, - - plugins: [ - inject({ - // biome-ignore lint/style/useNamingConvention: that's how it's called - Alpine: "alpinejs", - htmx: "htmx.org", - }), - visualizer({ filename: ".bundle-size-report.html" }) as PluginOption, - ], + // biome-ignore lint/style/useNamingConvention: that's how it's called + inject: { Alpine: "alpinejs", htmx: "htmx.org" }, + plugins: [visualizer({ filename: ".bundle-size-report.html" }) as PluginOption], } satisfies UserConfig; }); From 7fec05820cb2b73cc6b6ca976d29458026d0b4c7 Mon Sep 17 00:00:00 2001 From: imperosol Date: Sat, 2 May 2026 17:23:01 +0200 Subject: [PATCH 08/24] test: Picture.generate_thumbnails --- sas/baker_recipes.py | 15 +++++++++++++-- sas/models.py | 1 - sas/tests/test_model.py | 38 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/sas/baker_recipes.py b/sas/baker_recipes.py index 1f7a7667..1dcdc0fc 100644 --- a/sas/baker_recipes.py +++ b/sas/baker_recipes.py @@ -1,13 +1,24 @@ +from django.conf import settings from model_bakery import seq -from model_bakery.recipe import Recipe +from model_bakery.recipe import Recipe, foreign_key -from sas.models import Picture +from sas.models import Album, Picture + +album_recipe = Recipe( + Album, + is_in_sas=True, + is_folder=True, + is_moderated=True, + parent_id=settings.SITH_SAS_ROOT_DIR_ID, + name=seq("Album "), +) picture_recipe = Recipe( Picture, is_in_sas=True, is_folder=False, is_moderated=True, + parent=foreign_key(album_recipe), name=seq("Picture "), ) """A SAS Picture fixture. diff --git a/sas/models.py b/sas/models.py index da4f1773..234eb8bd 100644 --- a/sas/models.py +++ b/sas/models.py @@ -15,7 +15,6 @@ from __future__ import annotations -from io import BytesIO from pathlib import Path from typing import ClassVar, Self diff --git a/sas/tests/test_model.py b/sas/tests/test_model.py index 537d7fd7..71edf905 100644 --- a/sas/tests/test_model.py +++ b/sas/tests/test_model.py @@ -1,6 +1,11 @@ +from io import BytesIO +from pathlib import Path + import pytest +from django.core.files.base import ContentFile from django.test import TestCase from model_bakery import baker +from PIL import Image from core.baker_recipes import old_subscriber_user, subscriber_user from core.models import User @@ -67,3 +72,36 @@ def test_identifications_viewable_by_user(): assert list(picture.people.viewable_by(identifications[1].user)) == [ identifications[1] ] + + +@pytest.mark.django_db +@pytest.mark.parametrize("save", [True, False]) +@pytest.mark.parametrize("initially_saved", [True, False]) +@pytest.mark.parametrize("pass_img_kwarg", [True, False]) +def test_generate_thumbnail(save, initially_saved, pass_img_kwarg): + """Test that Picture.generate_thumbnails works properly""" + image = Image.new("RGB", (2, 1)) + image.putdata([(255, 0, 0), (0, 255, 0)]) + buffer = BytesIO() + image.save(buffer, format="PNG") + file = ContentFile(buffer.getvalue(), "img.png") + picture: Picture = picture_recipe.prepare( + file=file, + name=file.name, + mime_type="image/png", + _save_related=True, + ) + if initially_saved: + picture.save() + picture.generate_thumbnails(img=image if pass_img_kwarg else None, save=save) + storage = picture.file.storage + for f in picture.file, picture.compressed, picture.thumbnail: + # the tested picture is alone in its album, + # so there should be a single file in each folder + assert storage.exists(f.name) + _dirs, files = storage.listdir(str(Path(f.path).parent)) + assert files == [Path(f.name).name] + new_img = Image.open(picture.file) + assert new_img.get_flattened_data() == image.get_flattened_data() + assert Image.open(picture.thumbnail).size == (200, 100) + assert Image.open(picture.compressed).size == (1200, 600) From 2aa6eed2fc38256a426381cc1bb00e49daa5cb66 Mon Sep 17 00:00:00 2001 From: imperosol Date: Mon, 27 Apr 2026 19:26:52 +0200 Subject: [PATCH 09/24] improve main page style --- club/templates/club/club_detail.jinja | 3 +-- com/static/com/css/news-list.scss | 30 +++++++++++++++------------ com/templates/com/news_list.jinja | 2 +- core/static/core/style.scss | 2 +- 4 files changed, 20 insertions(+), 17 deletions(-) diff --git a/club/templates/club/club_detail.jinja b/club/templates/club/club_detail.jinja index 168c4b6c..14a5a384 100644 --- a/club/templates/club/club_detail.jinja +++ b/club/templates/club/club_detail.jinja @@ -26,10 +26,9 @@ {% if club.logo %} {% endif %} +

{{ club.name }}

{% if page_revision %} {{ page_revision|markdown }} - {% else %} -

{{ club.name }}

{% endif %} {% endblock %} diff --git a/com/static/com/css/news-list.scss b/com/static/com/css/news-list.scss index b53ff784..a1dcb966 100644 --- a/com/static/com/css/news-list.scss +++ b/com/static/com/css/news-list.scss @@ -3,6 +3,7 @@ #news { display: flex; + gap: 1em; @media (max-width: 800px) { flex-direction: column; @@ -26,12 +27,14 @@ } h3 { - background: $second-color; - box-shadow: $shadow-color 1px 1px 1px; - padding: 0.4em; + --box-shadow: rgb(60 64 67 / 30%) 0 1px 3px 0, rgb(60 64 67 / 15%) 0 3px 7px 2px; + background: lighten($second-color, 5%); + box-shadow: var(--box-shadow); + padding: .75rem; margin: 0 0 0.5em 0; text-transform: uppercase; font-size: 17px; + border-radius: 10px; &:not(:first-of-type) { margin: 2em 0 1em 0; @@ -39,12 +42,11 @@ .feed { float: right; - color: #f26522; + color: #e25512; } } @media screen and (max-width: $small-devices) { - #left_column, #right_column { flex: 100%; @@ -57,6 +59,7 @@ max-height: 600px; overflow-y: scroll; overflow-x: clip; + margin-top: 1em; #load-more-news-button { text-align: center; @@ -76,15 +79,11 @@ font-size: 70%; margin-bottom: 1em; - h3 { - margin-bottom: 0; - } - #links_content { overflow: auto; box-shadow: $shadow-color 1px 1px 1px; min-height: 20em; - padding-bottom: 1em; + padding: 1em; h4 { margin-left: 5px; @@ -121,6 +120,8 @@ } #birthdays_content { + box-shadow: $shadow-color 1px 1px 1px; + padding: 1em; ul.birthdays_year { margin: 0; list-style-type: none; @@ -135,8 +136,7 @@ } ul { - margin: 0; - margin-left: 1em; + margin: .5em 0 0 1em; list-style-type: square; list-style-position: inside; font-weight: normal; @@ -150,9 +150,13 @@ /* EVENTS TODAY AND NEXT FEW DAYS */ .news_events_group { box-shadow: $shadow-color 1px 1px 1px; - margin-left: 1em; + margin-left: 0; margin-bottom: 0.5em; + @media screen and (max-width: $small-devices) { + margin-left: 3px; + } + .news_events_group_date { display: table-cell; padding: 0.6em; diff --git a/com/templates/com/news_list.jinja b/com/templates/com/news_list.jinja index 2f6dc26e..975fb2ac 100644 --- a/com/templates/com/news_list.jinja +++ b/com/templates/com/news_list.jinja @@ -23,7 +23,7 @@ {% if user.is_authenticated and (user.is_com_admin or user.memberships.board().ongoing().exists()) %} - + {% trans %}Create news{% endtrans %} diff --git a/core/static/core/style.scss b/core/static/core/style.scss index b48aa7c1..67f03898 100644 --- a/core/static/core/style.scss +++ b/core/static/core/style.scss @@ -271,7 +271,7 @@ body { /*--------------------------------CONTENT------------------------------*/ #content { - padding: 1em 1%; + padding: 1.5em 3%; box-shadow: $shadow-color 0 5px 10px; background: $white-color; overflow: auto; From 4bd248f82720ec3d6ac20cabe719c45ea48af0c6 Mon Sep 17 00:00:00 2001 From: imperosol Date: Mon, 27 Apr 2026 19:28:54 +0200 Subject: [PATCH 10/24] add transition to user whitelist input --- core/templates/core/fragment/user_visibility.jinja | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/templates/core/fragment/user_visibility.jinja b/core/templates/core/fragment/user_visibility.jinja index ee78e907..b92901bf 100644 --- a/core/templates/core/fragment/user_visibility.jinja +++ b/core/templates/core/fragment/user_visibility.jinja @@ -18,7 +18,7 @@ {{ form.is_viewable.help_text }} {{ form.is_viewable.errors }} -
+
{{ form.whitelisted_users.as_field_group() }}
From f88c061b02d4ead45a1fe452b9cf166e27d9be37 Mon Sep 17 00:00:00 2001 From: imperosol Date: Tue, 28 Apr 2026 08:41:30 +0200 Subject: [PATCH 11/24] scss-ify eboutic.css --- eboutic/static/eboutic/css/eboutic.css | 163 ------------------- eboutic/static/eboutic/css/eboutic.scss | 162 ++++++++++++++++++ eboutic/templates/eboutic/eboutic_main.jinja | 8 +- 3 files changed, 166 insertions(+), 167 deletions(-) delete mode 100644 eboutic/static/eboutic/css/eboutic.css create mode 100644 eboutic/static/eboutic/css/eboutic.scss diff --git a/eboutic/static/eboutic/css/eboutic.css b/eboutic/static/eboutic/css/eboutic.css deleted file mode 100644 index fe4b88aa..00000000 --- a/eboutic/static/eboutic/css/eboutic.css +++ /dev/null @@ -1,163 +0,0 @@ -#eboutic { - display: flex; - flex-direction: row-reverse; - align-items: flex-start; - column-gap: 20px; - margin: 0 20px 20px; -} - -#eboutic-title { - margin-left: 20px; -} - -#eboutic h3 { - margin-left: 0; - margin-right: 0; -} - -#basket { - min-width: 300px; - border-radius: 8px; - box-shadow: - rgb(60 64 67 / 30%) 0 1px 3px 0, - rgb(60 64 67 / 15%) 0 4px 8px 3px; - padding: 10px; -} - -#basket h3 { - margin-top: 0; -} - -@media screen and (max-width: 765px) { - #eboutic { - flex-direction: column-reverse; - align-items: center; - margin: 10px; - row-gap: 20px; - } - #eboutic-title { - margin-bottom: 20px; - margin-top: 4px; - } - #basket { - width: -webkit-fill-available; - } -} - -#eboutic .item-list { - margin-left: 0; - list-style: none; -} - -#eboutic .item-list li { - display: flex; - align-items: center; - margin-bottom: 10px; -} - -#eboutic .item-row { - gap: 10px; -} - -#eboutic .item-name { - word-break: break-word; - width: 100%; - line-height: 100%; - white-space: normal; -} - -#eboutic .fa-plus, -#eboutic .fa-minus { - cursor: pointer; - background-color: #354a5f; - color: white; - border-radius: 50%; - padding: 5px; - font-size: 10px; - line-height: 10px; - width: 10px; - text-align: center; -} - -#eboutic .item-quantity { - min-width: 65px; - justify-content: space-between; - align-items: center; - display: flex; - gap: 5px; -} - -#eboutic .item-price { - min-width: 65px; - text-align: right; -} - -/* CSS du catalogue */ - -#eboutic #catalog { - display: flex; - flex-grow: 1; - flex-direction: column; - row-gap: 30px; -} - -#eboutic .category-header { - margin-bottom: 15px; -} - -#eboutic .product-group { - display: flex; - flex-wrap: wrap; - column-gap: 15px; - row-gap: 15px; -} - -#eboutic .card.selected::after { - content: "🛒"; - position: absolute; - top: 5px; - right: 5px; - padding: 5px; - border-radius: 50%; - box-shadow: 0 0 12px 2px rgb(0 0 0 / 14%); - background-color: white; - width: 20px; - height: 20px; - font-size: 16px; - line-height: 20px; -} - -#eboutic .catalog-buttons { - display: flex; - justify-content: center; - column-gap: 30px; - margin: 30px 0 0; -} - -#eboutic input { - all: unset; -} - -#eboutic .catalog-buttons button { - min-width: 60px; -} - -#eboutic .catalog-buttons form { - margin: 0; -} - -@media screen and (max-width: 765px) { - #eboutic #catalog { - row-gap: 15px; - width: 100%; - } - - #eboutic section { - text-align: center; - } - - #eboutic .product-group { - justify-content: space-around; - flex-direction: column; - } -} diff --git a/eboutic/static/eboutic/css/eboutic.scss b/eboutic/static/eboutic/css/eboutic.scss new file mode 100644 index 00000000..1e5ffa71 --- /dev/null +++ b/eboutic/static/eboutic/css/eboutic.scss @@ -0,0 +1,162 @@ +#eboutic-title { + margin-left: 20px; +} + +#eboutic { + display: flex; + flex-direction: row-reverse; + align-items: flex-start; + column-gap: 20px; + margin: 0 20px 20px; + + h3 { + margin-left: 0; + margin-right: 0; + } + + #basket { + --box-shadow: + rgb(60 64 67 / 30%) 0 1px 3px 0, + rgb(60 64 67 / 15%) 0 4px 8px 3px; + min-width: 300px; + border-radius: 8px; + box-shadow: var(--box-shadow); + padding: 10px; + h3 { + margin-top: 0; + } + } + + @media screen and (max-width: 765px) { + flex-direction: column-reverse; + align-items: center; + margin: 10px; + row-gap: 20px; + + #eboutic-title { + margin-bottom: 20px; + margin-top: 4px; + } + #basket { + width: -webkit-fill-available; + } + } + + .item-list { + margin-left: 0; + list-style: none; + + li { + display: flex; + align-items: center; + margin-bottom: 10px; + } + } + + .item-row { + gap: 10px; + } + + .item-name { + word-break: break-word; + width: 100%; + line-height: 100%; + white-space: normal; + } + + .fa-plus, + .fa-minus { + cursor: pointer; + background-color: #354a5f; + color: white; + border-radius: 50%; + padding: 5px; + font-size: 10px; + line-height: 10px; + width: 10px; + text-align: center; + } + + .item-quantity { + min-width: 65px; + justify-content: space-between; + align-items: center; + display: flex; + gap: 5px; + } + + .item-price { + min-width: 65px; + text-align: right; + } + +/* CSS du catalogue */ + + #catalog { + display: flex; + flex-grow: 1; + flex-direction: column; + row-gap: 30px; + } + + .category-header { + margin-bottom: 15px; + } + + .product-group { + display: flex; + flex-wrap: wrap; + column-gap: 15px; + row-gap: 15px; + } + + .card.selected::after { + --box-shadow: 0 0 12px 2px rgb(0 0 0 / 14%); + content: "🛒"; + position: absolute; + top: 5px; + right: 5px; + padding: 5px; + border-radius: 50%; + box-shadow: var(--box-shadow); + background-color: white; + width: 20px; + height: 20px; + font-size: 16px; + line-height: 20px; + } + + input { + all: unset; + } + .catalog-buttons { + display: flex; + justify-content: center; + column-gap: 30px; + margin: 30px 0 0; + + button { + min-width: 60px; + } + form { + margin: 0; + } + } + + + @media screen and (max-width: 765px) { + #catalog { + row-gap: 15px; + width: 100%; + } + + section { + text-align: center; + } + + .product-group { + justify-content: space-around; + flex-direction: column; + } + } +} diff --git a/eboutic/templates/eboutic/eboutic_main.jinja b/eboutic/templates/eboutic/eboutic_main.jinja index bad712a2..be53c9d0 100644 --- a/eboutic/templates/eboutic/eboutic_main.jinja +++ b/eboutic/templates/eboutic/eboutic_main.jinja @@ -15,7 +15,7 @@ {% endblock %} {% block additional_css %} - + {% endblock %} @@ -168,9 +168,9 @@ {% for prices in categories %} {% set category = prices[0].product.product_type %} -
-
-

{{ category.name }}

+
+
+

{{ category.name }}

{% if category.comment %}

{{ category.comment }}

{% endif %} From 2dbf4cff0544a6702e1e957eb07f6a10eae8d4ec Mon Sep 17 00:00:00 2001 From: imperosol Date: Tue, 28 Apr 2026 12:15:11 +0200 Subject: [PATCH 12/24] add og tags to eboutic page --- eboutic/templates/eboutic/eboutic_main.jinja | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/eboutic/templates/eboutic/eboutic_main.jinja b/eboutic/templates/eboutic/eboutic_main.jinja index be53c9d0..51945259 100644 --- a/eboutic/templates/eboutic/eboutic_main.jinja +++ b/eboutic/templates/eboutic/eboutic_main.jinja @@ -8,6 +8,14 @@ {% trans %}The online shop of the association.{% endtrans %} {%- endblock %} +{% block metatags %} + + + + + +{% endblock %} + {% block additional_js %} {# This script contains the code to perform requests to manipulate the user basket without having to reload the page #} @@ -168,9 +176,9 @@
{% for prices in categories %} {% set category = prices[0].product.product_type %} -
-
-

{{ category.name }}

+
+
+

{{ category.name }}

{% if category.comment %}

{{ category.comment }}

{% endif %} From 00f7afb9372e1b1555f0fc03309e38b500dc8ea5 Mon Sep 17 00:00:00 2001 From: imperosol Date: Sat, 2 May 2026 17:59:06 +0200 Subject: [PATCH 13/24] add translations --- locale/fr/LC_MESSAGES/django.po | 153 ++++++++++++++++---------------- 1 file changed, 75 insertions(+), 78 deletions(-) diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index cb0d1629..99516aa0 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-03-23 22:21+0100\n" +"POT-Creation-Date: 2026-05-02 17:57+0200\n" "PO-Revision-Date: 2016-07-18\n" "Last-Translator: Maréchal \n" @@ -181,6 +181,22 @@ msgstr "Vous devez être cotisant pour faire partie d'un club" msgid "You are already a member of this club" msgstr "Vous êtes déjà membre de ce club." +#: club/forms.py +msgid "Club status" +msgstr "État du club" + +#: club/forms.py +msgid "Active" +msgstr "Actif" + +#: club/forms.py +msgid "Inactive" +msgstr "Inactif" + +#: club/forms.py +msgid "All clubs" +msgstr "Tous les clubs" + #: club/models.py msgid "slug name" msgstr "nom slug" @@ -301,37 +317,22 @@ msgstr "Cet email est déjà abonné à cette mailing" msgid "Unregistered user" msgstr "Utilisateur non enregistré" -#: club/templates/club/club_list.jinja -msgid "Club list" -msgstr "Liste des clubs" - #: club/templates/club/club_list.jinja msgid "The list of all clubs existing at UTBM." msgstr "La liste de tous les clubs existants à l'UTBM" +#: club/templates/club/club_list.jinja +msgid "Club list" +msgstr "Liste des clubs" + #: club/templates/club/club_list.jinja msgid "Filters" msgstr "Filtres" -#: club/templates/club/club_list.jinja -msgid "Name" -msgstr "Nom" - -#: club/templates/club/club_list.jinja -msgid "Club state" -msgstr "Etat du club" - -#: club/templates/club/club_list.jinja -msgid "Active" -msgstr "Actif" - -#: club/templates/club/club_list.jinja -msgid "Inactive" -msgstr "Inactif" - -#: club/templates/club/club_list.jinja -msgid "All clubs" -msgstr "Tous les clubs" +#: club/templates/club/club_list.jinja core/templates/core/base/header.jinja +#: forum/templates/forum/macros.jinja matmat/templates/matmat/search_form.jinja +msgid "Search" +msgstr "Recherche" #: club/templates/club/club_list.jinja core/templates/core/user_tools.jinja msgid "New club" @@ -433,7 +434,7 @@ msgstr "Bénéfice : " #: counter/templates/counter/cash_summary_list.jinja #: counter/templates/counter/last_ops.jinja #: counter/templates/counter/refilling_list.jinja -#: rootplace/templates/rootplace/logs.jinja sas/forms.py +#: rootplace/templates/rootplace/logs.jinja #: trombi/templates/trombi/user_profile.jinja msgid "Date" msgstr "Date" @@ -1692,6 +1693,10 @@ msgstr "taille" msgid "date" msgstr "date" +#: core/models.py counter/models.py +msgid "updated at" +msgstr "mis à jour le" + #: core/models.py msgid "asked for removal" msgstr "retrait demandé" @@ -1863,11 +1868,6 @@ msgstr "Connexion" msgid "Register" msgstr "Inscription" -#: core/templates/core/base/header.jinja forum/templates/forum/macros.jinja -#: matmat/templates/matmat/search_form.jinja -msgid "Search" -msgstr "Recherche" - #: core/templates/core/base/header.jinja msgid "Logout" msgstr "Déconnexion" @@ -3195,10 +3195,6 @@ msgstr "groupe d'achat" msgid "archived" msgstr "archivé" -#: counter/models.py -msgid "updated at" -msgstr "mis à jour le" - #: counter/models.py eboutic/models.py msgid "product" msgstr "produit" @@ -3828,14 +3824,14 @@ msgstr "" "votre cotisation. Si vous ne renouvelez pas votre cotisation, il n'y aura " "aucune conséquence autre que le retrait de l'argent de votre compte." -#: counter/templates/counter/product_form.jinja -msgid "Remove price" -msgstr "Retirer le prix" - #: counter/templates/counter/product_form.jinja msgid "Remove this action" msgstr "Retirer cette action" +#: counter/templates/counter/product_form.jinja +msgid "Remove price" +msgstr "Retirer le prix" + #: counter/templates/counter/product_form.jinja #, python-format msgid "Edit product %(name)s" @@ -4205,6 +4201,47 @@ msgstr "" msgid "this page" msgstr "cette page" +#: eboutic/templates/eboutic/eboutic_main.jinja +msgid "Eurockéennes 2025 partnership" +msgstr "Partenariat Eurockéennes 2025" + +#: eboutic/templates/eboutic/eboutic_main.jinja +msgid "" +"Our partner uses Weezevent to sell tickets. Weezevent may collect user info " +"according to its own privacy policy. By clicking the accept button you " +"consent to their terms of services." +msgstr "" +"Notre partenaire utilises Wezevent pour vendre ses billets. Weezevent peut " +"collecter des informations utilisateur conformément à sa propre politique de " +"confidentialité. En cliquant sur le bouton d'acceptation vous consentez à " +"leurs termes de service." + +#: eboutic/templates/eboutic/eboutic_main.jinja +msgid "Privacy policy" +msgstr "Politique de confidentialité" + +#: eboutic/templates/eboutic/eboutic_main.jinja +#: trombi/templates/trombi/comment_moderation.jinja +msgid "Accept" +msgstr "Accepter" + +#: eboutic/templates/eboutic/eboutic_main.jinja +msgid "" +"You must be subscribed to benefit from the partnership with the Eurockéennes." +msgstr "" +"Vous devez être cotisant pour bénéficier du partenariat avec les " +"Eurockéennes." + +#: eboutic/templates/eboutic/eboutic_main.jinja +#, python-format +msgid "" +"This partnership offers a discount of up to 33%% on tickets for Friday, " +"Saturday and Sunday, as well as the 3-day package from Friday to Sunday." +msgstr "" +"Ce partenariat permet de profiter d'une réduction jusqu'à 33%% sur les " +"billets du vendredi, du samedi et du dimanche, ainsi qu'au forfait 3 jours, " +"du vendredi au dimanche." + #: eboutic/templates/eboutic/eboutic_main.jinja msgid "There are no items available for sale" msgstr "Aucun article n'est disponible à la vente" @@ -5631,10 +5668,6 @@ msgstr "fin" msgid "Moderate Trombi comments" msgstr "Modérer les commentaires du Trombi" -#: trombi/templates/trombi/comment_moderation.jinja -msgid "Accept" -msgstr "Accepter" - #: trombi/templates/trombi/comment_moderation.jinja msgid "Reject" msgstr "Refuser" @@ -5876,39 +5909,3 @@ msgstr "Vous ne pouvez plus écrire de commentaires, la date est passée." #, python-format msgid "Maximum characters: %(max_length)s" msgstr "Nombre de caractères max: %(max_length)s" - -#: eboutic/templates/eboutic/eboutic_main.jinja -msgid "Eurockéennes 2025 partnership" -msgstr "Partenariat Eurockéennes 2025" - -#: eboutic/templates/eboutic/eboutic_main.jinja -msgid "" -"Our partner uses Weezevent to sell tickets. Weezevent may collect user info " -"according to its own privacy policy. By clicking the accept button you " -"consent to their terms of services." -msgstr "" -"Notre partenaire utilises Wezevent pour vendre ses billets. Weezevent peut " -"collecter des informations utilisateur conformément à sa propre politique de " -"confidentialité. En cliquant sur le bouton d'acceptation vous consentez à " -"leurs termes de service." - -#: eboutic/templates/eboutic/eboutic_main.jinja -msgid "Privacy policy" -msgstr "Politique de confidentialité" - -#: eboutic/templates/eboutic/eboutic_main.jinja -msgid "" -"You must be subscribed to benefit from the partnership with the Eurockéennes." -msgstr "" -"Vous devez être cotisant pour bénéficier du partenariat avec les " -"Eurockéennes." - -#: eboutic/templates/eboutic/eboutic_main.jinja -#, python-format -msgid "" -"This partnership offers a discount of up to 33%% on tickets for Friday, " -"Saturday and Sunday, as well as the 3-day package from Friday to Sunday." -msgstr "" -"Ce partenariat permet de profiter d'une réduction jusqu'à 33%% sur les " -"billets du vendredi, du samedi et du dimanche, ainsi qu'au forfait 3 jours, " -"du vendredi au dimanche." From d123e5e35b64ee8594f0713537c91d193ed19b61 Mon Sep 17 00:00:00 2001 From: imperosol Date: Wed, 6 May 2026 12:39:27 +0200 Subject: [PATCH 14/24] fix: `QuotaExceededError` on user pictures load --- sas/static/bundled/sas/user/pictures-index.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/sas/static/bundled/sas/user/pictures-index.ts b/sas/static/bundled/sas/user/pictures-index.ts index d4c75f17..c9e67eeb 100644 --- a/sas/static/bundled/sas/user/pictures-index.ts +++ b/sas/static/bundled/sas/user/pictures-index.ts @@ -35,8 +35,23 @@ document.addEventListener("alpine:init", () => { // biome-ignore lint/style/useNamingConvention: from python api query: { users_identified: [config.userId] }, } as PicturesFetchPicturesData); - localStorage.setItem(localStorageInvalidationKey, config.nbPictures.toString()); - localStorage.setItem(localStorageKey, JSON.stringify(pictures)); + try { + localStorage.setItem(localStorageInvalidationKey, config.nbPictures.toString()); + localStorage.setItem(localStorageKey, JSON.stringify(pictures)); + } catch { + // an exception is raised if the localstorage is entirely filled + // so just delete all cached user pictures. + // A cache hit is not worth the page breaking. + Object.keys(localStorage) + .filter( + (key) => + key.startsWith("user") && + (key.endsWith("Pictures") || key.endsWith("PicturesNumber")), + ) + .forEach((key) => { + localStorage.removeItem(key); + }); + } return pictures; }, From 2c259de22c9fa511f8c7a7d8efbc1a58bc76e774 Mon Sep 17 00:00:00 2001 From: imperosol Date: Wed, 6 May 2026 23:04:43 +0200 Subject: [PATCH 15/24] fix: duplicated prices on counters --- counter/models.py | 2 +- counter/tests/test_product.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/counter/models.py b/counter/models.py index c22a1469..0c7790c9 100644 --- a/counter/models.py +++ b/counter/models.py @@ -431,7 +431,7 @@ class PriceQuerySet(models.QuerySet): ), product__archived=False, product__limit_age__lte=age, - ) + ).distinct() class Price(models.Model): diff --git a/counter/tests/test_product.py b/counter/tests/test_product.py index 7ab9ff88..5fb6e233 100644 --- a/counter/tests/test_product.py +++ b/counter/tests/test_product.py @@ -219,6 +219,6 @@ def test_price_for_user(): recipe.make(amount=1, groups=[groups[1]], is_always_shown=False), ] qs = Price.objects.order_by("-amount") - assert set(qs.for_user(users[0])) == {prices[0], prices[1], prices[4]} - assert set(qs.for_user(users[1])) == {prices[0], prices[4]} - assert set(qs.for_user(users[2])) == {prices[0], prices[3]} + assert list(qs.for_user(users[0])) == [prices[0], prices[1], prices[4]] + assert list(qs.for_user(users[1])) == [prices[0], prices[4]] + assert list(qs.for_user(users[2])) == [prices[0], prices[3]] From b79b7cbcf5a81301b183544bf07743789f5cf8bc Mon Sep 17 00:00:00 2001 From: imperosol Date: Wed, 6 May 2026 22:45:43 +0200 Subject: [PATCH 16/24] fix: `this.$refs.basketManagementForm.getElementById is not a function` --- eboutic/static/bundled/eboutic/eboutic-index.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/eboutic/static/bundled/eboutic/eboutic-index.ts b/eboutic/static/bundled/eboutic/eboutic-index.ts index e345d936..43080a58 100644 --- a/eboutic/static/bundled/eboutic/eboutic-index.ts +++ b/eboutic/static/bundled/eboutic/eboutic-index.ts @@ -28,11 +28,8 @@ document.addEventListener("alpine:init", () => { this.basket = []; } } - - // It's quite tricky to manually apply attributes to the management part - // of a formset so we dynamically apply it here - this.$refs.basketManagementForm - .getElementById("#id_form-TOTAL_FORMS") + document + .getElementById("id_form-TOTAL_FORMS") .setAttribute(":value", "basket.length"); }, From 0405ef424df2502269ae9761103c3981e48b735d Mon Sep 17 00:00:00 2001 From: Sli Date: Wed, 6 May 2026 23:19:35 +0200 Subject: [PATCH 17/24] Fix crashes on *-once elements when called at bad timings --- core/static/bundled/core/components/include-index.ts | 10 +++++++--- core/templates/core/widgets/autocomplete_select.jinja | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/core/static/bundled/core/components/include-index.ts b/core/static/bundled/core/components/include-index.ts index 9ee44bb8..fa046850 100644 --- a/core/static/bundled/core/components/include-index.ts +++ b/core/static/bundled/core/components/include-index.ts @@ -84,7 +84,11 @@ const refreshElement = < return; } - element.refresh(); + // This might be called at some bad timing + // This prevents crashes of the observer + if (element.refresh) { + element.refresh(); + } } }; @@ -130,7 +134,7 @@ startObserver(observer); export class LinkOnce extends elementOnce("link") { getElementQuerySelector(): string { // We get href from node.attributes instead of node.href to avoid getting the domain part - return `link[href='${this.node.attributes.getNamedItem("href").nodeValue}']`; + return `link[href='${this.node.attributes.getNamedItem("href")?.nodeValue}']`; } } @@ -142,6 +146,6 @@ export class LinkOnce extends elementOnce("link") { export class ScriptOnce extends inheritHtmlElement("script") { getElementQuerySelector(): string { // We get href from node.attributes instead of node.src to avoid getting the domain part - return `script[src='${this.node.attributes.getNamedItem("src").nodeValue}']`; + return `script[src='${this.node.attributes.getNamedItem("src")?.nodeValue}']`; } } diff --git a/core/templates/core/widgets/autocomplete_select.jinja b/core/templates/core/widgets/autocomplete_select.jinja index bcdf3a76..b2e20d64 100644 --- a/core/templates/core/widgets/autocomplete_select.jinja +++ b/core/templates/core/widgets/autocomplete_select.jinja @@ -3,7 +3,7 @@ {% endfor %} {% for css in statics.css %} - + {% endfor %} <{{ component }} name="{{ widget.name }}" {% include "django/forms/widgets/attrs.html" %}> From cb1a330983a081df2536710396f87500ca1e7ecf Mon Sep 17 00:00:00 2001 From: Sli Date: Thu, 7 May 2026 11:33:51 +0200 Subject: [PATCH 18/24] Fix component nesting bug --- core/static/bundled/core/components/include-index.ts | 10 +++------- core/static/bundled/core/components/tabs-index.ts | 4 ++-- core/static/bundled/utils/web-components.ts | 11 +++++++++++ 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/core/static/bundled/core/components/include-index.ts b/core/static/bundled/core/components/include-index.ts index fa046850..9ee44bb8 100644 --- a/core/static/bundled/core/components/include-index.ts +++ b/core/static/bundled/core/components/include-index.ts @@ -84,11 +84,7 @@ const refreshElement = < return; } - // This might be called at some bad timing - // This prevents crashes of the observer - if (element.refresh) { - element.refresh(); - } + element.refresh(); } }; @@ -134,7 +130,7 @@ startObserver(observer); export class LinkOnce extends elementOnce("link") { getElementQuerySelector(): string { // We get href from node.attributes instead of node.href to avoid getting the domain part - return `link[href='${this.node.attributes.getNamedItem("href")?.nodeValue}']`; + return `link[href='${this.node.attributes.getNamedItem("href").nodeValue}']`; } } @@ -146,6 +142,6 @@ export class LinkOnce extends elementOnce("link") { export class ScriptOnce extends inheritHtmlElement("script") { getElementQuerySelector(): string { // We get href from node.attributes instead of node.src to avoid getting the domain part - return `script[src='${this.node.attributes.getNamedItem("src")?.nodeValue}']`; + return `script[src='${this.node.attributes.getNamedItem("src").nodeValue}']`; } } diff --git a/core/static/bundled/core/components/tabs-index.ts b/core/static/bundled/core/components/tabs-index.ts index 7bd85998..260467d5 100644 --- a/core/static/bundled/core/components/tabs-index.ts +++ b/core/static/bundled/core/components/tabs-index.ts @@ -28,7 +28,7 @@ export class Tab extends HTMLElement { return html`