Compare commits

..

11 Commits

Author SHA1 Message Date
dependabot[bot] 7dabbce4df [UPDATE] Update redis requirement from <8.0.0,>=3.3.1 to >=8.0.0,<9.0.0
Updates the requirements on [redis](https://github.com/redis/redis-py) to permit the latest version.
- [Release notes](https://github.com/redis/redis-py/releases)
- [Changelog](https://github.com/redis/redis-py/blob/master/CHANGES)
- [Commits](https://github.com/redis/redis-py/compare/3.3.1...v8.0.0)

---
updated-dependencies:
- dependency-name: redis
  dependency-version: 8.0.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-02 01:40:18 +00:00
thomas girod e811aeaecd Merge pull request #1412 from ae-utbm/improve-mobile-counter
Improve counter click on smartphones
2026-05-31 11:48:07 +02:00
thomas girod 549a778be0 Merge pull request #1411 from ae-utbm/fix-club-role
fix: forgotten group assignation on club role update
2026-05-31 11:47:40 +02:00
thomas girod 5c42da273b Merge pull request #1392 from ae-utbm/basket-timeout
Basket timeout
2026-05-30 12:56:35 +02:00
thomas girod b8e0294df6 Merge pull request #1410 from ae-utbm/fix-payment-method
fix: wrong payment method for refills with eboutic
2026-05-30 12:41:44 +02:00
imperosol 78b24dc1e7 fix: product research with code 2026-05-28 18:10:56 +02:00
imperosol ebf0196bef improve counter basket item style 2026-05-27 18:22:07 +02:00
imperosol 362b9eea06 automatically add item to basket on counter product search 2026-05-27 18:22:07 +02:00
imperosol 3b3e33ed80 fix: forgotten group assignation on club role update 2026-05-27 12:24:27 +02:00
imperosol 649190debe fix: wrong payment method for refills with eboutic 2026-05-26 23:46:38 +02:00
imperosol 50c880719a feat: basket timeout 2026-05-22 11:38:03 +02:00
49 changed files with 514 additions and 2017 deletions
+24
View File
@@ -392,6 +392,30 @@ class ClubRoleForm(forms.ModelForm):
self.instance.order = cleaned_data["ORDER"] - 1 self.instance.order = cleaned_data["ORDER"] - 1
return cleaned_data return cleaned_data
def save(self, commit=True): # noqa: FBT002
instance: ClubRole = super().save(commit=commit)
if commit and "is_board" in self.changed_data:
# if the role was moved from board to simple member,
# remove all users with that role from the club board group.
# If the role became a board role, add users with
# that role to the club board group.
group_id = instance.club.board_group_id
if self.cleaned_data["is_board"]:
User.groups.through.objects.bulk_create(
[
User.groups.through(user_id=u, group_id=group_id)
for u in Membership.objects.ongoing()
.filter(role=instance)
.values_list("user_id", flat=True)
],
ignore_conflicts=True,
)
else:
User.groups.through.objects.filter(
user__memberships__role=instance, group_id=group_id
).delete()
return instance
class ClubRoleCreateForm(forms.ModelForm): class ClubRoleCreateForm(forms.ModelForm):
"""Form to create a club role. """Form to create a club role.
+28 -1
View File
@@ -4,6 +4,7 @@ import pytest
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.test import Client, TestCase from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import now
from model_bakery import baker, seq from model_bakery import baker, seq
from model_bakery.recipe import Recipe from model_bakery.recipe import Recipe
from pytest_django.asserts import assertRedirects from pytest_django.asserts import assertRedirects
@@ -239,7 +240,7 @@ class TestClubRoleUpdate(TestCase):
def test_president_moves_itself_out_of_the_presidency(self): def test_president_moves_itself_out_of_the_presidency(self):
"""Test that if the user moves its own role out of the presidency, """Test that if the user moves its own role out of the presidency,
then it's redirected to another page and loses access to the update page.""" then it loses access to the update page."""
self.payload["roles-0-is_presidency"] = False self.payload["roles-0-is_presidency"] = False
self.client.force_login(self.user) self.client.force_login(self.user)
res = self.client.post(self.url, data=self.payload) res = self.client.post(self.url, data=self.payload)
@@ -251,3 +252,29 @@ class TestClubRoleUpdate(TestCase):
res = self.client.get(self.url) res = self.client.get(self.url)
assert res.status_code == 403 assert res.status_code == 403
def test_role_stops_being_board(self):
"""Test that if a role stops being a board role,
its users lose the club board group."""
self.payload["roles-0-is_board"] = False
self.payload["roles-0-is_presidency"] = False
self.payload["roles-1-is_board"] = False
formset = ClubRoleFormSet(data=self.payload, instance=self.club)
assert formset.is_valid()
formset.save()
assert not self.user.groups.contains(self.club.board_group)
def test_role_becomes_board(self):
"""Test that if a role becomes a board role,
its active users get the club board group"""
members = [
baker.make(Membership, club=self.club, role=self.roles[0], end_date=None),
baker.make(Membership, club=self.club, role=self.roles[0], end_date=now()),
]
self.payload["roles-2-is_board"] = True
formset = ClubRoleFormSet(data=self.payload, instance=self.club)
assert formset.is_valid()
formset.save()
# the second membership is finished, so its user shouldn't get the role
assert members[0].user.groups.contains(self.club.board_group)
assert not members[1].user.groups.contains(self.club.board_group)
+2 -2
View File
@@ -99,9 +99,9 @@ class PageAdmin(admin.ModelAdmin):
@admin.register(SithFile) @admin.register(SithFile)
class SithFileAdmin(admin.ModelAdmin): class SithFileAdmin(admin.ModelAdmin):
list_display = ("name", "owner", "size", "date") list_display = ("name", "owner", "size", "date", "is_in_sas")
autocomplete_fields = ("parent", "owner", "moderator") autocomplete_fields = ("parent", "owner", "moderator")
search_fields = ("name",) search_fields = ("name", "parent__name")
@admin.register(OperationLog) @admin.register(OperationLog)
+1 -1
View File
@@ -110,7 +110,7 @@ class SithFileController(ControllerBase):
) )
@paginate(PageNumberPaginationExtra, page_size=50) @paginate(PageNumberPaginationExtra, page_size=50)
def search_files(self, search: Annotated[str, MinLen(1)]): def search_files(self, search: Annotated[str, MinLen(1)]):
return SithFile.objects.filter(name__icontains=search) return SithFile.objects.filter(is_in_sas=False).filter(name__icontains=search)
@api_controller("/group") @api_controller("/group")
+20 -9
View File
@@ -124,9 +124,8 @@ class Command(BaseCommand):
p.save(force_lock=True) p.save(force_lock=True)
club_root = SithFile.objects.create(name="clubs", owner=root) club_root = SithFile.objects.create(name="clubs", owner=root)
sas = SithFile.objects.create(name="SAS", owner=root) sas = SithFile.objects.create(
main_club = Club.objects.create( name="SAS", owner=root, id=settings.SITH_SAS_ROOT_DIR_ID
id=1, name="AE", address="6 Boulevard Anatole France, 90000 Belfort"
) )
clubs = self._create_clubs() clubs = self._create_clubs()
@@ -576,20 +575,32 @@ class Command(BaseCommand):
# SAS # SAS
for f in self.SAS_FIXTURE_PATH.glob("*"): for f in self.SAS_FIXTURE_PATH.glob("*"):
if f.is_dir(): if f.is_dir():
album = Album.objects.create(name=f.name, is_moderated=True) album = Album(
parent=sas,
name=f.name,
owner=root,
is_folder=True,
is_in_sas=True,
is_moderated=True,
)
album.clean()
album.save()
for p in f.iterdir(): for p in f.iterdir():
file = resize_image(Image.open(p), 1000, "WEBP") file = resize_image(Image.open(p), 1000, "WEBP")
pict = Picture( pict = Picture(
parent=album, parent=album,
name=p.name, name=p.name,
original=file, file=file,
owner=root, owner=root,
is_folder=False,
is_in_sas=True,
is_moderated=True, is_moderated=True,
mime_type="image/webp",
size=file.size,
) )
pict.original.name = pict.name pict.file.name = p.name
pict.generate_thumbnails() pict.full_clean()
pict.full_clean(save=True) pict.generate_thumbnails(save=True)
album.generate_thumbnail()
img_skia = Picture.objects.get(name="skia.jpg") img_skia = Picture.objects.get(name="skia.jpg")
img_sli = Picture.objects.get(name="sli.jpg") img_sli = Picture.objects.get(name="sli.jpg")
-27
View File
@@ -1,27 +0,0 @@
# Generated by Django 4.2.17 on 2025-01-26 15:01
from typing import TYPE_CHECKING
from django.db import migrations
from django.db.migrations.state import StateApps
if TYPE_CHECKING:
import core.models
def remove_sas_sithfiles(apps: StateApps, schema_editor):
SithFile: type[core.models.SithFile] = apps.get_model("core", "SithFile")
SithFile.objects.filter(is_in_sas=True).delete()
class Migration(migrations.Migration):
dependencies = [
("core", "0048_alter_user_options"),
("sas", "0007_alter_peoplepicturerelation_picture_and_more"),
]
operations = [
migrations.RunPython(
remove_sas_sithfiles, reverse_code=migrations.RunPython.noop, elidable=True
)
]
@@ -1,9 +0,0 @@
# Generated by Django 4.2.17 on 2025-02-14 11:58
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [("core", "0049_remove_sithfiles")]
operations = [migrations.RemoveField(model_name="sithfile", name="is_in_sas")]
+33
View File
@@ -876,6 +876,9 @@ class SithFile(models.Model):
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
) )
asked_for_removal = models.BooleanField(_("asked for removal"), default=False) asked_for_removal = models.BooleanField(_("asked for removal"), default=False)
is_in_sas = models.BooleanField(
_("is in the SAS"), default=False, db_index=True
) # Allows to query this flag, updated at each call to save()
class Meta: class Meta:
verbose_name = _("file") verbose_name = _("file")
@@ -884,10 +887,24 @@ class SithFile(models.Model):
return self.get_parent_path() + "/" + self.name return self.get_parent_path() + "/" + self.name
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
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 adding = self._state.adding
super().save(*args, **kwargs) super().save(*args, **kwargs)
if adding: if adding:
self.copy_rights() self.copy_rights()
if self.is_in_sas:
for user in User.objects.filter(
groups__id__in=[settings.SITH_GROUP_SAS_ADMIN_ID]
):
Notification(
user=user,
url=reverse("sas:moderation"),
type="SAS_MODERATION",
param="1",
).save()
def is_owned_by(self, user: User) -> bool: def is_owned_by(self, user: User) -> bool:
if user.is_anonymous: if user.is_anonymous:
@@ -900,6 +917,8 @@ class SithFile(models.Model):
return user.is_board_member return user.is_board_member
if user.is_com_admin: if user.is_com_admin:
return True return True
if self.is_in_sas and user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID):
return True
return user.id == self.owner_id return user.id == self.owner_id
def can_be_viewed_by(self, user: User) -> bool: def can_be_viewed_by(self, user: User) -> bool:
@@ -926,6 +945,8 @@ class SithFile(models.Model):
super().clean() super().clean()
if "/" in self.name: if "/" in self.name:
raise ValidationError(_("Character '/' not authorized in name")) raise ValidationError(_("Character '/' not authorized in name"))
if self == self.parent:
raise ValidationError(_("Loop in folder tree"), code="loop")
if self == self.parent or ( if self == self.parent or (
self.parent is not None and self in self.get_parent_list() self.parent is not None and self in self.get_parent_list()
): ):
@@ -1006,6 +1027,18 @@ class SithFile(models.Model):
def is_file(self): def is_file(self):
return not self.is_folder return not self.is_folder
@cached_property
def as_picture(self):
from sas.models import Picture
return Picture.objects.filter(id=self.id).first()
@cached_property
def as_album(self):
from sas.models import Album
return Album.objects.filter(id=self.id).first()
def get_parent_list(self): def get_parent_list(self):
parents = [] parents = []
current = self.parent current = self.parent
+4
View File
@@ -46,6 +46,10 @@ details.accordion>.accordion-content {
border-bottom-right-radius: 3px; border-bottom-right-radius: 3px;
border-bottom-left-radius: 3px; border-bottom-left-radius: 3px;
overflow: hidden; overflow: hidden;
@media screen and (max-width: 600px) {
padding: .75em 1.5em;
}
} }
@mixin animation($selector) { @mixin animation($selector) {
+11 -41
View File
@@ -5,7 +5,6 @@ from typing import Callable
from uuid import uuid4 from uuid import uuid4
import pytest import pytest
from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
from django.core.files.uploadedfile import SimpleUploadedFile, UploadedFile from django.core.files.uploadedfile import SimpleUploadedFile, UploadedFile
from django.test import Client, TestCase from django.test import Client, TestCase
@@ -18,8 +17,8 @@ from pytest_django.asserts import assertNumQueries
from core.baker_recipes import board_user, old_subscriber_user, subscriber_user from core.baker_recipes import board_user, old_subscriber_user, subscriber_user
from core.models import Group, QuickUploadImage, SithFile, User from core.models import Group, QuickUploadImage, SithFile, User
from core.utils import RED_PIXEL_PNG from core.utils import RED_PIXEL_PNG
from sas.baker_recipes import picture_recipe
from sas.models import Picture from sas.models import Picture
from sith import settings
@pytest.mark.django_db @pytest.mark.django_db
@@ -31,19 +30,24 @@ class TestImageAccess:
lambda: baker.make( lambda: baker.make(
User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)] User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)]
), ),
lambda: baker.make(
User, groups=[Group.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)]
),
], ],
) )
def test_sas_image_access(self, user_factory: Callable[[], User]): def test_sas_image_access(self, user_factory: Callable[[], User]):
"""Test that only authorized users can access the sas image.""" """Test that only authorized users can access the sas image."""
user = user_factory() user = user_factory()
picture = picture_recipe.make() picture: SithFile = baker.make(
assert user.can_edit(picture) Picture, parent=SithFile.objects.get(pk=settings.SITH_SAS_ROOT_DIR_ID)
)
assert picture.is_owned_by(user)
def test_sas_image_access_owner(self): def test_sas_image_access_owner(self):
"""Test that the owner of the image can access it.""" """Test that the owner of the image can access it."""
user = baker.make(User) user = baker.make(User)
picture = picture_recipe.make(owner=user) picture: Picture = baker.make(Picture, owner=user)
assert user.can_edit(picture) assert picture.is_owned_by(user)
@pytest.mark.parametrize( @pytest.mark.parametrize(
"user_factory", "user_factory",
@@ -59,41 +63,7 @@ class TestImageAccess:
user = user_factory() user = user_factory()
owner = baker.make(User) owner = baker.make(User)
picture: Picture = baker.make(Picture, owner=owner) picture: Picture = baker.make(Picture, owner=owner)
assert not user.can_edit(picture) assert not picture.is_owned_by(user)
@pytest.mark.django_db
class TestUserPicture:
def test_anonymous_user_unauthorized(self, client):
"""An anonymous user shouldn't have access to an user's photo page."""
response = client.get(
reverse(
"sas:user_pictures",
kwargs={"user_id": User.objects.get(username="sli").pk},
)
)
assert response.status_code == 403
@pytest.mark.parametrize(
("username", "status"),
[
("guy", 403),
("root", 200),
("skia", 200),
("sli", 200),
],
)
def test_page_is_working(self, client, username, status):
"""Only user that subscribed (or admins) should be able to see the page."""
# Test for simple user
client.force_login(User.objects.get(username=username))
response = client.get(
reverse(
"sas:user_pictures",
kwargs={"user_id": User.objects.get(username="sli").pk},
)
)
assert response.status_code == status
# TODO: many tests on the pages: # TODO: many tests on the pages:
+1 -2
View File
@@ -27,7 +27,6 @@ from counter.baker_recipes import sale_recipe
from counter.models import Counter, Customer, Permanency, Refilling, Selling from counter.models import Counter, Customer, Permanency, Refilling, Selling
from counter.utils import is_logged_in_counter from counter.utils import is_logged_in_counter
from eboutic.models import Invoice, InvoiceItem from eboutic.models import Invoice, InvoiceItem
from sas.models import Picture
class TestSearchUsers(TestCase): class TestSearchUsers(TestCase):
@@ -35,7 +34,7 @@ class TestSearchUsers(TestCase):
def setUpTestData(cls): def setUpTestData(cls):
# News.author has on_delete=PROTECT, so news must be deleted beforehand # News.author has on_delete=PROTECT, so news must be deleted beforehand
News.objects.all().delete() News.objects.all().delete()
Picture.objects.all().delete() # same for pictures SithFile.objects.all().delete()
User.objects.all().delete() User.objects.all().delete()
user_recipe = Recipe( user_recipe = Recipe(
User, User,
+3 -76
View File
@@ -12,23 +12,18 @@
# OR WITHIN THE LOCAL FILE "LICENSE" # OR WITHIN THE LOCAL FILE "LICENSE"
# #
# #
from dataclasses import dataclass
from datetime import date, timedelta from datetime import date, timedelta
# Image utils # Image utils
from io import BytesIO from io import BytesIO
from typing import Any, Final, Unpack from typing import Final
import PIL import PIL
from django.conf import settings from django.conf import settings
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.core.files.uploadedfile import UploadedFile from django.core.files.uploadedfile import UploadedFile
from django.db import models from django.http import HttpRequest
from django.forms import BaseForm
from django.http import Http404, HttpRequest
from django.shortcuts import get_list_or_404
from django.template.loader import render_to_string
from django.utils.safestring import SafeString
from django.utils.timezone import localdate from django.utils.timezone import localdate
from PIL.Image import Image, Resampling from PIL.Image import Image, Resampling
@@ -46,21 +41,6 @@ to generate a dummy image that is considered valid nonetheless
""" """
@dataclass
class FormFragmentTemplateData[T: BaseForm]:
"""Dataclass used to pre-render form fragments"""
form: T
template: str
context: dict[str, Any]
def render(self, request: HttpRequest) -> SafeString:
# Request is needed for csrf_tokens
return render_to_string(
self.template, context={"form": self.form, **self.context}, request=request
)
def get_start_of_semester(today: date | None = None) -> date: def get_start_of_semester(today: date | None = None) -> date:
"""Return the date of the start of the semester of the given date. """Return the date of the start of the semester of the given date.
If no date is given, return the start date of the current semester. If no date is given, return the start date of the current semester.
@@ -208,56 +188,3 @@ def get_client_ip(request: HttpRequest) -> str | None:
return ip return ip
return None return None
Filterable = type[models.Model] | models.QuerySet | models.Manager
ListFilter = dict[str, list | tuple | set]
def get_list_exact_or_404(klass: Filterable, **kwargs: Unpack[ListFilter]) -> list:
"""Use filter() to return a list of objects from a list of unique keys (like ids)
or raises Http404 if the list has not the same length as the given one.
Work like `get_object_or_404()` but for lists of objects, with some caveats :
- The filter must be a list, a tuple or a set.
- There can't be more than exactly one filter.
- There must be no duplicate in the filter.
- The filter should consist in unique keys (like ids), or it could fail randomly.
klass may be a Model, Manager, or QuerySet object. All other passed
arguments and keyword arguments are used in the filter() query.
Raises:
Http404: If the list is empty or doesn't have as many elements as the keys list.
ValueError: If the first argument is not a Model, Manager, or QuerySet object.
ValueError: If more than one filter is passed.
TypeError: If the given filter is not a list, a tuple or a set.
Examples:
Get all the products with ids 1, 2, 3: ::
products = get_list_exact_or_404(Product, id__in=[1, 2, 3])
Don't work with duplicate ids: ::
products = get_list_exact_or_404(Product, id__in=[1, 2, 3, 3])
# Raises Http404: "The list of keys must contain no duplicates."
"""
if len(kwargs) > 1:
raise ValueError("get_list_exact_or_404() only accepts one filter.")
key, list_filter = next(iter(kwargs.items()))
if not isinstance(list_filter, (list, tuple, set)):
raise TypeError(
f"The given filter must be a list, a tuple or a set, not {type(list_filter)}"
)
if len(list_filter) != len(set(list_filter)):
raise ValueError("The list of keys must contain no duplicates.")
kwargs = {key: list_filter}
obj_list = get_list_or_404(klass, **kwargs)
if len(obj_list) != len(list_filter):
raise Http404(
"The given list of keys doesn't match the number of objects found."
f"Expected {len(list_filter)} items, got {len(obj_list)}."
)
return obj_list
+1 -1
View File
@@ -374,7 +374,7 @@ class FileDeleteView(AllowFragment, CanEditPropMixin, DeleteView):
class FileModerationView(AllowFragment, ListView): class FileModerationView(AllowFragment, ListView):
model = SithFile model = SithFile
template_name = "core/file_moderation.jinja" template_name = "core/file_moderation.jinja"
queryset = SithFile.objects.filter(is_moderated=False) queryset = SithFile.objects.filter(is_moderated=False, is_in_sas=False)
ordering = "id" ordering = "id"
paginate_by = 100 paginate_by = 100
@@ -1,6 +1,6 @@
import type { RecursivePartial, TomSettings } from "tom-select/dist/types/types"; import type { RecursivePartial, TomSettings } from "tom-select/src/types";
import { AutoCompleteSelectBase } from "#core:core/components/ajax-select-base.ts"; import { AutoCompleteSelectBase } from "#core:core/components/ajax-select-base";
import { registerComponent } from "#core:utils/web-components.ts"; import { registerComponent } from "#core:utils/web-components";
const productParsingRegex = /^(\d+x)?(.*)/i; const productParsingRegex = /^(\d+x)?(.*)/i;
const codeParsingRegex = / \((\w+)\)$/; const codeParsingRegex = / \((\w+)\)$/;
@@ -63,13 +63,6 @@ export class CounterProductSelect extends AutoCompleteSelectBase {
); );
}, },
); );
this.widget.hook("after", "onOptionSelect", () => {
/* Focus the next element if it's an input */
if (this.nextElementSibling.nodeName === "INPUT") {
(this.nextElementSibling as HTMLInputElement).focus();
}
});
} }
protected tomSelectSettings(): RecursivePartial<TomSettings> { protected tomSelectSettings(): RecursivePartial<TomSettings> {
/* We disable the dropdown on focus because we're going to always autofocus the widget */ /* We disable the dropdown on focus because we're going to always autofocus the widget */
@@ -80,9 +73,7 @@ export class CounterProductSelect extends AutoCompleteSelectBase {
// We need to manually set weights or it results on an inconsistent // We need to manually set weights or it results on an inconsistent
// behavior between production and development environment // behavior between production and development environment
searchField: [ searchField: [
// @ts-expect-error documentation says it's fine, specified type is wrong
{ field: "code", weight: 2 }, { field: "code", weight: 2 },
// @ts-expect-error documentation says it's fine, specified type is wrong
{ field: "text", weight: 0.5 }, { field: "text", weight: 0.5 },
], ],
}; };
@@ -25,6 +25,9 @@ document.addEventListener("alpine:init", () => {
} }
this.codeField = this.$refs.codeField; this.codeField = this.$refs.codeField;
this.codeField.widget.hook("after", "onOptionSelect", () => {
this.handleCode();
});
this.codeField.widget.focus(); this.codeField.widget.focus();
// It's quite tricky to manually apply attributes to the management part // It's quite tricky to manually apply attributes to the management part
@@ -154,6 +157,7 @@ document.addEventListener("alpine:init", () => {
this.addToBasket(code, quantity); this.addToBasket(code, quantity);
} }
this.codeField.widget.clear(); this.codeField.widget.clear();
this.codeField.widget.setTextboxValue("");
this.codeField.widget.focus(); this.codeField.widget.focus();
}, },
})); }));
+22 -1
View File
@@ -42,7 +42,28 @@
min-width: 350px; min-width: 350px;
ul { ul {
list-style-type: none; list-style: none;
display: flex;
flex-direction: column;
gap: .5rem;
margin-left: 0;
.basket-row {
display: flex;
align-items: center;
gap: 1rem;
.product-name {
flex: 1 2 0;
min-width: 0;
text-wrap: wrap;
}
}
}
form {
margin-top: .5rem;
margin-bottom: .5rem;
} }
} }
+27 -18
View File
@@ -56,10 +56,15 @@
<div class="accordion-content"> <div class="accordion-content">
{% set counter_click_url = url('counter:click', counter_id=counter.id, user_id=customer.user_id) %} {% set counter_click_url = url('counter:click', counter_id=counter.id, user_id=customer.user_id) %}
<form method="post" action="" <form method="post" action="" @submit.prevent="handleCode">
class="code_form" @submit.prevent="handleCode">
<counter-product-select name="code" x-ref="codeField" autofocus required placeholder="{% trans %}Select a product...{% endtrans %}"> <counter-product-select
name="code"
x-ref="codeField"
autofocus
required
placeholder="{% trans %}Select a product...{% endtrans %}"
>
<option value=""></option> <option value=""></option>
<optgroup label="{% trans %}Operations{% endtrans %}"> <optgroup label="{% trans %}Operations{% endtrans %}">
<option value="FIN">{% trans %}Confirm (FIN){% endtrans %}</option> <option value="FIN">{% trans %}Confirm (FIN){% endtrans %}</option>
@@ -68,13 +73,11 @@
{%- for category, prices in categories.items() -%} {%- for category, prices in categories.items() -%}
<optgroup label="{{ category }}"> <optgroup label="{{ category }}">
{%- for price in prices -%} {%- for price in prices -%}
<option value="{{ price.id }}">{{ price.full_label }}</option> <option value="{{ price.id }}">{{ price.full_label }} ({{ price.product.code }})</option>
{%- endfor -%} {%- endfor -%}
</optgroup> </optgroup>
{%- endfor -%} {%- endfor -%}
</counter-product-select> </counter-product-select>
<input type="submit" value="{% trans %}Go{% endtrans %}"/>
</form> </form>
{% for error in form.non_form_errors() %} {% for error in form.non_form_errors() %}
@@ -102,7 +105,9 @@
{{ form.management_form }} {{ form.management_form }}
</div> </div>
<ul> <ul>
<li x-show="getBasketSize() === 0">{% trans %}This basket is empty{% endtrans %}</li> <li x-show="getBasketSize() === 0">
<em>{% trans %}This basket is empty{% endtrans %}</em>
</li>
<template x-for="(item, index) in Object.values(basket)" :key="item.product.price.id"> <template x-for="(item, index) in Object.values(basket)" :key="item.product.price.id">
<li> <li>
<template x-for="error in item.errors"> <template x-for="error in item.errors">
@@ -110,19 +115,23 @@
</div> </div>
</template> </template>
<button @click.prevent="addToBasket(item.product.price.id, -1)">-</button> <div class="basket-row">
<span class="quantity" x-text="item.quantity"></span> <div>
<button @click.prevent="addToBasket(item.product.price.id, 1)">+</button> <button @click.prevent="addToBasket(item.product.price.id, -1)">-</button>
<span class="quantity" x-text="item.quantity"></span>
<button @click.prevent="addToBasket(item.product.price.id, 1)">+</button>
</div>
<span x-text="item.product.name"></span> : <span class="product-name" x-text="item.product.name"></span>
<span x-text="item.sum().toLocaleString(undefined, { minimumFractionDigits: 2 })"></span> <span x-text="`${item.sum().toLocaleString(undefined, { minimumFractionDigits: 2 })} €`"></span>
<span x-show="item.getBonusQuantity() > 0" <span x-show="item.getBonusQuantity() > 0"
x-text="`${item.getBonusQuantity()} x P`"></span> x-text="`${item.getBonusQuantity()} x P`"></span>
<button <button
class="remove-item" class="remove-item"
@click.prevent="removeFromBasket(item.product.price.id)" @click.prevent="removeFromBasket(item.product.price.id)"
><i class="fa fa-trash-can delete-action"></i></button> ><i class="fa fa-trash-can delete-action"></i></button>
</div>
<input <input
type="hidden" type="hidden"
-32
View File
@@ -263,35 +263,3 @@ avec un unique champ permettant de sélectionner des groupes.
Par défaut, seuls les utilisateurs avec la permission Par défaut, seuls les utilisateurs avec la permission
`auth.change_permission` auront accès à ce formulaire `auth.change_permission` auront accès à ce formulaire
(donc, normalement, uniquement les utilisateurs Root). (donc, normalement, uniquement les utilisateurs Root).
```mermaid
sequenceDiagram
participant A as Utilisateur
participant B as ReverseProxy
participant C as MarkdownImage
participant D as Model
A->>B: GET /page/foo
B->>C: GET /page/foo
C-->>B: La page, avec les urls
B-->>A: La page, avec les urls
alt image publique
A->>B: GET markdown/public/2025/img.webp
B-->>A: img.webp
end
alt image privée
A->>B: GET markdown_image/{id}
B->>C: GET markdown_image/{id}
C->>D: user.can_view(image)
alt l'utilisateur a le droit de voir l'image
D-->>C: True
C-->>B: 200 (avec le X-Accel-Redirect)
B-->>A: img.webp
end
alt l'utilisateur n'a pas le droit de l'image
D-->>C: False
C-->>B: 403
B-->>A: 403
end
end
```
+10 -1
View File
@@ -1,3 +1,6 @@
from typing import Any
from ninja import Status
from ninja_extra import ControllerBase, api_controller, route from ninja_extra import ControllerBase, api_controller, route
from ninja_extra.exceptions import NotFound from ninja_extra.exceptions import NotFound
@@ -8,13 +11,19 @@ from eboutic.models import Basket
@api_controller("/etransaction", permissions=[CanView]) @api_controller("/etransaction", permissions=[CanView])
class EtransactionInfoController(ControllerBase): class EtransactionInfoController(ControllerBase):
@route.get("/data/{basket_id}", url_name="etransaction_data") @route.get(
"/data/{basket_id}",
url_name="etransaction_data",
response={200: dict[str, Any], 410: str},
)
def fetch_etransaction_data(self, basket_id: int): def fetch_etransaction_data(self, basket_id: int):
"""Generate the data to pay an eboutic command with paybox. """Generate the data to pay an eboutic command with paybox.
The data is generated with the basket that is used by the current session. The data is generated with the basket that is used by the current session.
""" """
basket: Basket = self.get_object_or_exception(Basket, pk=basket_id) basket: Basket = self.get_object_or_exception(Basket, pk=basket_id)
if basket.is_expired:
return Status(410, "This basket is expired.")
try: try:
return dict(basket.get_e_transaction_data()) return dict(basket.get_e_transaction_data())
except BillingInfo.DoesNotExist as e: except BillingInfo.DoesNotExist as e:
+26 -7
View File
@@ -24,6 +24,7 @@ from django.conf import settings
from django.db import DataError, models from django.db import DataError, models
from django.db.models import F, OuterRef, Subquery, Sum from django.db.models import F, OuterRef, Subquery, Sum
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from core.models import User from core.models import User
@@ -95,6 +96,10 @@ class Basket(models.Model):
] ]
) )
@property
def is_expired(self) -> bool:
return (self.date + settings.SITH_EBOUTIC_BASKET_TIMEOUT) <= now()
def generate_sales( def generate_sales(
self, counter, seller: User, payment_method: Selling.PaymentMethod self, counter, seller: User, payment_method: Selling.PaymentMethod
): ):
@@ -133,9 +138,20 @@ class Basket(models.Model):
] ]
def get_e_transaction_data(self) -> list[tuple[str, str]]: def get_e_transaction_data(self) -> list[tuple[str, str]]:
"""Get data for etransaction payment.
Raises:
Customer.DoesNotExist: if the user linked to this basket
has no customer account
BillingInfo.DoesNotExist: if the user linked to this basket has no
billing infos, or incorrect billing infos.
ValueError: if this is called on a basket which payment delay is expired.
"""
user = self.user user = self.user
if not hasattr(user, "customer"): if not hasattr(user, "customer"):
raise Customer.DoesNotExist raise Customer.DoesNotExist
if self.is_expired:
raise ValueError("This method cannot be called on an expired basket.")
customer = user.customer customer = user.customer
if ( if (
not hasattr(user.customer, "billing_infos") not hasattr(user.customer, "billing_infos")
@@ -155,6 +171,10 @@ class Basket(models.Model):
("PBX_IDENTIFIANT", settings.SITH_EBOUTIC_PBX_IDENTIFIANT), ("PBX_IDENTIFIANT", settings.SITH_EBOUTIC_PBX_IDENTIFIANT),
("PBX_TOTAL", str(int(self.total * 100))), ("PBX_TOTAL", str(int(self.total * 100))),
("PBX_DEVISE", "978"), # This is Euro ("PBX_DEVISE", "978"), # This is Euro
(
"PBX_DISPLAY",
str(int(settings.SITH_EBOUTIC_ETRANSACTION_TIMEOUT.total_seconds())),
),
("PBX_CMD", str(self.id)), ("PBX_CMD", str(self.id)),
("PBX_PORTEUR", user.email), ("PBX_PORTEUR", user.email),
("PBX_RETOUR", "Amount:M;BasketID:R;Auto:A;Error:E;Sig:K"), ("PBX_RETOUR", "Amount:M;BasketID:R;Auto:A;Error:E;Sig:K"),
@@ -219,16 +239,14 @@ class Invoice(models.Model):
if self.validated: if self.validated:
raise DataError(_("Invoice already validated")) raise DataError(_("Invoice already validated"))
customer, _created = Customer.get_or_create(user=self.user) customer, _created = Customer.get_or_create(user=self.user)
kwargs = { kwargs = {"counter": get_eboutic(), "customer": customer, "date": self.date}
"counter": get_eboutic(),
"customer": customer,
"date": self.date,
"payment_method": Selling.PaymentMethod.CARD,
}
for i in self.items.select_related("product"): for i in self.items.select_related("product"):
if i.product.product_type_id == settings.SITH_COUNTER_PRODUCTTYPE_REFILLING: if i.product.product_type_id == settings.SITH_COUNTER_PRODUCTTYPE_REFILLING:
Refilling.objects.create( Refilling.objects.create(
**kwargs, operator=self.user, amount=i.unit_price * i.quantity **kwargs,
operator=self.user,
amount=i.unit_price * i.quantity,
payment_method=Refilling.PaymentMethod.CARD,
) )
else: else:
Selling.objects.create( Selling.objects.create(
@@ -239,6 +257,7 @@ class Invoice(models.Model):
seller=self.user, seller=self.user,
unit_price=i.unit_price, unit_price=i.unit_price,
quantity=i.quantity, quantity=i.quantity,
payment_method=Selling.PaymentMethod.CARD,
) )
self.validated = True self.validated = True
self.save() self.save()
@@ -1,21 +1,71 @@
import { type Notification, NotificationLevel } from "#core:utils/notifications";
import { etransactioninfoFetchEtransactionData } from "#openapi"; import { etransactioninfoFetchEtransactionData } from "#openapi";
interface Basket {
id: number;
timeout: Date;
}
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
Alpine.data("etransaction", (initialData, basketId: number) => ({ Alpine.data("etransaction", (initialData, basket: Basket) => ({
data: initialData, data: initialData,
isCbAvailable: Object.keys(initialData).length > 0, isCbAvailable: Object.keys(initialData).length > 0,
isSithAvailable: true,
init() {
const now = new Date();
const timeout = basket.timeout.getTime() - now.getTime();
if (timeout <= 0) {
// basket was already outdated at initial page load
this.timeoutBasket();
} else {
setTimeout(() => this.timeoutBasket(), timeout);
}
},
/**
* Make this basket into a timeout state.
* All submission inputs are disabled, and an error message is displayed.
*/
timeoutBasket() {
this.isCbAvailable = false;
this.isSithAvailable = false;
const message = gettext("Basket expired");
const existingNotif: Notification | undefined = this.$notifications
.getAll()
.find(
(n: Notification) =>
n.tag === NotificationLevel.Error && n.message === message,
);
if (existingNotif === undefined) {
this.$notifications.error(message);
}
},
/**
* Refresh the data used for etransaction.
*
* Note: if this is called while the basket is expired, it will be a no-op
*/
async fill() { async fill() {
if (new Date() > basket.timeout) {
// refresh etransaction data only if the basket is still valid.
this.timeoutBasket();
return;
}
this.isCbAvailable = false; this.isCbAvailable = false;
const res = await etransactioninfoFetchEtransactionData({ const res = await etransactioninfoFetchEtransactionData({
path: { // biome-ignore lint/style/useNamingConvention: api is in snake_case
// biome-ignore lint/style/useNamingConvention: api is in snake_case path: { basket_id: basket.id },
basket_id: basketId,
},
}); });
if (res.response.ok) { if (res.response.ok) {
this.data = res.data; this.data = res.data;
this.isCbAvailable = true; this.isCbAvailable = true;
} else if (res.response.status === 410) {
// The basket is expired, so no payment method should be available at all.
// This shouldn't happen, because we don't send the request
// when the timeout is passed, but we are better safe than sorry
this.timeoutBasket();
} }
}, },
})); }));
@@ -21,6 +21,7 @@
hx-swap="outerHTML" hx-swap="outerHTML"
hx-target="#billing-infos-fragment" hx-target="#billing-infos-fragment"
x-show="collapsed" x-show="collapsed"
x-cloak
> >
{% csrf_token %} {% csrf_token %}
{{ form.as_p() }} {{ form.as_p() }}
@@ -15,11 +15,10 @@
{% block content %} {% block content %}
<h3>{% trans %}Eboutic{% endtrans %}</h3> <h3>{% trans %}Eboutic{% endtrans %}</h3>
<script type="text/javascript"> <div x-data='etransaction(
let billingInfos = {{ billing_infos|safe }}; {{ billing_infos|tojson }},
</script> { id: {{ basket.id }}, timeout: new Date('{{ basket.date + settings.SITH_EBOUTIC_BASKET_TIMEOUT }}') }
)'>
<div x-data="etransaction(billingInfos, {{ basket.id }})">
<p>{% trans %}Basket: {% endtrans %}</p> <p>{% trans %}Basket: {% endtrans %}</p>
<table> <table>
<thead> <thead>
@@ -72,7 +71,11 @@
x-cloak x-cloak
type="submit" type="submit"
id="bank-submit-button" id="bank-submit-button"
:disabled="!isCbAvailable" {% if basket.is_expired %}
disabled="disabled"
{% else %}
:disabled="!isCbAvailable"
{% endif %}
class="btn btn-blue" class="btn btn-blue"
value="{% trans %}Pay with credit card{% endtrans %}" value="{% trans %}Pay with credit card{% endtrans %}"
/> />
@@ -93,7 +96,16 @@
{% else %} {% else %}
<form method="post" action="{{ url('eboutic:pay_with_sith', basket_id=basket.id) }}" name="sith-pay-form"> <form method="post" action="{{ url('eboutic:pay_with_sith', basket_id=basket.id) }}" name="sith-pay-form">
{% csrf_token %} {% csrf_token %}
<input class="btn btn-blue" type="submit" value="{% trans %}Pay with Sith account{% endtrans %}"/> <input
{% if basket.is_expired %}
disabled="disabled"
{% else %}
:disabled="!isSithAvailable"
{% endif %}
class="btn btn-blue"
type="submit"
value="{% trans %}Pay with Sith account{% endtrans %}"
/>
</form> </form>
{% endif %} {% endif %}
</div> </div>
+27 -7
View File
@@ -3,6 +3,7 @@ import urllib
from decimal import Decimal from decimal import Decimal
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import freezegun
from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15 from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
from cryptography.hazmat.primitives.hashes import SHA1 from cryptography.hazmat.primitives.hashes import SHA1
@@ -17,7 +18,7 @@ from pytest_django.asserts import assertRedirects
from core.baker_recipes import old_subscriber_user, subscriber_user from core.baker_recipes import old_subscriber_user, subscriber_user
from counter.baker_recipes import price_recipe, product_recipe from counter.baker_recipes import price_recipe, product_recipe
from counter.models import Product, ProductType, Selling from counter.models import Product, ProductType, Refilling, Selling
from counter.tests.test_counter import force_refill_user from counter.tests.test_counter import force_refill_user
from eboutic.models import Basket, BasketItem from eboutic.models import Basket, BasketItem
@@ -105,7 +106,7 @@ class TestPaymentSith(TestPaymentBase):
), ),
reverse("eboutic:payment_result", kwargs={"result": "success"}), reverse("eboutic:payment_result", kwargs={"result": "success"}),
) )
assert Basket.objects.filter(id=self.basket.id).first() is None assert not Basket.objects.filter(id=self.basket.id).exists()
self.customer.customer.refresh_from_db() self.customer.customer.refresh_from_db()
assert self.customer.customer.amount == Decimal(1) assert self.customer.customer.amount == Decimal(1)
@@ -139,10 +140,7 @@ class TestPaymentSith(TestPaymentBase):
assert len(messages) == 1 assert len(messages) == 1
assert messages[0].level == DEFAULT_LEVELS["ERROR"] assert messages[0].level == DEFAULT_LEVELS["ERROR"]
assert messages[0].message == "Solde insuffisant" assert messages[0].message == "Solde insuffisant"
assert not Basket.objects.filter(id=self.basket.id).exists()
assert Basket.objects.contains(self.basket), (
"After an unsuccessful request, the basket should be kept"
)
def test_refilling_in_basket(self): def test_refilling_in_basket(self):
BasketItem.from_price(self.refilling.prices.first(), 1, self.basket).save() BasketItem.from_price(self.refilling.prices.first(), 1, self.basket).save()
@@ -157,7 +155,7 @@ class TestPaymentSith(TestPaymentBase):
response, response,
reverse("eboutic:payment_result", kwargs={"result": "failure"}), reverse("eboutic:payment_result", kwargs={"result": "failure"}),
) )
assert Basket.objects.filter(id=self.basket.id).first() is not None assert not Basket.objects.filter(id=self.basket.id).exists()
messages = list(get_messages(response.wsgi_request)) messages = list(get_messages(response.wsgi_request))
assert messages[0].level == DEFAULT_LEVELS["ERROR"] assert messages[0].level == DEFAULT_LEVELS["ERROR"]
assert ( assert (
@@ -167,6 +165,24 @@ class TestPaymentSith(TestPaymentBase):
self.customer.customer.refresh_from_db() self.customer.customer.refresh_from_db()
assert self.customer.customer.amount == initial_account_balance assert self.customer.customer.amount == initial_account_balance
def test_basket_expired(self):
self.client.force_login(self.customer)
initial_account_balance = self.customer.customer.amount
with freezegun.freeze_time(settings.SITH_EBOUTIC_BASKET_TIMEOUT):
response = self.client.post(
reverse("eboutic:pay_with_sith", kwargs={"basket_id": self.basket.id})
)
assertRedirects(
response,
reverse("eboutic:payment_result", kwargs={"result": "failure"}),
)
messages = list(get_messages(response.wsgi_request))
assert messages[0].level == DEFAULT_LEVELS["ERROR"]
assert messages[0].message == "Panier expiré"
assert not Basket.objects.filter(id=self.basket.id).exists()
self.customer.customer.refresh_from_db()
assert self.customer.customer.amount == initial_account_balance
class TestPaymentCard(TestPaymentBase): class TestPaymentCard(TestPaymentBase):
def generate_bank_valid_answer(self, basket: Basket): def generate_bank_valid_answer(self, basket: Basket):
@@ -236,6 +252,10 @@ class TestPaymentCard(TestPaymentBase):
self.customer.customer.refresh_from_db() self.customer.customer.refresh_from_db()
assert self.customer.customer.amount == price.amount * 2 assert self.customer.customer.amount == price.amount * 2
refill = self.customer.customer.refillings.last()
assert refill is not None
assert refill.amount == price.amount * 2
assert refill.payment_method == Refilling.PaymentMethod.CARD
def test_multiple_responses(self): def test_multiple_responses(self):
bank_response = self.generate_bank_valid_answer(self.basket) bank_response = self.generate_bank_valid_answer(self.basket)
+21 -6
View File
@@ -39,6 +39,8 @@ from django.db.utils import cached_property
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.urls import reverse from django.urls import reverse
from django.utils.formats import localize
from django.utils.timezone import localtime
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.decorators.http import require_GET from django.views.decorators.http import require_GET
from django.views.generic import DetailView, FormView, TemplateView, UpdateView, View from django.views.generic import DetailView, FormView, TemplateView, UpdateView, View
@@ -187,9 +189,7 @@ class BillingInfoFormFragment(
def get_initial(self): def get_initial(self):
if self.object is None: if self.object is None:
return { return {"country": Country(code="FR")}
"country": Country(code="FR"),
}
return {} return {}
def render_fragment(self, request, **kwargs) -> SafeString: def render_fragment(self, request, **kwargs) -> SafeString:
@@ -255,10 +255,19 @@ class EbouticCheckout(CanViewMixin, UseFragmentsMixin, DetailView):
kwargs["customer_amount"] = None kwargs["customer_amount"] = None
kwargs["billing_infos"] = {} kwargs["billing_infos"] = {}
with contextlib.suppress(BillingInfo.DoesNotExist): if self.object.is_expired:
kwargs["billing_infos"] = json.dumps( messages.error(self.request, _("Basket expired"))
dict(self.object.get_e_transaction_data()) else:
timeout = self.object.date + settings.SITH_EBOUTIC_BASKET_TIMEOUT
messages.warning(
self.request,
_("Basket available until %(until)s")
% {"until": localize(localtime(timeout).time())},
) )
with contextlib.suppress(BillingInfo.DoesNotExist):
kwargs["billing_infos"] = json.dumps(
dict(self.object.get_e_transaction_data())
)
return kwargs return kwargs
@@ -268,9 +277,14 @@ class EbouticPayWithSith(CanViewMixin, SingleObjectMixin, View):
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
basket = self.get_object() basket = self.get_object()
if basket.is_expired:
messages.error(self.request, _("Basket expired"))
basket.delete()
return redirect("eboutic:payment_result", "failure")
refilling = settings.SITH_COUNTER_PRODUCTTYPE_REFILLING refilling = settings.SITH_COUNTER_PRODUCTTYPE_REFILLING
if basket.items.filter(product__product_type_id=refilling).exists(): if basket.items.filter(product__product_type_id=refilling).exists():
messages.error(self.request, _("You can't buy a refilling with sith money")) messages.error(self.request, _("You can't buy a refilling with sith money"))
basket.delete()
return redirect("eboutic:payment_result", "failure") return redirect("eboutic:payment_result", "failure")
eboutic = get_eboutic() eboutic = get_eboutic()
@@ -288,6 +302,7 @@ class EbouticPayWithSith(CanViewMixin, SingleObjectMixin, View):
except DatabaseError as e: except DatabaseError as e:
sentry_sdk.capture_exception(e) sentry_sdk.capture_exception(e)
except ValidationError as e: except ValidationError as e:
basket.delete()
messages.error(self.request, e.message) messages.error(self.request, e.message)
return redirect("eboutic:payment_result", "failure") return redirect("eboutic:payment_result", "failure")
@@ -25,13 +25,14 @@ import warnings
from datetime import timedelta from datetime import timedelta
from typing import Final, Optional from typing import Final, Optional
from django.conf import settings
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.utils import timezone from django.utils import timezone
from model_bakery import baker from model_bakery import baker
from club.models import Club, ClubRole, Membership from club.models import Club, ClubRole, Membership
from core.models import Group, Page, User from core.models import Group, Page, SithFile, User
from core.utils import RED_PIXEL_PNG from core.utils import RED_PIXEL_PNG
from sas.models import Album, PeoplePictureRelation, Picture from sas.models import Album, PeoplePictureRelation, Picture
from subscription.models import Subscription from subscription.models import Subscription
@@ -91,8 +92,13 @@ class Command(BaseCommand):
self.NB_CLUBS = options["club_count"] self.NB_CLUBS = options["club_count"]
root = User.objects.filter(username="root").first() root = User.objects.filter(username="root").first()
sas = SithFile.objects.get(id=settings.SITH_SAS_ROOT_DIR_ID)
self.galaxy_album = Album.objects.create( self.galaxy_album = Album.objects.create(
name="galaxy-register-file", owner=root, is_moderated=True name="galaxy-register-file",
owner=root,
is_moderated=True,
is_in_sas=True,
parent=sas,
) )
self.make_clubs() self.make_clubs()
@@ -288,10 +294,14 @@ class Command(BaseCommand):
owner=u, owner=u,
name=f"galaxy-picture {u} {i // self.NB_USERS}", name=f"galaxy-picture {u} {i // self.NB_USERS}",
is_moderated=True, is_moderated=True,
is_folder=False,
parent=self.galaxy_album, parent=self.galaxy_album,
original=ContentFile(RED_PIXEL_PNG), is_in_sas=True,
file=ContentFile(RED_PIXEL_PNG),
compressed=ContentFile(RED_PIXEL_PNG), compressed=ContentFile(RED_PIXEL_PNG),
thumbnail=ContentFile(RED_PIXEL_PNG), thumbnail=ContentFile(RED_PIXEL_PNG),
mime_type="image/png",
size=len(RED_PIXEL_PNG),
) )
) )
self.picts[i].file.name = self.picts[i].name self.picts[i].file.name = self.picts[i].name
+21 -789
View File
@@ -6,7 +6,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-05-12 11:12+0200\n" "POT-Creation-Date: 2026-05-15 11:46+0200\n"
"PO-Revision-Date: 2016-07-18\n" "PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n" "Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -16,767 +16,6 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n"
#: accounting/models.py club/models.py com/models.py counter/models.py
#: forum/models.py launderette/models.py sas/models.py
msgid "name"
msgstr "nom"
#: accounting/models.py
msgid "street"
msgstr "rue"
#: accounting/models.py
msgid "city"
msgstr "ville"
#: accounting/models.py
msgid "postcode"
msgstr "code postal"
#: accounting/models.py
msgid "country"
msgstr "pays"
#: accounting/models.py core/models.py
msgid "phone"
msgstr "téléphone"
#: accounting/models.py
msgid "email"
msgstr "email"
#: accounting/models.py
msgid "website"
msgstr "site internet"
#: accounting/models.py
msgid "company"
msgstr "entreprise"
#: accounting/models.py
msgid "iban"
msgstr "IBAN"
#: accounting/models.py
msgid "account number"
msgstr "numéro de compte"
#: accounting/models.py club/models.py com/models.py counter/models.py
#: trombi/models.py
msgid "club"
msgstr "club"
#: accounting/models.py
msgid "Bank account"
msgstr "Compte en banque"
#: accounting/models.py
msgid "bank account"
msgstr "compte en banque"
#: accounting/models.py
msgid "Club account"
msgstr "Compte club"
#: accounting/models.py
#, python-format
msgid "%(club_account)s on %(bank_account)s"
msgstr "%(club_account)s sur %(bank_account)s"
#: accounting/models.py club/models.py counter/models.py election/models.py
#: launderette/models.py
msgid "start date"
msgstr "date de début"
#: accounting/models.py club/models.py counter/models.py election/models.py
msgid "end date"
msgstr "date de fin"
#: accounting/models.py
msgid "is closed"
msgstr "est fermé"
#: accounting/models.py
msgid "club account"
msgstr "compte club"
#: accounting/models.py counter/models.py
msgid "amount"
msgstr "montant"
#: accounting/models.py
msgid "effective_amount"
msgstr "montant effectif"
#: accounting/models.py
msgid "General journal"
msgstr "Classeur"
#: accounting/models.py
msgid "number"
msgstr "numéro"
#: accounting/models.py
msgid "journal"
msgstr "classeur"
#: accounting/models.py core/models.py counter/models.py eboutic/models.py
#: forum/models.py
msgid "date"
msgstr "date"
#: accounting/models.py counter/models.py pedagogy/models.py
msgid "comment"
msgstr "commentaire"
#: accounting/models.py counter/models.py subscription/models.py
msgid "payment method"
msgstr "méthode de paiement"
#: accounting/models.py
msgid "cheque number"
msgstr "numéro de chèque"
#: accounting/models.py eboutic/models.py
msgid "invoice"
msgstr "facture"
#: accounting/models.py
msgid "is done"
msgstr "est fait"
#: accounting/models.py
msgid "simple type"
msgstr "type simplifié"
#: accounting/models.py
msgid "accounting type"
msgstr "type comptable"
#: accounting/models.py core/models.py counter/models.py
msgid "label"
msgstr "étiquette"
#: accounting/models.py
msgid "target type"
msgstr "type de cible"
#: accounting/models.py club/models.py club/templates/club/club_members.jinja
#: club/templates/club/club_old_members.jinja club/templates/club/mailing.jinja
#: counter/templates/counter/cash_summary_list.jinja
#: counter/templates/counter/stats.jinja
#: launderette/templates/launderette/launderette_admin.jinja
msgid "User"
msgstr "Utilisateur"
#: accounting/models.py club/models.py club/templates/club/club_detail.jinja
#: com/templates/com/mailing_admin.jinja
#: com/templates/com/news_admin_list.jinja com/templates/com/weekmail.jinja
#: core/templates/core/user_clubs.jinja
#: counter/templates/counter/invoices_call.jinja
#: trombi/templates/trombi/edit_profile.jinja
#: trombi/templates/trombi/export.jinja
#: trombi/templates/trombi/user_profile.jinja
msgid "Club"
msgstr "Club"
#: accounting/models.py core/views/user.py
msgid "Account"
msgstr "Compte"
#: accounting/models.py
msgid "Company"
msgstr "Entreprise"
#: accounting/models.py core/models.py sith/settings.py
msgid "Other"
msgstr "Autre"
#: accounting/models.py
msgid "target id"
msgstr "id de la cible"
#: accounting/models.py
msgid "target label"
msgstr "nom de la cible"
#: accounting/models.py
msgid "linked operation"
msgstr "opération liée"
#: accounting/models.py
msgid "The date must be set."
msgstr "La date doit être indiquée."
#: accounting/models.py
#, python-format
msgid ""
"The date can not be before the start date of the journal, which is\n"
"%(start_date)s."
msgstr ""
"La date ne peut pas être avant la date de début du journal, qui est\n"
"%(start_date)s."
#: accounting/models.py
msgid "Target does not exists"
msgstr "La cible n'existe pas."
#: accounting/models.py
msgid "Please add a target label if you set no existing target"
msgstr ""
"Merci d'ajouter un nom de cible si vous ne spécifiez pas de cible existante"
#: accounting/models.py
msgid ""
"You need to provide ether a simplified accounting type or a standard "
"accounting type"
msgstr ""
"Vous devez fournir soit un type comptable simplifié ou un type comptable "
"standard"
#: accounting/models.py counter/models.py pedagogy/models.py
msgid "code"
msgstr "code"
#: accounting/models.py
msgid "An accounting type code contains only numbers"
msgstr "Un code comptable ne contient que des numéros"
#: accounting/models.py
msgid "movement type"
msgstr "type de mouvement"
#: accounting/models.py
#: accounting/templates/accounting/journal_statement_nature.jinja
#: accounting/templates/accounting/journal_statement_person.jinja
#: accounting/views.py
msgid "Credit"
msgstr "Crédit"
#: accounting/models.py
#: accounting/templates/accounting/journal_statement_nature.jinja
#: accounting/templates/accounting/journal_statement_person.jinja
#: accounting/views.py
msgid "Debit"
msgstr "Débit"
#: accounting/models.py
msgid "Neutral"
msgstr "Neutre"
#: accounting/models.py
msgid "simplified accounting types"
msgstr "type simplifié"
#: accounting/models.py
msgid "simplified type"
msgstr "type simplifié"
#: accounting/templates/accounting/accountingtype_list.jinja
msgid "Accounting type list"
msgstr "Liste des types comptable"
#: accounting/templates/accounting/accountingtype_list.jinja
#: accounting/templates/accounting/bank_account_details.jinja
#: accounting/templates/accounting/bank_account_list.jinja
#: accounting/templates/accounting/club_account_details.jinja
#: accounting/templates/accounting/journal_details.jinja
#: accounting/templates/accounting/label_list.jinja
#: accounting/templates/accounting/operation_edit.jinja
#: accounting/templates/accounting/simplifiedaccountingtype_list.jinja
#: core/templates/core/user_tools.jinja
msgid "Accounting"
msgstr "Comptabilité"
#: accounting/templates/accounting/accountingtype_list.jinja
msgid "Accounting types"
msgstr "Type comptable"
#: accounting/templates/accounting/accountingtype_list.jinja
msgid "New accounting type"
msgstr "Nouveau type comptable"
#: accounting/templates/accounting/accountingtype_list.jinja
#: accounting/templates/accounting/simplifiedaccountingtype_list.jinja
msgid "There is no types in this website."
msgstr "Il n'y a pas de types comptable dans ce site web."
#: accounting/templates/accounting/bank_account_details.jinja
#: core/templates/core/user_tools.jinja
msgid "Bank account: "
msgstr "Compte en banque : "
#: accounting/templates/accounting/bank_account_details.jinja
#: accounting/templates/accounting/club_account_details.jinja
#: accounting/templates/accounting/label_list.jinja
#: club/templates/club/club_sellings.jinja club/templates/club/mailing.jinja
#: com/templates/com/macros.jinja com/templates/com/mailing_admin.jinja
#: com/templates/com/news_admin_list.jinja com/templates/com/poster_edit.jinja
#: com/templates/com/screen_edit.jinja com/templates/com/weekmail.jinja
#: core/templates/core/file_detail.jinja
#: core/templates/core/file_moderation.jinja
#: core/templates/core/group_detail.jinja core/templates/core/group_list.jinja
#: core/templates/core/macros.jinja core/templates/core/page_prop.jinja
#: core/templates/core/user_account_detail.jinja
#: core/templates/core/user_clubs.jinja core/templates/core/user_edit.jinja
#: counter/templates/counter/fragments/create_student_card.jinja
#: counter/templates/counter/last_ops.jinja
#: election/templates/election/election_detail.jinja
#: forum/templates/forum/macros.jinja
#: launderette/templates/launderette/launderette_admin.jinja
#: launderette/views.py pedagogy/templates/pedagogy/guide.jinja
#: pedagogy/templates/pedagogy/uv_detail.jinja sas/templates/sas/album.jinja
#: sas/templates/sas/moderation.jinja sas/templates/sas/picture.jinja
#: trombi/templates/trombi/detail.jinja
#: trombi/templates/trombi/edit_profile.jinja
msgid "Delete"
msgstr "Supprimer"
#: accounting/templates/accounting/bank_account_details.jinja club/views.py
#: core/views/user.py sas/templates/sas/picture.jinja
msgid "Infos"
msgstr "Infos"
#: accounting/templates/accounting/bank_account_details.jinja
msgid "IBAN: "
msgstr "IBAN : "
#: accounting/templates/accounting/bank_account_details.jinja
msgid "Number: "
msgstr "Numéro : "
#: accounting/templates/accounting/bank_account_details.jinja
msgid "New club account"
msgstr "Nouveau compte club"
#: accounting/templates/accounting/bank_account_details.jinja
#: accounting/templates/accounting/bank_account_list.jinja
#: accounting/templates/accounting/club_account_details.jinja
#: accounting/templates/accounting/journal_details.jinja club/views.py
#: com/templates/com/news_admin_list.jinja com/templates/com/poster_list.jinja
#: com/templates/com/screen_list.jinja com/templates/com/weekmail.jinja
#: core/templates/core/file.jinja core/templates/core/group_list.jinja
#: core/templates/core/page.jinja core/templates/core/user_tools.jinja
#: core/views/user.py counter/templates/counter/cash_summary_list.jinja
#: counter/templates/counter/counter_list.jinja
#: election/templates/election/election_detail.jinja
#: forum/templates/forum/macros.jinja
#: launderette/templates/launderette/launderette_list.jinja
#: pedagogy/templates/pedagogy/guide.jinja
#: pedagogy/templates/pedagogy/uv_detail.jinja sas/templates/sas/album.jinja
#: trombi/templates/trombi/detail.jinja
#: trombi/templates/trombi/edit_profile.jinja
msgid "Edit"
msgstr "Éditer"
#: accounting/templates/accounting/bank_account_list.jinja
msgid "Bank account list"
msgstr "Liste des comptes en banque"
#: accounting/templates/accounting/bank_account_list.jinja
msgid "Manage simplified types"
msgstr "Gérer les types simplifiés"
#: accounting/templates/accounting/bank_account_list.jinja
msgid "Manage accounting types"
msgstr "Gérer les types comptable"
#: accounting/templates/accounting/bank_account_list.jinja
msgid "New bank account"
msgstr "Nouveau compte en banque"
#: accounting/templates/accounting/bank_account_list.jinja
msgid "There is no accounts in this website."
msgstr "Il n'y a pas de comptes dans ce site web."
#: accounting/templates/accounting/club_account_details.jinja
msgid "Club account:"
msgstr "Compte club : "
#: accounting/templates/accounting/club_account_details.jinja
#: accounting/templates/accounting/journal_details.jinja
#: accounting/templates/accounting/label_list.jinja
msgid "New label"
msgstr "Nouvelle étiquette"
#: accounting/templates/accounting/club_account_details.jinja
#: accounting/templates/accounting/journal_details.jinja
#: accounting/templates/accounting/label_list.jinja
msgid "Label list"
msgstr "Liste des étiquettes"
#: accounting/templates/accounting/club_account_details.jinja
msgid "New journal"
msgstr "Nouveau classeur"
#: accounting/templates/accounting/club_account_details.jinja
msgid "You can not create new journal while you still have one opened"
msgstr "Vous ne pouvez pas créer de journal tant qu'il y en a un d'ouvert"
#: accounting/templates/accounting/club_account_details.jinja
#: launderette/templates/launderette/launderette_admin.jinja
msgid "Name"
msgstr "Nom"
#: accounting/templates/accounting/club_account_details.jinja
#: com/templates/com/news_admin_list.jinja
msgid "Start"
msgstr "Début"
#: accounting/templates/accounting/club_account_details.jinja
#: com/templates/com/news_admin_list.jinja
msgid "End"
msgstr "Fin"
#: accounting/templates/accounting/club_account_details.jinja
#: accounting/templates/accounting/journal_details.jinja
#: core/templates/core/user_account_detail.jinja
#: counter/templates/counter/last_ops.jinja
#: counter/templates/counter/refilling_list.jinja
msgid "Amount"
msgstr "Montant"
#: accounting/templates/accounting/club_account_details.jinja
msgid "Effective amount"
msgstr "Montant effectif"
#: accounting/templates/accounting/club_account_details.jinja sith/settings.py
msgid "Closed"
msgstr "Fermé"
#: accounting/templates/accounting/club_account_details.jinja
#: accounting/templates/accounting/journal_details.jinja
#: com/templates/com/mailing_admin.jinja
#: com/templates/com/news_admin_list.jinja com/templates/com/weekmail.jinja
#: counter/templates/counter/refilling_list.jinja
msgid "Actions"
msgstr "Actions"
#: accounting/templates/accounting/club_account_details.jinja
#: accounting/templates/accounting/journal_details.jinja
msgid "Yes"
msgstr "Oui"
#: accounting/templates/accounting/club_account_details.jinja
#: accounting/templates/accounting/journal_details.jinja
msgid "No"
msgstr "Non"
#: accounting/templates/accounting/club_account_details.jinja
#: com/templates/com/news_admin_list.jinja core/templates/core/file.jinja
#: core/templates/core/page.jinja
msgid "View"
msgstr "Voir"
#: accounting/templates/accounting/co_list.jinja
#: accounting/templates/accounting/journal_details.jinja
#: core/templates/core/user_tools.jinja
msgid "Company list"
msgstr "Liste des entreprises"
#: accounting/templates/accounting/co_list.jinja
msgid "Create new company"
msgstr "Nouvelle entreprise"
#: accounting/templates/accounting/co_list.jinja
msgid "Companies"
msgstr "Entreprises"
#: accounting/templates/accounting/journal_details.jinja
#: accounting/templates/accounting/journal_statement_accounting.jinja
#: accounting/templates/accounting/journal_statement_nature.jinja
#: accounting/templates/accounting/journal_statement_person.jinja
msgid "General journal:"
msgstr "Classeur : "
#: accounting/templates/accounting/journal_details.jinja
#: accounting/templates/accounting/journal_statement_accounting.jinja
#: core/templates/core/user_account.jinja
#: core/templates/core/user_account_detail.jinja
#: counter/templates/counter/counter_click.jinja
msgid "Amount: "
msgstr "Montant : "
#: accounting/templates/accounting/journal_details.jinja
#: accounting/templates/accounting/journal_statement_accounting.jinja
msgid "Effective amount: "
msgstr "Montant effectif: "
#: accounting/templates/accounting/journal_details.jinja
msgid "Journal is closed, you can not create operation"
msgstr "Le classeur est fermé, vous ne pouvez pas créer d'opération"
#: accounting/templates/accounting/journal_details.jinja
msgid "New operation"
msgstr "Nouvelle opération"
#: accounting/templates/accounting/journal_details.jinja
msgid "Nb"
msgstr "No"
#: accounting/templates/accounting/journal_details.jinja
#: club/templates/club/club_sellings.jinja
#: core/templates/core/user_account_detail.jinja
#: 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
#: trombi/templates/trombi/user_profile.jinja
msgid "Date"
msgstr "Date"
#: accounting/templates/accounting/journal_details.jinja
#: club/templates/club/club_sellings.jinja
#: core/templates/core/user_account_detail.jinja
#: counter/templates/counter/last_ops.jinja
#: rootplace/templates/rootplace/logs.jinja
msgid "Label"
msgstr "Étiquette"
#: accounting/templates/accounting/journal_details.jinja
msgid "Payment mode"
msgstr "Méthode de paiement"
#: accounting/templates/accounting/journal_details.jinja
msgid "Target"
msgstr "Cible"
#: accounting/templates/accounting/journal_details.jinja
msgid "Code"
msgstr "Code"
#: accounting/templates/accounting/journal_details.jinja
msgid "Nature"
msgstr "Nature"
#: accounting/templates/accounting/journal_details.jinja
msgid "Done"
msgstr "Effectuées"
#: accounting/templates/accounting/journal_details.jinja
#: counter/templates/counter/cash_summary_list.jinja counter/views/cash.py
#: pedagogy/templates/pedagogy/moderation.jinja
#: pedagogy/templates/pedagogy/uv_detail.jinja
#: trombi/templates/trombi/comment.jinja
#: trombi/templates/trombi/user_tools.jinja
msgid "Comment"
msgstr "Commentaire"
#: accounting/templates/accounting/journal_details.jinja
msgid "File"
msgstr "Fichier"
#: accounting/templates/accounting/journal_details.jinja
msgid "PDF"
msgstr "PDF"
#: accounting/templates/accounting/journal_details.jinja
msgid ""
"Warning: this operation has no linked operation because the targeted club "
"account has no opened journal."
msgstr ""
"Attention: cette opération n'a pas d'opération liée parce qu'il n'y a pas de "
"classeur ouvert dans le compte club cible"
#: accounting/templates/accounting/journal_details.jinja
#, python-format
msgid ""
"Open a journal in <a href=\"%(url)s\">this club account</a>, then save this "
"operation again to make the linked operation."
msgstr ""
"Ouvrez un classeur dans <a href=\"%(url)s\">ce compte club</a>, puis sauver "
"cette opération à nouveau pour créer l'opération liée."
#: accounting/templates/accounting/journal_details.jinja
msgid "Generate"
msgstr "Générer"
#: accounting/templates/accounting/journal_statement_accounting.jinja
msgid "Accounting statement: "
msgstr "Bilan comptable : "
#: accounting/templates/accounting/journal_statement_accounting.jinja
#: rootplace/templates/rootplace/logs.jinja
msgid "Operation type"
msgstr "Type d'opération"
#: accounting/templates/accounting/journal_statement_accounting.jinja
#: accounting/templates/accounting/journal_statement_nature.jinja
#: accounting/templates/accounting/journal_statement_person.jinja
#: counter/templates/counter/invoices_call.jinja
msgid "Sum"
msgstr "Somme"
#: accounting/templates/accounting/journal_statement_nature.jinja
msgid "Nature of operation"
msgstr "Nature de l'opération"
#: accounting/templates/accounting/journal_statement_nature.jinja
#: club/templates/club/club_sellings.jinja
#: counter/templates/counter/counter_main.jinja
msgid "Total: "
msgstr "Total : "
#: accounting/templates/accounting/journal_statement_nature.jinja
msgid "Statement by nature: "
msgstr "Bilan par nature : "
#: accounting/templates/accounting/journal_statement_person.jinja
msgid "Statement by person: "
msgstr "Bilan par personne : "
#: accounting/templates/accounting/journal_statement_person.jinja
msgid "Target of the operation"
msgstr "Cible de l'opération"
#: accounting/templates/accounting/label_list.jinja
msgid "Back to club account"
msgstr "Retour au compte club"
#: accounting/templates/accounting/label_list.jinja
msgid "There is no label in this club account."
msgstr "Il n'y a pas d'étiquette dans ce compte club."
#: accounting/templates/accounting/operation_edit.jinja
msgid "Edit operation"
msgstr "Éditer l'opération"
#: accounting/templates/accounting/operation_edit.jinja
msgid ""
"Warning: if you select <em>Account</em>, the opposite operation will be "
"created in the target account. If you don't want that, select <em>Club</em> "
"instead of <em>Account</em>."
msgstr ""
"Attention : si vous sélectionnez <em>Compte</em>, l'opération inverse sera "
"créée dans le compte cible. Si vous ne le voulez pas, sélectionnez <em>Club</"
"em> à la place de <em>Compte</em>."
#: accounting/templates/accounting/operation_edit.jinja
msgid "Linked operation:"
msgstr "Opération liée : "
#: accounting/templates/accounting/operation_edit.jinja
#: com/templates/com/news_edit.jinja com/templates/com/poster_edit.jinja
#: com/templates/com/screen_edit.jinja com/templates/com/weekmail.jinja
#: core/templates/core/create.jinja core/templates/core/edit.jinja
#: core/templates/core/file_edit.jinja core/templates/core/macros_pages.jinja
#: core/templates/core/page_prop.jinja
#: core/templates/core/user_godfathers.jinja
#: core/templates/core/user_godfathers_tree.jinja
#: core/templates/core/user_preferences.jinja
#: counter/templates/counter/cash_register_summary.jinja
#: forum/templates/forum/reply.jinja
#: subscription/templates/subscription/fragments/creation_form.jinja
#: trombi/templates/trombi/comment.jinja
#: trombi/templates/trombi/edit_profile.jinja
#: trombi/templates/trombi/user_tools.jinja
msgid "Save"
msgstr "Sauver"
#: accounting/templates/accounting/refound_account.jinja accounting/views.py
msgid "Refound account"
msgstr "Remboursement de compte"
#: accounting/templates/accounting/refound_account.jinja
msgid "Refound"
msgstr "Rembourser"
#: accounting/templates/accounting/simplifiedaccountingtype_list.jinja
msgid "Simplified type list"
msgstr "Liste des types simplifiés"
#: accounting/templates/accounting/simplifiedaccountingtype_list.jinja
msgid "Simplified types"
msgstr "Types simplifiés"
#: accounting/templates/accounting/simplifiedaccountingtype_list.jinja
msgid "New simplified type"
msgstr "Nouveau type simplifié"
#: accounting/views.py
msgid "Journal"
msgstr "Classeur"
#: accounting/views.py
msgid "Statement by nature"
msgstr "Bilan par nature"
#: accounting/views.py
msgid "Statement by person"
msgstr "Bilan par personne"
#: accounting/views.py
msgid "Accounting statement"
msgstr "Bilan comptable"
#: accounting/views.py
msgid "Link this operation to the target account"
msgstr "Lier cette opération au compte cible"
#: accounting/views.py
msgid "The target must be set."
msgstr "La cible doit être indiquée."
#: accounting/views.py
msgid "The amount must be set."
msgstr "Le montant doit être indiqué."
#: accounting/views.py
msgid "Operation"
msgstr "Opération"
#: accounting/views.py
msgid "Financial proof: "
msgstr "Justificatif de libellé : "
#: accounting/views.py
#, python-format
msgid "Club: %(club_name)s"
msgstr "Club : %(club_name)s"
#: accounting/views.py
#, python-format
msgid "Label: %(op_label)s"
msgstr "Libellé : %(op_label)s"
#: accounting/views.py
#, python-format
msgid "Date: %(date)s"
msgstr "Date : %(date)s"
#: accounting/views.py
#, python-format
msgid "Amount: %(amount).2f"
msgstr "Montant : %(amount).2f"
#: accounting/views.py
msgid "Debtor"
msgstr "Débiteur"
#: accounting/views.py
msgid "Creditor"
msgstr "Créditeur"
#: accounting/views.py
msgid "Comment:"
msgstr "Commentaire :"
#: accounting/views.py
msgid "Signature:"
msgstr "Signature :"
#: accounting/views.py
msgid "General statement"
msgstr "Bilan général"
#: accounting/views.py
msgid "No label operations"
msgstr "Opérations sans étiquette"
#: accounting/views.py
msgid "Refound this account"
msgstr "Rembourser ce compte"
#: antispam/forms.py #: antispam/forms.py
msgid "Email domain is not allowed." msgid "Email domain is not allowed."
msgstr "Le domaine de l'addresse e-mail n'est pas autorisé." msgstr "Le domaine de l'addresse e-mail n'est pas autorisé."
@@ -1088,7 +327,7 @@ msgid "Enter a valid address. Only the root of the address is needed."
msgstr "" msgstr ""
"Entrez une adresse valide. Seule la racine de l'adresse est nécessaire." "Entrez une adresse valide. Seule la racine de l'adresse est nécessaire."
#: club/models.py com/models.py core/models.py sas/models.py #: club/models.py com/models.py core/models.py
msgid "is moderated" msgid "is moderated"
msgstr "est modéré" msgstr "est modéré"
@@ -2646,11 +1885,11 @@ msgstr "avoir une notification pour chaque click"
msgid "get a notification for every refilling" msgid "get a notification for every refilling"
msgstr "avoir une notification pour chaque rechargement" msgstr "avoir une notification pour chaque rechargement"
#: core/models.py sas/models.py #: core/models.py sas/forms.py
msgid "file name" msgid "file name"
msgstr "nom du fichier" msgstr "nom du fichier"
#: core/models.py sas/models.py #: core/models.py
msgid "parent" msgid "parent"
msgstr "parent" msgstr "parent"
@@ -2658,14 +1897,10 @@ msgstr "parent"
msgid "compressed file" msgid "compressed file"
msgstr "version allégée" msgstr "version allégée"
#: core/models.py sas/models.py #: core/models.py
msgid "thumbnail" msgid "thumbnail"
msgstr "miniature" msgstr "miniature"
#: core/models.py sas/models.py
msgid "owner"
msgstr "propriétaire"
#: core/models.py #: core/models.py
msgid "edit group" msgid "edit group"
msgstr "groupe d'édition" msgstr "groupe d'édition"
@@ -2694,6 +1929,10 @@ msgstr "date"
msgid "asked for removal" msgid "asked for removal"
msgstr "retrait demandé" msgstr "retrait demandé"
#: core/models.py
msgid "is in the SAS"
msgstr "est dans le SAS"
#: core/models.py #: core/models.py
msgid "Character '/' not authorized in name" msgid "Character '/' not authorized in name"
msgstr "Le caractère '/' n'est pas autorisé dans les noms de fichier" msgstr "Le caractère '/' n'est pas autorisé dans les noms de fichier"
@@ -4342,7 +3581,7 @@ msgstr "élément de relevé de caisse"
msgid "banner" msgid "banner"
msgstr "bannière" msgstr "bannière"
#: counter/models.py sas/models.py #: counter/models.py
msgid "event date" msgid "event date"
msgstr "date de l'événement" msgstr "date de l'événement"
@@ -5266,6 +4505,15 @@ msgstr ""
"souhaitez payer par carte, vous devez rajouter un numéro de téléphone aux " "souhaitez payer par carte, vous devez rajouter un numéro de téléphone aux "
"données que vous aviez déjà fourni." "données que vous aviez déjà fourni."
#: eboutic/views.py
msgid "Basket expired"
msgstr "Panier expiré"
#: eboutic/views.py
#, python-format
msgid "Basket available until %(until)s"
msgstr "Panier disponible jusqu'à %(until)s"
#: eboutic/views.py #: eboutic/views.py
msgid "You can't buy a refilling with sith money" msgid "You can't buy a refilling with sith money"
msgstr "Vous ne pouvez pas acheter un rechargement avec de l'argent du sith" msgstr "Vous ne pouvez pas acheter un rechargement avec de l'argent du sith"
@@ -5302,11 +4550,11 @@ msgstr "début des candidatures"
msgid "end candidature" msgid "end candidature"
msgstr "fin des candidatures" msgstr "fin des candidatures"
#: election/models.py sas/models.py #: election/models.py
msgid "edit groups" msgid "edit groups"
msgstr "groupe d'édition" msgstr "groupe d'édition"
#: election/models.py sas/models.py #: election/models.py
msgid "view groups" msgid "view groups"
msgstr "groupe de vue" msgstr "groupe de vue"
@@ -5996,22 +5244,6 @@ msgstr "Envoyer les images"
msgid "You already requested moderation for this picture." msgid "You already requested moderation for this picture."
msgstr "Vous avez déjà déposé une demande de retrait pour cette photo." msgstr "Vous avez déjà déposé une demande de retrait pour cette photo."
#: sas/models.py
msgid "The date on which the photos in this album were taken"
msgstr "La date à laquelle les photos de cet album ont été prises"
#: sas/models.py
msgid "album"
msgstr "album"
#: sas/models.py
msgid "original image"
msgstr "image originale"
#: sas/models.py
msgid "compressed image"
msgstr "version compressée"
#: sas/models.py #: sas/models.py
msgid "picture" msgid "picture"
msgstr "photo" msgstr "photo"
+5 -1
View File
@@ -7,7 +7,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-17 22:42+0200\n" "POT-Creation-Date: 2026-05-17 10:03+0200\n"
"PO-Revision-Date: 2024-09-17 11:54+0200\n" "PO-Revision-Date: 2024-09-17 11:54+0200\n"
"Last-Translator: Sli <antoine@bartuccio.fr>\n" "Last-Translator: Sli <antoine@bartuccio.fr>\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -263,6 +263,10 @@ msgstr "Types de produits réordonnés !"
msgid "Product type reorganisation failed with status code : %d" msgid "Product type reorganisation failed with status code : %d"
msgstr "La réorganisation des types de produit a échoué avec le code : %d" msgstr "La réorganisation des types de produit a échoué avec le code : %d"
#: eboutic/static/bundled/eboutic/checkout-index.ts
msgid "Basket expired"
msgstr "Panier expiré"
#: sas/static/bundled/sas/pictures-download-index.ts #: sas/static/bundled/sas/pictures-download-index.ts
msgid "pictures.%(extension)s" msgid "pictures.%(extension)s"
msgstr "photos.%(extension)s" msgstr "photos.%(extension)s"
+1 -1
View File
@@ -44,7 +44,7 @@ dependencies = [
"django-honeypot>=1.3.0,<2", "django-honeypot>=1.3.0,<2",
"pydantic-extra-types>=2.11.1,<3.0.0", "pydantic-extra-types>=2.11.1,<3.0.0",
"ical>=12.0.0,<14.0.0", "ical>=12.0.0,<14.0.0",
"redis[hiredis]>=3.3.1,<8.0.0", "redis[hiredis]>=8.0.0,<9.0.0",
"environs[django]>=15.0.1,<16", "environs[django]>=15.0.1,<16",
"requests>=2.34.2,<3.0.0", "requests>=2.34.2,<3.0.0",
"honcho>=2.0.0", "honcho>=2.0.0",
+4 -4
View File
@@ -20,9 +20,9 @@ from sas.models import Album, PeoplePictureRelation, Picture, PictureModerationR
@admin.register(Picture) @admin.register(Picture)
class PictureAdmin(admin.ModelAdmin): class PictureAdmin(admin.ModelAdmin):
list_display = ("name", "parent", "is_moderated") list_display = ("name", "parent", "date", "size", "is_moderated")
search_fields = ("name",) search_fields = ("name",)
autocomplete_fields = ("owner", "parent", "moderator") autocomplete_fields = ("owner", "parent", "edit_groups", "view_groups", "moderator")
@admin.register(PeoplePictureRelation) @admin.register(PeoplePictureRelation)
@@ -33,9 +33,9 @@ class PeoplePictureRelationAdmin(admin.ModelAdmin):
@admin.register(Album) @admin.register(Album)
class AlbumAdmin(admin.ModelAdmin): class AlbumAdmin(admin.ModelAdmin):
list_display = ("name", "parent") list_display = ("name", "parent", "date", "owner", "is_moderated")
search_fields = ("name",) search_fields = ("name",)
autocomplete_fields = ("parent", "edit_groups", "view_groups") autocomplete_fields = ("owner", "parent", "edit_groups", "view_groups")
@admin.register(PictureModerationRequest) @admin.register(PictureModerationRequest)
+11 -52
View File
@@ -3,8 +3,7 @@ from typing import Any, Literal
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.urls import reverse from django.urls import reverse
from ninja import Body, Query, UploadedFile from ninja import Body, File, Query
from ninja.errors import HttpError
from ninja.security import SessionAuth from ninja.security import SessionAuth
from ninja_extra import ControllerBase, api_controller, paginate, route from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.exceptions import NotFound, PermissionDenied from ninja_extra.exceptions import NotFound, PermissionDenied
@@ -17,12 +16,11 @@ from api.permissions import (
CanAccessLookup, CanAccessLookup,
CanEdit, CanEdit,
CanView, CanView,
HasPerm,
IsInGroup, IsInGroup,
IsRoot, IsRoot,
) )
from core.models import Notification, User from core.models import Notification, User
from core.utils import get_list_exact_or_404 from core.schemas import UploadedImage
from sas.models import Album, PeoplePictureRelation, Picture from sas.models import Album, PeoplePictureRelation, Picture
from sas.schemas import ( from sas.schemas import (
AlbumAutocompleteSchema, AlbumAutocompleteSchema,
@@ -30,7 +28,6 @@ from sas.schemas import (
AlbumSchema, AlbumSchema,
IdentifiedUserSchema, IdentifiedUserSchema,
ModerationRequestSchema, ModerationRequestSchema,
MoveAlbumSchema,
PictureFilterSchema, PictureFilterSchema,
PictureSchema, PictureSchema,
) )
@@ -72,44 +69,6 @@ class AlbumController(ControllerBase):
Album.objects.viewable_by(self.context.request.user).order_by("-date") Album.objects.viewable_by(self.context.request.user).order_by("-date")
) )
@route.patch("/parent")
def change_album_parent(self, payload: list[MoveAlbumSchema]):
"""Change parents of albums
Note:
For this operation to work, the user must be authorized
to edit both the moved albums and their new parent.
"""
user: User = self.context.request.user
albums: list[Album] = get_list_exact_or_404(
Album, pk__in={a.id for a in payload}
)
if not user.has_perm("sas.change_album"):
unauthorized = [a.id for a in albums if not user.can_edit(a)]
if unauthorized:
raise PermissionDenied(
f"You can't move the following albums : {unauthorized}"
)
parents: list[Album] = get_list_exact_or_404(
Album, pk__in={a.new_parent_id for a in payload}
)
if not user.has_perm("sas.change_album"):
unauthorized = [a.id for a in parents if not user.can_edit(a)]
if unauthorized:
raise PermissionDenied(
f"You can't move to the following albums : {unauthorized}"
)
id_to_new_parent = {i.id: i.new_parent_id for i in payload}
for album in albums:
album.parent_id = id_to_new_parent[album.id]
# known caveat : moving an album won't move it's thumbnail.
# E.g. if the album foo/bar is moved to foo/baz,
# the thumbnail will still be foo/bar/thumb.webp
# This has no impact for the end user
# and doing otherwise would be hard for us to implement,
# because we would then have to manage rollbacks on fail.
Album.objects.bulk_update(albums, fields=["parent_id"])
@api_controller("/sas/picture") @api_controller("/sas/picture")
class PicturesController(ControllerBase): class PicturesController(ControllerBase):
@@ -137,7 +96,7 @@ class PicturesController(ControllerBase):
return ( return (
filters.filter(Picture.objects.viewable_by(user)) filters.filter(Picture.objects.viewable_by(user))
.distinct() .distinct()
.order_by("-parent__event_date", "created_at") .order_by("-parent__date", "date")
.select_related("owner", "parent") .select_related("owner", "parent")
) )
@@ -151,26 +110,26 @@ class PicturesController(ControllerBase):
}, },
url_name="upload_picture", url_name="upload_picture",
) )
def upload_picture(self, album_id: Body[int], picture: UploadedFile): def upload_picture(self, album_id: Body[int], picture: File[UploadedImage]):
album = self.get_object_or_exception(Album, pk=album_id) album = self.get_object_or_exception(Album, pk=album_id)
user = self.context.request.user user = self.context.request.user
self_moderate = user.has_perm("sas.moderate_sasfile") self_moderate = user.has_perm("sas.moderate_sasfile")
new = Picture( new = Picture(
parent=album, parent=album,
name=picture.name, name=picture.name,
original=picture, file=picture,
owner=user, owner=user,
is_moderated=self_moderate, is_moderated=self_moderate,
is_folder=False,
mime_type=picture.content_type,
) )
if self_moderate: if self_moderate:
new.moderator = user new.moderator = user
new.generate_thumbnails()
try: try:
new.full_clean() new.full_clean()
new.generate_thumbnails(save=True) new.generate_thumbnails(save=True)
except ValidationError as e: except ValidationError as e:
raise HttpError(status_code=409, message=str(e)) from e return self.create_response({"detail": dict(e)}, status_code=409)
new.save()
@route.get( @route.get(
"/{picture_id}/identified", "/{picture_id}/identified",
@@ -268,9 +227,9 @@ class UsersIdentifiedController(ControllerBase):
relation = self.get_object_or_exception(PeoplePictureRelation, pk=relation_id) relation = self.get_object_or_exception(PeoplePictureRelation, pk=relation_id)
user: User = self.context.request.user user: User = self.context.request.user
if ( if (
relation.user_id != user.id relation.user_id != user.id
and not user.is_root and not user.is_root
and not user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID) and not user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID)
): ):
raise PermissionDenied raise PermissionDenied
relation.delete() relation.delete()
+13 -20
View File
@@ -1,36 +1,29 @@
from django.core.files.uploadedfile import SimpleUploadedFile
from django.conf import settings from django.conf import settings
from model_bakery import seq from model_bakery import seq
from model_bakery.recipe import Recipe, foreign_key from model_bakery.recipe import Recipe, foreign_key
from core.utils import RED_PIXEL_PNG
from sas.models import Album, Picture from sas.models import Album, Picture
album_recipe = Recipe( album_recipe = Recipe(
Album, Album,
is_in_sas=True,
is_folder=True,
is_moderated=True,
parent_id=settings.SITH_SAS_ROOT_DIR_ID,
name=seq("Album "), name=seq("Album "),
thumbnail=SimpleUploadedFile(
name="thumb.webp", content=b"", content_type="image/webp"
),
) )
picture_recipe = Recipe( picture_recipe = Recipe(
Picture, Picture,
is_in_sas=True,
is_folder=False,
is_moderated=True, is_moderated=True,
parent=foreign_key(album_recipe),
name=seq("Picture "), name=seq("Picture "),
original=SimpleUploadedFile(
# compressed and thumbnail are generated on save (except if bulk creating).
# For this step no to fail, original must be a valid image.
name="img.png",
content=RED_PIXEL_PNG,
content_type="image/png",
),
compressed=SimpleUploadedFile(
name="img.webp", content=b"", content_type="image/webp"
),
thumbnail=SimpleUploadedFile(
name="img.webp", content=b"", content_type="image/webp"
),
) )
"""A SAS Picture fixture.""" """A SAS Picture fixture.
Warnings:
If you don't `bulk_create` this, you need
to explicitly set the parent album, or it won't work
"""
+1 -2
View File
@@ -57,11 +57,10 @@ class PictureEditForm(forms.ModelForm):
class AlbumEditForm(forms.ModelForm): class AlbumEditForm(forms.ModelForm):
class Meta: class Meta:
model = Album model = Album
fields = ["name", "date", "thumbnail", "parent", "edit_groups"] fields = ["name", "date", "file", "parent", "edit_groups"]
widgets = {"edit_groups": AutoCompleteSelectMultipleGroup, "date": SelectDate} widgets = {"edit_groups": AutoCompleteSelectMultipleGroup, "date": SelectDate}
name = forms.CharField(max_length=Album.NAME_MAX_LENGTH, label=_("file name")) 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) recursive = forms.BooleanField(label=_("Apply rights recursively"), required=False)
parent = forms.ModelChoiceField( parent = forms.ModelChoiceField(
Album.objects.all(), required=True, widget=AutoCompleteSelectAlbum Album.objects.all(), required=True, widget=AutoCompleteSelectAlbum
-357
View File
@@ -1,357 +0,0 @@
# Generated by Django 4.2.17 on 2025-01-22 21:53
import collections
import itertools
import logging
from typing import TYPE_CHECKING
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
from django.db.migrations.state import StateApps
import sas.models
if TYPE_CHECKING:
import core.models
# NB : tous les commentaires sont écrits en français,
# parce qu'on est sur des opérations qui sont complexes,
# et qui sont surtout DANGEREUSES.
# Ici, la clarté des explications prime sur toute autre considération.
def copy_albums_and_pictures(apps: StateApps, schema_editor):
SithFile: type[core.models.SithFile] = apps.get_model("core", "SithFile")
Album: type[sas.models.Album] = apps.get_model("sas", "Album")
Picture: type[sas.models.Picture] = apps.get_model("sas", "Picture")
logger = logging.getLogger("django")
# Il y a environ 1800 albums, 257k photos et 488k identifications
# d'utilisateurs dans la db de prod.
# En supposant qu'une insertion prenne 10ms (ce qui est très optimiste),
# migrer tous les enregistrements de la db prendrait plus de 2h.
# C'est trop long.
# Mais d'un autre côté, j'ai pas assez confiance dans les capacités de nos
# machines pour charger presque un million d'objets en mémoire.
# Pour faire un compromis, les albums sont migrés individuellement un à un,
# mais tous les objets liés à ces albums
# (photos, groupes de vue, groupe d'édition, identification d'utilisateurs)
# sont migrés en tas.
#
# Ordre des opérations :
# 1. On migre les albums 1 à 1 (il y en a 1800, donc c'est relativement court)
# 2. On migre les photos par paquet de 2500 (soit ~une centaine d'opérations)
# 3. On migre tous les groupes de vue et tous les groupes d'édition des albums
#
# Au total, la migration devrait demander aux alentours de 2000 insertions,
# ce qui est un compromis acceptable entre une migration
# pas trop longue et une RAM pas trop surchargée.
#
# Pour ce qui est de la répartition des tables, quatre nouvelles tables
# sont créées : sas_album, sas_picture,
# sas_pictureviewgroups et sas_picture_editgroups.
# Tous les albums et toutes les photos qui sont dans core_sithfile
# vont être copiés dans ces tables.
# Comme les albums sont migrés un à un, ils recevront une nouvelle
# clef primaire.
# Pour les photos, en revanche, c'est beaucoup plus sûr de leur donner
# le même id que celui qu'il y avait dans core_sithfile.
#
# Les identifications des photos ne sont pas migrées pour l'instant.
# Ce qu'on va faire, c'est qu'on va changer la contrainte de clef étrangère
# sur la colonne des photos pour pointer vers sas_picture
# au lieu de core_sithfile.
# Cependant, pour que ça marche,
# il faut qu'au moment où ce changement est effectué,
# toutes les clefs primaires référencées existent à la fois dans
# les deux tables, sinon les contraintes d'intégrité ne sont pas respectées.
# La migration de ce fichier va donc s'occuper de créer les nouvelles tables
# et d'y copier les données nécessaires.
# Puis une deuxième migration s'occupera de changer les contraintes.
# Et enfin une troisième migration supprimera les anciennes données.
#
# Pavé César
albums = SithFile.objects.filter(is_in_sas=True, is_folder=True).prefetch_related(
"view_groups", "edit_groups"
)
old_albums = collections.deque(
albums.filter(parent_id=settings.SITH_SAS_ROOT_DIR_ID)
)
# Changement de représentation en DB.
# Dans l'ancien système, un fichier était dans le SAS si
# un fichier spécial (le SAS_ROOT) était parmi ses ancêtres.
# Comme maintenant les fichiers du SAS sont dans des tables à part,
# il ne peut plus y avoir de confusion.
# Les photos ont donc obligatoirement un parent (qui est un album)
# et les albums peuvent avoir un parent null.
# Un album sans parent est considéré comme se trouvant à la racine
# de l'arborescence.
# En quelque sorte, None est le nouveau SITH_SAS_ROOT_DIR_ID
album_id_old_to_new = {settings.SITH_SAS_ROOT_DIR_ID: None}
logger.info(f"migrating {albums.count()} albums")
while len(old_albums) > 0:
# Comme les albums référencent leur parent, les albums doivent être migrés
# par ordre croissant de profondeur dans l'arborescence.
# Chaque album est donc pris par la gauche de la file
# et ses enfants ajoutés sur la droite.
old_album = old_albums.popleft()
old_albums.extend(list(albums.filter(parent=old_album)))
new_album = Album.objects.create(
parent_id=album_id_old_to_new[old_album.parent_id],
event_date=old_album.date.date(),
name=old_album.name,
thumbnail=(old_album.file or None),
is_moderated=old_album.is_moderated,
)
# on garde un dictionnaire qui associe les id des albums dans l'ancienne table
# à leur id dans la nouvelle table, pour pouvoir recréer
# les liens de parenté entre albums
album_id_old_to_new[old_album.id] = new_album.id
pictures = SithFile.objects.filter(is_in_sas=True, is_folder=False)
nb_pictures = pictures.count()
logger.info(f"migrating {nb_pictures} pictures")
for i, pictures_batch in enumerate(itertools.batched(pictures, 2500), start=1):
Picture.objects.bulk_create(
[
Picture(
id=p.id,
name=p.name,
parent_id=album_id_old_to_new[p.parent_id],
thumbnail=p.thumbnail,
compressed=p.compressed,
original=p.file,
owner_id=p.owner_id,
created_at=p.date,
is_moderated=p.is_moderated,
asked_for_removal=p.asked_for_removal,
moderator_id=p.moderator_id,
)
for p in pictures_batch
]
)
logger.info(f"Migrated {min(i * 2500, nb_pictures)} / {nb_pictures} pictures")
logger.info("Migrating album groups")
albums = SithFile.objects.filter(is_in_sas=True, is_folder=True).exclude(
id=settings.SITH_SAS_ROOT_DIR_ID
)
Album.edit_groups.through.objects.bulk_create(
[
Album.view_groups.through(
album=album_id_old_to_new[g.sithfile_id], group_id=g.group_id
)
for g in SithFile.view_groups.through.objects.filter(sithfile__in=albums)
]
)
Album.edit_groups.through.objects.bulk_create(
[
Album.view_groups.through(
album=album_id_old_to_new[g.sithfile_id], group_id=g.group_id
)
for g in SithFile.view_groups.through.objects.filter(sithfile__in=albums)
]
)
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("core", "0044_alter_userban_options"),
("sas", "0005_alter_sasfile_options"),
]
operations = [
# les relations et les demandes de modération étaient liées à SithFile,
# via le model proxy Picture.
# Pour que la migration marche malgré la disparition du modèle Proxy,
# on change la relation pour qu'elle pointe directement vers SithFile
migrations.AlterField(
model_name="peoplepicturerelation",
name="picture",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="people",
to="core.sithfile",
verbose_name="picture",
),
),
migrations.AlterField(
model_name="picturemoderationrequest",
name="picture",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="moderation_requests",
to="core.sithfile",
verbose_name="Picture",
),
),
migrations.DeleteModel(name="Album"),
migrations.DeleteModel(name="Picture"),
migrations.DeleteModel(name="SasFile"),
migrations.CreateModel(
name="Album",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"thumbnail",
models.FileField(
max_length=256,
upload_to=sas.models.get_thumbnail_directory,
verbose_name="thumbnail",
),
),
("name", models.CharField(max_length=100, verbose_name="name")),
(
"event_date",
models.DateField(
default=django.utils.timezone.localdate,
help_text="The date on which the photos in this album were taken",
verbose_name="event date",
),
),
(
"is_moderated",
models.BooleanField(default=False, verbose_name="is moderated"),
),
(
"edit_groups",
models.ManyToManyField(
related_name="editable_albums",
to="core.group",
verbose_name="edit groups",
),
),
(
"parent",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="children",
to="sas.album",
verbose_name="parent",
),
),
(
"view_groups",
models.ManyToManyField(
related_name="viewable_albums",
to="core.group",
verbose_name="view groups",
),
),
],
options={"verbose_name": "album"},
),
migrations.CreateModel(
name="Picture",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"thumbnail",
models.FileField(
unique=True,
upload_to=sas.models.get_thumbnail_directory,
verbose_name="thumbnail",
max_length=256,
),
),
("name", models.CharField(max_length=256, verbose_name="file name")),
(
"original",
models.FileField(
unique=True,
upload_to=sas.models.get_directory,
verbose_name="original image",
max_length=256,
),
),
(
"compressed",
models.FileField(
unique=True,
upload_to=sas.models.get_compressed_directory,
verbose_name="compressed image",
max_length=256,
),
),
("created_at", models.DateTimeField(default=django.utils.timezone.now)),
(
"is_moderated",
models.BooleanField(default=False, verbose_name="is moderated"),
),
(
"asked_for_removal",
models.BooleanField(
default=False, verbose_name="asked for removal"
),
),
(
"moderator",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="moderated_pictures",
to=settings.AUTH_USER_MODEL,
),
),
(
"owner",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="owned_pictures",
to=settings.AUTH_USER_MODEL,
verbose_name="owner",
),
),
(
"parent",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="pictures",
to="sas.album",
verbose_name="album",
),
),
],
options={"abstract": False, "verbose_name": "picture"},
),
migrations.AddConstraint(
model_name="picture",
constraint=models.UniqueConstraint(
fields=("name", "parent"), name="sas_picture_unique_per_album"
),
),
migrations.AddConstraint(
model_name="album",
constraint=models.UniqueConstraint(
fields=("name", "parent"), name="unique_album_name_if_same_parent"
),
),
migrations.RunPython(
copy_albums_and_pictures,
reverse_code=migrations.RunPython.noop,
elidable=True,
),
]
@@ -1,31 +0,0 @@
# Generated by Django 4.2.17 on 2025-01-25 23:50
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("sas", "0006_move_the_whole_sas")]
operations = [
migrations.AlterField(
model_name="peoplepicturerelation",
name="picture",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="people",
to="sas.picture",
verbose_name="picture",
),
),
migrations.AlterField(
model_name="picturemoderationrequest",
name="picture",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="moderation_requests",
to="sas.picture",
verbose_name="Picture",
),
),
]
+18 -264
View File
@@ -15,60 +15,31 @@
from __future__ import annotations from __future__ import annotations
import contextlib
from io import BytesIO
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, ClassVar, Self from typing import ClassVar, Self
from django.conf import settings from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.db import models from django.db import models
from django.db.models import Exists, OuterRef, Q from django.db.models import Exists, OuterRef, Q
from django.db.models.deletion import Collector
from django.urls import reverse from django.urls import reverse
from django.utils import timezone
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from PIL import Image from PIL import Image
from core.models import Group, Notification, User from core.models import Notification, SithFile, User
from core.utils import resize_image from core.utils import resize_image
if TYPE_CHECKING:
from django.db.models.fields.files import FieldFile
class SasFile(SithFile):
"""Proxy model for any file in the SAS.
def get_directory(instance: SasFile, filename: str): May be used to have logic that should be shared by both
return f"./{instance.parent_path}/{filename}"
def get_compressed_directory(instance: SasFile, filename: str):
return f"./.compressed/{instance.parent_path}/{filename}"
def get_thumbnail_directory(instance: SasFile, filename: str):
if isinstance(instance, Album):
_, extension = filename.rsplit(".", 1)
filename = f"{instance.name}/thumb.{extension}"
return f"./.thumbnails/{instance.parent_path}/{filename}"
class SasFile(models.Model):
"""Abstract model for SAS files
This model is used to have logic that should be shared by both
[Picture][sas.models.Picture] and [Album][sas.models.Album]. [Picture][sas.models.Picture] and [Album][sas.models.Album].
Notes:
This is an abstract model.
[Album][sas.models.Album] and [Picture][sas.models.Picture]
are separated tables in the database.
""" """
class Meta: class Meta:
abstract = True proxy = True
permissions = [ permissions = [
("moderate_sasfile", "Can moderate SAS files"), ("moderate_sasfile", "Can moderate SAS files"),
("view_unmoderated_sasfile", "Can view not moderated SAS files"), ("view_unmoderated_sasfile", "Can view not moderated SAS files"),
@@ -93,169 +64,6 @@ class SasFile(models.Model):
def can_be_edited_by(self, user): def can_be_edited_by(self, user):
return user.has_perm("sas.change_sasfile") return user.has_perm("sas.change_sasfile")
@cached_property
def parent_path(self) -> str:
"""The parent location in the SAS album tree (e.g. `SAS/foo/bar`)."""
return "/".join(["SAS", *[p.name for p in self.parent_list]])
@cached_property
def parent_list(self) -> list[Album]:
"""The ancestors of this SAS object.
The result is ordered from the direct parent to the farthest one.
"""
parents = []
current = self.parent
while current is not None:
parents.append(current)
current = current.parent
return parents
class AlbumQuerySet(models.QuerySet):
def viewable_by(self, user: User) -> Self:
"""Filter the albums that this user can view.
Warning:
Calling this queryset method may add several additional requests.
"""
if user.is_root or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID):
return self.all()
if user.was_subscribed:
return self.filter(is_moderated=True)
# known bug : if all children of an album are also albums
# then this album is excluded, even if one of the sub-albums should be visible.
# The fs-like navigation is likely to be half-broken for non-subscribers,
# but that's ok, since non-subscribers are expected to see only the albums
# containing pictures on which they have been identified (hence, very few).
# Most, if not all, of their albums will be displayed on the
# `latest albums` section of the SAS.
# Moreover, they will still see all of their picture in their profile.
return self.filter(
Exists(Picture.objects.filter(parent_id=OuterRef("pk")).viewable_by(user))
)
class Album(SasFile):
NAME_MAX_LENGTH: ClassVar[int] = 50
name = models.CharField(_("name"), max_length=100)
parent = models.ForeignKey(
"self",
related_name="children",
verbose_name=_("parent"),
null=True,
blank=True,
on_delete=models.CASCADE,
)
thumbnail = models.FileField(
upload_to=get_thumbnail_directory,
verbose_name=_("thumbnail"),
max_length=256,
blank=True,
)
view_groups = models.ManyToManyField(
Group, related_name="viewable_albums", verbose_name=_("view groups"), blank=True
)
edit_groups = models.ManyToManyField(
Group, related_name="editable_albums", verbose_name=_("edit groups"), blank=True
)
event_date = models.DateField(
_("event date"),
help_text=_("The date on which the photos in this album were taken"),
default=timezone.localdate,
blank=True,
)
is_moderated = models.BooleanField(_("is moderated"), default=False)
objects = AlbumQuerySet.as_manager()
class Meta:
verbose_name = _("album")
constraints = [
models.UniqueConstraint(
fields=["name", "parent"],
name="unique_album_name_if_same_parent",
# TODO : add `nulls_distinct=True` after upgrading to django>=5.0
)
]
def __str__(self):
return f"Album {self.name}"
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
for user in User.objects.filter(
groups__id__in=[settings.SITH_GROUP_SAS_ADMIN_ID]
):
Notification(
user=user,
url=reverse("sas:moderation"),
type="SAS_MODERATION",
param="1",
).save()
def get_absolute_url(self):
return reverse("sas:album", kwargs={"album_id": self.id})
def clean(self):
super().clean()
if "/" in self.name:
raise ValidationError(_("Character '/' not authorized in name"))
if self.parent_id is not None and (
self.id == self.parent_id or self in self.parent_list
):
raise ValidationError(_("Loop in album tree"), code="loop")
if self.thumbnail:
try:
Image.open(BytesIO(self.thumbnail.read()))
except Image.UnidentifiedImageError as e:
raise ValidationError(_("This is not a valid album thumbnail")) from e
def delete(self, *args, **kwargs):
"""Delete the album, all of its children and all linked disk files"""
collector = Collector(using="default")
collector.collect([self])
albums: set[Album] = collector.data[Album]
pictures: set[Picture] = collector.data[Picture]
files: list[FieldFile] = [
*[a.thumbnail for a in albums],
*[p.thumbnail for p in pictures],
*[p.compressed for p in pictures],
*[p.original for p in pictures],
]
# `bool(f)` checks that the file actually exists on the disk
files = [f for f in files if bool(f)]
folders = {Path(f.path).parent for f in files}
res = super().delete(*args, **kwargs)
# once the model instances have been deleted,
# delete the actual files.
for file in files:
# save=False ensures that django doesn't recreate the db record,
# which would make the whole deletion pointless
# cf. https://docs.djangoproject.com/en/stable/ref/models/fields/#django.db.models.fields.files.FieldFile.delete
file.delete(save=False)
for folder in folders:
# now that the files are deleted, remove the empty folders
if folder.is_dir() and next(folder.iterdir(), None) is None:
folder.rmdir()
return res
def get_download_url(self):
return reverse("sas:album_preview", kwargs={"album_id": self.id})
def generate_thumbnail(self):
p = (
self.pictures.exclude(thumbnail="").order_by("?").first()
or self.children.exclude(thumbnail="").order_by("?").first()
)
if p:
# The file is loaded into memory to duplicate it.
# It may not be the most efficient way, but thumbnails are
# usually quite small, so it's still ok
self.thumbnail = ContentFile(p.thumbnail.read(), name="thumb.webp")
self.save()
class PictureQuerySet(models.QuerySet): class PictureQuerySet(models.QuerySet):
def viewable_by(self, user: User) -> Self: def viewable_by(self, user: User) -> Self:
@@ -271,65 +79,16 @@ class PictureQuerySet(models.QuerySet):
return self.filter(people__user_id=user.id, is_moderated=True) return self.filter(people__user_id=user.id, is_moderated=True)
class SASPictureManager(models.Manager):
def get_queryset(self):
return super().get_queryset().filter(is_in_sas=True, is_folder=False)
class Picture(SasFile): class Picture(SasFile):
name = models.CharField(_("file name"), max_length=256)
parent = models.ForeignKey(
Album,
related_name="pictures",
verbose_name=_("album"),
on_delete=models.CASCADE,
)
thumbnail = models.FileField(
upload_to=get_thumbnail_directory,
verbose_name=_("thumbnail"),
max_length=256,
unique=True,
)
original = models.FileField(
upload_to=get_directory,
verbose_name=_("original image"),
max_length=256,
unique=True,
)
compressed = models.FileField(
upload_to=get_compressed_directory,
verbose_name=_("compressed image"),
max_length=256,
unique=True,
)
created_at = models.DateTimeField(default=timezone.now)
owner = models.ForeignKey(
User,
related_name="owned_pictures",
verbose_name=_("owner"),
on_delete=models.PROTECT,
)
is_moderated = models.BooleanField(_("is moderated"), default=False)
asked_for_removal = models.BooleanField(_("asked for removal"), default=False)
moderator = models.ForeignKey(
User,
related_name="moderated_pictures",
null=True,
blank=True,
on_delete=models.SET_NULL,
)
objects = PictureQuerySet.as_manager()
class Meta: class Meta:
verbose_name = _("picture") proxy = True
constraints = [
models.UniqueConstraint(
fields=["name", "parent"], name="sas_picture_unique_per_album"
)
]
def __str__(self): objects = SASPictureManager.from_queryset(PictureQuerySet)()
return self.name
def get_absolute_url(self):
return reverse("sas:picture", kwargs={"picture_id": self.id})
def get_download_url(self): def get_download_url(self):
return reverse( return reverse(
@@ -352,13 +111,8 @@ class Picture(SasFile):
query={"date": int(self.updated_at.timestamp())}, query={"date": int(self.updated_at.timestamp())},
) )
@property def get_absolute_url(self):
def is_vertical(self): return reverse("sas:picture", kwargs={"picture_id": self.id})
# original, compressed and thumbnail image have all three the same ratio,
# so the smallest one is used to tell if the image is vertical
im = Image.open(BytesIO(self.thumbnail.read()))
(w, h) = im.size
return w < h
def generate_thumbnails( def generate_thumbnails(
self, *, img: Image.Image | None = None, save: bool = False self, *, img: Image.Image | None = None, save: bool = False
@@ -368,13 +122,13 @@ class Picture(SasFile):
Args: Args:
img: if given, this will be used to generate img: if given, this will be used to generate
all three images (file, compressed, thumbnail). all three images (file, compressed, thumbnail).
Else, `self.original` will be used Else, `self.file` will be used
save: if True, save the instance in database. save: if True, save the instance in database.
""" """
img = img or Image.open(self.original) img = img or Image.open(self.file)
extension = self.mime_type.split("/")[-1] extension = self.mime_type.split("/")[-1]
previous_files = [ previous_files = [
f.name for f in (self.original, self.thumbnail, self.compressed) if f f.name for f in (self.file, self.thumbnail, self.compressed) if f
] ]
# convert the compressed image and the thumbnail into webp # convert the compressed image and the thumbnail into webp
# The original image keeps its original type, because it's not # The original image keeps its original type, because it's not
+12 -8
View File
@@ -26,10 +26,19 @@ class SimpleAlbumSchema(ModelSchema):
class AlbumSchema(ModelSchema): class AlbumSchema(ModelSchema):
class Meta: class Meta:
model = Album model = Album
fields = ["id", "name", "is_moderated", "thumbnail"] fields = ["id", "name", "is_moderated"]
thumbnail: str | None
sas_url: str sas_url: str
@staticmethod
def resolve_thumbnail(obj: Album) -> str | None:
# Album thumbnails aren't stored in `Album.thumbnail` but in `Album.file`
# Don't ask me why.
if not obj.file:
return None
return obj.get_download_url()
@staticmethod @staticmethod
def resolve_sas_url(obj: Album) -> str: def resolve_sas_url(obj: Album) -> str:
return obj.get_absolute_url() return obj.get_absolute_url()
@@ -46,12 +55,7 @@ class AlbumAutocompleteSchema(ModelSchema):
@staticmethod @staticmethod
def resolve_path(obj: Album) -> str: def resolve_path(obj: Album) -> str:
return str(Path(obj.parent_path) / obj.name) return str(Path(obj.get_parent_path()) / obj.name)
class MoveAlbumSchema(Schema):
id: int
new_parent_id: int
class PictureFilterSchema(FilterSchema): class PictureFilterSchema(FilterSchema):
@@ -69,7 +73,7 @@ class PictureSchema(ModelSchema):
fields = [ fields = [
"id", "id",
"name", "name",
"created_at", "date",
"updated_at", "updated_at",
"size", "size",
"is_moderated", "is_moderated",
-105
View File
@@ -128,108 +128,3 @@ document.addEventListener("alpine:init", () => {
}, },
})); }));
}); });
// Todo: migrate to alpine.js if we have some time
// $("form#upload_form").submit(function (event) {
// const formData = new FormData($(this)[0]);
//
// if (!formData.get("album_name") && !formData.get("images").name) return false;
//
// if (!formData.get("images").name) {
// return true;
// }
//
// event.preventDefault();
//
// let errorList = this.querySelector("#upload_form ul.errorlist.nonfield");
// if (errorList === null) {
// errorList = document.createElement("ul");
// errorList.classList.add("errorlist", "nonfield");
// this.insertBefore(errorList, this.firstElementChild);
// }
//
// while (errorList.childElementCount > 0)
// errorList.removeChild(errorList.firstElementChild);
//
// let progress = this.querySelector("progress");
// if (progress === null) {
// progress = document.createElement("progress");
// progress.value = 0;
// const p = document.createElement("p");
// p.appendChild(progress);
// this.insertBefore(p, this.lastElementChild);
// }
//
// let dataHolder;
//
// if (formData.get("album_name")) {
// dataHolder = new FormData();
// dataHolder.set("csrfmiddlewaretoken", "{{ csrf_token }}");
// dataHolder.set("album_name", formData.get("album_name"));
// $.ajax({
// method: "POST",
// url: "{{ url('sas:album_upload', album_id=object.id) }}",
// data: dataHolder,
// processData: false,
// contentType: false,
// success: onSuccess,
// });
// }
//
// const images = formData.getAll("images");
// const imagesCount = images.length;
// let completeCount = 0;
//
// const poolSize = 1;
// const imagePool = [];
//
// while (images.length > 0 && imagePool.length < poolSize) {
// const image = images.shift();
// imagePool.push(image);
// sendImage(image);
// }
//
// function sendImage(image) {
// dataHolder = new FormData();
// dataHolder.set("csrfmiddlewaretoken", "{{ csrf_token }}");
// dataHolder.set("images", image);
//
// $.ajax({
// method: "POST",
// url: "{{ url('sas:album_upload', album_id=object.id) }}",
// data: dataHolder,
// processData: false,
// contentType: false,
// })
// .fail(onSuccess.bind(undefined, image))
// .done(onSuccess.bind(undefined, image))
// .always(next.bind(undefined, image));
// }
//
// function next(image, _, __) {
// const index = imagePool.indexOf(image);
// const nextImage = images.shift();
//
// if (index !== -1) {
// imagePool.splice(index, 1);
// }
//
// if (nextImage) {
// imagePool.push(nextImage);
// sendImage(nextImage);
// }
// }
//
// function onSuccess(image, data, _, __) {
// let errors = [];
//
// if ($(data.responseText).find(".errorlist.nonfield")[0])
// errors = Array.from($(data.responseText).find(".errorlist.nonfield")[0].children);
//
// while (errors.length > 0) errorList.appendChild(errors.shift());
//
// progress.value = ++completeCount / imagesCount;
// if (progress.value === 1 && errorList.children.length === 0)
// document.location.reload();
// }
// });
@@ -31,10 +31,10 @@ document.addEventListener("alpine:init", () => {
await Promise.all( await Promise.all(
this.downloadPictures.map((p: PictureSchema) => { this.downloadPictures.map((p: PictureSchema) => {
const imgName = `${p.album.name}/IMG_${p.id}_${p.created_at.replace(/[:-]/g, "_")}${p.name.slice(p.name.lastIndexOf("."))}`; const imgName = `${p.album.name}/IMG_${p.id}_${p.date.replace(/[:-]/g, "_")}${p.name.slice(p.name.lastIndexOf("."))}`;
return zipWriter.add(imgName, new HttpReader(p.full_size_url), { return zipWriter.add(imgName, new HttpReader(p.full_size_url), {
level: 9, level: 9,
lastModDate: new Date(p.created_at), lastModDate: new Date(p.date),
onstart: incrementProgressBar, onstart: incrementProgressBar,
}); });
}), }),
+1 -1
View File
@@ -169,7 +169,7 @@ document.addEventListener("alpine:init", () => {
full_size_url: "", full_size_url: "",
owner: "", owner: "",
// biome-ignore lint/style/useNamingConvention: api is in snake_case // biome-ignore lint/style/useNamingConvention: api is in snake_case
created_at: new Date(), created_at: new Date(),
identifications: [] as IdentifiedUserSchema[], identifications: [] as IdentifiedUserSchema[],
}, },
/** /**
+19 -19
View File
@@ -20,7 +20,7 @@
{% block content %} {% block content %}
<code> <code>
<a href="{{ url('sas:main') }}">SAS</a> / {{ print_path(album.parent) }} {{ album.name }} <a href="{{ url('sas:main') }}">SAS</a> / {{ print_path(album.parent) }} {{ album.get_display_name() }}
</code> </code>
{% set is_sas_admin = user.can_edit(album) %} {% set is_sas_admin = user.can_edit(album) %}
@@ -30,7 +30,7 @@
<form action="" method="post" enctype="multipart/form-data"> <form action="" method="post" enctype="multipart/form-data">
{% csrf_token %} {% csrf_token %}
<div class="album-navbar"> <div class="album-navbar">
<h3>{{ album.name }}</h3> <h3>{{ album.get_display_name() }}</h3>
<div class="toolbar"> <div class="toolbar">
<a href="{{ url('sas:album_edit', album_id=album.id) }}">{% trans %}Edit{% endtrans %}</a> <a href="{{ url('sas:album_edit', album_id=album.id) }}">{% trans %}Edit{% endtrans %}</a>
@@ -40,17 +40,17 @@
</div> </div>
</div> </div>
{# {% if clipboard %}#} {% if clipboard %}
{# <div class="clipboard">#} <div class="clipboard">
{# {% trans %}Clipboard: {% endtrans %}#} {% trans %}Clipboard: {% endtrans %}
{# <ul>#} <ul>
{# {% for f in clipboard["albums"] %}#} {% for f in clipboard %}
{# <li>{{ f.get_full_path() }}</li>#} <li>{{ f.get_full_path() }}</li>
{# {% endfor %}#} {% endfor %}
{# </ul>#} </ul>
{# <input name="clear" type="submit" value="{% trans %}Clear clipboard{% endtrans %}">#} <input name="clear" type="submit" value="{% trans %}Clear clipboard{% endtrans %}">
{# </div>#} </div>
{# {% endif %}#} {% endif %}
{% endif %} {% endif %}
{% if show_albums %} {% if show_albums %}
@@ -73,8 +73,8 @@
<div class="text">{% trans %}To be moderated{% endtrans %}</div> <div class="text">{% trans %}To be moderated{% endtrans %}</div>
</template> </template>
</div> </div>
{% if edit_mode %} {% if is_sas_admin %}
<input type="checkbox" name="album_list" :value="album.id"> <input type="checkbox" name="file_list" :value="album.id">
{% endif %} {% endif %}
</a> </a>
</template> </template>
@@ -100,7 +100,7 @@
</template> </template>
</div> </div>
{% if is_sas_admin %} {% if is_sas_admin %}
<input type="checkbox" name="picture_list" :value="picture.id"> <input type="checkbox" name="file_list" :value="picture.id">
{% endif %} {% endif %}
</a> </a>
</template> </template>
@@ -120,9 +120,9 @@
{% csrf_token %} {% csrf_token %}
<div class="inputs"> <div class="inputs">
<p> <p>
<label for="{{ form.images.id_for_label }}">{{ form.images.label }} :</label> <label for="{{ upload_form.images.id_for_label }}">{{ upload_form.images.label }} :</label>
{{ form.images|add_attr("x-ref=pictures") }} {{ upload_form.images|add_attr("x-ref=pictures") }}
<span class="helptext">{{ form.images.help_text }}</span> <span class="helptext">{{ upload_form.images.help_text }}</span>
</p> </p>
<input type="submit" value="{% trans %}Upload{% endtrans %}" /> <input type="submit" value="{% trans %}Upload{% endtrans %}" />
<progress x-ref="progress" x-show="sending"></progress> <progress x-ref="progress" x-show="sending"></progress>
+5 -3
View File
@@ -1,6 +1,6 @@
{% macro display_album(a, edit_mode) %} {% macro display_album(a, edit_mode) %}
<a href="{{ url('sas:album', album_id=a.id) }}"> <a href="{{ url('sas:album', album_id=a.id) }}">
{% if a.thumbnail %} {% if a.file %}
{% set img = a.get_download_url() %} {% set img = a.get_download_url() %}
{% set alt = a.name %} {% set alt = a.name %}
{% elif a.children.filter(is_folder=False, is_moderated=True).exists() %} {% elif a.children.filter(is_folder=False, is_moderated=True).exists() %}
@@ -11,7 +11,9 @@
{% set img = static('core/img/sas.jpg') %} {% set img = static('core/img/sas.jpg') %}
{% set alt = "sas.jpg" %} {% set alt = "sas.jpg" %}
{% endif %} {% endif %}
<div class="album{% if not a.is_moderated %} not_moderated{% endif %}"> <div
class="album{% if not a.is_moderated %} not_moderated{% endif %}"
>
<img src="{{ img }}" alt="{{ alt }}" loading="lazy" /> <img src="{{ img }}" alt="{{ alt }}" loading="lazy" />
{% if not a.is_moderated %} {% if not a.is_moderated %}
<div class="overlay">&nbsp;</div> <div class="overlay">&nbsp;</div>
@@ -29,7 +31,7 @@
{% macro print_path(file) %} {% macro print_path(file) %}
{% if file and file.parent %} {% if file and file.parent %}
{{ print_path(file.parent) }} {{ print_path(file.parent) }}
<a href="{{ url("sas:album", album_id=file.id) }}">{{ file.name }}</a> / <a href="{{ url('sas:album', album_id=file.id) }}">{{ file.get_display_name() }}</a> /
{% endif %} {% endif %}
{% endmacro %} {% endmacro %}
+1 -1
View File
@@ -100,7 +100,7 @@
<span <span
x-text="Intl.DateTimeFormat( x-text="Intl.DateTimeFormat(
'{{ LANGUAGE_CODE }}', {dateStyle: 'long'} '{{ LANGUAGE_CODE }}', {dateStyle: 'long'}
).format(Date.parse(currentPicture.created_at))" ).format(Date.parse(currentPicture.date))"
> >
</span> </span>
</div> </div>
+7 -7
View File
@@ -27,8 +27,8 @@ class TestSas(TestCase):
cls.user_b, cls.user_c = subscriber_user.make(_quantity=2) cls.user_b, cls.user_c = subscriber_user.make(_quantity=2)
picture = picture_recipe.extend(owner=owner) picture = picture_recipe.extend(owner=owner)
cls.album_a = baker.make(Album) cls.album_a = baker.make(Album, is_in_sas=True, parent=sas)
cls.album_b = baker.make(Album) cls.album_b = baker.make(Album, is_in_sas=True, parent=sas)
relation_recipe = Recipe(PeoplePictureRelation) relation_recipe = Recipe(PeoplePictureRelation)
relations = [] relations = []
for album in cls.album_a, cls.album_b: for album in cls.album_a, cls.album_b:
@@ -61,7 +61,7 @@ class TestPictureSearch(TestSas):
self.client.force_login(self.user_b) self.client.force_login(self.user_b)
res = self.client.get(self.url + f"?album_id={self.album_a.id}") res = self.client.get(self.url + f"?album_id={self.album_a.id}")
assert res.status_code == 200 assert res.status_code == 200
expected = list(self.album_a.pictures.values_list("id", flat=True)) expected = list(self.album_a.children_pictures.values_list("id", flat=True))
assert [i["id"] for i in res.json()["results"]] == expected assert [i["id"] for i in res.json()["results"]] == expected
def test_filter_by_user(self): def test_filter_by_user(self):
@@ -70,7 +70,7 @@ class TestPictureSearch(TestSas):
assert res.status_code == 200 assert res.status_code == 200
expected = list( expected = list(
self.user_a.pictures.order_by( self.user_a.pictures.order_by(
"-picture__parent__event_date", "picture__created_at" "-picture__parent__date", "picture__date"
).values_list("picture_id", flat=True) ).values_list("picture_id", flat=True)
) )
assert [i["id"] for i in res.json()["results"]] == expected assert [i["id"] for i in res.json()["results"]] == expected
@@ -84,7 +84,7 @@ class TestPictureSearch(TestSas):
assert res.status_code == 200 assert res.status_code == 200
expected = list( expected = list(
self.user_a.pictures.union(self.user_b.pictures.all()) self.user_a.pictures.union(self.user_b.pictures.all())
.order_by("-picture__parent__event_date", "picture__created_at") .order_by("-picture__parent__date", "picture__date")
.values_list("picture_id", flat=True) .values_list("picture_id", flat=True)
) )
assert [i["id"] for i in res.json()["results"]] == expected assert [i["id"] for i in res.json()["results"]] == expected
@@ -97,7 +97,7 @@ class TestPictureSearch(TestSas):
assert res.status_code == 200 assert res.status_code == 200
expected = list( expected = list(
self.user_a.pictures.order_by( self.user_a.pictures.order_by(
"-picture__parent__event_date", "picture__created_at" "-picture__parent__date", "picture__date"
).values_list("picture_id", flat=True) ).values_list("picture_id", flat=True)
) )
assert [i["id"] for i in res.json()["results"]] == expected assert [i["id"] for i in res.json()["results"]] == expected
@@ -123,7 +123,7 @@ class TestPictureSearch(TestSas):
assert res.status_code == 200 assert res.status_code == 200
expected = list( expected = list(
self.user_b.pictures.intersection(self.user_a.pictures.all()) self.user_b.pictures.intersection(self.user_a.pictures.all())
.order_by("-picture__parent__event_date", "picture__created_at") .order_by("-picture__parent__date", "picture__date")
.values_list("picture_id", flat=True) .values_list("picture_id", flat=True)
) )
assert [i["id"] for i in res.json()["results"]] == expected assert [i["id"] for i in res.json()["results"]] == expected
+2 -21
View File
@@ -9,8 +9,8 @@ from PIL import Image
from core.baker_recipes import old_subscriber_user, subscriber_user from core.baker_recipes import old_subscriber_user, subscriber_user
from core.models import User from core.models import User
from sas.baker_recipes import album_recipe, picture_recipe from sas.baker_recipes import picture_recipe
from sas.models import Album, PeoplePictureRelation, Picture from sas.models import PeoplePictureRelation, Picture
class TestPictureQuerySet(TestCase): class TestPictureQuerySet(TestCase):
@@ -105,22 +105,3 @@ def test_generate_thumbnail(save, initially_saved, pass_img_kwarg):
assert new_img.get_flattened_data() == image.get_flattened_data() assert new_img.get_flattened_data() == image.get_flattened_data()
assert Image.open(picture.thumbnail).size == (200, 100) assert Image.open(picture.thumbnail).size == (200, 100)
assert Image.open(picture.compressed).size == (1200, 600) assert Image.open(picture.compressed).size == (1200, 600)
class TestDeleteAlbum(TestCase):
def setUp(cls):
cls.album: Album = album_recipe.make()
cls.album_pictures = picture_recipe.make(parent=cls.album, _quantity=5)
cls.sub_album = album_recipe.make(parent=cls.album)
cls.sub_album_pictures = picture_recipe.make(parent=cls.sub_album, _quantity=5)
def test_delete(self):
album_ids = [self.album.id, self.sub_album.id]
picture_ids = [
*[p.id for p in self.album_pictures],
*[p.id for p in self.sub_album_pictures],
]
self.album.delete()
# assert not p.exists()
assert not Album.objects.filter(id__in=album_ids).exists()
assert not Picture.objects.filter(id__in=picture_ids).exists()
+3 -1
View File
@@ -234,7 +234,9 @@ class TestPictureRotation:
class TestSasModeration(TestCase): class TestSasModeration(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
album = baker.make(Album) album = baker.make(
Album, parent_id=settings.SITH_SAS_ROOT_DIR_ID, is_moderated=True
)
cls.pictures = picture_recipe.make( cls.pictures = picture_recipe.make(
parent=album, _quantity=10, _bulk_create=True parent=album, _quantity=10, _bulk_create=True
) )
+24 -58
View File
@@ -12,7 +12,6 @@
# OR WITHIN THE LOCAL FILE "LICENSE" # OR WITHIN THE LOCAL FILE "LICENSE"
# #
# #
from pathlib import Path
from typing import Any from typing import Any
from django.conf import settings from django.conf import settings
@@ -24,12 +23,12 @@ from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse from django.urls import reverse
from django.utils.safestring import SafeString from django.utils.safestring import SafeString
from django.views.generic import CreateView, DetailView, TemplateView from django.views.generic import CreateView, DetailView, TemplateView
from django.views.generic.edit import FormMixin, FormView, UpdateView from django.views.generic.edit import FormView, UpdateView
from core.auth.mixins import CanEditMixin, CanViewMixin from core.auth.mixins import CanEditMixin, CanViewMixin
from core.models import SithFile, User from core.models import SithFile, User
from core.views import FileView, UseFragmentsMixin from core.views import UseFragmentsMixin
from core.views.files import send_raw_file from core.views.files import FileView, send_file
from core.views.mixins import FragmentMixin, FragmentRenderer from core.views.mixins import FragmentMixin, FragmentRenderer
from core.views.user import UserTabsMixin from core.views.user import UserTabsMixin
from sas.forms import ( from sas.forms import (
@@ -65,7 +64,6 @@ class AlbumCreateFragment(FragmentMixin, CreateView):
class SASMainView(UseFragmentsMixin, TemplateView): class SASMainView(UseFragmentsMixin, TemplateView):
form_class = AlbumCreateForm
template_name = "sas/main.jinja" template_name = "sas/main.jinja"
def get_fragments(self) -> dict[str, FragmentRenderer]: def get_fragments(self) -> dict[str, FragmentRenderer]:
@@ -82,26 +80,12 @@ class SASMainView(UseFragmentsMixin, TemplateView):
root_user = User.objects.get(pk=settings.SITH_ROOT_USER_ID) root_user = User.objects.get(pk=settings.SITH_ROOT_USER_ID)
return {"album_create_fragment": {"owner": root_user}} return {"album_create_fragment": {"owner": root_user}}
def dispatch(self, request, *args, **kwargs):
if request.method == "POST" and not self.request.user.has_perm("sas.add_album"):
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
def get_form(self, form_class=None):
if not self.request.user.has_perm("sas.add_album"):
return None
return super().get_form(form_class)
def get_form_kwargs(self):
return super().get_form_kwargs() | {
"owner": User.objects.get(pk=settings.SITH_ROOT_USER_ID),
"parent": None,
}
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)
albums_qs = Album.objects.viewable_by(self.request.user) albums_qs = Album.objects.viewable_by(self.request.user)
kwargs["categories"] = list(albums_qs.filter(parent=None).order_by("id")) kwargs["categories"] = list(
albums_qs.filter(parent_id=settings.SITH_SAS_ROOT_DIR_ID).order_by("id")
)
kwargs["latest"] = list( kwargs["latest"] = list(
albums_qs.exclude(id=settings.SITH_SAS_ROOT_DIR_ID).order_by("-id")[:5] albums_qs.exclude(id=settings.SITH_SAS_ROOT_DIR_ID).order_by("-id")[:5]
) )
@@ -110,50 +94,38 @@ class SASMainView(UseFragmentsMixin, TemplateView):
class PictureView(CanViewMixin, DetailView): class PictureView(CanViewMixin, DetailView):
model = Picture model = Picture
queryset = Picture.objects.select_related("parent")
pk_url_kwarg = "picture_id" pk_url_kwarg = "picture_id"
template_name = "sas/picture.jinja" template_name = "sas/picture.jinja"
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {"album": self.object.parent} return super().get_context_data(**kwargs) | {
"album": Album.objects.get(children=self.object)
}
def send_album(request, album_id): def send_album(request, album_id):
album = get_object_or_404(Album, id=album_id) return send_file(request, album_id, Album)
if not album.can_be_viewed_by(request.user):
raise PermissionDenied
return send_raw_file(Path(album.thumbnail.path))
def send_pict(request, picture_id): def send_pict(request, picture_id):
picture = get_object_or_404(Picture, id=picture_id) return send_file(request, picture_id, Picture)
if not picture.can_be_viewed_by(request.user):
raise PermissionDenied
return send_raw_file(Path(picture.original.path))
def send_compressed(request, picture_id): def send_compressed(request, picture_id):
picture = get_object_or_404(Picture, id=picture_id) return send_file(request, picture_id, Picture, "compressed")
if not picture.can_be_viewed_by(request.user):
raise PermissionDenied
return send_raw_file(Path(picture.compressed.path))
def send_thumb(request, picture_id): def send_thumb(request, picture_id):
picture = get_object_or_404(Picture, id=picture_id) return send_file(request, picture_id, Picture, "thumbnail")
if not picture.can_be_viewed_by(request.user):
raise PermissionDenied
return send_raw_file(Path(picture.thumbnail.path))
class AlbumView(CanViewMixin, UseFragmentsMixin, FormMixin, DetailView): class AlbumView(CanViewMixin, UseFragmentsMixin, DetailView):
model = Album model = Album
# exclude the SAS from the album accessible with this view # exclude the SAS from the album accessible with this view
# the SAS can be viewed only with SASMainView # the SAS can be viewed only with SASMainView
queryset = Album.objects.exclude(id=settings.SITH_SAS_ROOT_DIR_ID) queryset = Album.objects.exclude(id=settings.SITH_SAS_ROOT_DIR_ID)
pk_url_kwarg = "album_id" pk_url_kwarg = "album_id"
template_name = "sas/album.jinja" template_name = "sas/album.jinja"
form_class = PictureUploadForm
def get_fragments(self) -> dict[str, FragmentRenderer]: def get_fragments(self) -> dict[str, FragmentRenderer]:
return { return {
@@ -168,32 +140,26 @@ class AlbumView(CanViewMixin, UseFragmentsMixin, FormMixin, DetailView):
except ValueError as e: except ValueError as e:
raise Http404 from e raise Http404 from e
if "clipboard" not in request.session: if "clipboard" not in request.session:
request.session["clipboard"] = {"albums": [], "pictures": []} request.session["clipboard"] = []
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
def get_form(self, *args, **kwargs):
if not self.request.user.can_edit(self.object):
return None
return super().get_form(*args, **kwargs)
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
self.object = self.get_object() self.object = self.get_object()
form = self.get_form() if not request.user.can_edit(self.object):
if not form:
# the form is reserved for users that can edit this album.
# If there is no form, it means the user has no right to do a POST
raise PermissionDenied raise PermissionDenied
FileView.handle_clipboard(self.request, self.object) FileView.handle_clipboard(request, self.object)
if not form.is_valid(): return HttpResponseRedirect(self.request.path)
return self.form_invalid(form)
return self.form_valid(form)
def get_fragment_data(self) -> dict[str, dict[str, Any]]: def get_fragment_data(self) -> dict[str, dict[str, Any]]:
return {"album_create_fragment": {"owner": self.request.user}} return {"album_create_fragment": {"owner": self.request.user}}
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)
kwargs["clipboard"] = {} if ids := self.request.session.get("clipboard", None):
kwargs["clipboard"] = SithFile.objects.filter(id__in=ids)
kwargs["upload_form"] = PictureUploadForm()
# if True, the albums will be fetched with a request to the API
# if False, the section won't be displayed at all
kwargs["show_albums"] = ( kwargs["show_albums"] = (
Album.objects.viewable_by(self.request.user) Album.objects.viewable_by(self.request.user)
.filter(parent_id=self.object.id) .filter(parent_id=self.object.id)
@@ -241,7 +207,7 @@ class ModerationView(PermissionRequiredMixin, TemplateView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)
kwargs["albums_to_moderate"] = Album.objects.filter( kwargs["albums_to_moderate"] = Album.objects.filter(
is_moderated=False is_moderated=False, is_in_sas=True, is_folder=True
).order_by("id") ).order_by("id")
pictures = Picture.objects.filter(is_moderated=False).select_related("parent") pictures = Picture.objects.filter(is_moderated=False).select_related("parent")
kwargs["pictures"] = pictures kwargs["pictures"] = pictures
+5
View File
@@ -571,6 +571,11 @@ SITH_BARMAN_TIMEOUT = 30
# Minutes to delete the last operations # Minutes to delete the last operations
SITH_LAST_OPERATIONS_LIMIT = 10 SITH_LAST_OPERATIONS_LIMIT = 10
# time before a basket is considered expired
SITH_EBOUTIC_BASKET_TIMEOUT = timedelta(minutes=10)
# time that a user can spend on the CB payment page before it to timeout
SITH_EBOUTIC_ETRANSACTION_TIMEOUT = timedelta(minutes=10)
# ET variables # ET variables
SITH_EBOUTIC_CB_ENABLED = env.bool("SITH_EBOUTIC_CB_ENABLED", default=True) SITH_EBOUTIC_CB_ENABLED = env.bool("SITH_EBOUTIC_CB_ENABLED", default=True)
SITH_EBOUTIC_ET_URL = env.str( SITH_EBOUTIC_ET_URL = env.str(
+3 -2
View File
@@ -182,12 +182,13 @@ class OpenApi:
path[action]["operationId"] = "_".join( path[action]["operationId"] = "_".join(
desc["operationId"].split("_")[:-1] desc["operationId"].split("_")[:-1]
) )
schema = str(schema) schema = str(schema)
if old_hash == sha1(schema.encode("utf-8")).hexdigest(): if old_hash == sha1(schema.encode("utf-8")).hexdigest():
logging.getLogger("django").info("✨ Api did not change, nothing to do ✨") logging.getLogger("django").info("✨ Api did not change, nothing to do ✨")
return return
out.write_text(schema) with open(out, "w") as f:
_ = f.write(schema)
return subprocess.Popen(["npm", "run", "openapi"]) return subprocess.Popen(["npm", "run", "openapi"])