4 Commits

Author SHA1 Message Date
imperosol
e5b5568d51 WIP 2025-08-30 19:31:34 +02:00
imperosol
e1331c7cb6 get_list_exact_or_404 util function 2025-08-30 19:31:34 +02:00
imperosol
6f7167c5b4 fix some tests 2025-08-30 19:31:34 +02:00
imperosol
078ee3a016 Migrate albums and pictures to their own tables 2025-08-30 19:31:34 +02:00
72 changed files with 2891 additions and 1720 deletions

View File

@@ -4,19 +4,11 @@
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2 version: 2
updates:
multi-ecosystem-groups: - package-ecosystem: "pip" # See documentation for possible values
common: directory: "/" # Location of package manifests
directory: "/"
schedule: schedule:
interval: "weekly" interval: "weekly"
target-branch: "taiste" target-branch: "taiste"
commit-message: commit-message:
prefix: "[UPDATE] " prefix: "[UPDATE] "
updates:
- package-ecosystem: "uv"
multi-ecosystem-group: "common"
- package-ecosystem: "npm"
multi-ecosystem-group: "common"

View File

@@ -29,7 +29,7 @@ class Migration(migrations.Migration):
migrations.AddConstraint( migrations.AddConstraint(
model_name="membership", model_name="membership",
constraint=models.CheckConstraint( constraint=models.CheckConstraint(
condition=models.Q(("end_date__gte", models.F("start_date"))), check=models.Q(("end_date__gte", models.F("start_date"))),
name="end_after_start", name="end_after_start",
), ),
), ),

View File

@@ -347,7 +347,7 @@ class Membership(models.Model):
class Meta: class Meta:
constraints = [ constraints = [
models.CheckConstraint( models.CheckConstraint(
condition=Q(end_date__gte=F("start_date")), name="end_after_start" check=Q(end_date__gte=F("start_date")), name="end_after_start"
), ),
] ]

View File

@@ -1,14 +1,6 @@
{% extends "core/base.jinja" %} {% extends "core/base.jinja" %}
{% from 'core/macros.jinja' import user_profile_link %} {% from 'core/macros.jinja' import user_profile_link %}
{% block title -%}
{{ club.name }}
{%- endblock %}
{% block description -%}
{{ club.short_description }}
{%- endblock %}
{% block content %} {% block content %}
<div id="club_detail"> <div id="club_detail">
{% if club.logo %} {% if club.logo %}

View File

@@ -1,12 +1,8 @@
{% extends "core/base.jinja" %} {% extends "core/base.jinja" %}
{% block title -%} {% block title %}
{% trans %}Club list{% endtrans %} {% trans %}Club list{% endtrans %}
{%- endblock %} {% endblock %}
{% block description -%}
{% trans %}The list of all clubs existing at UTBM.{% endtrans %}
{%- endblock %}
{% macro display_club(club) -%} {% macro display_club(club) -%}
@@ -25,7 +21,7 @@
{%- if club.children.all()|length != 0 %} {%- if club.children.all()|length != 0 %}
<ul> <ul>
{%- for c in club.children.order_by('name').prefetch_related("children") %} {%- for c in club.children.order_by('name') %}
{{ display_club(c) }} {{ display_club(c) }}
{%- endfor %} {%- endfor %}
</ul> </ul>
@@ -40,8 +36,8 @@
{% if club_list %} {% if club_list %}
<h3>{% trans %}Club list{% endtrans %}</h3> <h3>{% trans %}Club list{% endtrans %}</h3>
<ul> <ul>
{%- for club in club_list %} {%- for c in club_list.all().order_by('name') if c.parent is none %}
{{ display_club(club) }} {{ display_club(c) }}
{%- endfor %} {%- endfor %}
</ul> </ul>
{% else %} {% else %}

View File

@@ -171,10 +171,6 @@ class ClubListView(ListView):
model = Club model = Club
template_name = "club/club_list.jinja" template_name = "club/club_list.jinja"
queryset = (
Club.objects.filter(parent=None).order_by("name").prefetch_related("children")
)
context_object_name = "club_list"
class ClubView(ClubTabsMixin, DetailView): class ClubView(ClubTabsMixin, DetailView):

View File

@@ -68,7 +68,7 @@ class IcsCalendar:
start=news_date.start_date, start=news_date.start_date,
end=news_date.end_date, end=news_date.end_date,
url=as_absolute_url( url=as_absolute_url(
reverse("com:news_detail", kwargs={"news_id": news_date.news_id}) reverse("com:news_detail", kwargs={"news_id": news_date.news.id})
), ),
) )
calendar.events.append(event) calendar.events.append(event)

View File

@@ -54,7 +54,7 @@ class Migration(migrations.Migration):
migrations.AddConstraint( migrations.AddConstraint(
model_name="newsdate", model_name="newsdate",
constraint=models.CheckConstraint( constraint=models.CheckConstraint(
condition=models.Q(("end_date__gte", models.F("start_date"))), check=models.Q(("end_date__gte", models.F("start_date"))),
name="news_date_end_date_after_start_date", name="news_date_end_date_after_start_date",
), ),
), ),

View File

@@ -27,7 +27,7 @@ from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.mail import EmailMultiAlternatives from django.core.mail import EmailMultiAlternatives
from django.db import models, transaction from django.db import models, transaction
from django.db.models import Exists, F, OuterRef, Q from django.db.models import F, Q
from django.shortcuts import render from django.shortcuts import render
from django.templatetags.static import static from django.templatetags.static import static
from django.urls import reverse from django.urls import reverse
@@ -55,17 +55,9 @@ class Sith(models.Model):
class NewsQuerySet(models.QuerySet): class NewsQuerySet(models.QuerySet):
def published(self) -> Self: def moderated(self) -> Self:
return self.filter(is_published=True) return self.filter(is_published=True)
def waiting_moderation(self) -> Self:
"""Filter all non-finished non-published news"""
# Because of the way News and NewsDates are created,
# there may be some cases where this method is called before
# the NewsDates linked to a Date are actually persisted in db.
# Thus, it's important to filter by "not past date" rather than by "future date"
return self.filter(~Q(dates__start_date__lt=timezone.now()), is_published=False)
def viewable_by(self, user: User) -> Self: def viewable_by(self, user: User) -> Self:
"""Filter news that the given user can view. """Filter news that the given user can view.
@@ -135,28 +127,20 @@ class News(models.Model):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
super().save(*args, **kwargs) super().save(*args, **kwargs)
if not self.is_published: if self.is_published:
admins_without_notif = User.objects.filter( return
~Exists( for user in User.objects.filter(
Notification.objects.filter( groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID]
user=OuterRef("pk"), type="NEWS_MODERATION" ):
Notification.objects.create(
user=user, url=reverse("com:news_admin_list"), type="NEWS_MODERATION"
) )
),
groups__id=settings.SITH_GROUP_COM_ADMIN_ID,
)
notif_url = reverse("com:news_admin_list")
new_notifs = [
Notification(user=user, url=notif_url, type="NEWS_MODERATION")
for user in admins_without_notif
]
Notification.objects.bulk_create(new_notifs)
self.update_moderation_notifs()
def get_absolute_url(self): def get_absolute_url(self):
return reverse("com:news_detail", kwargs={"news_id": self.id}) return reverse("com:news_detail", kwargs={"news_id": self.id})
def get_full_url(self): def get_full_url(self):
return f"https://{settings.SITH_URL}{self.get_absolute_url()}" return "https://%s%s" % (settings.SITH_URL, self.get_absolute_url())
def is_owned_by(self, user): def is_owned_by(self, user):
if user.is_anonymous: if user.is_anonymous:
@@ -175,16 +159,19 @@ class News(models.Model):
or (user.is_authenticated and self.author_id == user.id) or (user.is_authenticated and self.author_id == user.id)
) )
@staticmethod
def update_moderation_notifs(): def news_notification_callback(notif: Notification):
count = News.objects.waiting_moderation().count() # the NewsDate linked to the News
notifs_qs = Notification.objects.filter( # which creation triggered this callback may not exist yet,
type="NEWS_MODERATION", user__groups__id=settings.SITH_GROUP_COM_ADMIN_ID # so it's important to filter by "not past date" rather than by "future date"
) count = News.objects.filter(
~Q(dates__start_date__gt=timezone.now()), is_published=False
).count()
if count: if count:
notifs_qs.update(viewed=False, param=str(count)) notif.viewed = False
notif.param = str(count)
else: else:
notifs_qs.update(viewed=True) notif.viewed = True
class NewsDateQuerySet(models.QuerySet): class NewsDateQuerySet(models.QuerySet):
@@ -225,7 +212,7 @@ class NewsDate(models.Model):
verbose_name_plural = _("news dates") verbose_name_plural = _("news dates")
constraints = [ constraints = [
models.CheckConstraint( models.CheckConstraint(
condition=Q(end_date__gte=F("start_date")), check=Q(end_date__gte=F("start_date")),
name="news_date_end_date_after_start_date", name="news_date_end_date_after_start_date",
) )
] ]

View File

@@ -1,6 +1,10 @@
{% extends "core/base.jinja" %} {% extends "core/base.jinja" %}
{% from "com/macros.jinja" import news_moderation_alert %} {% from "com/macros.jinja" import news_moderation_alert %}
{% block title %}
{% trans %}News{% endtrans %}
{% endblock %}
{% block additional_css %} {% block additional_css %}
<link rel="stylesheet" href="{{ static('com/css/news-list.scss') }}"> <link rel="stylesheet" href="{{ static('com/css/news-list.scss') }}">
<link rel="stylesheet" href="{{ static('com/components/ics-calendar.scss') }}"> <link rel="stylesheet" href="{{ static('com/components/ics-calendar.scss') }}">

View File

@@ -1,22 +1,13 @@
from datetime import timedelta
import pytest import pytest
from django.conf import settings from django.conf import settings
from django.utils.timezone import now
from model_bakery import baker from model_bakery import baker
from com.models import News, NewsDate from com.models import News
from core.baker_recipes import subscriber_user
from core.models import Group, Notification, User from core.models import Group, Notification, User
@pytest.mark.django_db @pytest.mark.django_db
def test_notification_created(): def test_notification_created():
# this news is unpublished, but is set in the past
# it shouldn't be taken into account when counting the number
# of news that are to be moderated
past_news = baker.make(News, is_published=False)
baker.make(NewsDate, news=past_news, start_date=now() - timedelta(days=1))
com_admin_group = Group.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID) com_admin_group = Group.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)
com_admin_group.users.all().delete() com_admin_group.users.all().delete()
Notification.objects.all().delete() Notification.objects.all().delete()
@@ -24,28 +15,9 @@ def test_notification_created():
for i in range(2): for i in range(2):
# news notifications are permanent, so the notification created # news notifications are permanent, so the notification created
# during the first iteration should be reused during the second one. # during the first iteration should be reused during the second one.
baker.make(News, is_published=False) baker.make(News)
notifications = list(Notification.objects.all()) notifications = list(Notification.objects.all())
assert len(notifications) == 1 assert len(notifications) == 1
assert notifications[0].user == com_admin assert notifications[0].user == com_admin
assert notifications[0].type == "NEWS_MODERATION" assert notifications[0].type == "NEWS_MODERATION"
assert notifications[0].param == str(i + 1) assert notifications[0].param == str(i + 1)
@pytest.mark.django_db
def test_notification_edited_when_moderating_news():
com_admin_group = Group.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)
com_admins = subscriber_user.make(_quantity=3)
com_admin_group.users.set(com_admins)
Notification.objects.all().delete()
news = baker.make(News, is_published=False)
assert Notification.objects.count() == 3
assert Notification.objects.filter(viewed=False).count() == 3
news.is_published = True
news.moderator = com_admins[0]
news.save()
# when the news is moderated, the notification should be marked as read
# for all admins
assert Notification.objects.count() == 3
assert Notification.objects.filter(viewed=False).count() == 0

View File

@@ -88,9 +88,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", "is_in_sas") list_display = ("name", "owner", "size", "date")
autocomplete_fields = ("parent", "owner", "moderator") autocomplete_fields = ("parent", "owner", "moderator")
search_fields = ("name", "parent__name") search_fields = ("name",)
@admin.register(OperationLog) @admin.register(OperationLog)

View File

@@ -25,7 +25,6 @@ from core.schemas import (
UserFamilySchema, UserFamilySchema,
UserFilterSchema, UserFilterSchema,
UserProfileSchema, UserProfileSchema,
UserSchema,
) )
from core.templatetags.renderer import markdown from core.templatetags.renderer import markdown
@@ -70,22 +69,16 @@ class MailingListController(ControllerBase):
return data return data
@api_controller("/user") @api_controller("/user", permissions=[CanAccessLookup])
class UserController(ControllerBase): class UserController(ControllerBase):
@route.get("", response=list[UserProfileSchema], permissions=[CanAccessLookup]) @route.get("", response=list[UserProfileSchema])
def fetch_profiles(self, pks: Query[set[int]]): def fetch_profiles(self, pks: Query[set[int]]):
return User.objects.filter(pk__in=pks) return User.objects.filter(pk__in=pks)
@route.get("/{int:user_id}", response=UserSchema, permissions=[CanView])
def fetch_user(self, user_id: int):
"""Fetch a single user"""
return self.get_object_or_exception(User, id=user_id)
@route.get( @route.get(
"/search", "/search",
response=PaginatedResponseSchema[UserProfileSchema], response=PaginatedResponseSchema[UserProfileSchema],
url_name="search_users", url_name="search_users",
permissions=[CanAccessLookup],
) )
@paginate(PageNumberPaginationExtra, page_size=20) @paginate(PageNumberPaginationExtra, page_size=20)
def search_users(self, filters: Query[UserFilterSchema]): def search_users(self, filters: Query[UserFilterSchema]):
@@ -104,7 +97,7 @@ class SithFileController(ControllerBase):
) )
@paginate(PageNumberPaginationExtra, page_size=50) @paginate(PageNumberPaginationExtra, page_size=50)
def search_files(self, search: Annotated[str, annotated_types.MinLen(1)]): def search_files(self, search: Annotated[str, annotated_types.MinLen(1)]):
return SithFile.objects.filter(is_in_sas=False).filter(name__icontains=search) return SithFile.objects.filter(name__icontains=search)
@api_controller("/group") @api_controller("/group")

View File

@@ -1,41 +0,0 @@
import pathlib
from django.apps import apps
from django.core.management.base import BaseCommand
from PIL import Image, UnidentifiedImageError
class Command(BaseCommand):
def add_arguments(self, parser):
parser.add_argument("number", type=int)
parser.add_argument("path", type=pathlib.Path)
parser.add_argument("-f", "--force", action="store_true")
def handle(self, number: int, path: pathlib.Path, force: int, *args, **options):
if not path.exists() or path.is_dir():
self.stderr.write(f"{path} is not a file or does not exist")
return
dest_path = (
pathlib.Path(apps.get_app_config("core").path)
/ "static"
/ "core"
/ "img"
/ f"promo_{number}.png"
)
if dest_path.exists() and not force:
over = input("File already exists, do you want to overwrite it? (y/N):")
if over.lower() != "y":
self.stdout.write("exiting")
return
try:
im = Image.open(path)
im.resize((120, 120), resample=Image.Resampling.LANCZOS).save(
dest_path, format="PNG"
)
self.stdout.write(
f"Promo logo moved and resized successfully at {dest_path}"
)
except UnidentifiedImageError:
self.stderr.write("image cannot be opened and identified.")

View File

@@ -110,7 +110,6 @@ 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)
main_club = Club.objects.create( main_club = Club.objects.create(
id=1, name="AE", address="6 Boulevard Anatole France, 90000 Belfort" id=1, name="AE", address="6 Boulevard Anatole France, 90000 Belfort"
) )
@@ -693,33 +692,21 @@ 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( album = Album.objects.create(name=f.name, is_moderated=True)
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,
file=file, original=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.file.name = p.name pict.original.name = pict.name
pict.full_clean()
pict.generate_thumbnails() pict.generate_thumbnails()
pict.full_clean()
pict.save() pict.save()
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")

View File

@@ -94,11 +94,7 @@ class Command(BaseCommand):
username=self.faker.user_name(), username=self.faker.user_name(),
first_name=self.faker.first_name(), first_name=self.faker.first_name(),
last_name=self.faker.last_name(), last_name=self.faker.last_name(),
date_of_birth=( date_of_birth=self.faker.date_of_birth(minimum_age=15, maximum_age=25),
None
if random.random() < 0.2
else self.faker.date_of_birth(minimum_age=15, maximum_age=25)
),
email=self.faker.email(), email=self.faker.email(),
phone=self.faker.phone_number(), phone=self.faker.phone_number(),
address=self.faker.address(), address=self.faker.address(),

View File

@@ -154,7 +154,7 @@ class Migration(migrations.Migration):
migrations.AddConstraint( migrations.AddConstraint(
model_name="userban", model_name="userban",
constraint=models.CheckConstraint( constraint=models.CheckConstraint(
condition=models.Q(("expires_at__gte", models.F("created_at"))), check=models.Q(("expires_at__gte", models.F("created_at"))),
name="user_ban_end_after_start", name="user_ban_end_after_start",
), ),
), ),

View File

@@ -0,0 +1,27 @@
# 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", "0047_alter_notification_date_alter_notification_type"),
("sas", "0007_alter_peoplepicturerelation_picture_and_more"),
]
operations = [
migrations.RunPython(
remove_sas_sithfiles, reverse_code=migrations.RunPython.noop, elidable=True
)
]

View File

@@ -0,0 +1,9 @@
# Generated by Django 4.2.17 on 2025-02-14 11:58
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [("core", "0048_remove_sithfiles")]
operations = [migrations.RemoveField(model_name="sithfile", name="is_in_sas")]

View File

@@ -745,7 +745,7 @@ class UserBan(models.Model):
fields=["ban_group", "user"], name="unique_ban_type_per_user" fields=["ban_group", "user"], name="unique_ban_type_per_user"
), ),
models.CheckConstraint( models.CheckConstraint(
condition=Q(expires_at__gte=F("created_at")), check=Q(expires_at__gte=F("created_at")),
name="user_ban_end_after_start", name="user_ban_end_after_start",
), ),
] ]
@@ -863,9 +863,6 @@ class SithFile(models.Model):
on_delete=models.CASCADE, on_delete=models.CASCADE,
) )
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")
@@ -874,22 +871,10 @@ 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 = SithFile.objects.filter(id=settings.SITH_SAS_ROOT_DIR_ID).first()
self.is_in_sas = sas in self.get_parent_list() or self == sas
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:
@@ -902,8 +887,6 @@ 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:
@@ -930,8 +913,6 @@ 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()
): ):
@@ -1069,18 +1050,6 @@ 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

