From f19b3056efde00df9db5f8fae91b685baf17644a Mon Sep 17 00:00:00 2001 From: Sli Date: Sat, 25 Apr 2026 01:05:38 +0200 Subject: [PATCH 1/7] 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 3/7] 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 4/7] 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 5/7] 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 7fec05820cb2b73cc6b6ca976d29458026d0b4c7 Mon Sep 17 00:00:00 2001 From: imperosol Date: Sat, 2 May 2026 17:23:01 +0200 Subject: [PATCH 6/7] 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 00f7afb9372e1b1555f0fc03309e38b500dc8ea5 Mon Sep 17 00:00:00 2001 From: imperosol Date: Sat, 2 May 2026 17:59:06 +0200 Subject: [PATCH 7/7] 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."