View File

@@ -34,22 +34,6 @@ class SimpleUserSchema(ModelSchema):
fields = ["id", "nick_name", "first_name", "last_name"] fields = ["id", "nick_name", "first_name", "last_name"]
class UserSchema(ModelSchema):
class Meta:
model = User
fields = [
"id",
"nick_name",
"first_name",
"last_name",
"date_of_birth",
"email",
"role",
"quote",
"promo",
]
class UserProfileSchema(ModelSchema): class UserProfileSchema(ModelSchema):
"""The necessary information to show a user profile""" """The necessary information to show a user profile"""

View File

@@ -2,14 +2,8 @@
<html lang="fr"> <html lang="fr">
<head> <head>
{% block head %} {% block head %}
<title>{% block title %}Association des Étudiants de l'UTBM{% endblock %}</title> <title>{% block title %}{% trans %}Welcome!{% endtrans %}{% endblock %} - Association des Étudiants UTBM</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="{% block description -%}
{% trans trimmed %}
AE UTBM is a voluntary organisation run by UTBM students.
It organises student life at UTBM and manages its student facilities.
{% endtrans %}
{%- endblock %}">
<link rel="shortcut icon" href="{{ static('core/img/favicon.ico') }}"> <link rel="shortcut icon" href="{{ static('core/img/favicon.ico') }}">
<link rel="stylesheet" href="{{ static('core/base.css') }}"> <link rel="stylesheet" href="{{ static('core/base.css') }}">
<link rel="stylesheet" href="{{ static('core/style.scss') }}"> <link rel="stylesheet" href="{{ static('core/style.scss') }}">

View File

@@ -5,6 +5,7 @@ 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
@@ -17,8 +18,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
@@ -30,24 +31,19 @@ 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: SithFile = baker.make( picture = picture_recipe.make()
Picture, parent=SithFile.objects.get(pk=settings.SITH_SAS_ROOT_DIR_ID) assert user.can_edit(picture)
)
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 = baker.make(Picture, owner=user) picture = picture_recipe.make(owner=user)
assert picture.is_owned_by(user) assert user.can_edit(picture)
@pytest.mark.parametrize( @pytest.mark.parametrize(
"user_factory", "user_factory",
@@ -63,7 +59,41 @@ 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 picture.is_owned_by(user) assert not user.can_edit(picture)
@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(
"core: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(
"core: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:

View File

@@ -22,6 +22,7 @@ from core.models import Group, User
from core.views import UserTabsMixin from core.views import UserTabsMixin
from counter.models import Counter, Refilling, Selling from counter.models import Counter, Refilling, Selling
from eboutic.models import Invoice, InvoiceItem from eboutic.models import Invoice, InvoiceItem
from sas.models import Picture
class TestSearchUsers(TestCase): class TestSearchUsers(TestCase):
@@ -29,6 +30,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
User.objects.all().delete() User.objects.all().delete()
user_recipe = Recipe( user_recipe = Recipe(
User, User,

View File

@@ -12,18 +12,23 @@
# 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 Final from typing import Any, Final, Unpack
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.http import HttpRequest from django.db import models
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 import ExifTags from PIL import ExifTags
from PIL.Image import Image, Resampling from PIL.Image import Image, Resampling
@@ -42,6 +47,21 @@ 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.
@@ -195,3 +215,56 @@ def get_client_ip(request: HttpRequest) -> str | None:
return ip return ip
return None return None
Filterable = 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

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, is_in_sas=False) queryset = SithFile.objects.filter(is_moderated=False)
ordering = "id" ordering = "id"
paginate_by = 100 paginate_by = 100

View File

@@ -58,7 +58,7 @@ class Migration(migrations.Migration):
migrations.AddConstraint( migrations.AddConstraint(
model_name="returnableproduct", model_name="returnableproduct",
constraint=models.CheckConstraint( constraint=models.CheckConstraint(
condition=models.Q( check=models.Q(
("product", models.F("returned_product")), _negated=True ("product", models.F("returned_product")), _negated=True
), ),
name="returnableproduct_product_different_from_returned", name="returnableproduct_product_different_from_returned",

View File

@@ -1278,7 +1278,7 @@ class ReturnableProduct(models.Model):
verbose_name_plural = _("returnable products") verbose_name_plural = _("returnable products")
constraints = [ constraints = [
models.CheckConstraint( models.CheckConstraint(
condition=~Q(product=F("returned_product")), check=~Q(product=F("returned_product")),
name="returnableproduct_product_different_from_returned", name="returnableproduct_product_different_from_returned",
violation_error_message=_( violation_error_message=_(
"The returnable product cannot be the same as the returned one" "The returnable product cannot be the same as the returned one"

View File

@@ -12,15 +12,6 @@ nouveau logo d'une promo. C'est un processus manuel.
de faire cette opération manuellement, ça prend quelques de faire cette opération manuellement, ça prend quelques
minutes et on est certain de la qualité à la fin. minutes et on est certain de la qualité à la fin.
### avec une commande django
```bash
./manage.py add_promo_logo numero_de_promo chemin_dacces_du_logo
```
options:
* `--force/-f` pour automatiquement écraser les logos de promo avec le même nom.
### manuellement
Les logos de promo sont à manuellement ajouter dans le projet. Les logos de promo sont à manuellement ajouter dans le projet.
Ils se situent dans le dossier `core/static/core/img/`. Ils se situent dans le dossier `core/static/core/img/`.

View File

@@ -263,3 +263,35 @@ 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
```

View File

@@ -1,12 +1,8 @@
{% extends "core/base.jinja" %} {% extends "core/base.jinja" %}
{% block title -%} {% block title %}
{% trans %}Eboutic{% endtrans %} {% trans %}Eboutic{% endtrans %}
{%- endblock %} {% endblock %}
{% block description -%}
{% trans %}The online shop of the association.{% endtrans %}
{%- endblock %}
{% block additional_js %} {% block additional_js %}
{# This script contains the code to perform requests to manipulate the {# This script contains the code to perform requests to manipulate the

View File

@@ -1,5 +1,3 @@
from datetime import datetime, timezone
import pytest import pytest
from django.http import HttpResponse from django.http import HttpResponse
from django.test import TestCase from django.test import TestCase
@@ -11,13 +9,8 @@ from pytest_django.asserts import assertRedirects
from core.baker_recipes import subscriber_user from core.baker_recipes import subscriber_user
from core.models import Group, User from core.models import Group, User
from counter.baker_recipes import product_recipe, refill_recipe, sale_recipe from counter.baker_recipes import product_recipe
from counter.models import ( from counter.models import Counter, ProductType, get_eboutic
Counter,
Customer,
ProductType,
get_eboutic,
)
from counter.tests.test_counter import BasketItem from counter.tests.test_counter import BasketItem
from eboutic.models import Basket from eboutic.models import Basket
@@ -31,96 +24,6 @@ def test_get_eboutic():
assert Counter.objects.get(name="Eboutic") == get_eboutic() assert Counter.objects.get(name="Eboutic") == get_eboutic()
@pytest.mark.django_db
def test_eboutic_access_unregistered(client: Client):
eboutic_url = reverse("eboutic:main")
assertRedirects(
client.get(eboutic_url), reverse("core:login", query={"next": eboutic_url})
)
@pytest.mark.django_db
def test_eboutic_access_new_customer(client: Client):
user = baker.make(User)
assert not Customer.objects.filter(user=user).exists()
client.force_login(user)
assert client.get(reverse("eboutic:main")).status_code == 200
assert Customer.objects.filter(user=user).exists()
@pytest.mark.django_db
def test_eboutic_access_old_customer(client: Client):
user = baker.make(User)
customer = Customer.get_or_create(user)[0]
client.force_login(user)
assert client.get(reverse("eboutic:main")).status_code == 200
assert Customer.objects.filter(user=user).first() == customer
@pytest.mark.django_db
@pytest.mark.parametrize(
("sellings", "refillings", "expected"),
(
([], [], None),
(
[datetime(2025, 3, 7, 1, 2, 3, tzinfo=timezone.utc)],
[],
datetime(2025, 3, 7, 1, 2, 3, tzinfo=timezone.utc),
),
(
[],
[datetime(2025, 3, 7, 1, 2, 3, tzinfo=timezone.utc)],
datetime(2025, 3, 7, 1, 2, 3, tzinfo=timezone.utc),
),
(
[datetime(2025, 2, 7, 1, 2, 3, tzinfo=timezone.utc)],
[datetime(2025, 3, 7, 1, 2, 3, tzinfo=timezone.utc)],
datetime(2025, 3, 7, 1, 2, 3, tzinfo=timezone.utc),
),
(
[datetime(2025, 3, 7, 1, 2, 3, tzinfo=timezone.utc)],
[datetime(2025, 2, 7, 1, 2, 3, tzinfo=timezone.utc)],
datetime(2025, 3, 7, 1, 2, 3, tzinfo=timezone.utc),
),
(
[
datetime(2025, 3, 7, 1, 2, 3, tzinfo=timezone.utc),
datetime(2025, 2, 7, 1, 2, 3, tzinfo=timezone.utc),
],
[datetime(2025, 3, 7, 1, 2, 3, tzinfo=timezone.utc)],
datetime(2025, 3, 7, 1, 2, 3, tzinfo=timezone.utc),
),
),
)
def test_eboutic_basket_expiry(
client: Client,
sellings: list[datetime],
refillings: list[datetime],
expected: datetime | None,
):
eboutic = get_eboutic()
customer = baker.make(Customer)
client.force_login(customer.user)
for date in sellings:
sale_recipe.make(
customer=customer, counter=eboutic, date=date, is_validated=True
)
for date in refillings:
refill_recipe.make(customer=customer, counter=eboutic, date=date)
assert (
f'x-data="basket({int(expected.timestamp() * 1000) if expected else "null"})"'
in client.get(reverse("eboutic:main")).text
)
class TestEboutic(TestCase): class TestEboutic(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):

View File

@@ -34,7 +34,6 @@ from django.contrib.auth.mixins import (
from django.contrib.messages.views import SuccessMessageMixin from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import SuspiciousOperation, ValidationError from django.core.exceptions import SuspiciousOperation, ValidationError
from django.db import DatabaseError, transaction from django.db import DatabaseError, transaction
from django.db.models import Subquery
from django.db.models.fields import forms from django.db.models.fields import forms
from django.db.utils import cached_property from django.db.utils import cached_property
from django.http import HttpResponse from django.http import HttpResponse
@@ -49,14 +48,7 @@ from django_countries.fields import Country
from core.auth.mixins import CanViewMixin from core.auth.mixins import CanViewMixin
from core.views.mixins import FragmentMixin, UseFragmentsMixin from core.views.mixins import FragmentMixin, UseFragmentsMixin
from counter.forms import BaseBasketForm, BillingInfoForm, ProductForm from counter.forms import BaseBasketForm, BillingInfoForm, ProductForm
from counter.models import ( from counter.models import BillingInfo, Customer, Product, Selling, get_eboutic
BillingInfo,
Customer,
Product,
Refilling,
Selling,
get_eboutic,
)
from eboutic.models import ( from eboutic.models import (
Basket, Basket,
BasketItem, BasketItem,
@@ -132,36 +124,13 @@ class EbouticMainView(LoginRequiredMixin, FormView):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context["products"] = self.products context["products"] = self.products
context["customer_amount"] = self.request.user.account_balance context["customer_amount"] = self.request.user.account_balance
last_purchase: Selling | None = (
purchases = ( self.customer.buyings.filter(counter__type="EBOUTIC")
Customer.objects.filter(pk=self.customer.pk)
.annotate(
last_refill=Subquery(
Refilling.objects.filter(
counter__type="EBOUTIC", customer_id=self.customer.pk
)
.order_by("-date") .order_by("-date")
.values("date")[:1] .first()
),
last_purchase=Subquery(
Selling.objects.filter(
counter__type="EBOUTIC", customer_id=self.customer.pk
) )
.order_by("-date")
.values("date")[:1]
),
)
.values_list("last_refill", "last_purchase")
)[0]
purchase_times = [
int(purchase.timestamp() * 1000)
for purchase in purchases
if purchase is not None
]
context["last_purchase_time"] = ( context["last_purchase_time"] = (
max(purchase_times) if len(purchase_times) > 0 else "null" int(last_purchase.date.timestamp() * 1000) if last_purchase else "null"
) )
return context return context

View File

@@ -2,13 +2,9 @@
{% from 'core/macros.jinja' import user_profile_link %} {% from 'core/macros.jinja' import user_profile_link %}
{% from 'forum/macros.jinja' import display_forum, display_search_bar %} {% from 'forum/macros.jinja' import display_forum, display_search_bar %}
{% block title -%} {% block title %}
{% trans %}Forum{% endtrans %} {% trans %}Forum{% endtrans %}
{%- endblock %} {% endblock %}
{% block description -%}
{% trans %}A forum dedicated to the UTBM students.{% endtrans %}
{%- endblock %}
{% block additional_css %} {% block additional_css %}
<link rel="stylesheet" href="{{ static('forum/css/forum.scss') }}"> <link rel="stylesheet" href="{{ static('forum/css/forum.scss') }}">

View File

@@ -25,13 +25,12 @@ 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 club.models import Club, Membership from club.models import Club, Membership
from core.models import Group, Page, SithFile, User from core.models import Group, Page, 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,13 +90,8 @@ 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", name="galaxy-register-file", owner=root, is_moderated=True
owner=root,
is_moderated=True,
is_in_sas=True,
parent=sas,
) )
self.make_clubs() self.make_clubs()
@@ -285,14 +279,10 @@ 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,
is_in_sas=True, original=ContentFile(RED_PIXEL_PNG),
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

View File

@@ -23,21 +23,20 @@
from __future__ import annotations from __future__ import annotations
import itertools
import logging import logging
import math import math
import time import time
from collections import defaultdict
from typing import NamedTuple, TypedDict from typing import NamedTuple, TypedDict
from django.db import models from django.db import models
from django.db.models import Count, F, Q, QuerySet from django.db.models import Case, Count, F, Q, Value, When
from django.db.models.functions import Concat
from django.utils.timezone import localdate from django.utils.timezone import localdate
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from club.models import Membership from club.models import Club
from core.models import User from core.models import User
from sas.models import PeoplePictureRelation, Picture from sas.models import Picture
class GalaxyStar(models.Model): class GalaxyStar(models.Model):
@@ -115,9 +114,18 @@ class GalaxyLane(models.Model):
default=0, default=0,
help_text=_("Distance separating star1 and star2"), help_text=_("Distance separating star1 and star2"),
) )
family = models.PositiveIntegerField(_("family score"), default=0) family = models.PositiveIntegerField(
pictures = models.PositiveIntegerField(_("pictures score"), default=0) _("family score"),
clubs = models.PositiveIntegerField(_("clubs score"), default=0) default=0,
)
pictures = models.PositiveIntegerField(
_("pictures score"),
default=0,
)
clubs = models.PositiveIntegerField(
_("clubs score"),
default=0,
)
def __str__(self): def __str__(self):
return f"{self.star1} -> {self.star2} ({self.distance})" return f"{self.star1} -> {self.star2} ({self.distance})"
@@ -166,7 +174,6 @@ class Galaxy(models.Model):
logger = logging.getLogger("main") logger = logging.getLogger("main")
GALAXY_SCALE_FACTOR = 2_000 GALAXY_SCALE_FACTOR = 2_000
DEFAULT_PICTURE_COUNT_THRESHOLD = 10
FAMILY_LINK_POINTS = 366 # Equivalent to a leap year together in a club, because. FAMILY_LINK_POINTS = 366 # Equivalent to a leap year together in a club, because.
PICTURE_POINTS = 2 # Equivalent to two days as random members of a club. PICTURE_POINTS = 2 # Equivalent to two days as random members of a club.
CLUBS_POINTS = 1 # One day together as random members in a club is one point. CLUBS_POINTS = 1 # One day together as random members in a club is one point.
@@ -180,13 +187,15 @@ class Galaxy(models.Model):
stars_count = self.stars.count() stars_count = self.stars.count()
s = f"GLX-ID{self.pk}-SC{stars_count}-" s = f"GLX-ID{self.pk}-SC{stars_count}-"
if self.state is None: if self.state is None:
s += "CHAOS" s += "CHS" # CHAOS
else: else:
s += "RULED" s += "RLD" # RULED
return s return s
@classmethod @classmethod
def get_current_galaxy(cls) -> Galaxy: def get_current_galaxy(
cls,
) -> Galaxy: # __future__.annotations is required for this
return Galaxy.objects.filter(state__isnull=False).last() return Galaxy.objects.filter(state__isnull=False).last()
################### ###################
@@ -194,18 +203,7 @@ class Galaxy(models.Model):
################### ###################
@classmethod @classmethod
def get_rulable_users( def compute_user_score(cls, user: User) -> int:
cls, picture_count_threshold: int = DEFAULT_PICTURE_COUNT_THRESHOLD
) -> QuerySet[User]:
return (
User.objects.exclude(subscriptions=None)
.annotate(pictures_count=Count("pictures"))
.filter(pictures_count__gt=picture_count_threshold)
.distinct()
)
@classmethod
def compute_individual_scores(cls) -> dict[int, int]:
"""Compute an individual score for each citizen. """Compute an individual score for each citizen.
It will later be used by the graph algorithm to push It will later be used by the graph algorithm to push
@@ -213,50 +211,87 @@ class Galaxy(models.Model):
Idea: This could be added to the computation: Idea: This could be added to the computation:
- Forum posts
- Picture count - Picture count
- Counter consumption - Counter consumption
- Barman time - Barman time
- ... - ...
""" """
users = ( user_score = 1
User.objects.annotate( user_score += cls.query_user_score(user)
score=(
Count("godchildren", distinct=True) * cls.FAMILY_LINK_POINTS
+ Count("godfathers", distinct=True) * cls.FAMILY_LINK_POINTS
+ Count("pictures", distinct=True) * cls.PICTURE_POINTS
+ Count("memberships", distinct=True) * cls.CLUBS_POINTS
)
)
.filter(score__gt=0)
.values("id", "score")
)
# TODO: # TODO:
# Scale that value with some magic number to accommodate to typical data # Scale that value with some magic number to accommodate to typical data
# Really active galaxy citizen after 5 years typically have a score of about XXX # Really active galaxy citizen after 5 years typically have a score of about XXX
# Citizen that were seen regularly without taking much part in organizations typically have a score of about XXX # Citizen that were seen regularly without taking much part in organizations typically have a score of about XXX
# Citizen that only went to a few events typically score about XXX # Citizen that only went to a few events typically score about XXX
res = {u["id"]: int(math.log2(u["score"] + 1)) for u in users} user_score = int(math.log2(user_score))
return res
return user_score
@classmethod
def query_user_score(cls, user: User) -> int:
"""Get the individual score of the given user in the galaxy."""
score_query = (
User.objects.filter(id=user.id)
.annotate(
godchildren_count=Count("godchildren", distinct=True)
* cls.FAMILY_LINK_POINTS,
godfathers_count=Count("godfathers", distinct=True)
* cls.FAMILY_LINK_POINTS,
pictures_score=Count("pictures", distinct=True) * cls.PICTURE_POINTS,
clubs_score=Count("memberships", distinct=True) * cls.CLUBS_POINTS,
)
.aggregate(
score=models.Sum(
F("godchildren_count")
+ F("godfathers_count")
+ F("pictures_score")
+ F("clubs_score")
)
)
)
return score_query.get("score")
#################### ####################
# Inter-user score # # Inter-user score #
#################### ####################
@classmethod @classmethod
def compute_user_family_score(cls, user: User) -> defaultdict[int, int]: def compute_users_score(cls, user1: User, user2: User) -> RelationScore:
"""Compute the relationship scores of the two given users.
The computation is done with the following fields :
- family: if they have some godfather/godchild relation
- pictures: in how many pictures are both tagged
- clubs: during how many days they were members of the same clubs
"""
family = cls.compute_users_family_score(user1, user2)
pictures = cls.compute_users_pictures_score(user1, user2)
clubs = cls.compute_users_clubs_score(user1, user2)
return RelationScore(family=family, pictures=pictures, clubs=clubs)
@classmethod
def compute_users_family_score(cls, user1: User, user2: User) -> int:
"""Compute the family score of the relation between the given users. """Compute the family score of the relation between the given users.
This takes into account mutual godfathers. This takes into account mutual godfathers.
Returns:
366 if user1 is the godfather of user2 (or vice versa) else 0
""" """
godchildren = User.objects.filter(godchildren=user).values_list("id", flat=True) link_count = User.objects.filter(
godfathers = User.objects.filter(godfathers=user).values_list("id", flat=True) Q(id=user1.id, godfathers=user2) | Q(id=user2.id, godfathers=user1)
result = defaultdict(int) ).count()
for parent in itertools.chain(godchildren, godfathers): if link_count > 0:
result[parent] += cls.FAMILY_LINK_POINTS cls.logger.debug(
return result f"\t\t- '{user1}' and '{user2}' have {link_count} direct family link"
)
return link_count * cls.FAMILY_LINK_POINTS
@classmethod @classmethod
def compute_user_pictures_score(cls, user: User) -> defaultdict[int, int]: def compute_users_pictures_score(cls, user1: User, user2: User) -> int:
"""Compute the pictures score of the relation between the given users. """Compute the pictures score of the relation between the given users.
The pictures score is obtained by counting the number The pictures score is obtained by counting the number
@@ -266,19 +301,19 @@ class Galaxy(models.Model):
Returns: Returns:
The number of pictures both users have in common, times 2 The number of pictures both users have in common, times 2
""" """
common_photos = ( picture_count = (
PeoplePictureRelation.objects.filter( Picture.objects.filter(people__user__in=(user1,))
picture__in=Picture.objects.filter(people__user=user) .filter(people__user__in=(user2,))
.count()
) )
.values("user") if picture_count:
.annotate(count=Count("user")) cls.logger.debug(
) f"\t\t- '{user1}' was pictured with '{user2}' {picture_count} times"
return defaultdict(
int, {p["user"]: p["count"] * cls.PICTURE_POINTS for p in common_photos}
) )
return picture_count * cls.PICTURE_POINTS
@classmethod @classmethod
def compute_user_clubs_score(cls, user: User) -> defaultdict[int, int]: def compute_users_clubs_score(cls, user1: User, user2: User) -> int:
"""Compute the clubs score of the relation between the given users. """Compute the clubs score of the relation between the given users.
The club score is obtained by counting the number of days The club score is obtained by counting the number of days
@@ -289,36 +324,54 @@ class Galaxy(models.Model):
(two years) and user2 was a member of the same club from 01/01/2021 to (two years) and user2 was a member of the same club from 01/01/2021 to
31/12/2022 (also two years, but with an offset of one year), then their 31/12/2022 (also two years, but with an offset of one year), then their
club score is 365. club score is 365.
Returns:
the number of days during which both users were in the same club
""" """
memberships = user.memberships.only("start_date", "end_date", "club_id") common_clubs = Club.objects.filter(members__in=user1.memberships.all()).filter(
result = defaultdict(int) members__in=user2.memberships.all()
now = localdate()
for membership in memberships:
# This is a N+1 query, but 92% of galaxy users have less than 10 memberships.
# Only 5 users have more than 30 memberships.
common_memberships = (
Membership.objects.exclude(user=user)
.filter(
Q( # start2 <= start1 <= end2
start_date__lte=membership.start_date,
end_date__gte=membership.start_date,
) )
| Q( # start2 <= start1 <= now user1_memberships = user1.memberships.filter(club__in=common_clubs)
start_date__lte=membership.start_date, end_date=None user2_memberships = user2.memberships.filter(club__in=common_clubs)
score = 0
for user1_membership in user1_memberships:
if user1_membership.end_date is None:
# user1_membership.save() is not called in this function, hence this is safe
user1_membership.end_date = localdate()
query = Q( # start2 <= start1 <= end2
start_date__lte=user1_membership.start_date,
end_date__gte=user1_membership.start_date,
) )
| Q( # start1 <= start2 <= end2 query |= Q( # start2 <= start1 <= now
start_date__gte=membership.start_date, start_date__lte=user1_membership.start_date, end_date=None
start_date__lte=membership.end_date or now,
),
club_id=membership.club_id,
) )
.only("start_date", "end_date", "user_id") query |= Q( # start1 <= start2 <= end2
start_date__gte=user1_membership.start_date,
start_date__lte=user1_membership.end_date,
) )
for other in common_memberships: for user2_membership in user2_memberships.filter(
start = max(membership.start_date, other.start_date) query, club=user1_membership.club
end = min(membership.end_date or now, other.end_date or now) ):
result[other.user_id] += (end - start).days * cls.CLUBS_POINTS if user2_membership.end_date is None:
return result user2_membership.end_date = localdate()
latest_start = max(
user1_membership.start_date, user2_membership.start_date
)
earliest_end = min(user1_membership.end_date, user2_membership.end_date)
cls.logger.debug(
"\t\t- '%s' was with '%s' in %s starting on %s until %s (%s days)"
% (
user1,
user2,
user2_membership.club,
latest_start,
earliest_end,
(earliest_end - latest_start).days,
)
)
score += cls.CLUBS_POINTS * (earliest_end - latest_start).days
return score
################### ###################
# Rule the galaxy # # Rule the galaxy #
@@ -353,9 +406,7 @@ class Galaxy(models.Model):
cls.logger.debug(f"\t\t> Scaled distance: {value}") cls.logger.debug(f"\t\t> Scaled distance: {value}")
return int(value) return int(value)
def rule( def rule(self, picture_count_threshold=10) -> None:
self, picture_count_threshold: int = DEFAULT_PICTURE_COUNT_THRESHOLD
) -> None:
"""Main function of the Galaxy. """Main function of the Galaxy.
Iterate over all the rulable users to promote them to citizens. Iterate over all the rulable users to promote them to citizens.
@@ -376,30 +427,41 @@ class Galaxy(models.Model):
""" """
total_time = time.time() total_time = time.time()
self.logger.info("Listing rulable citizen.") self.logger.info("Listing rulable citizen.")
rulable_users = (
User.objects.filter(subscriptions__isnull=False)
.annotate(pictures_count=Count("pictures"))
.filter(pictures_count__gt=picture_count_threshold)
.distinct()
)
# force fetch of the whole query to make sure there won't # force fetch of the whole query to make sure there won't
# be any more db hits # be any more db hits
# this is memory expensive but prevents a lot of db hits, therefore # this is memory expensive but prevents a lot of db hits, therefore
# is far more time efficient # is far more time efficient
rulable_users = list(self.get_rulable_users(picture_count_threshold)) rulable_users = list(rulable_users)
rulable_users_count = len(rulable_users) rulable_users_count = len(rulable_users)
user1_count = 0 user1_count = 0
self.logger.info( self.logger.info(
f"{rulable_users_count} citizen have been listed. Starting to rule." f"{rulable_users_count} citizen have been listed. Starting to rule."
) )
stars = []
self.logger.info("Creating stars for all citizen") self.logger.info("Creating stars for all citizen")
individual_scores = self.compute_individual_scores() for user in rulable_users:
GalaxyStar.objects.bulk_create( star = GalaxyStar(
[ owner=user, galaxy=self, mass=self.compute_user_score(user)
GalaxyStar(owner=user, galaxy=self, mass=individual_scores[user.id])
for user in rulable_users
]
) )
stars = {star.owner_id: star for star in self.stars.all()} stars.append(star)
GalaxyStar.objects.bulk_create(stars)
stars = {}
for star in GalaxyStar.objects.filter(galaxy=self):
stars[star.owner.id] = star
self.logger.info("Creating lanes between stars") self.logger.info("Creating lanes between stars")
# Display current speed every $speed_count_frequency users
speed_count_frequency = max(rulable_users_count // 10, 1) # ten time at most
global_avg_speed_accumulator = 0 global_avg_speed_accumulator = 0
global_avg_speed_count = 0 global_avg_speed_count = 0
t_global_start = time.time() t_global_start = time.time()
@@ -410,19 +472,20 @@ class Galaxy(models.Model):
star1 = stars[user1.id] star1 = stars[user1.id]
lanes = [] user_avg_speed = 0
family_scores = self.compute_user_family_score(user1) user_avg_speed_count = 0
picture_scores = self.compute_user_pictures_score(user1)
club_scores = self.compute_user_clubs_score(user1) tstart = time.time()
lanes = []
for user2_count, user2 in enumerate(rulable_users, start=1):
self.logger.debug("")
self.logger.debug(
f"\t> Examining '{user1}' ({user1_count}/{rulable_users_count}) with '{user2}' ({user2_count}/{rulable_users_count2})"
)
for user2 in rulable_users:
star2 = stars[user2.id] star2 = stars[user2.id]
score = RelationScore( score = Galaxy.compute_users_score(user1, user2)
family=family_scores.get(user2.id, 0),
pictures=picture_scores.get(user2.id, 0),
clubs=club_scores.get(user2.id, 0),
)
distance = self.scale_distance(sum(score)) distance = self.scale_distance(sum(score))
if distance < 30: # TODO: this needs tuning with real-world data if distance < 30: # TODO: this needs tuning with real-world data
lanes.append( lanes.append(
@@ -435,8 +498,22 @@ class Galaxy(models.Model):
clubs=score.clubs, clubs=score.clubs,
) )
) )
if user2_count % speed_count_frequency == 0:
tend = time.time()
delta = tend - tstart
speed = float(speed_count_frequency) / delta
user_avg_speed += speed
user_avg_speed_count += 1
self.logger.debug(
f"\tSpeed: {speed:.2f} users per second (time for last {speed_count_frequency} citizens: {delta:.2f} second)"
)
tstart = time.time()
GalaxyLane.objects.bulk_create(lanes) GalaxyLane.objects.bulk_create(lanes)
self.logger.info("")
t_global_end = time.time() t_global_end = time.time()
global_delta = t_global_end - t_global_start global_delta = t_global_end - t_global_start
speed = 1.0 / global_delta speed = 1.0 / global_delta
@@ -444,17 +521,19 @@ class Galaxy(models.Model):
global_avg_speed_count += 1 global_avg_speed_count += 1
global_avg_speed = global_avg_speed_accumulator / global_avg_speed_count global_avg_speed = global_avg_speed_accumulator / global_avg_speed_count
if user1_count % 50 == 0:
self.logger.info("")
self.logger.info(f" Ruling of {self} ".center(60, "#")) self.logger.info(f" Ruling of {self} ".center(60, "#"))
self.logger.info( self.logger.info(
f"Progression: {user1_count}/{rulable_users_count} " f"Progression: {user1_count}/{rulable_users_count} citizen -- {rulable_users_count - user1_count} remaining"
f"citizen -- {rulable_users_count - user1_count} remaining"
) )
self.logger.info(f"Speed: {global_avg_speed:.2f} citizen per second") self.logger.info(f"Speed: {60.0 * global_avg_speed:.2f} citizen per minute")
eta = rulable_users_count2 // global_avg_speed
# We can divide the computed ETA by 2 because each loop, there is one citizen less to check, and maths tell
# us that this averages to a division by two
eta = rulable_users_count2 / global_avg_speed / 2
eta_hours = int(eta // 3600)
eta_minutes = int(eta // 60 % 60)
self.logger.info( self.logger.info(
f"ETA: {int(eta // 60 % 60)} minutes {int(eta % 60)} seconds" f"ETA: {eta_hours} hours {eta_minutes} minutes ({eta / 3600 / 24:.2f} days)"
) )
self.logger.info("#" * 60) self.logger.info("#" * 60)
t_global_start = time.time() t_global_start = time.time()
@@ -477,10 +556,11 @@ class Galaxy(models.Model):
Galaxy.objects.filter(pk__in=old_galaxies_pks).delete() Galaxy.objects.filter(pk__in=old_galaxies_pks).delete()
total_time = time.time() - total_time total_time = time.time() - total_time
total_time_hours = int(total_time // 3600)
total_time_minutes = int(total_time // 60 % 60) total_time_minutes = int(total_time // 60 % 60)
total_time_seconds = int(total_time % 60) total_time_seconds = int(total_time % 60)
self.logger.info( self.logger.info(
f"{self} ruled in {total_time_minutes} minutes, {total_time_seconds} seconds" f"{self} ruled in {total_time:.2f} seconds ({total_time_hours} hours, {total_time_minutes} minutes, {total_time_seconds} seconds)"
) )
def make_state(self) -> None: def make_state(self) -> None:
@@ -488,33 +568,58 @@ class Galaxy(models.Model):
self.logger.info( self.logger.info(
"Caching current Galaxy state for a quicker display of the Empire's power." "Caching current Galaxy state for a quicker display of the Empire's power."
) )
without_nickname = Concat(
F("owner__first_name"), Value(" "), F("owner__last_name")
)
with_nickname = Concat(
F("owner__first_name"),
Value(" "),
F("owner__last_name"),
Value(" ("),
F("owner__nick_name"),
Value(")"),
)
stars = ( stars = (
GalaxyStar.objects.filter(galaxy=self) GalaxyStar.objects.filter(galaxy=self)
.order_by("owner_id") .order_by(
.select_related("owner") "owner"
) # This helps determinism for the tests and doesn't cost much
.annotate(
owner_name=Case(
When(owner__nick_name=None, then=without_nickname),
default=with_nickname,
)
)
) )
lanes = ( lanes = (
GalaxyLane.objects.filter(star1__galaxy=self) GalaxyLane.objects.filter(star1__galaxy=self)
.order_by("star1") .order_by(
"star1"
) # This helps determinism for the tests and doesn't cost much
.annotate( .annotate(
star1_owner=F("star1__owner_id"), star2_owner=F("star2__owner_id") star1_owner=F("star1__owner__id"),
star2_owner=F("star2__owner__id"),
) )
) )
json = GalaxyDict( json = GalaxyDict(
nodes=[ nodes=[
StarDict( StarDict(
id=star.owner_id, name=star.owner.get_display_name(), mass=star.mass id=star.owner_id,
name=star.owner_name,
mass=star.mass,
) )
for star in stars for star in stars
], ],
links=[ links=[],
)
for path in lanes:
json["links"].append(
{ {
"source": path.star1_owner, "source": path.star1_owner,
"target": path.star2_owner, "target": path.star2_owner,
"value": path.distance, "value": path.distance,
} }
for path in lanes
],
) )
self.state = json self.state = json
self.save() self.save()

View File

@@ -33,7 +33,7 @@ from core.models import User
from galaxy.models import Galaxy from galaxy.models import Galaxy
# @pytest.mark.skip(reason="Galaxy is disabled for now") @pytest.mark.skip(reason="Galaxy is disabled for now")
class TestGalaxyModel(TestCase): class TestGalaxyModel(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@@ -48,19 +48,15 @@ class TestGalaxyModel(TestCase):
def test_user_self_score(self): def test_user_self_score(self):
"""Test that individual user scores are correct.""" """Test that individual user scores are correct."""
with self.assertNumQueries(1): with self.assertNumQueries(8):
scores = Galaxy.compute_individual_scores() assert Galaxy.compute_user_score(self.root) == 9
expected = { assert Galaxy.compute_user_score(self.skia) == 10
self.root.id: 9, assert Galaxy.compute_user_score(self.sli) == 8
self.skia.id: 10, assert Galaxy.compute_user_score(self.krophil) == 2
self.sli.id: 8, assert Galaxy.compute_user_score(self.richard) == 10
self.krophil.id: 2, assert Galaxy.compute_user_score(self.subscriber) == 8
self.richard.id: 10, assert Galaxy.compute_user_score(self.public) == 8
self.subscriber.id: 8, assert Galaxy.compute_user_score(self.com) == 1
self.public.id: 8,
self.com.id: 1,
}
assert scores.items() >= expected.items()
def test_users_score(self): def test_users_score(self):
"""Test on the default dataset generated by the `populate` command """Test on the default dataset generated by the `populate` command
@@ -122,23 +118,17 @@ class TestGalaxyModel(TestCase):
self.com, self.com,
] ]
with self.assertNumQueries(44): with self.assertNumQueries(100):
while len(users) > 0: while len(users) > 0:
user1 = users.pop(0) user1 = users.pop(0)
family_scores = Galaxy.compute_user_family_score(user1)
picture_scores = Galaxy.compute_user_pictures_score(user1)
club_scores = Galaxy.compute_user_clubs_score(user1)
for user2 in users: for user2 in users:
score = Galaxy.compute_users_score(user1, user2)
u1 = computed_scores.get(user1.username, {}) u1 = computed_scores.get(user1.username, {})
u1[user2.username] = { u1[user2.username] = {
"score": ( "score": sum(score),
family_scores[user2.id] "family": score.family,
+ picture_scores[user2.id] "pictures": score.pictures,
+ club_scores[user2.id] "clubs": score.clubs,
),
"family": family_scores[user2.id],
"pictures": picture_scores[user2.id],
"clubs": club_scores[user2.id],
} }
computed_scores[user1.username] = u1 computed_scores[user1.username] = u1
@@ -150,12 +140,12 @@ class TestGalaxyModel(TestCase):
that the number of queries to rule the galaxy is stable. that the number of queries to rule the galaxy is stable.
""" """
galaxy = Galaxy.objects.create() galaxy = Galaxy.objects.create()
with self.assertNumQueries(39): with self.assertNumQueries(58):
galaxy.rule(0) # We want everybody here galaxy.rule(0) # We want everybody here
@pytest.mark.slow @pytest.mark.slow
# @pytest.mark.skip(reason="Galaxy is disabled for now") @pytest.mark.skip(reason="Galaxy is disabled for now")
class TestGalaxyView(TestCase): class TestGalaxyView(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):

View File

@@ -6,7 +6,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-09-02 15:56+0200\n" "POT-Creation-Date: 2025-08-23 15:30+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,6 +16,767 @@ 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é."
@@ -267,7 +1028,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 #: club/models.py com/models.py core/models.py sas/models.py
msgid "is moderated" msgid "is moderated"
msgstr "est modéré" msgstr "est modéré"
@@ -306,10 +1067,6 @@ msgstr "Utilisateur non enregistré"
msgid "Club list" msgid "Club list"
msgstr "Liste des clubs" msgstr "Liste des clubs"
#: club/templates/club/club_list.jinja
msgid "The list of all clubs existing at UTBM."
msgstr "La liste de tous les clubs existants à l'UTBM"
#: club/templates/club/club_list.jinja #: club/templates/club/club_list.jinja
msgid "inactive" msgid "inactive"
msgstr "inactif" msgstr "inactif"
@@ -905,7 +1662,7 @@ msgid "News admin"
msgstr "Administration des nouvelles" msgstr "Administration des nouvelles"
#: com/templates/com/news_admin_list.jinja com/templates/com/news_detail.jinja #: com/templates/com/news_admin_list.jinja com/templates/com/news_detail.jinja
#: com/views.py #: com/templates/com/news_list.jinja com/views.py
msgid "News" msgid "News"
msgstr "Nouvelles" msgstr "Nouvelles"
@@ -1039,7 +1796,7 @@ msgstr "Liens"
msgid "Our services" msgid "Our services"
msgstr "Nos services" msgstr "Nos services"
#: com/templates/com/news_list.jinja #: com/templates/com/news_list.jinja pedagogy/templates/pedagogy/guide.jinja
msgid "UV Guide" msgid "UV Guide"
msgstr "Guide des UVs" msgstr "Guide des UVs"
@@ -1538,11 +2295,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/forms.py #: core/models.py sas/models.py
msgid "file name" msgid "file name"
msgstr "nom du fichier" msgstr "nom du fichier"
#: core/models.py #: core/models.py sas/models.py
msgid "parent" msgid "parent"
msgstr "parent" msgstr "parent"
@@ -1550,10 +2307,14 @@ msgstr "parent"
msgid "compressed file" msgid "compressed file"
msgstr "version allégée" msgstr "version allégée"
#: core/models.py #: core/models.py sas/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"
@@ -1582,10 +2343,6 @@ 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"
@@ -1709,12 +2466,8 @@ msgid "500, Server Error"
msgstr "500, Erreur Serveur" msgstr "500, Erreur Serveur"
#: core/templates/core/base.jinja #: core/templates/core/base.jinja
msgid "" msgid "Welcome!"
"AE UTBM is a voluntary organisation run by UTBM students. It organises " msgstr "Bienvenue !"
"student life at UTBM and manages its student facilities."
msgstr ""
"L'AE UTBM est une association bénévole gérée par les étudiants de "
"l'UTBM. Elle organise la vie étudiante de l'UTBM et gère ses lieux de vie."
#: core/templates/core/base/footer.jinja core/templates/core/base/navbar.jinja #: core/templates/core/base/footer.jinja core/templates/core/base/navbar.jinja
msgid "Contacts" msgid "Contacts"
@@ -3177,7 +3930,7 @@ msgstr "élément de relevé de caisse"
msgid "banner" msgid "banner"
msgstr "bannière" msgstr "bannière"
#: counter/models.py #: counter/models.py sas/models.py
msgid "event date" msgid "event date"
msgstr "date de l'événement" msgstr "date de l'événement"
@@ -3827,10 +4580,6 @@ msgstr ""
msgid "Pay with Sith account" msgid "Pay with Sith account"
msgstr "Payer avec un compte AE" msgstr "Payer avec un compte AE"
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid "The online shop of the association."
msgstr "La boutique en ligne de l'association."
#: eboutic/templates/eboutic/eboutic_main.jinja #: eboutic/templates/eboutic/eboutic_main.jinja
msgid "Clear" msgid "Clear"
msgstr "Vider" msgstr "Vider"
@@ -3905,11 +4654,11 @@ msgstr "début des candidatures"
msgid "end candidature" msgid "end candidature"
msgstr "fin des candidatures" msgstr "fin des candidatures"
#: election/models.py #: election/models.py sas/models.py
msgid "edit groups" msgid "edit groups"
msgstr "groupe d'édition" msgstr "groupe d'édition"
#: election/models.py #: election/models.py sas/models.py
msgid "view groups" msgid "view groups"
msgstr "groupe de vue" msgstr "groupe de vue"
@@ -4160,10 +4909,6 @@ msgstr "Message supprimé ou non-visible."
msgid "Order by date" msgid "Order by date"
msgstr "Trier par date" msgstr "Trier par date"
#: forum/templates/forum/main.jinja
msgid "A forum dedicated to the UTBM students."
msgstr "Un forum dédié aux étudiants de l'UTBM."
#: forum/templates/forum/main.jinja #: forum/templates/forum/main.jinja
msgid "View last unread messages" msgid "View last unread messages"
msgstr "Voir les derniers messages non lus" msgstr "Voir les derniers messages non lus"
@@ -4390,14 +5135,6 @@ msgstr "signaler"
msgid "reporter" msgid "reporter"
msgstr "signalant" msgstr "signalant"
#: pedagogy/templates/pedagogy/guide.jinja
msgid "UE Guide"
msgstr "Guide des UEs"
#: pedagogy/templates/pedagogy/guide.jinja
msgid "A guide of courses available at UTBM."
msgstr "Un guide de tous les cours disponibles à l'UTBM."
#: pedagogy/templates/pedagogy/guide.jinja #: pedagogy/templates/pedagogy/guide.jinja
#, python-format #, python-format
msgid "%(display_name)s" msgid "%(display_name)s"
@@ -4636,6 +5373,22 @@ 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"
@@ -4690,11 +5443,6 @@ msgstr "Demande de retrait d'image"
msgid "Request removal" msgid "Request removal"
msgstr "Demander le retrait" msgstr "Demander le retrait"
#: sas/templates/sas/main.jinja
msgid "See all the photos taken during events organised by the AE."
msgstr ""
"Retrouvez toutes les photos prises lors des événements organisés par l'AE."
#: sas/templates/sas/main.jinja #: sas/templates/sas/main.jinja
msgid "You must be logged in to see the SAS." msgid "You must be logged in to see the SAS."
msgstr "Vous devez être connecté pour voir les photos." msgstr "Vous devez être connecté pour voir les photos."
@@ -5135,10 +5883,6 @@ msgstr "Tee-shirt AE"
msgid "A user with that email address already exists" msgid "A user with that email address already exists"
msgstr "Un utilisateur avec cette adresse email existe déjà" msgstr "Un utilisateur avec cette adresse email existe déjà"
#: subscription/forms.py
msgid "This user didn't fill its birthdate yet."
msgstr "Cet utilisateur n'a pas encore renseigné sa date de naissance"
#: subscription/models.py #: subscription/models.py
msgid "Bad subscription type" msgid "Bad subscription type"
msgstr "Mauvais type de cotisation" msgstr "Mauvais type de cotisation"
@@ -5178,7 +5922,7 @@ msgid ""
"%(user)s received its new %(type)s subscription. It will be active until " "%(user)s received its new %(type)s subscription. It will be active until "
"%(end)s included." "%(end)s included."
msgstr "" msgstr ""
"%(user)s a reçu sa nouvelle cotisaton %(type)s. Elle sera active jusqu'au " "%(user)s a reçu sa nouvelle cotisaton %(type)s. Elle sert active jusqu'au "
"%(end)s inclu." "%(end)s inclu."
#: subscription/templates/subscription/fragments/creation_success.jinja #: subscription/templates/subscription/fragments/creation_success.jinja

1070
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -35,9 +35,9 @@
"@types/jquery": "^3.5.31", "@types/jquery": "^3.5.31",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vite": "^6.2.6", "vite": "^6.2.5",
"vite-bundle-visualizer": "^1.2.1", "vite-bundle-visualizer": "^1.2.1",
"vite-plugin-static-copy": "^3.1.2" "vite-plugin-static-copy": "^3.0.2"
}, },
"dependencies": { "dependencies": {
"@alpinejs/sort": "^3.14.7", "@alpinejs/sort": "^3.14.7",

View File

@@ -2,13 +2,9 @@
{% from 'core/macros.jinja' import paginate_alpine %} {% from 'core/macros.jinja' import paginate_alpine %}
{% block title %} {% block title %}
{% trans %}UE Guide{% endtrans %} {% trans %}UV Guide{% endtrans %}
{% endblock %} {% endblock %}
{% block description -%}
{% trans %}A guide of courses available at UTBM.{% endtrans %}
{%- endblock %}
{% block additional_css %} {% block additional_css %}
<link rel="stylesheet" href="{{ static('pedagogy/css/pedagogy.scss') }}"> <link rel="stylesheet" href="{{ static('pedagogy/css/pedagogy.scss') }}">
{% endblock %} {% endblock %}

View File

@@ -43,8 +43,8 @@ dependencies = [
"tomli<3.0.0,>=2.2.1", "tomli<3.0.0,>=2.2.1",
"django-honeypot>=1.3.0,<2", "django-honeypot>=1.3.0,<2",
"pydantic-extra-types<3.0.0,>=2.10.3", "pydantic-extra-types<3.0.0,>=2.10.3",
"ical>=11,<12", "ical>=10.0.3,<11",
"redis[hiredis]<7,>=5.3.0", "redis[hiredis]<6.0.0,>=5.3.0",
"environs[django]<15.0.0,>=14.1.1", "environs[django]<15.0.0,>=14.1.1",
"requests>=2.32.3", "requests>=2.32.3",
"honcho>=2.0.0", "honcho>=2.0.0",
@@ -63,7 +63,7 @@ prod = [
"psycopg[c]>=3.2.9,<4.0.0", "psycopg[c]>=3.2.9,<4.0.0",
] ]
dev = [ dev = [
"django-debug-toolbar>=6,<7", "django-debug-toolbar>=5.2.0,<6.0.0",
"ipython<10.0.0,>=9.0.2", "ipython<10.0.0,>=9.0.2",
"pre-commit<5.0.0,>=4.1.0", "pre-commit<5.0.0,>=4.1.0",
"ruff>=0.11.13,<1.0.0", "ruff>=0.11.13,<1.0.0",
@@ -78,7 +78,7 @@ tests = [
"pytest-django<5.0.0,>=4.10.0", "pytest-django<5.0.0,>=4.10.0",
"model-bakery<2.0.0,>=1.20.4", "model-bakery<2.0.0,>=1.20.4",
"beautifulsoup4>=4.13.3,<5", "beautifulsoup4>=4.13.3,<5",
"lxml>=6,<7", "lxml>=5.3.1,<6",
] ]
docs = [ docs = [
"mkdocs<2.0.0,>=1.6.1", "mkdocs<2.0.0,>=1.6.1",

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", "date", "size", "is_moderated") list_display = ("name", "parent", "is_moderated")
search_fields = ("name",) search_fields = ("name",)
autocomplete_fields = ("owner", "parent", "edit_groups", "view_groups", "moderator") autocomplete_fields = ("owner", "parent", "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", "date", "owner", "is_moderated") list_display = ("name", "parent")
search_fields = ("name",) search_fields = ("name",)
autocomplete_fields = ("owner", "parent", "edit_groups", "view_groups") autocomplete_fields = ("parent", "edit_groups", "view_groups")
@admin.register(PictureModerationRequest) @admin.register(PictureModerationRequest)

View File

@@ -2,8 +2,10 @@ 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.shortcuts import get_list_or_404
from django.urls import reverse from django.urls import reverse
from ninja import Body, File, Query from ninja import Body, Query, UploadedFile
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,11 +19,12 @@ 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.schemas import UploadedImage from core.utils import get_list_exact_or_404
from sas.models import Album, PeoplePictureRelation, Picture from sas.models import Album, PeoplePictureRelation, Picture
from sas.schemas import ( from sas.schemas import (
AlbumAutocompleteSchema, AlbumAutocompleteSchema,
@@ -29,6 +32,7 @@ from sas.schemas import (
AlbumSchema, AlbumSchema,
IdentifiedUserSchema, IdentifiedUserSchema,
ModerationRequestSchema, ModerationRequestSchema,
MoveAlbumSchema,
PictureFilterSchema, PictureFilterSchema,
PictureSchema, PictureSchema,
) )
@@ -71,6 +75,48 @@ 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", permissions=[IsAuthenticated])
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)]
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)]
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"])
@route.delete("", permissions=[HasPerm("sas.delete_album")])
def delete_album(self, album_ids: list[int]):
# known caveat : deleting an album doesn't delete the pictures on the disk.
# It's a db only operation.
albums: list[Album] = get_list_or_404(Album, pk__in=album_ids)
@api_controller("/sas/picture") @api_controller("/sas/picture")
class PicturesController(ControllerBase): class PicturesController(ControllerBase):
@@ -103,7 +149,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__date", "date") .order_by("-parent__event_date", "created_at")
.select_related("owner", "parent") .select_related("owner", "parent")
) )
@@ -117,27 +163,25 @@ class PicturesController(ControllerBase):
}, },
url_name="upload_picture", url_name="upload_picture",
) )
def upload_picture(self, album_id: Body[int], picture: File[UploadedImage]): def upload_picture(self, album_id: Body[int], picture: UploadedFile):
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,
file=picture, original=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
try:
new.generate_thumbnails() new.generate_thumbnails()
try:
new.full_clean() new.full_clean()
new.save()
except ValidationError as e: except ValidationError as e:
return self.create_response({"detail": dict(e)}, status_code=409) raise HttpError(status_code=409, message=str(e)) from e
new.save()
@route.get( @route.get(
"/{picture_id}/identified", "/{picture_id}/identified",

View File

@@ -1,18 +1,35 @@
from django.core.files.uploadedfile import SimpleUploadedFile
from model_bakery import seq from model_bakery import seq
from model_bakery.recipe import Recipe from model_bakery.recipe import Recipe
from sas.models import Picture from core.utils import RED_PIXEL_PNG
from sas.models import Album, Picture
album_recipe = Recipe(
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,
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
"""

View File

@@ -48,13 +48,12 @@ class PictureEditForm(forms.ModelForm):
class AlbumEditForm(forms.ModelForm): class AlbumEditForm(forms.ModelForm):
class Meta: class Meta:
model = Album model = Album
fields = ["name", "date", "file", "parent", "edit_groups"] fields = ["name", "date", "thumbnail", "parent", "edit_groups"]
widgets = { widgets = {
"parent": AutoCompleteSelectAlbum, "parent": AutoCompleteSelectAlbum,
"edit_groups": AutoCompleteSelectMultipleGroup, "edit_groups": AutoCompleteSelectMultipleGroup,
} }
name = forms.CharField(max_length=Album.NAME_MAX_LENGTH, label=_("file name"))
date = forms.DateField(label=_("Date"), widget=SelectDate, required=True) 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)

View File

@@ -0,0 +1,357 @@
# 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,
),
]

View File

@@ -0,0 +1,31 @@
# 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",
),
),
]

View File

@@ -18,29 +18,52 @@ from __future__ import annotations
import contextlib import contextlib
from io import BytesIO from io import BytesIO
from pathlib import Path from pathlib import Path
from typing import ClassVar, Self from typing import TYPE_CHECKING, 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.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 Notification, SithFile, User from core.models import Group, Notification, User
from core.utils import exif_auto_rotate, resize_image from core.utils import exif_auto_rotate, 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):
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
May be used to have logic that should be shared by both May be 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].
""" """
class Meta: class Meta:
proxy = True abstract = 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"),
@@ -65,6 +88,169 @@ class SasFile(SithFile):
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:
@@ -80,23 +266,65 @@ 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:
proxy = True verbose_name = _("picture")
constraints = [
models.UniqueConstraint(
fields=["name", "parent"], name="sas_picture_unique_per_album"
)
]
objects = SASPictureManager.from_queryset(PictureQuerySet)() def __str__(self):
return self.name
@property def get_absolute_url(self):
def is_vertical(self): return reverse("sas:picture", kwargs={"picture_id": self.id})
with open(settings.MEDIA_ROOT / self.file.name, "rb") as f:
im = Image.open(BytesIO(f.read()))
(w, h) = im.size
return (w / h) < 1
def get_download_url(self): def get_download_url(self):
return reverse("sas:download", kwargs={"picture_id": self.id}) return reverse("sas:download", kwargs={"picture_id": self.id})
@@ -107,41 +335,34 @@ class Picture(SasFile):
def get_download_thumb_url(self): def get_download_thumb_url(self):
return reverse("sas:download_thumb", kwargs={"picture_id": self.id}) return reverse("sas:download_thumb", kwargs={"picture_id": self.id})
def get_absolute_url(self): @property
return reverse("sas:picture", kwargs={"picture_id": self.id}) def is_vertical(self):
# 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(self, *, overwrite=False): def generate_thumbnails(self):
im = Image.open(BytesIO(self.file.read())) im = Image.open(self.original)
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
im = exif_auto_rotate(im) im = exif_auto_rotate(im)
# 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
# meant to be shown on the website, but rather to keep the real image
# for less frequent cases (like downloading the pictures of an user)
extension = self.mime_type.split("/")[-1]
# the HD version of the image doesn't need to be optimized, because : # the HD version of the image doesn't need to be optimized, because :
# - it isn't frequently queried # - it isn't frequently queried
# - optimizing large images takes a lot time, which greatly hinders the UX # - optimizing large images takes a lot of time, which greatly hinders the UX
# - photographers usually already optimize their images # - photographers usually already optimize their images
file = resize_image(im, max(im.size), extension, optimize=False)
thumb = resize_image(im, 200, "webp") thumb = resize_image(im, 200, "webp")
compressed = resize_image(im, 1200, "webp") compressed = resize_image(im, 1200, "webp")
if overwrite: new_extension_name = str(Path(self.original.name).with_suffix(".webp"))
self.file.delete()
self.thumbnail.delete()
self.compressed.delete()
new_extension_name = str(Path(self.name).with_suffix(".webp"))
self.file = file
self.file.name = self.name
self.thumbnail = thumb self.thumbnail = thumb
self.thumbnail.name = new_extension_name self.thumbnail.name = new_extension_name
self.compressed = compressed self.compressed = compressed
self.compressed.name = new_extension_name self.compressed.name = new_extension_name
def rotate(self, degree): def rotate(self, degree):
for attr in ["file", "compressed", "thumbnail"]: for field in self.original, self.compressed, self.thumbnail:
name = self.__getattribute__(attr).name with open(field.file, "r+b") as file:
with open(settings.MEDIA_ROOT / name, "r+b") as file:
if file: if file:
im = Image.open(BytesIO(file.read())) im = Image.open(BytesIO(file.read()))
file.seek(0) file.seek(0)
@@ -154,110 +375,6 @@ class Picture(SasFile):
progressive=True, progressive=True,
) )
def get_next(self):
if self.is_moderated:
pictures_qs = self.parent.children.filter(
is_moderated=True,
asked_for_removal=False,
is_folder=False,
id__gt=self.id,
)
else:
pictures_qs = Picture.objects.filter(id__gt=self.id, is_moderated=False)
return pictures_qs.order_by("id").first()
def get_previous(self):
if self.is_moderated:
pictures_qs = self.parent.children.filter(
is_moderated=True,
asked_for_removal=False,
is_folder=False,
id__lt=self.id,
)
else:
pictures_qs = Picture.objects.filter(id__lt=self.id, is_moderated=False)
return pictures_qs.order_by("-id").first()
class AlbumQuerySet(models.QuerySet):
def viewable_by(self, user: User) -> Self:
"""Filter the albums that this user can view.
Warning:
Calling this queryset method may add several additional requests.
"""
if user.has_perm("sas.moderate_sasfile"):
return self.all()
if user.was_subscribed:
return self.filter(Q(is_moderated=True) | Q(owner=user))
# 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 SASAlbumManager(models.Manager):
def get_queryset(self):
return super().get_queryset().filter(is_in_sas=True, is_folder=True)
class Album(SasFile):
NAME_MAX_LENGTH: ClassVar[int] = 50
"""Maximum length of an album's name.
[SithFile][core.models.SithFile] have a maximum length
of 256 characters.
However, this limit is too high for albums.
Names longer than 50 characters are harder to read
and harder to display on the SAS page.
It is to be noted, though, that this does not
add or modify any db behaviour.
It's just a constant to be used in views and forms.
"""
class Meta:
proxy = True
objects = SASAlbumManager.from_queryset(AlbumQuerySet)()
@property
def children_pictures(self):
return Picture.objects.filter(parent=self)
@property
def children_albums(self):
return Album.objects.filter(parent=self)
def get_absolute_url(self):
if self.id == settings.SITH_SAS_ROOT_DIR_ID:
return reverse("sas:main")
return reverse("sas:album", kwargs={"album_id": self.id})
def get_download_url(self):
return reverse("sas:album_preview", kwargs={"album_id": self.id})
def generate_thumbnail(self):
p = (
self.children_pictures.order_by("?").first()
or self.children_albums.exclude(file=None)
.exclude(file="")
.order_by("?")
.first()
)
if p and p.file:
image = resize_image(Image.open(BytesIO(p.file.read())), 200, "webp")
self.file = image
self.file.name = f"{self.name}/thumb.webp"
self.save()
def sas_notification_callback(notif: Notification): def sas_notification_callback(notif: Notification):
count = Picture.objects.filter(is_moderated=False).count() count = Picture.objects.filter(is_moderated=False).count()

View File

@@ -56,7 +56,12 @@ class AlbumAutocompleteSchema(ModelSchema):
@staticmethod @staticmethod
def resolve_path(obj: Album) -> str: def resolve_path(obj: Album) -> str:
return str(Path(obj.get_parent_path()) / obj.name) return str(Path(obj.parent_path) / obj.name)
class MoveAlbumSchema(Schema):
id: int
new_parent_id: int
class PictureFilterSchema(FilterSchema): class PictureFilterSchema(FilterSchema):
@@ -69,7 +74,7 @@ class PictureFilterSchema(FilterSchema):
class PictureSchema(ModelSchema): class PictureSchema(ModelSchema):
class Meta: class Meta:
model = Picture model = Picture
fields = ["id", "name", "date", "size", "is_moderated", "asked_for_removal"] fields = ["id", "name", "created_at", "is_moderated", "asked_for_removal"]
owner: UserProfileSchema owner: UserProfileSchema
sas_url: str sas_url: str

View File

@@ -125,3 +125,108 @@ 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();
// }
// });

View File

@@ -30,10 +30,10 @@ document.addEventListener("alpine:init", () => {
await Promise.all( await Promise.all(
this.pictures.map((p: PictureSchema) => { this.pictures.map((p: PictureSchema) => {
const imgName = `${p.album}/IMG_${p.date.replace(/[:\-]/g, "_")}${p.name.slice(p.name.lastIndexOf("."))}`; const imgName = `${p.album}/IMG_${p.created_at.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.date), lastModDate: new Date(p.created_at),
onstart: incrementProgressBar, onstart: incrementProgressBar,
}); });
}), }),

View File

@@ -142,7 +142,8 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
// biome-ignore lint/style/useNamingConvention: api is in snake_case // biome-ignore lint/style/useNamingConvention: api is in snake_case
full_size_url: "", full_size_url: "",
owner: "", owner: "",
date: new Date(), // biome-ignore lint/style/useNamingConvention: api is in snake_case
created_at: new Date(),
identifications: [] as IdentifiedUserSchema[], identifications: [] as IdentifiedUserSchema[],
}, },
/** /**

View File

@@ -20,7 +20,7 @@
{% block content %} {% block content %}
<code> <code>
<a href="{{ url('sas:main') }}">SAS</a> / {{ print_path(album.parent) }} {{ album.get_display_name() }} <a href="{{ url('sas:main') }}">SAS</a> / {{ print_path(album.parent) }} {{ album.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.get_display_name() }}</h3> <h3>{{ album.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 %} {# {% for f in clipboard["albums"] %}#}
<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 is_sas_admin %} {% if edit_mode %}
<input type="checkbox" name="file_list" :value="album.id"> <input type="checkbox" name="album_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="file_list" :value="picture.id"> <input type="checkbox" name="picture_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="{{ upload_form.images.id_for_label }}">{{ upload_form.images.label }} :</label> <label for="{{ form.images.id_for_label }}">{{ form.images.label }} :</label>
{{ upload_form.images|add_attr("x-ref=pictures") }} {{ form.images|add_attr("x-ref=pictures") }}
<span class="helptext">{{ upload_form.images.help_text }}</span> <span class="helptext">{{ 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>

View File

@@ -1,19 +1,13 @@
{% 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.file %} {% if a.thumbnail %}
{% set img = a.get_download_url() %} {% set img = a.get_download_url() %}
{% set src = a.name %} {% set src = a.name %}
{% elif a.children.filter(is_folder=False, is_moderated=True).exists() %}
{% set picture = a.children.filter(is_folder=False).first().as_picture %}
{% set img = picture.get_download_thumb_url() %}
{% set src = picture.name %}
{% else %} {% else %}
{% set img = static('core/img/sas.jpg') %} {% set img = static('core/img/sas.jpg') %}
{% set src = "sas.jpg" %} {% set src = "sas.jpg" %}
{% endif %} {% endif %}
<div <div class="album{% if not a.is_moderated %} not_moderated{% endif %}">
class="album{% if not a.is_moderated %} not_moderated{% endif %}"
>
<img src="{{ img }}" alt="{{ src }}" loading="lazy" /> <img src="{{ img }}" alt="{{ src }}" loading="lazy" />
{% if not a.is_moderated %} {% if not a.is_moderated %}
<div class="overlay">&nbsp;</div> <div class="overlay">&nbsp;</div>
@@ -31,7 +25,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.get_display_name() }}</a> / <a href="{{ url("sas:album", album_id=file.id) }}">{{ file.name }}</a> /
{% endif %} {% endif %}
{% endmacro %} {% endmacro %}

View File

@@ -8,10 +8,6 @@
{% trans %}SAS{% endtrans %} {% trans %}SAS{% endtrans %}
{% endblock %} {% endblock %}
{% block description -%}
{% trans %}See all the photos taken during events organised by the AE.{% endtrans %}
{%- endblock %}
{% set is_sas_admin = user.is_root or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID) %} {% set is_sas_admin = user.is_root or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID) %}
{% from "sas/macros.jinja" import display_album %} {% from "sas/macros.jinja" import display_album %}

View File

@@ -1,9 +1,9 @@
{% extends "core/base.jinja" %} {% extends "core/base.jinja" %}
{%- block additional_css -%} {%- block additional_css -%}
<link defer rel="stylesheet" href="{{ static('bundled/core/components/ajax-select-index.css') }}"> <link rel="stylesheet" href="{{ static('bundled/core/components/ajax-select-index.css') }}">
<link defer rel="stylesheet" href="{{ static('core/components/ajax-select.scss') }}"> <link rel="stylesheet" href="{{ static('core/components/ajax-select.scss') }}">
<link defer rel="stylesheet" href="{{ static('sas/css/picture.scss') }}"> <link rel="stylesheet" href="{{ static('sas/css/picture.scss') }}">
{%- endblock -%} {%- endblock -%}
{%- block additional_js -%} {%- block additional_js -%}
@@ -104,7 +104,7 @@
<span <span
x-text="Intl.DateTimeFormat( x-text="Intl.DateTimeFormat(
'{{ LANGUAGE_CODE }}', {dateStyle: 'long'} '{{ LANGUAGE_CODE }}', {dateStyle: 'long'}
).format(new Date(currentPicture.date))" ).format(new Date(currentPicture.created_at))"
> >
</span> </span>
</div> </div>

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, is_in_sas=True, parent=sas) cls.album_a = baker.make(Album)
cls.album_b = baker.make(Album, is_in_sas=True, parent=sas) cls.album_b = baker.make(Album)
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.children_pictures.values_list("id", flat=True)) expected = list(self.album_a.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__date", "picture__date" "-picture__parent__event_date", "picture__created_at"
).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__date", "picture__date") .order_by("-picture__parent__event_date", "picture__created_at")
.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__date", "picture__date" "-picture__parent__event_date", "picture__created_at"
).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__date", "picture__date") .order_by("-picture__parent__event_date", "picture__created_at")
.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

View File

@@ -3,8 +3,8 @@ from model_bakery import baker
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 picture_recipe from sas.baker_recipes import album_recipe, picture_recipe
from sas.models import Picture from sas.models import Album, Picture
class TestPictureQuerySet(TestCase): class TestPictureQuerySet(TestCase):
@@ -44,3 +44,22 @@ class TestPictureQuerySet(TestCase):
user.pictures.create(picture=self.pictures[1]) # moderated user.pictures.create(picture=self.pictures[1]) # moderated
pictures = list(Picture.objects.viewable_by(user)) pictures = list(Picture.objects.viewable_by(user))
assert pictures == [self.pictures[1]] assert pictures == [self.pictures[1]]
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()

View File

@@ -136,9 +136,7 @@ class TestAlbumUpload:
class TestSasModeration(TestCase): class TestSasModeration(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
album = baker.make( album = baker.make(Album)
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
) )

View File

@@ -12,6 +12,7 @@
# 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
@@ -21,12 +22,12 @@ from django.shortcuts import get_object_or_404
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 FormView, UpdateView from django.views.generic.edit import FormMixin, 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 UseFragmentsMixin from core.views import FileView, UseFragmentsMixin
from core.views.files import FileView, send_file from core.views.files import send_raw_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 (
@@ -62,6 +63,7 @@ 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]:
@@ -78,12 +80,26 @@ 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( kwargs["categories"] = list(albums_qs.filter(parent=None).order_by("id"))
albums_qs.filter(parent_id=settings.SITH_SAS_ROOT_DIR_ID).order_by("id")
)
kwargs["latest"] = list(albums_qs.order_by("-id")[:5]) kwargs["latest"] = list(albums_qs.order_by("-id")[:5])
return kwargs return kwargs
@@ -93,6 +109,9 @@ class PictureView(CanViewMixin, DetailView):
pk_url_kwarg = "picture_id" pk_url_kwarg = "picture_id"
template_name = "sas/picture.jinja" template_name = "sas/picture.jinja"
def get_queryset(self):
return super().get_queryset().select_related("parent")
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
self.object = self.get_object() self.object = self.get_object()
if "rotate_right" in request.GET: if "rotate_right" in request.GET:
@@ -102,31 +121,42 @@ class PictureView(CanViewMixin, DetailView):
return super().get(request, *args, **kwargs) return super().get(request, *args, **kwargs)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | { return super().get_context_data(**kwargs) | {"album": self.object.parent}
"album": Album.objects.get(children=self.object)
}
def send_album(request, album_id): def send_album(request, album_id):
return send_file(request, album_id, Album) album = get_object_or_404(Album, id=album_id)
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):
return send_file(request, picture_id, Picture) picture = get_object_or_404(Picture, id=picture_id)
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):
return send_file(request, picture_id, Picture, "compressed") picture = get_object_or_404(Picture, id=picture_id)
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):
return send_file(request, picture_id, Picture, "thumbnail") picture = get_object_or_404(Picture, id=picture_id)
if not picture.can_be_viewed_by(request.user):
raise PermissionDenied
return send_raw_file(Path(picture.thumbnail.path))
class AlbumView(CanViewMixin, UseFragmentsMixin, DetailView): class AlbumView(CanViewMixin, UseFragmentsMixin, FormMixin, DetailView):
model = Album model = Album
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 {
@@ -141,27 +171,32 @@ class AlbumView(CanViewMixin, UseFragmentsMixin, 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"] = [] request.session["clipboard"] = {"albums": [], "pictures": []}
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()
if not self.object.file: form = self.get_form()
self.object.generate_thumbnail() if not form:
if request.user.can_edit(self.object): # Handle the copy-paste functions # the form is reserved for users that can edit this album.
FileView.handle_clipboard(request, self.object) # If there is no form, it means the user has no right to do a POST
return HttpResponseRedirect(self.request.path) raise PermissionDenied
FileView.handle_clipboard(self.request, self.object)
if not form.is_valid():
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)
if ids := self.request.session.get("clipboard", None): kwargs["clipboard"] = {}
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)
@@ -207,7 +242,7 @@ class ModerationView(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_in_sas=True, is_folder=True is_moderated=False
).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

View File

@@ -99,10 +99,9 @@ INSTALLED_APPS = (
"django.contrib.auth", "django.contrib.auth",
"django.contrib.contenttypes", "django.contrib.contenttypes",
"django.contrib.sessions", "django.contrib.sessions",
"django.contrib.sitemaps",
"django.contrib.sites",
"django.contrib.messages", "django.contrib.messages",
"staticfiles", "staticfiles",
"django.contrib.sites",
"honeypot", "honeypot",
"django_jinja", "django_jinja",
"ninja_extra", "ninja_extra",
@@ -687,10 +686,8 @@ SITH_NOTIFICATIONS = [
# The keys are the notification names as found in SITH_NOTIFICATIONS, and the # The keys are the notification names as found in SITH_NOTIFICATIONS, and the
# values are the callback function to update the notifs. # values are the callback function to update the notifs.
# The callback must take the notif object as first and single argument. # The callback must take the notif object as first and single argument.
# If a notification is permanent but requires no post-action, set the
# callback import string as None
SITH_PERMANENT_NOTIFICATIONS = { SITH_PERMANENT_NOTIFICATIONS = {
"NEWS_MODERATION": None, "NEWS_MODERATION": "com.models.news_notification_callback",
"SAS_MODERATION": "sas.models.sas_notification_callback", "SAS_MODERATION": "sas.models.sas_notification_callback",
} }

View File

@@ -1,46 +0,0 @@
from django.conf import settings
from django.contrib.sitemaps import Sitemap
from django.db.models import OuterRef, Subquery
from django.urls import reverse
from club.models import Club
from core.models import Page, PageRev
class SithSitemap(Sitemap):
def items(self):
return [
"core:index",
"eboutic:main",
"sas:main",
"forum:main",
"club:club_list",
"election:list",
]
def location(self, item):
return reverse(item)
class PagesSitemap(Sitemap):
def items(self):
return (
Page.objects.filter(view_groups=settings.SITH_GROUP_PUBLIC_ID)
.exclude(revisions=None, _full_name__startswith="club")
.annotate(
lastmod=Subquery(
PageRev.objects.filter(page=OuterRef("pk"))
.values("date")
.order_by("-date")[:1]
)
)
.all()
)
def lastmod(self, item: Page):
return item.lastmod
class ClubSitemap(Sitemap):
def items(self):
return Club.objects.filter(is_active=True)

View File

@@ -15,24 +15,20 @@
from django.conf import settings from django.conf import settings
from django.conf.urls.static import static from django.conf.urls.static import static
from django.contrib import admin from django.contrib import admin
from django.contrib.sitemaps.views import sitemap
from django.http import Http404 from django.http import Http404
from django.urls import include, path from django.urls import include, path
from django.views.decorators.cache import cache_page
from django.views.i18n import JavaScriptCatalog from django.views.i18n import JavaScriptCatalog
from api.urls import api from api.urls import api
from sith.sitemap import ClubSitemap, PagesSitemap, SithSitemap
js_info_dict = {"packages": ("sith",)} js_info_dict = {"packages": ("sith",)}
handler403 = "core.views.forbidden" handler403 = "core.views.forbidden"
handler404 = "core.views.not_found" handler404 = "core.views.not_found"
handler500 = "core.views.internal_servor_error" handler500 = "core.views.internal_servor_error"
sitemaps = {"sith": SithSitemap, "pages": PagesSitemap, "clubs": ClubSitemap}
urlpatterns = [ urlpatterns = [
path("", include(("core.urls", "core"), namespace="core")), path("", include(("core.urls", "core"), namespace="core")),
path("sitemap.xml", cache_page(86400)(sitemap), {"sitemaps": sitemaps}),
path("api/", api.urls), path("api/", api.urls),
path("rootplace/", include(("rootplace.urls", "rootplace"), namespace="rootplace")), path("rootplace/", include(("rootplace.urls", "rootplace"), namespace="rootplace")),
path( path(

View File

@@ -182,13 +182,12 @@ 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
with open(out, "w") as f: out.write_text(schema)
_ = f.write(schema)
return subprocess.Popen(["npm", "run", "openapi"]) return subprocess.Popen(["npm", "run", "openapi"])

View File

@@ -23,8 +23,8 @@ class SelectionDateForm(forms.Form):
class SubscriptionForm(forms.ModelForm): class SubscriptionForm(forms.ModelForm):
def __init__(self, *args, initial=None, **kwargs): def __init__(self, *args, **kwargs):
initial = initial or {} initial = kwargs.pop("initial", {})
if "subscription_type" not in initial: if "subscription_type" not in initial:
initial["subscription_type"] = "deux-semestres" initial["subscription_type"] = "deux-semestres"
if "payment_method" not in initial: if "payment_method" not in initial:
@@ -131,57 +131,8 @@ class SubscriptionExistingUserForm(SubscriptionForm):
"""Form to add a subscription to an existing user.""" """Form to add a subscription to an existing user."""
template_name = "subscription/forms/create_existing_user.html" template_name = "subscription/forms/create_existing_user.html"
required_css_class = "required"
birthdate = forms.fields_for_model(
User,
["date_of_birth"],
widgets={"date_of_birth": SelectDate(attrs={"hidden": True})},
help_texts={"date_of_birth": _("This user didn't fill its birthdate yet.")},
)["date_of_birth"]
class Meta: class Meta:
model = Subscription model = Subscription
fields = ["member", "subscription_type", "payment_method", "location"] fields = ["member", "subscription_type", "payment_method", "location"]
widgets = {"member": AutoCompleteSelectUser} widgets = {"member": AutoCompleteSelectUser}
field_order = [
"member",
"birthdate",
"subscription_type",
"payment_method",
"location",
]
def __init__(self, *args, initial=None, **kwargs):
super().__init__(*args, initial=initial, **kwargs)
self.fields["birthdate"].required = True
if not initial:
return
member: str | None = initial.get("member")
if member and member.isdigit():
member: User | None = User.objects.filter(id=int(member)).first()
else:
member = None
if member and member.date_of_birth:
# if there is an initial member with a birthdate,
# there is no need to ask this to the user
self.fields["birthdate"].initial = member.date_of_birth
elif member:
# if there is an initial member without a birthdate,
# then the field must be displayed
self.fields["birthdate"].widget.attrs.update({"hidden": False})
# if there is no initial member, it means that it will be
# dynamically selected using the AutoCompleteSelectUser widget.
# JS will take care of un-hiding the field if necessary
def save(self, *args, **kwargs):
if self.errors:
return super().save(*args, **kwargs)
if (
self.cleaned_data["birthdate"] is not None
and self.instance.member.date_of_birth is None
):
self.instance.member.date_of_birth = self.cleaned_data["birthdate"]
self.instance.member.save()
return super().save(*args, **kwargs)

View File

@@ -18,6 +18,7 @@ from datetime import date, timedelta
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django.conf import settings from django.conf import settings
from django.contrib.auth.forms import PasswordResetForm
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
@@ -71,22 +72,24 @@ class Subscription(models.Model):
else: else:
return f"No user - {self.pk}" return f"No user - {self.pk}"
def save(self, *args, **kwargs) -> None: def save(self, *args, **kwargs):
if self.member.was_subscribed:
super().save() super().save()
return
from counter.models import Customer from counter.models import Customer
customer, _ = Customer.get_or_create(self.member) _, account_created = Customer.get_or_create(self.member)
if account_created:
# Someone who subscribed once will be considered forever # Someone who subscribed once will be considered forever
# as an old subscriber. # as an old subscriber.
self.member.groups.add(settings.SITH_GROUP_OLD_SUBSCRIBERS_ID) self.member.groups.add(settings.SITH_GROUP_OLD_SUBSCRIBERS_ID)
form = PasswordResetForm({"email": self.member.email})
if form.is_valid():
form.save(
use_https=True,
email_template_name="core/new_user_email.jinja",
subject_template_name="core/new_user_email_subject.jinja",
from_email="ae@utbm.fr",
)
self.member.make_home() self.member.make_home()
# now that the user is an old subscriber, change the cached
# property accordingly
self.member.__dict__["was_subscribed"] = True
super().save()
def get_absolute_url(self): def get_absolute_url(self):
return reverse("core:user_edit", kwargs={"user_id": self.member_id}) return reverse("core:user_edit", kwargs={"user_id": self.member_id})

View File

@@ -1,5 +1,3 @@
import { userFetchUser } from "#openapi";
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
Alpine.data("existing_user_subscription_form", () => ({ Alpine.data("existing_user_subscription_form", () => ({
loading: false, loading: false,
@@ -14,24 +12,13 @@ document.addEventListener("alpine:init", () => {
}, },
async loadProfile(userId: number) { async loadProfile(userId: number) {
const birthdayInput = document.getElementById("id_birthdate") as HTMLInputElement;
if (!Number.isInteger(userId)) { if (!Number.isInteger(userId)) {
this.profileFragment = ""; this.profileFragment = "";
birthdayInput.hidden = true;
return; return;
} }
this.loading = true; this.loading = true;
const [miniProfile, userInfos] = await Promise.all([ const response = await fetch(`/user/${userId}/mini/`);
fetch(`/user/${userId}/mini/`), this.profileFragment = await response.text();
// biome-ignore lint/style/useNamingConvention: api is snake_case
userFetchUser({ path: { user_id: userId } }),
]);
this.profileFragment = await miniProfile.text();
// If the user has no birthdate yet, show the form input
// to fill this info.
// Else keep the input hidden and change its value to the user birthdate
birthdayInput.value = userInfos.data.date_of_birth;
birthdayInput.hidden = userInfos.data.date_of_birth !== null;
this.loading = false; this.loading = false;
}, },
})); }));

View File

@@ -1,14 +1,4 @@
#subscription-form form { #subscription-form form {
margin-top: 0;
.form-content {
margin-top: 0;
}
fieldset p:first-of-type, & > p:first-of-type {
margin-top: 0;
}
.form-content.existing-user { .form-content.existing-user {
max-height: 100%; max-height: 100%;
display: flex; display: flex;
@@ -23,11 +13,6 @@
* then display the user profile right in the middle of the remaining space. */ * then display the user profile right in the middle of the remaining space. */
fieldset { fieldset {
flex: 0 1 auto; flex: 0 1 auto;
p:has(input[hidden]) {
// when the input is hidden, hide the whole label+input+help text group
display: none;
}
} }
#subscription-form-user-mini-profile { #subscription-form-user-mini-profile {

View File

@@ -1,11 +1,10 @@
"""Tests focused on testing subscription creation""" """Tests focused on testing subscription creation"""
from datetime import date, timedelta from datetime import timedelta
from typing import Callable from typing import Callable
import pytest import pytest
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django.conf import settings
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.test import Client from django.test import Client
from django.urls import reverse from django.urls import reverse
@@ -15,10 +14,8 @@ from pytest_django.asserts import assertRedirects
from pytest_django.fixtures import SettingsWrapper from pytest_django.fixtures import SettingsWrapper
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, User from core.models import User
from counter.models import Customer
from subscription.forms import SubscriptionExistingUserForm, SubscriptionNewUserForm from subscription.forms import SubscriptionExistingUserForm, SubscriptionNewUserForm
from subscription.models import Subscription
@pytest.mark.django_db @pytest.mark.django_db
@@ -31,26 +28,6 @@ def test_form_existing_user_valid(
): ):
"""Test `SubscriptionExistingUserForm`""" """Test `SubscriptionExistingUserForm`"""
user = user_factory() user = user_factory()
user.date_of_birth = date(year=1967, month=3, day=14)
user.save()
data = {
"member": user,
"birthdate": user.date_of_birth,
"subscription_type": "deux-semestres",
"location": settings.SITH_SUBSCRIPTION_LOCATIONS[0][0],
"payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0],
}
form = SubscriptionExistingUserForm(data)
assert form.is_valid()
form.save()
user.refresh_from_db()
assert user.is_subscribed
@pytest.mark.django_db
def test_form_existing_user_with_birthdate(settings: SettingsWrapper):
"""Test `SubscriptionExistingUserForm`"""
user = baker.make(User, date_of_birth=None)
data = { data = {
"member": user, "member": user,
"subscription_type": "deux-semestres", "subscription_type": "deux-semestres",
@@ -58,15 +35,11 @@ def test_form_existing_user_with_birthdate(settings: SettingsWrapper):
"payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0], "payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0],
} }
form = SubscriptionExistingUserForm(data) form = SubscriptionExistingUserForm(data)
assert not form.is_valid()
data |= {"birthdate": date(year=1967, month=3, day=14)}
form = SubscriptionExistingUserForm(data)
assert form.is_valid() assert form.is_valid()
form.save() form.save()
user.refresh_from_db() user.refresh_from_db()
assert user.is_subscribed assert user.is_subscribed
assert user.date_of_birth == date(year=1967, month=3, day=14)
@pytest.mark.django_db @pytest.mark.django_db
@@ -156,14 +129,6 @@ def test_page_access(
assert res.status_code == status_code assert res.status_code == status_code
@pytest.mark.django_db
def test_page_access_with_get_data(client: Client):
user = old_subscriber_user.make()
client.force_login(baker.make(User, is_superuser=True))
res = client.get(reverse("subscription:subscription", query={"member": user.id}))
assert res.status_code == 200
@pytest.mark.django_db @pytest.mark.django_db
def test_submit_form_existing_user(client: Client, settings: SettingsWrapper): def test_submit_form_existing_user(client: Client, settings: SettingsWrapper):
client.force_login( client.force_login(
@@ -172,12 +137,11 @@ def test_submit_form_existing_user(client: Client, settings: SettingsWrapper):
user_permissions=Permission.objects.filter(codename="add_subscription"), user_permissions=Permission.objects.filter(codename="add_subscription"),
) )
) )
user = old_subscriber_user.make(date_of_birth=date(year=1967, month=3, day=14)) user = old_subscriber_user.make()
response = client.post( response = client.post(
reverse("subscription:fragment-existing-user"), reverse("subscription:fragment-existing-user"),
{ {
"member": user.id, "member": user.id,
"birthdate": user.date_of_birth,
"subscription_type": "deux-semestres", "subscription_type": "deux-semestres",
"location": settings.SITH_SUBSCRIPTION_LOCATIONS[0][0], "location": settings.SITH_SUBSCRIPTION_LOCATIONS[0][0],
"payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0], "payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0],
@@ -225,17 +189,3 @@ def test_submit_form_new_user(client: Client, settings: SettingsWrapper):
kwargs={"subscription_id": current_subscription.id}, kwargs={"subscription_id": current_subscription.id},
), ),
) )
@pytest.mark.django_db
def test_subscription_for_user_that_had_a_sith_account():
"""Test that a newly subscribed user is added to the old subscribers group,
even if there already was a sith account (e.g. created during an eboutic purchase).
"""
user = baker.make(User)
Customer.get_or_create(user)
group = Group.objects.get(id=settings.SITH_GROUP_OLD_SUBSCRIBERS_ID)
assert not user.groups.contains(group)
subscription = baker.prepare(Subscription, member=user)
subscription.save()
assert user.groups.contains(group)

View File

@@ -14,7 +14,6 @@
# #
from django.conf import settings from django.conf import settings
from django.contrib.auth.forms import PasswordResetForm
from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.auth.mixins import PermissionRequiredMixin
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
@@ -66,23 +65,11 @@ class CreateSubscriptionExistingUserFragment(CreateSubscriptionFragment):
class CreateSubscriptionNewUserFragment(CreateSubscriptionFragment): class CreateSubscriptionNewUserFragment(CreateSubscriptionFragment):
"""Create a subscription for a user who doesn't exist yet.""" """Create a subscription for a user who already exists."""
form_class = SubscriptionNewUserForm form_class = SubscriptionNewUserForm
extra_context = {"post_url": reverse_lazy("subscription:fragment-new-user")} extra_context = {"post_url": reverse_lazy("subscription:fragment-new-user")}
def form_valid(self, form):
res = super().form_valid(form)
reset_form = PasswordResetForm({"email": form.cleaned_data["email"]})
if reset_form.is_valid():
reset_form.save(
use_https=True,
email_template_name="core/new_user_email.jinja",
subject_template_name="core/new_user_email_subject.jinja",
from_email="ae@utbm.fr",
)
return res
class SubscriptionCreatedFragment(PermissionRequiredMixin, DetailView): class SubscriptionCreatedFragment(PermissionRequiredMixin, DetailView):
template_name = "subscription/fragments/creation_success.jinja" template_name = "subscription/fragments/creation_success.jinja"

122
uv.lock generated
View File

@@ -492,15 +492,15 @@ wheels = [
[[package]] [[package]]
name = "django-debug-toolbar" name = "django-debug-toolbar"
version = "6.0.0" version = "5.2.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "django" }, { name = "django" },
{ name = "sqlparse" }, { name = "sqlparse" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/c5/d5/5fc90234532088aeec5faa48d5b09951cc7eab6626030ed427d3bd8cd9bc/django_debug_toolbar-6.0.0.tar.gz", hash = "sha256:6eb9fa6f4a5884bf04004700ffb5a44043f1fff38784447fc52c1633448c8c14", size = 305331, upload-time = "2025-07-25T13:11:48.68Z" } sdist = { url = "https://files.pythonhosted.org/packages/2a/9f/97ba2648f66fa208fc7f19d6895586d08bc5f0ab930a1f41032e60f31a41/django_debug_toolbar-5.2.0.tar.gz", hash = "sha256:9e7f0145e1a1b7d78fcc3b53798686170a5b472d9cf085d88121ff823e900821", size = 297901, upload-time = "2025-04-29T05:23:57.533Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/05/b5/4724a8c18fcc5b09dca7b7a0e70c34208317bb110075ad12484d6588ae91/django_debug_toolbar-6.0.0-py3-none-any.whl", hash = "sha256:0cf2cac5c307b77d6e143c914e5c6592df53ffe34642d93929e5ef095ae56841", size = 266967, upload-time = "2025-07-25T13:11:47.265Z" }, { url = "https://files.pythonhosted.org/packages/fa/c2/ed3cb815002664349e9e50799b8c00ef15941f4cad797247cadbdeebab02/django_debug_toolbar-5.2.0-py3-none-any.whl", hash = "sha256:15627f4c2836a9099d795e271e38e8cf5204ccd79d5dbcd748f8a6c284dcd195", size = 262834, upload-time = "2025-04-29T05:23:55.472Z" },
] ]
[[package]] [[package]]
@@ -776,16 +776,17 @@ wheels = [
[[package]] [[package]]
name = "ical" name = "ical"
version = "11.0.0" version = "10.0.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "pydantic" }, { name = "pydantic" },
{ name = "pyparsing" },
{ name = "python-dateutil" }, { name = "python-dateutil" },
{ name = "tzdata" }, { name = "tzdata" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/ec/83/90976eec9a5146973aab21435b8997524b731a8b07b15d5911e088106131/ical-11.0.0.tar.gz", hash = "sha256:78efadc0711dc30da1392271c22713d3081a1df710eacac596f9d49f0030cc9d", size = 124045, upload-time = "2025-07-27T15:53:15.228Z" } sdist = { url = "https://files.pythonhosted.org/packages/05/c2/4e9ee79dc0d9542c7758eed3eb92a95309cf022ea7187649427b163e1fcf/ical-10.0.3.tar.gz", hash = "sha256:bd318aa4cbdeaf3d161c8d676722690daa0e87a8c8553ae756f85bd54f60d2f0", size = 123230, upload-time = "2025-06-16T03:45:04.552Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/34/6b/9299af242cd6c98a57ab0dbc8634351900d470857e7b8d85f226ec57067b/ical-11.0.0-py3-none-any.whl", hash = "sha256:e22aa04010bc4e8621dd3503c5095087ca473450684a69527f37eabe91e145ea", size = 122728, upload-time = "2025-07-27T15:53:13.462Z" }, { url = "https://files.pythonhosted.org/packages/ce/84/4b508115461cdec6510d31c6075d50d185c0097b377f437101f254d6a72e/ical-10.0.3-py3-none-any.whl", hash = "sha256:4b0804ce78dff206190b749c838866d286fff2a193a602b540f2c23ad149ea80", size = 122124, upload-time = "2025-06-16T03:45:03.025Z" },
] ]
[[package]] [[package]]
@@ -920,64 +921,44 @@ wheels = [
[[package]] [[package]]
name = "lxml" name = "lxml"
version = "6.0.1" version = "5.4.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8f/bd/f9d01fd4132d81c6f43ab01983caea69ec9614b913c290a26738431a015d/lxml-6.0.1.tar.gz", hash = "sha256:2b3a882ebf27dd026df3801a87cf49ff791336e0f94b0fad195db77e01240690", size = 4070214, upload-time = "2025-08-22T10:37:53.525Z" } sdist = { url = "https://files.pythonhosted.org/packages/76/3d/14e82fc7c8fb1b7761f7e748fd47e2ec8276d137b6acfe5a4bb73853e08f/lxml-5.4.0.tar.gz", hash = "sha256:d12832e1dbea4be280b22fd0ea7c9b87f0d8fc51ba06e92dc62d52f804f78ebd", size = 3679479, upload-time = "2025-04-23T01:50:29.322Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/b0/a9/82b244c8198fcdf709532e39a1751943a36b3e800b420adc739d751e0299/lxml-6.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:c03ac546adaabbe0b8e4a15d9ad815a281afc8d36249c246aecf1aaad7d6f200", size = 8422788, upload-time = "2025-08-22T10:32:56.612Z" }, { url = "https://files.pythonhosted.org/packages/f8/4c/d101ace719ca6a4ec043eb516fcfcb1b396a9fccc4fcd9ef593df34ba0d5/lxml-5.4.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:b5aff6f3e818e6bdbbb38e5967520f174b18f539c2b9de867b1e7fde6f8d95a4", size = 8127392, upload-time = "2025-04-23T01:46:04.09Z" },
{ url = "https://files.pythonhosted.org/packages/c9/8d/1ed2bc20281b0e7ed3e6c12b0a16e64ae2065d99be075be119ba88486e6d/lxml-6.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:33b862c7e3bbeb4ba2c96f3a039f925c640eeba9087a4dc7a572ec0f19d89392", size = 4593547, upload-time = "2025-08-22T10:32:59.016Z" }, { url = "https://files.pythonhosted.org/packages/11/84/beddae0cec4dd9ddf46abf156f0af451c13019a0fa25d7445b655ba5ccb7/lxml-5.4.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:942a5d73f739ad7c452bf739a62a0f83e2578afd6b8e5406308731f4ce78b16d", size = 4415103, upload-time = "2025-04-23T01:46:07.227Z" },
{ url = "https://files.pythonhosted.org/packages/76/53/d7fd3af95b72a3493bf7fbe842a01e339d8f41567805cecfecd5c71aa5ee/lxml-6.0.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7a3ec1373f7d3f519de595032d4dcafae396c29407cfd5073f42d267ba32440d", size = 4948101, upload-time = "2025-08-22T10:33:00.765Z" }, { url = "https://files.pythonhosted.org/packages/d0/25/d0d93a4e763f0462cccd2b8a665bf1e4343dd788c76dcfefa289d46a38a9/lxml-5.4.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:460508a4b07364d6abf53acaa0a90b6d370fafde5693ef37602566613a9b0779", size = 5024224, upload-time = "2025-04-23T01:46:10.237Z" },
{ url = "https://files.pythonhosted.org/packages/9d/51/4e57cba4d55273c400fb63aefa2f0d08d15eac021432571a7eeefee67bed/lxml-6.0.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03b12214fb1608f4cffa181ec3d046c72f7e77c345d06222144744c122ded870", size = 5108090, upload-time = "2025-08-22T10:33:03.108Z" }, { url = "https://files.pythonhosted.org/packages/31/ce/1df18fb8f7946e7f3388af378b1f34fcf253b94b9feedb2cec5969da8012/lxml-5.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:529024ab3a505fed78fe3cc5ddc079464e709f6c892733e3f5842007cec8ac6e", size = 4769913, upload-time = "2025-04-23T01:46:12.757Z" },
{ url = "https://files.pythonhosted.org/packages/f6/6e/5f290bc26fcc642bc32942e903e833472271614e24d64ad28aaec09d5dae/lxml-6.0.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:207ae0d5f0f03b30f95e649a6fa22aa73f5825667fee9c7ec6854d30e19f2ed8", size = 5021791, upload-time = "2025-08-22T10:33:06.972Z" }, { url = "https://files.pythonhosted.org/packages/4e/62/f4a6c60ae7c40d43657f552f3045df05118636be1165b906d3423790447f/lxml-5.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ca56ebc2c474e8f3d5761debfd9283b8b18c76c4fc0967b74aeafba1f5647f9", size = 5290441, upload-time = "2025-04-23T01:46:16.037Z" },
{ url = "https://files.pythonhosted.org/packages/13/d4/2e7551a86992ece4f9a0f6eebd4fb7e312d30f1e372760e2109e721d4ce6/lxml-6.0.1-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:32297b09ed4b17f7b3f448de87a92fb31bb8747496623483788e9f27c98c0f00", size = 5358861, upload-time = "2025-08-22T10:33:08.967Z" }, { url = "https://files.pythonhosted.org/packages/9e/aa/04f00009e1e3a77838c7fc948f161b5d2d5de1136b2b81c712a263829ea4/lxml-5.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a81e1196f0a5b4167a8dafe3a66aa67c4addac1b22dc47947abd5d5c7a3f24b5", size = 4820165, upload-time = "2025-04-23T01:46:19.137Z" },
{ url = "https://files.pythonhosted.org/packages/8a/5f/cb49d727fc388bf5fd37247209bab0da11697ddc5e976ccac4826599939e/lxml-6.0.1-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e18224ea241b657a157c85e9cac82c2b113ec90876e01e1f127312006233756", size = 5652569, upload-time = "2025-08-22T10:33:10.815Z" }, { url = "https://files.pythonhosted.org/packages/c9/1f/e0b2f61fa2404bf0f1fdf1898377e5bd1b74cc9b2cf2c6ba8509b8f27990/lxml-5.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00b8686694423ddae324cf614e1b9659c2edb754de617703c3d29ff568448df5", size = 4932580, upload-time = "2025-04-23T01:46:21.963Z" },
{ url = "https://files.pythonhosted.org/packages/ca/b8/66c1ef8c87ad0f958b0a23998851e610607c74849e75e83955d5641272e6/lxml-6.0.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a07a994d3c46cd4020c1ea566345cf6815af205b1e948213a4f0f1d392182072", size = 5252262, upload-time = "2025-08-22T10:33:12.673Z" }, { url = "https://files.pythonhosted.org/packages/24/a2/8263f351b4ffe0ed3e32ea7b7830f845c795349034f912f490180d88a877/lxml-5.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:c5681160758d3f6ac5b4fea370495c48aac0989d6a0f01bb9a72ad8ef5ab75c4", size = 4759493, upload-time = "2025-04-23T01:46:24.316Z" },
{ url = "https://files.pythonhosted.org/packages/1a/ef/131d3d6b9590e64fdbb932fbc576b81fcc686289da19c7cb796257310e82/lxml-6.0.1-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:2287fadaa12418a813b05095485c286c47ea58155930cfbd98c590d25770e225", size = 4710309, upload-time = "2025-08-22T10:33:14.952Z" }, { url = "https://files.pythonhosted.org/packages/05/00/41db052f279995c0e35c79d0f0fc9f8122d5b5e9630139c592a0b58c71b4/lxml-5.4.0-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:2dc191e60425ad70e75a68c9fd90ab284df64d9cd410ba8d2b641c0c45bc006e", size = 5324679, upload-time = "2025-04-23T01:46:27.097Z" },
{ url = "https://files.pythonhosted.org/packages/bc/3f/07f48ae422dce44902309aa7ed386c35310929dc592439c403ec16ef9137/lxml-6.0.1-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b4e597efca032ed99f418bd21314745522ab9fa95af33370dcee5533f7f70136", size = 5265786, upload-time = "2025-08-22T10:33:16.721Z" }, { url = "https://files.pythonhosted.org/packages/1d/be/ee99e6314cdef4587617d3b3b745f9356d9b7dd12a9663c5f3b5734b64ba/lxml-5.4.0-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:67f779374c6b9753ae0a0195a892a1c234ce8416e4448fe1e9f34746482070a7", size = 4890691, upload-time = "2025-04-23T01:46:30.009Z" },
{ url = "https://files.pythonhosted.org/packages/11/c7/125315d7b14ab20d9155e8316f7d287a4956098f787c22d47560b74886c4/lxml-6.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9696d491f156226decdd95d9651c6786d43701e49f32bf23715c975539aa2b3b", size = 5062272, upload-time = "2025-08-22T10:33:18.478Z" }, { url = "https://files.pythonhosted.org/packages/ad/36/239820114bf1d71f38f12208b9c58dec033cbcf80101cde006b9bde5cffd/lxml-5.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:79d5bfa9c1b455336f52343130b2067164040604e41f6dc4d8313867ed540079", size = 4955075, upload-time = "2025-04-23T01:46:32.33Z" },
{ url = "https://files.pythonhosted.org/packages/8b/c3/51143c3a5fc5168a7c3ee626418468ff20d30f5a59597e7b156c1e61fba8/lxml-6.0.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e4e3cd3585f3c6f87cdea44cda68e692cc42a012f0131d25957ba4ce755241a7", size = 4786955, upload-time = "2025-08-22T10:33:20.34Z" }, { url = "https://files.pythonhosted.org/packages/d4/e1/1b795cc0b174efc9e13dbd078a9ff79a58728a033142bc6d70a1ee8fc34d/lxml-5.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3d3c30ba1c9b48c68489dc1829a6eede9873f52edca1dda900066542528d6b20", size = 4838680, upload-time = "2025-04-23T01:46:34.852Z" },
{ url = "https://files.pythonhosted.org/packages/11/86/73102370a420ec4529647b31c4a8ce8c740c77af3a5fae7a7643212d6f6e/lxml-6.0.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:45cbc92f9d22c28cd3b97f8d07fcefa42e569fbd587dfdac76852b16a4924277", size = 5673557, upload-time = "2025-08-22T10:33:22.282Z" }, { url = "https://files.pythonhosted.org/packages/72/48/3c198455ca108cec5ae3662ae8acd7fd99476812fd712bb17f1b39a0b589/lxml-5.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1af80c6316ae68aded77e91cd9d80648f7dd40406cef73df841aa3c36f6907c8", size = 5391253, upload-time = "2025-04-23T01:46:37.608Z" },
{ url = "https://files.pythonhosted.org/packages/d7/2d/aad90afaec51029aef26ef773b8fd74a9e8706e5e2f46a57acd11a421c02/lxml-6.0.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f8c9bcfd2e12299a442fba94459adf0b0d001dbc68f1594439bfa10ad1ecb74b", size = 5254211, upload-time = "2025-08-22T10:33:24.15Z" }, { url = "https://files.pythonhosted.org/packages/d6/10/5bf51858971c51ec96cfc13e800a9951f3fd501686f4c18d7d84fe2d6352/lxml-5.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4d885698f5019abe0de3d352caf9466d5de2baded00a06ef3f1216c1a58ae78f", size = 5261651, upload-time = "2025-04-23T01:46:40.183Z" },
{ url = "https://files.pythonhosted.org/packages/63/01/c9e42c8c2d8b41f4bdefa42ab05448852e439045f112903dd901b8fbea4d/lxml-6.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1e9dc2b9f1586e7cd77753eae81f8d76220eed9b768f337dc83a3f675f2f0cf9", size = 5275817, upload-time = "2025-08-22T10:33:26.007Z" }, { url = "https://files.pythonhosted.org/packages/2b/11/06710dd809205377da380546f91d2ac94bad9ff735a72b64ec029f706c85/lxml-5.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aea53d51859b6c64e7c51d522c03cc2c48b9b5d6172126854cc7f01aa11f52bc", size = 5024315, upload-time = "2025-04-23T01:46:43.333Z" },
{ url = "https://files.pythonhosted.org/packages/bc/1f/962ea2696759abe331c3b0e838bb17e92224f39c638c2068bf0d8345e913/lxml-6.0.1-cp312-cp312-win32.whl", hash = "sha256:987ad5c3941c64031f59c226167f55a04d1272e76b241bfafc968bdb778e07fb", size = 3610889, upload-time = "2025-08-22T10:33:28.169Z" }, { url = "https://files.pythonhosted.org/packages/f5/b0/15b6217834b5e3a59ebf7f53125e08e318030e8cc0d7310355e6edac98ef/lxml-5.4.0-cp312-cp312-win32.whl", hash = "sha256:d90b729fd2732df28130c064aac9bb8aff14ba20baa4aee7bd0795ff1187545f", size = 3486149, upload-time = "2025-04-23T01:46:45.684Z" },
{ url = "https://files.pythonhosted.org/packages/41/e2/22c86a990b51b44442b75c43ecb2f77b8daba8c4ba63696921966eac7022/lxml-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:abb05a45394fd76bf4a60c1b7bec0e6d4e8dfc569fc0e0b1f634cd983a006ddc", size = 4010925, upload-time = "2025-08-22T10:33:29.874Z" }, { url = "https://files.pythonhosted.org/packages/91/1e/05ddcb57ad2f3069101611bd5f5084157d90861a2ef460bf42f45cced944/lxml-5.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1dc4ca99e89c335a7ed47d38964abcb36c5910790f9bd106f2a8fa2ee0b909d2", size = 3817095, upload-time = "2025-04-23T01:46:48.521Z" },
{ url = "https://files.pythonhosted.org/packages/b2/21/dc0c73325e5eb94ef9c9d60dbb5dcdcb2e7114901ea9509735614a74e75a/lxml-6.0.1-cp312-cp312-win_arm64.whl", hash = "sha256:c4be29bce35020d8579d60aa0a4e95effd66fcfce31c46ffddf7e5422f73a299", size = 3671922, upload-time = "2025-08-22T10:33:31.535Z" }, { url = "https://files.pythonhosted.org/packages/87/cb/2ba1e9dd953415f58548506fa5549a7f373ae55e80c61c9041b7fd09a38a/lxml-5.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:773e27b62920199c6197130632c18fb7ead3257fce1ffb7d286912e56ddb79e0", size = 8110086, upload-time = "2025-04-23T01:46:52.218Z" },
{ url = "https://files.pythonhosted.org/packages/43/c4/cd757eeec4548e6652eff50b944079d18ce5f8182d2b2cf514e125e8fbcb/lxml-6.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:485eda5d81bb7358db96a83546949c5fe7474bec6c68ef3fa1fb61a584b00eea", size = 8405139, upload-time = "2025-08-22T10:33:34.09Z" }, { url = "https://files.pythonhosted.org/packages/b5/3e/6602a4dca3ae344e8609914d6ab22e52ce42e3e1638c10967568c5c1450d/lxml-5.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ce9c671845de9699904b1e9df95acfe8dfc183f2310f163cdaa91a3535af95de", size = 4404613, upload-time = "2025-04-23T01:46:55.281Z" },
{ url = "https://files.pythonhosted.org/packages/ff/99/0290bb86a7403893f5e9658490c705fcea103b9191f2039752b071b4ef07/lxml-6.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d12160adea318ce3d118f0b4fbdff7d1225c75fb7749429541b4d217b85c3f76", size = 4585954, upload-time = "2025-08-22T10:33:36.294Z" }, { url = "https://files.pythonhosted.org/packages/4c/72/bf00988477d3bb452bef9436e45aeea82bb40cdfb4684b83c967c53909c7/lxml-5.4.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9454b8d8200ec99a224df8854786262b1bd6461f4280064c807303c642c05e76", size = 5012008, upload-time = "2025-04-23T01:46:57.817Z" },
{ url = "https://files.pythonhosted.org/packages/88/a7/4bb54dd1e626342a0f7df6ec6ca44fdd5d0e100ace53acc00e9a689ead04/lxml-6.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48c8d335d8ab72f9265e7ba598ae5105a8272437403f4032107dbcb96d3f0b29", size = 4944052, upload-time = "2025-08-22T10:33:38.19Z" }, { url = "https://files.pythonhosted.org/packages/92/1f/93e42d93e9e7a44b2d3354c462cd784dbaaf350f7976b5d7c3f85d68d1b1/lxml-5.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cccd007d5c95279e529c146d095f1d39ac05139de26c098166c4beb9374b0f4d", size = 4760915, upload-time = "2025-04-23T01:47:00.745Z" },
{ url = "https://files.pythonhosted.org/packages/71/8d/20f51cd07a7cbef6214675a8a5c62b2559a36d9303fe511645108887c458/lxml-6.0.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:405e7cf9dbdbb52722c231e0f1257214202dfa192327fab3de45fd62e0554082", size = 5098885, upload-time = "2025-08-22T10:33:40.035Z" }, { url = "https://files.pythonhosted.org/packages/45/0b/363009390d0b461cf9976a499e83b68f792e4c32ecef092f3f9ef9c4ba54/lxml-5.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0fce1294a0497edb034cb416ad3e77ecc89b313cff7adbee5334e4dc0d11f422", size = 5283890, upload-time = "2025-04-23T01:47:04.702Z" },
{ url = "https://files.pythonhosted.org/packages/5a/63/efceeee7245d45f97d548e48132258a36244d3c13c6e3ddbd04db95ff496/lxml-6.0.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:299a790d403335a6a057ade46f92612ebab87b223e4e8c5308059f2dc36f45ed", size = 5017542, upload-time = "2025-08-22T10:33:41.896Z" }, { url = "https://files.pythonhosted.org/packages/19/dc/6056c332f9378ab476c88e301e6549a0454dbee8f0ae16847414f0eccb74/lxml-5.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:24974f774f3a78ac12b95e3a20ef0931795ff04dbb16db81a90c37f589819551", size = 4812644, upload-time = "2025-04-23T01:47:07.833Z" },
{ url = "https://files.pythonhosted.org/packages/57/5d/92cb3d3499f5caba17f7933e6be3b6c7de767b715081863337ced42eb5f2/lxml-6.0.1-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:48da704672f6f9c461e9a73250440c647638cc6ff9567ead4c3b1f189a604ee8", size = 5347303, upload-time = "2025-08-22T10:33:43.868Z" }, { url = "https://files.pythonhosted.org/packages/ee/8a/f8c66bbb23ecb9048a46a5ef9b495fd23f7543df642dabeebcb2eeb66592/lxml-5.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:497cab4d8254c2a90bf988f162ace2ddbfdd806fce3bda3f581b9d24c852e03c", size = 4921817, upload-time = "2025-04-23T01:47:10.317Z" },
{ url = "https://files.pythonhosted.org/packages/69/f8/606fa16a05d7ef5e916c6481c634f40870db605caffed9d08b1a4fb6b989/lxml-6.0.1-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:21e364e1bb731489e3f4d51db416f991a5d5da5d88184728d80ecfb0904b1d68", size = 5641055, upload-time = "2025-08-22T10:33:45.784Z" }, { url = "https://files.pythonhosted.org/packages/04/57/2e537083c3f381f83d05d9b176f0d838a9e8961f7ed8ddce3f0217179ce3/lxml-5.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:e794f698ae4c5084414efea0f5cc9f4ac562ec02d66e1484ff822ef97c2cadff", size = 4753916, upload-time = "2025-04-23T01:47:12.823Z" },
{ url = "https://files.pythonhosted.org/packages/b3/01/15d5fc74ebb49eac4e5df031fbc50713dcc081f4e0068ed963a510b7d457/lxml-6.0.1-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bce45a2c32032afddbd84ed8ab092130649acb935536ef7a9559636ce7ffd4a", size = 5242719, upload-time = "2025-08-22T10:33:48.089Z" }, { url = "https://files.pythonhosted.org/packages/d8/80/ea8c4072109a350848f1157ce83ccd9439601274035cd045ac31f47f3417/lxml-5.4.0-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:2c62891b1ea3094bb12097822b3d44b93fc6c325f2043c4d2736a8ff09e65f60", size = 5289274, upload-time = "2025-04-23T01:47:15.916Z" },
{ url = "https://files.pythonhosted.org/packages/42/a5/1b85e2aaaf8deaa67e04c33bddb41f8e73d07a077bf9db677cec7128bfb4/lxml-6.0.1-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:fa164387ff20ab0e575fa909b11b92ff1481e6876835014e70280769920c4433", size = 4717310, upload-time = "2025-08-22T10:33:49.852Z" }, { url = "https://files.pythonhosted.org/packages/b3/47/c4be287c48cdc304483457878a3f22999098b9a95f455e3c4bda7ec7fc72/lxml-5.4.0-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:142accb3e4d1edae4b392bd165a9abdee8a3c432a2cca193df995bc3886249c8", size = 4874757, upload-time = "2025-04-23T01:47:19.793Z" },
{ url = "https://files.pythonhosted.org/packages/42/23/f3bb1292f55a725814317172eeb296615db3becac8f1a059b53c51fc1da8/lxml-6.0.1-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7587ac5e000e1594e62278422c5783b34a82b22f27688b1074d71376424b73e8", size = 5254024, upload-time = "2025-08-22T10:33:52.22Z" }, { url = "https://files.pythonhosted.org/packages/2f/04/6ef935dc74e729932e39478e44d8cfe6a83550552eaa072b7c05f6f22488/lxml-5.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:1a42b3a19346e5601d1b8296ff6ef3d76038058f311902edd574461e9c036982", size = 4947028, upload-time = "2025-04-23T01:47:22.401Z" },
{ url = "https://files.pythonhosted.org/packages/b4/be/4d768f581ccd0386d424bac615d9002d805df7cc8482ae07d529f60a3c1e/lxml-6.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:57478424ac4c9170eabf540237125e8d30fad1940648924c058e7bc9fb9cf6dd", size = 5055335, upload-time = "2025-08-22T10:33:54.041Z" }, { url = "https://files.pythonhosted.org/packages/cb/f9/c33fc8daa373ef8a7daddb53175289024512b6619bc9de36d77dca3df44b/lxml-5.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4291d3c409a17febf817259cb37bc62cb7eb398bcc95c1356947e2871911ae61", size = 4834487, upload-time = "2025-04-23T01:47:25.513Z" },
{ url = "https://files.pythonhosted.org/packages/40/07/ed61d1a3e77d1a9f856c4fab15ee5c09a2853fb7af13b866bb469a3a6d42/lxml-6.0.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:09c74afc7786c10dd6afaa0be2e4805866beadc18f1d843cf517a7851151b499", size = 4784864, upload-time = "2025-08-22T10:33:56.382Z" }, { url = "https://files.pythonhosted.org/packages/8d/30/fc92bb595bcb878311e01b418b57d13900f84c2b94f6eca9e5073ea756e6/lxml-5.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4f5322cf38fe0e21c2d73901abf68e6329dc02a4994e483adbcf92b568a09a54", size = 5381688, upload-time = "2025-04-23T01:47:28.454Z" },
{ url = "https://files.pythonhosted.org/packages/01/37/77e7971212e5c38a55431744f79dff27fd751771775165caea096d055ca4/lxml-6.0.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7fd70681aeed83b196482d42a9b0dc5b13bab55668d09ad75ed26dff3be5a2f5", size = 5657173, upload-time = "2025-08-22T10:33:58.698Z" }, { url = "https://files.pythonhosted.org/packages/43/d1/3ba7bd978ce28bba8e3da2c2e9d5ae3f8f521ad3f0ca6ea4788d086ba00d/lxml-5.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:0be91891bdb06ebe65122aa6bf3fc94489960cf7e03033c6f83a90863b23c58b", size = 5242043, upload-time = "2025-04-23T01:47:31.208Z" },
{ url = "https://files.pythonhosted.org/packages/32/a3/e98806d483941cd9061cc838b1169626acef7b2807261fbe5e382fcef881/lxml-6.0.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:10a72e456319b030b3dd900df6b1f19d89adf06ebb688821636dc406788cf6ac", size = 5245896, upload-time = "2025-08-22T10:34:00.586Z" }, { url = "https://files.pythonhosted.org/packages/ee/cd/95fa2201041a610c4d08ddaf31d43b98ecc4b1d74b1e7245b1abdab443cb/lxml-5.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:15a665ad90054a3d4f397bc40f73948d48e36e4c09f9bcffc7d90c87410e478a", size = 5021569, upload-time = "2025-04-23T01:47:33.805Z" },
{ url = "https://files.pythonhosted.org/packages/07/de/9bb5a05e42e8623bf06b4638931ea8c8f5eb5a020fe31703abdbd2e83547/lxml-6.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b0fa45fb5f55111ce75b56c703843b36baaf65908f8b8d2fbbc0e249dbc127ed", size = 5267417, upload-time = "2025-08-22T10:34:02.719Z" }, { url = "https://files.pythonhosted.org/packages/2d/a6/31da006fead660b9512d08d23d31e93ad3477dd47cc42e3285f143443176/lxml-5.4.0-cp313-cp313-win32.whl", hash = "sha256:d5663bc1b471c79f5c833cffbc9b87d7bf13f87e055a5c86c363ccd2348d7e82", size = 3485270, upload-time = "2025-04-23T01:47:36.133Z" },
{ url = "https://files.pythonhosted.org/packages/f2/43/c1cb2a7c67226266c463ef8a53b82d42607228beb763b5fbf4867e88a21f/lxml-6.0.1-cp313-cp313-win32.whl", hash = "sha256:01dab65641201e00c69338c9c2b8a0f2f484b6b3a22d10779bb417599fae32b5", size = 3610051, upload-time = "2025-08-22T10:34:04.553Z" }, { url = "https://files.pythonhosted.org/packages/fc/14/c115516c62a7d2499781d2d3d7215218c0731b2c940753bf9f9b7b73924d/lxml-5.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:bcb7a1096b4b6b24ce1ac24d4942ad98f983cd3810f9711bcd0293f43a9d8b9f", size = 3814606, upload-time = "2025-04-23T01:47:39.028Z" },
{ url = "https://files.pythonhosted.org/packages/34/96/6a6c3b8aa480639c1a0b9b6faf2a63fb73ab79ffcd2a91cf28745faa22de/lxml-6.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:bdf8f7c8502552d7bff9e4c98971910a0a59f60f88b5048f608d0a1a75e94d1c", size = 4009325, upload-time = "2025-08-22T10:34:06.24Z" },
{ url = "https://files.pythonhosted.org/packages/8c/66/622e8515121e1fd773e3738dae71b8df14b12006d9fb554ce90886689fd0/lxml-6.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:a6aeca75959426b9fd8d4782c28723ba224fe07cfa9f26a141004210528dcbe2", size = 3670443, upload-time = "2025-08-22T10:34:07.974Z" },
{ url = "https://files.pythonhosted.org/packages/38/e3/b7eb612ce07abe766918a7e581ec6a0e5212352194001fd287c3ace945f0/lxml-6.0.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:29b0e849ec7030e3ecb6112564c9f7ad6881e3b2375dd4a0c486c5c1f3a33859", size = 8426160, upload-time = "2025-08-22T10:34:10.154Z" },
{ url = "https://files.pythonhosted.org/packages/35/8f/ab3639a33595cf284fe733c6526da2ca3afbc5fd7f244ae67f3303cec654/lxml-6.0.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:02a0f7e629f73cc0be598c8b0611bf28ec3b948c549578a26111b01307fd4051", size = 4589288, upload-time = "2025-08-22T10:34:12.972Z" },
{ url = "https://files.pythonhosted.org/packages/2c/65/819d54f2e94d5c4458c1db8c1ccac9d05230b27c1038937d3d788eb406f9/lxml-6.0.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:beab5e54de016e730875f612ba51e54c331e2fa6dc78ecf9a5415fc90d619348", size = 4964523, upload-time = "2025-08-22T10:34:15.474Z" },
{ url = "https://files.pythonhosted.org/packages/5b/4a/d4a74ce942e60025cdaa883c5a4478921a99ce8607fc3130f1e349a83b28/lxml-6.0.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:92a08aefecd19ecc4ebf053c27789dd92c87821df2583a4337131cf181a1dffa", size = 5101108, upload-time = "2025-08-22T10:34:17.348Z" },
{ url = "https://files.pythonhosted.org/packages/cb/48/67f15461884074edd58af17b1827b983644d1fae83b3d909e9045a08b61e/lxml-6.0.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36c8fa7e177649470bc3dcf7eae6bee1e4984aaee496b9ccbf30e97ac4127fa2", size = 5053498, upload-time = "2025-08-22T10:34:19.232Z" },
{ url = "https://files.pythonhosted.org/packages/b6/d4/ec1bf1614828a5492f4af0b6a9ee2eb3e92440aea3ac4fa158e5228b772b/lxml-6.0.1-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:5d08e0f1af6916267bb7eff21c09fa105620f07712424aaae09e8cb5dd4164d1", size = 5351057, upload-time = "2025-08-22T10:34:21.143Z" },
{ url = "https://files.pythonhosted.org/packages/65/2b/c85929dacac08821f2100cea3eb258ce5c8804a4e32b774f50ebd7592850/lxml-6.0.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9705cdfc05142f8c38c97a61bd3a29581ceceb973a014e302ee4a73cc6632476", size = 5671579, upload-time = "2025-08-22T10:34:23.528Z" },
{ url = "https://files.pythonhosted.org/packages/d0/36/cf544d75c269b9aad16752fd9f02d8e171c5a493ca225cb46bb7ba72868c/lxml-6.0.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74555e2da7c1636e30bff4e6e38d862a634cf020ffa591f1f63da96bf8b34772", size = 5250403, upload-time = "2025-08-22T10:34:25.642Z" },
{ url = "https://files.pythonhosted.org/packages/c2/e8/83dbc946ee598fd75fdeae6151a725ddeaab39bb321354a9468d4c9f44f3/lxml-6.0.1-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:e38b5f94c5a2a5dadaddd50084098dfd005e5a2a56cd200aaf5e0a20e8941782", size = 4696712, upload-time = "2025-08-22T10:34:27.753Z" },
{ url = "https://files.pythonhosted.org/packages/f4/72/889c633b47c06205743ba935f4d1f5aa4eb7f0325d701ed2b0540df1b004/lxml-6.0.1-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a5ec101a92ddacb4791977acfc86c1afd624c032974bfb6a21269d1083c9bc49", size = 5268177, upload-time = "2025-08-22T10:34:29.804Z" },
{ url = "https://files.pythonhosted.org/packages/b0/b6/f42a21a1428479b66ea0da7bd13e370436aecaff0cfe93270c7e165bd2a4/lxml-6.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5c17e70c82fd777df586c12114bbe56e4e6f823a971814fd40dec9c0de518772", size = 5094648, upload-time = "2025-08-22T10:34:31.703Z" },
{ url = "https://files.pythonhosted.org/packages/51/b0/5f8c1e8890e2ee1c2053c2eadd1cb0e4b79e2304e2912385f6ca666f48b1/lxml-6.0.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:45fdd0415a0c3d91640b5d7a650a8f37410966a2e9afebb35979d06166fd010e", size = 4745220, upload-time = "2025-08-22T10:34:33.595Z" },
{ url = "https://files.pythonhosted.org/packages/eb/f9/820b5125660dae489ca3a21a36d9da2e75dd6b5ffe922088f94bbff3b8a0/lxml-6.0.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d417eba28981e720a14fcb98f95e44e7a772fe25982e584db38e5d3b6ee02e79", size = 5692913, upload-time = "2025-08-22T10:34:35.482Z" },
{ url = "https://files.pythonhosted.org/packages/23/8e/a557fae9eec236618aecf9ff35fec18df41b6556d825f3ad6017d9f6e878/lxml-6.0.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:8e5d116b9e59be7934febb12c41cce2038491ec8fdb743aeacaaf36d6e7597e4", size = 5259816, upload-time = "2025-08-22T10:34:37.482Z" },
{ url = "https://files.pythonhosted.org/packages/fa/fd/b266cfaab81d93a539040be699b5854dd24c84e523a1711ee5f615aa7000/lxml-6.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c238f0d0d40fdcb695c439fe5787fa69d40f45789326b3bb6ef0d61c4b588d6e", size = 5276162, upload-time = "2025-08-22T10:34:39.507Z" },
{ url = "https://files.pythonhosted.org/packages/25/6c/6f9610fbf1de002048e80585ea4719591921a0316a8565968737d9f125ca/lxml-6.0.1-cp314-cp314-win32.whl", hash = "sha256:537b6cf1c5ab88cfd159195d412edb3e434fee880f206cbe68dff9c40e17a68a", size = 3669595, upload-time = "2025-08-22T10:34:41.783Z" },
{ url = "https://files.pythonhosted.org/packages/72/a5/506775e3988677db24dc75a7b03e04038e0b3d114ccd4bccea4ce0116c15/lxml-6.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:911d0a2bb3ef3df55b3d97ab325a9ca7e438d5112c102b8495321105d25a441b", size = 4079818, upload-time = "2025-08-22T10:34:44.04Z" },
{ url = "https://files.pythonhosted.org/packages/0a/44/9613f300201b8700215856e5edd056d4e58dd23368699196b58877d4408b/lxml-6.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:2834377b0145a471a654d699bdb3a2155312de492142ef5a1d426af2c60a0a31", size = 3753901, upload-time = "2025-08-22T10:34:45.799Z" },
] ]
[[package]] [[package]]
@@ -1525,6 +1506,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/d1/c54e608505776ce4e7966d03358ae635cfd51dff1da6ee421c090dbc797b/pymdown_extensions-10.15-py3-none-any.whl", hash = "sha256:46e99bb272612b0de3b7e7caf6da8dd5f4ca5212c0b273feb9304e236c484e5f", size = 265845, upload-time = "2025-04-27T23:48:27.359Z" }, { url = "https://files.pythonhosted.org/packages/a7/d1/c54e608505776ce4e7966d03358ae635cfd51dff1da6ee421c090dbc797b/pymdown_extensions-10.15-py3-none-any.whl", hash = "sha256:46e99bb272612b0de3b7e7caf6da8dd5f4ca5212c0b273feb9304e236c484e5f", size = 265845, upload-time = "2025-04-27T23:48:27.359Z" },
] ]
[[package]]
name = "pyparsing"
version = "3.2.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bb/22/f1129e69d94ffff626bdb5c835506b3a5b4f3d070f17ea295e12c2c6f60f/pyparsing-3.2.3.tar.gz", hash = "sha256:b9c13f1ab8b3b542f72e28f634bad4de758ab3ce4546e4301970ad6fa77c38be", size = 1088608, upload-time = "2025-03-25T05:01:28.114Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/05/e7/df2285f3d08fee213f2d041540fa4fc9ca6c2d44cf36d3a035bf2a8d2bcc/pyparsing-3.2.3-py3-none-any.whl", hash = "sha256:a749938e02d6fd0b59b356ca504a24982314bb090c383e3cf201c95ef7e2bfcf", size = 111120, upload-time = "2025-03-25T05:01:24.908Z" },
]
[[package]] [[package]]
name = "pytest" name = "pytest"
version = "8.4.0" version = "8.4.0"
@@ -1836,7 +1826,7 @@ requires-dist = [
{ name = "django-simple-captcha", specifier = ">=0.6.2,<1.0.0" }, { name = "django-simple-captcha", specifier = ">=0.6.2,<1.0.0" },
{ name = "environs", extras = ["django"], specifier = ">=14.1.1,<15.0.0" }, { name = "environs", extras = ["django"], specifier = ">=14.1.1,<15.0.0" },
{ name = "honcho", specifier = ">=2.0.0" }, { name = "honcho", specifier = ">=2.0.0" },
{ name = "ical", specifier = ">=11,<12" }, { name = "ical", specifier = ">=10.0.3,<11" },
{ name = "jinja2", specifier = ">=3.1.6,<4.0.0" }, { name = "jinja2", specifier = ">=3.1.6,<4.0.0" },
{ name = "libsass", specifier = ">=0.23.0,<1.0.0" }, { name = "libsass", specifier = ">=0.23.0,<1.0.0" },
{ name = "mistune", specifier = ">=3.1.3,<4.0.0" }, { name = "mistune", specifier = ">=3.1.3,<4.0.0" },
@@ -1845,7 +1835,7 @@ requires-dist = [
{ name = "psutil", specifier = ">=7.0.0" }, { name = "psutil", specifier = ">=7.0.0" },
{ name = "pydantic-extra-types", specifier = ">=2.10.3,<3.0.0" }, { name = "pydantic-extra-types", specifier = ">=2.10.3,<3.0.0" },
{ name = "python-dateutil", specifier = ">=2.9.0.post0,<3.0.0.0" }, { name = "python-dateutil", specifier = ">=2.9.0.post0,<3.0.0.0" },
{ name = "redis", extras = ["hiredis"], specifier = ">=5.3.0,<7" }, { name = "redis", extras = ["hiredis"], specifier = ">=5.3.0,<6.0.0" },
{ name = "reportlab", specifier = ">=4.3.1,<5.0.0" }, { name = "reportlab", specifier = ">=4.3.1,<5.0.0" },
{ name = "requests", specifier = ">=2.32.3" }, { name = "requests", specifier = ">=2.32.3" },
{ name = "sentry-sdk", specifier = ">=2.25.1,<3.0.0" }, { name = "sentry-sdk", specifier = ">=2.25.1,<3.0.0" },
@@ -1856,7 +1846,7 @@ requires-dist = [
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [ dev = [
{ name = "django-debug-toolbar", specifier = ">=6,<7" }, { name = "django-debug-toolbar", specifier = ">=5.2.0,<6.0.0" },
{ name = "djhtml", specifier = ">=3.0.7,<4.0.0" }, { name = "djhtml", specifier = ">=3.0.7,<4.0.0" },
{ name = "faker", specifier = ">=37.0.0,<38.0.0" }, { name = "faker", specifier = ">=37.0.0,<38.0.0" },
{ name = "ipython", specifier = ">=9.0.2,<10.0.0" }, { name = "ipython", specifier = ">=9.0.2,<10.0.0" },
@@ -1875,7 +1865,7 @@ prod = [{ name = "psycopg", extras = ["c"], specifier = ">=3.2.9,<4.0.0" }]
tests = [ tests = [
{ name = "beautifulsoup4", specifier = ">=4.13.3,<5" }, { name = "beautifulsoup4", specifier = ">=4.13.3,<5" },
{ name = "freezegun", specifier = ">=1.5.1,<2.0.0" }, { name = "freezegun", specifier = ">=1.5.1,<2.0.0" },
{ name = "lxml", specifier = ">=6,<7" }, { name = "lxml", specifier = ">=5.3.1,<6" },
{ name = "model-bakery", specifier = ">=1.20.4,<2.0.0" }, { name = "model-bakery", specifier = ">=1.20.4,<2.0.0" },
{ name = "pytest", specifier = ">=8.3.5,<9.0.0" }, { name = "pytest", specifier = ">=8.3.5,<9.0.0" },
{ name = "pytest-cov", specifier = ">=6.0.0,<7.0.0" }, { name = "pytest-cov", specifier = ">=6.0.0,<7.0.0" },