40 Commits

Author SHA1 Message Date
imperosol
7ee645c5ee remove services from navbar 2025-09-13 20:00:41 +02:00
thomas girod
b767079c5a Merge pull request #1167 from ae-utbm/page-n+1
Page n+1
2025-09-08 11:28:55 +02:00
imperosol
37961e437b fix: N+1 queries on PageListView 2025-09-04 17:39:17 +02:00
imperosol
b97a1a2e56 improve User.can_view and User.can_edit 2025-09-04 17:38:58 +02:00
thomas girod
fdf5e4fbe9 Merge pull request #1161 from ae-utbm/meta-tags
Meta tags
2025-09-04 10:51:25 +02:00
thomas girod
4e08591721 Merge pull request #1163 from ae-utbm/sitemap
Add sitemap
2025-09-04 10:51:10 +02:00
thomas girod
27b98f4a48 Merge pull request #1166 from ae-utbm/com-notification
Com notification
2025-09-03 14:40:06 +02:00
imperosol
cb454935ad fix: N+1 queries on ICS generation 2025-09-03 14:00:09 +02:00
imperosol
17c50934bb fix: news notifications
Résout trois problèmes :
- la création des notifications faisait un N+1 queries
- le décompte du nombre de nouvelles à modérer était mauvais
- modérer une nouvelle ne modifiait pas les notifications des autres admins
2025-09-03 13:55:07 +02:00
imperosol
5646f22968 feat: add sitemap 2025-09-02 16:00:03 +02:00
thomas girod
cf3daa2574 Merge pull request #1160 from ae-utbm/fix-old-subscribers-group
fix old subscribers group attribution
2025-09-01 20:10:37 +02:00
imperosol
03759fd83e fix translations 2025-09-01 18:21:55 +02:00
imperosol
83c96884d8 add missing meta description tags 2025-09-01 18:20:27 +02:00
imperosol
8524996f06 simplify Subscription.save() 2025-09-01 15:30:39 +02:00
thomas girod
57e3a930ba Merge pull request #1136 from ae-utbm/galaxy
Optimize galaxy generation
2025-09-01 14:18:02 +02:00
imperosol
2086d23b50 fix old subscribers group attribution
Si un utilisateur faisait sa première cotisation alors qu'il avait déjà un compte AE (par exemple, en effectuant un achat sur l'eboutic avant sa cotisation), alors il pouvait se retrouver hors du groupe Anciens cotisants.
2025-08-31 20:49:56 +02:00
imperosol
d8f907fc70 Optimize galaxy generation
En réorganisant les requêtes à la db, on diminue par 100 le temps d'exécution de la commande `rule_galaxy` (~6h => ~2min)
2025-08-30 19:05:41 +02:00
Bartuccio Antoine
81260b34a2 Merge pull request #1159 from ae-utbm/update
Update dependencies
2025-08-29 08:16:56 +02:00
Bartuccio Antoine
7bd3f69c76 Merge pull request #1158 from ae-utbm/dependabot-config
Update dependabot config
2025-08-29 08:16:31 +02:00
thomas girod
257ad0f7e4 Merge pull request #1157 from ae-utbm/checkconstraint
replace deprecated CheckConstraint.check by CheckConstraint.condition
2025-08-29 00:45:41 +02:00
Sli
f3fe67cf75 Update dependencies 2025-08-28 23:42:06 +02:00
Sli
142dd6a16f Update dependabot config 2025-08-28 22:06:35 +02:00
imperosol
e864e82573 replace deprecated CheckConstraint.check by CheckConstraint.condition 2025-08-28 16:31:54 +02:00
Kenneth Soares
95b476b212 Merge pull request #1072 from ae-utbm/promo_add_tool
custom django command for promo logos
2025-08-27 21:00:22 +02:00
Bartuccio Antoine
0e9c470f41 Merge pull request #1155 from ae-utbm/eboutic
Fix auto basket cleaning after refilling account
2025-08-26 19:09:49 +02:00
Sli
ed9c718cf1 Apply review comments 2025-08-26 10:30:08 +02:00
Sli
25099528bf Improve eboutic readability 2025-08-24 00:26:34 +02:00
Bartuccio Antoine
23103950b8 Merge pull request #1156 from ae-utbm/euroks
Remove euroks partnership
2025-08-23 22:06:56 +02:00
Sli
cbf2678f6d Remove euroks partnership 2025-08-23 15:33:05 +02:00
Sli
0bc18be75e Add basket cleaning tests 2025-08-23 15:16:57 +02:00
Sli
f44fe72423 Get customer last purchases in one request 2025-08-23 15:09:05 +02:00
Sli
c016dbc8bc Fix auto basket cleaning after refilling account 2025-08-22 10:36:57 +02:00
Bartuccio Antoine
080dd7756d Merge pull request #1148 from ae-utbm/footer
fix footer alignment on small screens
2025-07-07 16:32:06 +02:00
Sli
ae5165af19 fix footer alignment on small screens 2025-07-07 11:49:45 +02:00
Kenneth SOARES
5b57f75b4e custom django command for promo logos
added path vailidity verification and IOError handling

added option to overwrite existing logo and force flag

improved uppon suggestions

mistake correction

fixed string conversion bugs and logical error

corrected path conversion

f

better error handling and corrections

ajout d'une section de documentation pour la feature

copié coller

fixed documentation bullet points

added resampling clean up error handling

removed useless IOError
2025-07-03 14:28:16 +02:00
thomas girod
3e3c6631ff Merge pull request #1146 from ae-utbm/fix-ts
Fix ts
2025-07-02 09:01:24 +02:00
imperosol
a3ac04fc9e fix TS types 2025-06-30 18:35:53 +02:00
imperosol
6e724a9c74 extract AlertMessage to its own file 2025-06-30 18:17:29 +02:00
thomas girod
c177ef2a3a Merge pull request #1145 from ae-utbm/xapian
fix: xapian compilation flags
2025-06-30 13:46:02 +02:00
imperosol
6cf8910626 fix: xapian compilation flags 2025-06-30 13:09:24 +02:00
55 changed files with 1378 additions and 1230 deletions

View File

@@ -4,11 +4,19 @@
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
- package-ecosystem: "pip" # See documentation for possible values
directory: "/" # Location of package manifests
multi-ecosystem-groups:
common:
directory: "/"
schedule:
interval: "weekly"
target-branch: "taiste"
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(
model_name="membership",
constraint=models.CheckConstraint(
check=models.Q(("end_date__gte", models.F("start_date"))),
condition=models.Q(("end_date__gte", models.F("start_date"))),
name="end_after_start",
),
),

View File

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

View File

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

View File

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

View File

@@ -171,6 +171,10 @@ class ClubListView(ListView):
model = Club
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):

View File

@@ -68,7 +68,7 @@ class IcsCalendar:
start=news_date.start_date,
end=news_date.end_date,
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)

View File

@@ -54,7 +54,7 @@ class Migration(migrations.Migration):
migrations.AddConstraint(
model_name="newsdate",
constraint=models.CheckConstraint(
check=models.Q(("end_date__gte", models.F("start_date"))),
condition=models.Q(("end_date__gte", models.F("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.mail import EmailMultiAlternatives
from django.db import models, transaction
from django.db.models import F, Q
from django.db.models import Exists, F, OuterRef, Q
from django.shortcuts import render
from django.templatetags.static import static
from django.urls import reverse
@@ -55,9 +55,17 @@ class Sith(models.Model):
class NewsQuerySet(models.QuerySet):
def moderated(self) -> Self:
def published(self) -> Self:
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:
"""Filter news that the given user can view.
@@ -127,20 +135,28 @@ class News(models.Model):
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
if self.is_published:
return
for user in User.objects.filter(
groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID]
):
Notification.objects.create(
user=user, url=reverse("com:news_admin_list"), type="NEWS_MODERATION"
if not self.is_published:
admins_without_notif = User.objects.filter(
~Exists(
Notification.objects.filter(
user=OuterRef("pk"), 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):
return reverse("com:news_detail", kwargs={"news_id": self.id})
def get_full_url(self):
return "https://%s%s" % (settings.SITH_URL, self.get_absolute_url())
return f"https://{settings.SITH_URL}{self.get_absolute_url()}"
def is_owned_by(self, user):
if user.is_anonymous:
@@ -159,19 +175,16 @@ class News(models.Model):
or (user.is_authenticated and self.author_id == user.id)
)
def news_notification_callback(notif: Notification):
# the NewsDate linked to the News
# which creation triggered this callback may not exist yet,
# 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:
notif.viewed = False
notif.param = str(count)
else:
notif.viewed = True
@staticmethod
def update_moderation_notifs():
count = News.objects.waiting_moderation().count()
notifs_qs = Notification.objects.filter(
type="NEWS_MODERATION", user__groups__id=settings.SITH_GROUP_COM_ADMIN_ID
)
if count:
notifs_qs.update(viewed=False, param=str(count))
else:
notifs_qs.update(viewed=True)
class NewsDateQuerySet(models.QuerySet):
@@ -212,7 +225,7 @@ class NewsDate(models.Model):
verbose_name_plural = _("news dates")
constraints = [
models.CheckConstraint(
check=Q(end_date__gte=F("start_date")),
condition=Q(end_date__gte=F("start_date")),
name="news_date_end_date_after_start_date",
)
]

View File

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

View File

@@ -1,13 +1,22 @@
from datetime import timedelta
import pytest
from django.conf import settings
from django.utils.timezone import now
from model_bakery import baker
from com.models import News
from com.models import News, NewsDate
from core.baker_recipes import subscriber_user
from core.models import Group, Notification, User
@pytest.mark.django_db
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.users.all().delete()
Notification.objects.all().delete()
@@ -15,9 +24,28 @@ def test_notification_created():
for i in range(2):
# news notifications are permanent, so the notification created
# during the first iteration should be reused during the second one.
baker.make(News)
baker.make(News, is_published=False)
notifications = list(Notification.objects.all())
assert len(notifications) == 1
assert notifications[0].user == com_admin
assert notifications[0].type == "NEWS_MODERATION"
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

@@ -0,0 +1,41 @@
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

@@ -4,13 +4,13 @@
VERSION="$1"
# Cleanup env vars for auto discovery mechanism
export CPATH=
export LIBRARY_PATH=
export CFLAGS=
export LDFLAGS=
export CCFLAGS=
export CXXFLAGS=
export CPPFLAGS=
unset CPATH
unset LIBRARY_PATH
unset CFLAGS
unset LDFLAGS
unset CCFLAGS
unset CXXFLAGS
unset CPPFLAGS
# prepare
rm -rf "$VIRTUAL_ENV/packages"

View File

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

View File

@@ -560,7 +560,7 @@ class User(AbstractUser):
"""Determine if the object is owned by the user."""
if hasattr(obj, "is_owned_by") and obj.is_owned_by(self):
return True
if hasattr(obj, "owner_group") and self.is_in_group(pk=obj.owner_group.id):
if hasattr(obj, "owner_group") and self.is_in_group(pk=obj.owner_group_id):
return True
return self.is_root
@@ -569,9 +569,15 @@ class User(AbstractUser):
if hasattr(obj, "can_be_edited_by") and obj.can_be_edited_by(self):
return True
if hasattr(obj, "edit_groups"):
for pk in obj.edit_groups.values_list("pk", flat=True):
if self.is_in_group(pk=pk):
return True
if (
hasattr(obj, "_prefetched_objects_cache")
and "edit_groups" in obj._prefetched_objects_cache
):
pks = [g.id for g in obj.edit_groups.all()]
else:
pks = list(obj.edit_groups.values_list("id", flat=True))
if any(self.is_in_group(pk=pk) for pk in pks):
return True
if isinstance(obj, User) and obj == self:
return True
return self.is_owner(obj)
@@ -581,9 +587,18 @@ class User(AbstractUser):
if hasattr(obj, "can_be_viewed_by") and obj.can_be_viewed_by(self):
return True
if hasattr(obj, "view_groups"):
for pk in obj.view_groups.values_list("pk", flat=True):
if self.is_in_group(pk=pk):
return True
# if "view_groups" has already been prefetched, use
# the prefetch cache, else fetch only the ids, to make
# the query lighter.
if (
hasattr(obj, "_prefetched_objects_cache")
and "view_groups" in obj._prefetched_objects_cache
):
pks = [g.id for g in obj.view_groups.all()]
else:
pks = list(obj.view_groups.values_list("id", flat=True))
if any(self.is_in_group(pk=pk) for pk in pks):
return True
return self.can_edit(obj)
def can_be_edited_by(self, user):
@@ -745,7 +760,7 @@ class UserBan(models.Model):
fields=["ban_group", "user"], name="unique_ban_type_per_user"
),
models.CheckConstraint(
check=Q(expires_at__gte=F("created_at")),
condition=Q(expires_at__gte=F("created_at")),
name="user_ban_end_after_start",
),
]
@@ -1384,9 +1399,9 @@ class Page(models.Model):
@cached_property
def is_club_page(self):
club_root_page = Page.objects.filter(name=settings.SITH_CLUB_ROOT_PAGE).first()
return club_root_page is not None and (
self == club_root_page or club_root_page in self.get_parent_list()
return (
self.name == settings.SITH_CLUB_ROOT_PAGE
or settings.SITH_CLUB_ROOT_PAGE in [p.name for p in self.get_parent_list()]
)
@cached_property

View File

@@ -0,0 +1,38 @@
interface AlertParams {
success?: boolean;
duration?: number;
}
export class AlertMessage {
public open: boolean;
public success: boolean;
public content: string;
private timeoutId?: number;
private readonly defaultDuration: number;
constructor(params?: { defaultDuration: number }) {
this.open = false;
this.content = "";
this.timeoutId = null;
this.defaultDuration = params?.defaultDuration ?? 2000;
}
public display(message: string, params: AlertParams) {
this.clear();
this.open = true;
this.content = message;
this.success = params.success ?? true;
this.timeoutId = setTimeout(() => {
this.open = false;
this.timeoutId = null;
}, params.duration ?? this.defaultDuration);
}
public clear() {
if (this.timeoutId !== null) {
clearTimeout(this.timeoutId);
this.timeoutId = null;
}
this.open = false;
}
}

View File

@@ -1,5 +1,5 @@
import type { Client, Options, RequestResult, TDataShape } from "@hey-api/client-fetch";
import { client } from "#openapi";
import type { Client, RequestResult, TDataShape } from "#openapi:client";
import { type Options, client } from "#openapi";
export interface PaginatedResponse<T> {
count: number;

View File

@@ -2,6 +2,10 @@
@import "devices";
footer.bottom-links {
>section>a {
text-align: center;
}
@media (max-width: $small-devices) {
margin-top: 0.6em;
padding: 1.25em;

View File

@@ -2,8 +2,14 @@
<html lang="fr">
<head>
{% block head %}
<title>{% block title %}{% trans %}Welcome!{% endtrans %}{% endblock %} - Association des Étudiants UTBM</title>
<title>{% block title %}Association des Étudiants de l'UTBM{% endblock %}</title>
<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="stylesheet" href="{{ static('core/base.css') }}">
<link rel="stylesheet" href="{{ static('core/style.scss') }}">

View File

@@ -20,14 +20,6 @@
<a class="link" href="{{ url('forum:main') }}">{% trans %}Forum{% endtrans %}</a>
<a class="link" href="{{ url('sas:main') }}">{% trans %}Gallery{% endtrans %}</a>
<a class="link" href="{{ url('eboutic:main') }}">{% trans %}Eboutic{% endtrans %}</a>
<details name="navbar" class="menu">
<summary class="head">{% trans %}Services{% endtrans %}</summary>
<ul class="content">
<li><a href="{{ url('matmat:search_clear') }}">{% trans %}Matmatronch{% endtrans %}</a></li>
<li><a href="{{ url('core:file_list') }}">{% trans %}Files{% endtrans %}</a></li>
<li><a href="{{ url('pedagogy:guide') }}">{% trans %}Pedagogy{% endtrans %}</a></li>
</ul>
</details>
<details name="navbar" class="menu">
<summary class="head">{% trans %}My Benefits{% endtrans %}</summary>
<ul class="content">

View File

@@ -5,16 +5,12 @@
{% endblock %}
{% block content %}
{% if page_list %}
<h3>{% trans %}Page list{% endtrans %}</h3>
<ul>
{% for p in page_list %}
<li><a href="{{ p.get_absolute_url() }}">{{ p.get_display_name() }}</a></li>
{% endfor %}
</ul>
{% else %}
{% trans %}There is no page in this website.{% endtrans %}
{% endif %}
<h3>{% trans %}Page list{% endtrans %}</h3>
<ul>
{% for p in page_list %}
<li><a href="{{ p.get_absolute_url() }}">{{ p.display_name }}</a></li>
{% endfor %}
</ul>
{% endblock %}

View File

@@ -12,7 +12,10 @@
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db.models import F, OuterRef, Subquery
from django.db.models.functions import Coalesce
# This file contains all the views that concern the page model
from django.forms.models import modelform_factory
@@ -43,6 +46,20 @@ class CanEditPagePropMixin(CanEditPropMixin):
class PageListView(CanViewMixin, ListView):
model = Page
template_name = "core/page_list.jinja"
queryset = (
Page.objects.annotate(
display_name=Coalesce(
Subquery(
PageRev.objects.filter(page=OuterRef("id"))
.order_by("-date")
.values("title")[:1]
),
F("name"),
)
)
.prefetch_related("view_groups")
.select_related("parent")
)
class PageView(CanViewMixin, DetailView):

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
import { AlertMessage } from "#core:utils/alert-message";
import { BasketItem } from "#counter:counter/basket";
import type { CounterConfig, ErrorMessage } from "#counter:counter/types";
import type { CounterProductSelect } from "./components/counter-product-select-index.ts";
@@ -5,14 +6,9 @@ import type { CounterProductSelect } from "./components/counter-product-select-i
document.addEventListener("alpine:init", () => {
Alpine.data("counter", (config: CounterConfig) => ({
basket: {} as Record<string, BasketItem>,
errors: [],
customerBalance: config.customerBalance,
codeField: null as CounterProductSelect | null,
alertMessage: {
content: "",
show: false,
timeout: null,
},
alertMessage: new AlertMessage({ defaultDuration: 2000 }),
init() {
// Fill the basket with the initial data
@@ -77,22 +73,10 @@ document.addEventListener("alpine:init", () => {
return total;
},
showAlertMessage(message: string) {
if (this.alertMessage.timeout !== null) {
clearTimeout(this.alertMessage.timeout);
}
this.alertMessage.content = message;
this.alertMessage.show = true;
this.alertMessage.timeout = setTimeout(() => {
this.alertMessage.show = false;
this.alertMessage.timeout = null;
}, 2000);
},
addToBasketWithMessage(id: string, quantity: number) {
const message = this.addToBasket(id, quantity);
if (message.length > 0) {
this.showAlertMessage(message);
this.alertMessage.display(message, { success: false });
}
},
@@ -109,7 +93,9 @@ document.addEventListener("alpine:init", () => {
finish() {
if (this.getBasketSize() === 0) {
this.showAlertMessage(gettext("You can't send an empty basket."));
this.alertMessage.display(gettext("You can't send an empty basket."), {
success: false,
});
return;
}
this.$refs.basketForm.submit();

View File

@@ -167,7 +167,7 @@ document.addEventListener("alpine:init", () => {
});
// if products to download are already in-memory, directly take them.
// If not, fetch them.
const products =
const products: ProductSchema[] =
this.nbPages > 1
? await paginated(productSearchProductsDetailed, this.getQueryParams())
: Object.values<ProductSchema[]>(this.products).flat();

View File

@@ -1,15 +1,11 @@
import { AlertMessage } from "#core:utils/alert-message";
import Alpine from "alpinejs";
import { producttypeReorder } from "#openapi";
document.addEventListener("alpine:init", () => {
Alpine.data("productTypesList", () => ({
loading: false,
alertMessage: {
open: false,
success: true,
content: "",
timeout: null,
},
alertMessage: new AlertMessage({ defaultDuration: 2000 }),
async reorder(itemId: number, newPosition: number) {
// The sort plugin of Alpine doesn't manage dynamic lists with x-sort
@@ -41,23 +37,14 @@ document.addEventListener("alpine:init", () => {
},
openAlertMessage(response: Response) {
if (response.ok) {
this.alertMessage.success = true;
this.alertMessage.content = gettext("Products types reordered!");
} else {
this.alertMessage.success = false;
this.alertMessage.content = interpolate(
gettext("Product type reorganisation failed with status code : %d"),
[response.status],
);
}
this.alertMessage.open = true;
if (this.alertMessage.timeout !== null) {
clearTimeout(this.alertMessage.timeout);
}
this.alertMessage.timeout = setTimeout(() => {
this.alertMessage.open = false;
}, 2000);
const success = response.ok;
const content = response.ok
? gettext("Products types reordered!")
: interpolate(
gettext("Product type reorganisation failed with status code : %d"),
[response.status],
);
this.alertMessage.display(content, { success: success });
this.loading = false;
},
}));

View File

@@ -1,4 +1,4 @@
type ErrorMessage = string;
export type ErrorMessage = string;
export interface InitialFormData {
/* Used to refill the form when the backend raises an error */

View File

@@ -1,108 +0,0 @@
Cette page expose la politique du Pôle informatique de l'AE
en ce qui concerne l'usage et l'implémentation de systèmes d'IA
dans le cadre de l'AE et du développement de ses outils.
## Cadre
En accord avec le règlement européen sur
l'intelligence artificielle du 13 juin 2024,
nous définissons comme IA :
> Un système basé sur une machine qui est
> conçu pour fonctionner avec différents niveaux d'autonomie
> et qui peut faire preuve d'adaptabilité après son déploiement,
> et qui, pour des objectifs explicites ou implicites, déduit,
> à partir des données qu'il reçoit,
> comment générer des résultats tels que des prédictions,
> du contenu, des recommandations ou des décisions
> qui peuvent influencer des environnements physiques ou virtuels.
Cette définition recouvre toutes les IAs génératives, ce qui inclut
ChatGPT, DeepSeek, Claude, Copilot, Llama et autres outils similaires.
## Utilisation dans le développement
!!!danger
La soumission de code généré par IA est strictement interdite.
Aucune contribution contenant du code généré par IA n'est acceptée.
Toute PR contenant en proportion significative du code duquel
on peut raisonnablement penser qu'il a été généré par IA
pourra être refusée sans aucun autre motif.
Bien que nous ne puissions pas l'interdire,
nous déconseillons également fortement l'usage de tout
recours à un système d'IA dans le processus de développement,
quel que soit son usage (debug, recherche d'information ou autres).
Référez-vous en priorité à la documentation du site,
à celle de Django et à l'aide des autres développeurs,
mais par pitié, ne faites jamais appel à l'IA.
## Intégration dans le site
L'intégration sur le site AE de systèmes d'IA
et de toute fonctionnalité basée sur des systèmes d'IA
est strictement prohibée, quel qu'en soit l'objectif.
Toute tâche de modération, de génération
ou de détection de contenu ne doit être accomplie
par des êtres humains ou par des algorithmes
déterministes, testés et compris.
L'usage des données du site a des fins d'entrainement d'IA,
ainsi que la transmission de ces données à un système d'IA
est strictement interdit.
Tout acte de cette nature sera considéré comme une violation
grave de la politique de gestion des données de l'AE.
## Motifs de cette politique
Le site AE est un programme écrit par des humains, pour des humains.
C'est un logiciel dont la complexité nécessite des connaissances
plus approfondies que ce qui est attendu de la part d'un
étudiant en TC ou en base branche.
À ce titre, l'interdiction de l'IA dans le cadre de son
développement est pensée avant tout dans une optique
de formation des développeurs, de stabilité de la base de code
et de transmission des connaissances.
Nous ferons ici abstraction de l'impact écologique néfaste de l'IA,
qui n'en reste pas moins préoccupant et qui renforce
les autres motifs ayant poussé à interdire l'IA dans le cadre de l'AE.
### Formation des développeurs
Travailler sur le site AE est possiblement le meilleur moyen de
monter en compétences en informatique pour un étudiant de l'UTBM.
Automatisation des tests, gestion des données et de la sécurité,
infrastructure, maintenance du code existant...
Le site AE est un logiciel complet, dont le développement
possède une dimension pédagogique réelle.
En utilisant l'IA, le développement n'est plus un moyen efficace
de se former.
### Stabilité de la base de code
Les développeurs du site AE sont pour la plupart en cours de formation,
sans compréhension globale de la base de code du site,
des outils logiciels sur lesquels il se base et des bonnes
pratiques permettant d'écrire du code viable.
En se reposant sur un système d'IA sans être capacité
de comprendre intégralement le code proposé ni de le mettre
en perspective avec le reste de la base de code,
c'est toute la maintenance de la base de code qui se retrouve compromise.
### Transmission des connaissances
L'équipe du pôle informatique se renouvelle très souvent.
À ce titre, les nouveaux développeurs se doivent d'hériter
d'une base de code viable.
Quant aux anciens développeurs, ils se doivent d'en avoir
compris le fonctionnement, afin d'être en mesure
de guider et d'aider leurs successeurs.
Comme développé dans les deux points précédents,
cet objectif est incompatible avec l'usage de systèmes d'IA.

View File

@@ -12,6 +12,15 @@ nouveau logo d'une promo. C'est un processus manuel.
de faire cette opération manuellement, ça prend quelques
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.
Ils se situent dans le dossier `core/static/core/img/`.

View File

@@ -1,8 +1,12 @@
{% extends "core/base.jinja" %}
{% block title %}
{% block title -%}
{% trans %}Eboutic{% endtrans %}
{% endblock %}
{%- endblock %}
{% block description -%}
{% trans %}The online shop of the association.{% endtrans %}
{%- endblock %}
{% block additional_js %}
{# This script contains the code to perform requests to manipulate the
@@ -120,56 +124,6 @@
</span>
</div>
{% endif %}
<section>
<div class="category-header">
<h3 class="margin-bottom">{% trans %}Eurockéennes 2025 partnership{% endtrans %}</h3>
{% if user.is_subscribed %}
<div id="eurok-partner" style="
min-height: 600px;
background-color: lightgrey;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
gap: 10px;
">
<p style="text-align: center;">
{% trans trimmed %}
Our partner uses Weezevent to sell tickets.
Weezevent may collect user info according to
its own privacy policy.
By clicking the accept button you consent to
their terms of services.
{% endtrans %}
</p>
<a href="https://weezevent.com/fr/politique-de-confidentialite/">{% trans %}Privacy policy{% endtrans %}</a>
<button
hx-get="{{ url("eboutic:eurok") }}"
hx-target="#eurok-partner"
hx-swap="outerHTML"
hx-trigger="click, load[document.cookie.includes('weezevent_accept=true')]"
@htmx:after-request="document.cookie = 'weezevent_accept=true'"
>{% trans %}Accept{% endtrans %}
</button>
</div>
{% else %}
<p>
{%- trans trimmed %}
You must be subscribed to benefit from the partnership with the Eurockéennes.
{% endtrans -%}
</p>
<p>
{%- trans trimmed %}
This partnership offers a discount of up to 33%
on tickets for Friday, Saturday and Sunday,
as well as the 3-day package from Friday to Sunday.
{% endtrans -%}
</p>
{% endif %}
</div>
</section>
{% for priority_groups in products|groupby('order') %}
{% for category, items in priority_groups.list|groupby('category') %}
{% if items|count > 0 %}

View File

@@ -1,3 +1,5 @@
from datetime import datetime, timezone
import pytest
from django.http import HttpResponse
from django.test import TestCase
@@ -9,8 +11,13 @@ from pytest_django.asserts import assertRedirects
from core.baker_recipes import subscriber_user
from core.models import Group, User
from counter.baker_recipes import product_recipe
from counter.models import Counter, ProductType, get_eboutic
from counter.baker_recipes import product_recipe, refill_recipe, sale_recipe
from counter.models import (
Counter,
Customer,
ProductType,
get_eboutic,
)
from counter.tests.test_counter import BasketItem
from eboutic.models import Basket
@@ -24,6 +31,96 @@ def test_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):
@classmethod
def setUpTestData(cls):

View File

@@ -31,7 +31,6 @@ from eboutic.views import (
EbouticMainView,
EbouticPayWithSith,
EtransactionAutoAnswer,
EurokPartnerFragment,
payment_result,
)
@@ -46,7 +45,6 @@ urlpatterns = [
"pay/sith/<int:basket_id>", EbouticPayWithSith.as_view(), name="pay_with_sith"
),
path("pay/<res:result>/", payment_result, name="payment_result"),
path("eurok/", EurokPartnerFragment.as_view(), name="eurok"),
path(
"et_autoanswer",
EtransactionAutoAnswer.as_view(),

View File

@@ -34,6 +34,7 @@ from django.contrib.auth.mixins import (
from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import SuspiciousOperation, ValidationError
from django.db import DatabaseError, transaction
from django.db.models import Subquery
from django.db.models.fields import forms
from django.db.utils import cached_property
from django.http import HttpResponse
@@ -41,14 +42,21 @@ from django.shortcuts import redirect, render
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.views.decorators.http import require_GET
from django.views.generic import DetailView, FormView, TemplateView, UpdateView, View
from django.views.generic import DetailView, FormView, UpdateView, View
from django.views.generic.edit import SingleObjectMixin
from django_countries.fields import Country
from core.auth.mixins import CanViewMixin, IsSubscriberMixin
from core.auth.mixins import CanViewMixin
from core.views.mixins import FragmentMixin, UseFragmentsMixin
from counter.forms import BaseBasketForm, BillingInfoForm, ProductForm
from counter.models import BillingInfo, Customer, Product, Selling, get_eboutic
from counter.models import (
BillingInfo,
Customer,
Product,
Refilling,
Selling,
get_eboutic,
)
from eboutic.models import (
Basket,
BasketItem,
@@ -124,13 +132,36 @@ class EbouticMainView(LoginRequiredMixin, FormView):
context = super().get_context_data(**kwargs)
context["products"] = self.products
context["customer_amount"] = self.request.user.account_balance
last_purchase: Selling | None = (
self.customer.buyings.filter(counter__type="EBOUTIC")
.order_by("-date")
.first()
)
purchases = (
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")
.values("date")[:1]
),
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"] = (
int(last_purchase.date.timestamp() * 1000) if last_purchase else "null"
max(purchase_times) if len(purchase_times) > 0 else "null"
)
return context
@@ -142,10 +173,6 @@ def payment_result(request, result: str) -> HttpResponse:
return render(request, "eboutic/eboutic_payment_result.jinja", context)
class EurokPartnerFragment(IsSubscriberMixin, TemplateView):
template_name = "eboutic/eurok_fragment.jinja"
class BillingInfoFormFragment(
LoginRequiredMixin, FragmentMixin, SuccessMessageMixin, UpdateView
):

View File

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

View File

@@ -23,20 +23,21 @@
from __future__ import annotations
import itertools
import logging
import math
import time
from collections import defaultdict
from typing import NamedTuple, TypedDict
from django.db import models
from django.db.models import Case, Count, F, Q, Value, When
from django.db.models.functions import Concat
from django.db.models import Count, F, Q, QuerySet
from django.utils.timezone import localdate
from django.utils.translation import gettext_lazy as _
from club.models import Club
from club.models import Membership
from core.models import User
from sas.models import Picture
from sas.models import PeoplePictureRelation, Picture
class GalaxyStar(models.Model):
@@ -114,18 +115,9 @@ class GalaxyLane(models.Model):
default=0,
help_text=_("Distance separating star1 and star2"),
)
family = models.PositiveIntegerField(
_("family score"),
default=0,
)
pictures = models.PositiveIntegerField(
_("pictures score"),
default=0,
)
clubs = models.PositiveIntegerField(
_("clubs score"),
default=0,
)
family = models.PositiveIntegerField(_("family score"), default=0)
pictures = models.PositiveIntegerField(_("pictures score"), default=0)
clubs = models.PositiveIntegerField(_("clubs score"), default=0)
def __str__(self):
return f"{self.star1} -> {self.star2} ({self.distance})"
@@ -174,6 +166,7 @@ class Galaxy(models.Model):
logger = logging.getLogger("main")
GALAXY_SCALE_FACTOR = 2_000
DEFAULT_PICTURE_COUNT_THRESHOLD = 10
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.
CLUBS_POINTS = 1 # One day together as random members in a club is one point.
@@ -187,15 +180,13 @@ class Galaxy(models.Model):
stars_count = self.stars.count()
s = f"GLX-ID{self.pk}-SC{stars_count}-"
if self.state is None:
s += "CHS" # CHAOS
s += "CHAOS"
else:
s += "RLD" # RULED
s += "RULED"
return s
@classmethod
def get_current_galaxy(
cls,
) -> Galaxy: # __future__.annotations is required for this
def get_current_galaxy(cls) -> Galaxy:
return Galaxy.objects.filter(state__isnull=False).last()
###################
@@ -203,7 +194,18 @@ class Galaxy(models.Model):
###################
@classmethod
def compute_user_score(cls, user: User) -> int:
def get_rulable_users(
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.
It will later be used by the graph algorithm to push
@@ -211,87 +213,50 @@ class Galaxy(models.Model):
Idea: This could be added to the computation:
- Forum posts
- Picture count
- Counter consumption
- Barman time
- ...
"""
user_score = 1
user_score += cls.query_user_score(user)
users = (
User.objects.annotate(
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:
# 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
# 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
user_score = int(math.log2(user_score))
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")
res = {u["id"]: int(math.log2(u["score"] + 1)) for u in users}
return res
####################
# Inter-user score #
####################
@classmethod
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:
def compute_user_family_score(cls, user: User) -> defaultdict[int, int]:
"""Compute the family score of the relation between the given users.
This takes into account mutual godfathers.
Returns:
366 if user1 is the godfather of user2 (or vice versa) else 0
"""
link_count = User.objects.filter(
Q(id=user1.id, godfathers=user2) | Q(id=user2.id, godfathers=user1)
).count()
if link_count > 0:
cls.logger.debug(
f"\t\t- '{user1}' and '{user2}' have {link_count} direct family link"
)
return link_count * cls.FAMILY_LINK_POINTS
godchildren = User.objects.filter(godchildren=user).values_list("id", flat=True)
godfathers = User.objects.filter(godfathers=user).values_list("id", flat=True)
result = defaultdict(int)
for parent in itertools.chain(godchildren, godfathers):
result[parent] += cls.FAMILY_LINK_POINTS
return result
@classmethod
def compute_users_pictures_score(cls, user1: User, user2: User) -> int:
def compute_user_pictures_score(cls, user: User) -> defaultdict[int, int]:
"""Compute the pictures score of the relation between the given users.
The pictures score is obtained by counting the number
@@ -301,19 +266,19 @@ class Galaxy(models.Model):
Returns:
The number of pictures both users have in common, times 2
"""
picture_count = (
Picture.objects.filter(people__user__in=(user1,))
.filter(people__user__in=(user2,))
.count()
)
if picture_count:
cls.logger.debug(
f"\t\t- '{user1}' was pictured with '{user2}' {picture_count} times"
common_photos = (
PeoplePictureRelation.objects.filter(
picture__in=Picture.objects.filter(people__user=user)
)
return picture_count * cls.PICTURE_POINTS
.values("user")
.annotate(count=Count("user"))
)
return defaultdict(
int, {p["user"]: p["count"] * cls.PICTURE_POINTS for p in common_photos}
)
@classmethod
def compute_users_clubs_score(cls, user1: User, user2: User) -> int:
def compute_user_clubs_score(cls, user: User) -> defaultdict[int, int]:
"""Compute the clubs score of the relation between the given users.
The club score is obtained by counting the number of days
@@ -324,54 +289,36 @@ class Galaxy(models.Model):
(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
club score is 365.
Returns:
the number of days during which both users were in the same club
"""
common_clubs = Club.objects.filter(members__in=user1.memberships.all()).filter(
members__in=user2.memberships.all()
)
user1_memberships = user1.memberships.filter(club__in=common_clubs)
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,
)
query |= Q( # start2 <= start1 <= now
start_date__lte=user1_membership.start_date, end_date=None
)
query |= Q( # start1 <= start2 <= end2
start_date__gte=user1_membership.start_date,
start_date__lte=user1_membership.end_date,
)
for user2_membership in user2_memberships.filter(
query, club=user1_membership.club
):
if user2_membership.end_date is None:
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,
memberships = user.memberships.only("start_date", "end_date", "club_id")
result = defaultdict(int)
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
start_date__lte=membership.start_date, end_date=None
)
| Q( # start1 <= start2 <= end2
start_date__gte=membership.start_date,
start_date__lte=membership.end_date or now,
),
club_id=membership.club_id,
)
score += cls.CLUBS_POINTS * (earliest_end - latest_start).days
return score
.only("start_date", "end_date", "user_id")
)
for other in common_memberships:
start = max(membership.start_date, other.start_date)
end = min(membership.end_date or now, other.end_date or now)
result[other.user_id] += (end - start).days * cls.CLUBS_POINTS
return result
###################
# Rule the galaxy #
@@ -406,7 +353,9 @@ class Galaxy(models.Model):
cls.logger.debug(f"\t\t> Scaled distance: {value}")
return int(value)
def rule(self, picture_count_threshold=10) -> None:
def rule(
self, picture_count_threshold: int = DEFAULT_PICTURE_COUNT_THRESHOLD
) -> None:
"""Main function of the Galaxy.
Iterate over all the rulable users to promote them to citizens.
@@ -427,41 +376,30 @@ class Galaxy(models.Model):
"""
total_time = time.time()
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
# be any more db hits
# this is memory expensive but prevents a lot of db hits, therefore
# is far more time efficient
rulable_users = list(rulable_users)
rulable_users = list(self.get_rulable_users(picture_count_threshold))
rulable_users_count = len(rulable_users)
user1_count = 0
self.logger.info(
f"{rulable_users_count} citizen have been listed. Starting to rule."
)
stars = []
self.logger.info("Creating stars for all citizen")
for user in rulable_users:
star = GalaxyStar(
owner=user, galaxy=self, mass=self.compute_user_score(user)
)
stars.append(star)
GalaxyStar.objects.bulk_create(stars)
stars = {}
for star in GalaxyStar.objects.filter(galaxy=self):
stars[star.owner.id] = star
individual_scores = self.compute_individual_scores()
GalaxyStar.objects.bulk_create(
[
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()}
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_count = 0
t_global_start = time.time()
@@ -472,20 +410,19 @@ class Galaxy(models.Model):
star1 = stars[user1.id]
user_avg_speed = 0
user_avg_speed_count = 0
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})"
)
family_scores = self.compute_user_family_score(user1)
picture_scores = self.compute_user_pictures_score(user1)
club_scores = self.compute_user_clubs_score(user1)
for user2 in rulable_users:
star2 = stars[user2.id]
score = Galaxy.compute_users_score(user1, user2)
score = RelationScore(
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))
if distance < 30: # TODO: this needs tuning with real-world data
lanes.append(
@@ -498,22 +435,8 @@ class Galaxy(models.Model):
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)
self.logger.info("")
t_global_end = time.time()
global_delta = t_global_end - t_global_start
speed = 1.0 / global_delta
@@ -521,21 +444,19 @@ class Galaxy(models.Model):
global_avg_speed_count += 1
global_avg_speed = global_avg_speed_accumulator / global_avg_speed_count
self.logger.info(f" Ruling of {self} ".center(60, "#"))
self.logger.info(
f"Progression: {user1_count}/{rulable_users_count} citizen -- {rulable_users_count - user1_count} remaining"
)
self.logger.info(f"Speed: {60.0 * global_avg_speed:.2f} citizen per minute")
# 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(
f"ETA: {eta_hours} hours {eta_minutes} minutes ({eta / 3600 / 24:.2f} days)"
)
self.logger.info("#" * 60)
if user1_count % 50 == 0:
self.logger.info("")
self.logger.info(f" Ruling of {self} ".center(60, "#"))
self.logger.info(
f"Progression: {user1_count}/{rulable_users_count} "
f"citizen -- {rulable_users_count - user1_count} remaining"
)
self.logger.info(f"Speed: {global_avg_speed:.2f} citizen per second")
eta = rulable_users_count2 // global_avg_speed
self.logger.info(
f"ETA: {int(eta // 60 % 60)} minutes {int(eta % 60)} seconds"
)
self.logger.info("#" * 60)
t_global_start = time.time()
# Here, we get the IDs of the old galaxies that we'll need to delete. In normal operation, only one galaxy
@@ -556,11 +477,10 @@ class Galaxy(models.Model):
Galaxy.objects.filter(pk__in=old_galaxies_pks).delete()
total_time = time.time() - total_time
total_time_hours = int(total_time // 3600)
total_time_minutes = int(total_time // 60 % 60)
total_time_seconds = int(total_time % 60)
self.logger.info(
f"{self} ruled in {total_time:.2f} seconds ({total_time_hours} hours, {total_time_minutes} minutes, {total_time_seconds} seconds)"
f"{self} ruled in {total_time_minutes} minutes, {total_time_seconds} seconds"
)
def make_state(self) -> None:
@@ -568,59 +488,34 @@ class Galaxy(models.Model):
self.logger.info(
"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 = (
GalaxyStar.objects.filter(galaxy=self)
.order_by(
"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,
)
)
.order_by("owner_id")
.select_related("owner")
)
lanes = (
GalaxyLane.objects.filter(star1__galaxy=self)
.order_by(
"star1"
) # This helps determinism for the tests and doesn't cost much
.order_by("star1")
.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(
nodes=[
StarDict(
id=star.owner_id,
name=star.owner_name,
mass=star.mass,
id=star.owner_id, name=star.owner.get_display_name(), mass=star.mass
)
for star in stars
],
links=[],
)
for path in lanes:
json["links"].append(
links=[
{
"source": path.star1_owner,
"target": path.star2_owner,
"value": path.distance,
}
)
for path in lanes
],
)
self.state = json
self.save()
self.logger.info(f"{self} is now ready!")

View File

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

View File

@@ -6,7 +6,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-06-25 16:29+0200\n"
"POT-Creation-Date: 2025-09-01 18:18+0200\n"
"PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -306,6 +306,10 @@ msgstr "Utilisateur non enregistré"
msgid "Club list"
msgstr "Liste des clubs"
#: club/templates/club/club_list.jinja
msgid "The list of all clubs existing at UTBM."
msgstr "La liste de tous les clubs existants à l'UTBM"
#: club/templates/club/club_list.jinja
msgid "inactive"
msgstr "inactif"
@@ -901,7 +905,7 @@ msgid "News admin"
msgstr "Administration des nouvelles"
#: com/templates/com/news_admin_list.jinja com/templates/com/news_detail.jinja
#: com/templates/com/news_list.jinja com/views.py
#: com/views.py
msgid "News"
msgstr "Nouvelles"
@@ -1035,7 +1039,7 @@ msgstr "Liens"
msgid "Our services"
msgstr "Nos services"
#: com/templates/com/news_list.jinja pedagogy/templates/pedagogy/guide.jinja
#: com/templates/com/news_list.jinja
msgid "UV Guide"
msgstr "Guide des UVs"
@@ -1705,30 +1709,34 @@ msgid "500, Server Error"
msgstr "500, Erreur Serveur"
#: core/templates/core/base.jinja
msgid "Welcome!"
msgstr "Bienvenue !"
msgid ""
"AE UTBM is a voluntary organisation run by UTBM students. It organises "
"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.jinja core/templates/core/base/navbar.jinja
#: core/templates/core/base/footer.jinja core/templates/core/base/navbar.jinja
msgid "Contacts"
msgstr "Contacts"
#: core/templates/core/base.jinja
#: core/templates/core/base/footer.jinja
msgid "Legal notices"
msgstr "Mentions légales"
#: core/templates/core/base.jinja
#: core/templates/core/base/footer.jinja
msgid "Intellectual property"
msgstr "Propriété intellectuelle"
#: core/templates/core/base.jinja
#: core/templates/core/base/footer.jinja
msgid "Help & Documentation"
msgstr "Aide & Documentation"
#: core/templates/core/base.jinja
#: core/templates/core/base/footer.jinja
msgid "R&D"
msgstr "R&D"
#: core/templates/core/base.jinja
#: core/templates/core/base/footer.jinja
msgid "Site created by the IT Department of the AE"
msgstr "Site réalisé par le Pôle Informatique de l'AE"
@@ -3819,6 +3827,10 @@ msgstr ""
msgid "Pay with Sith account"
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
msgid "Clear"
msgstr "Vider"
@@ -3838,47 +3850,6 @@ msgstr ""
msgid "this page"
msgstr "cette page"
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid "Eurockéennes 2025 partnership"
msgstr "Partenariat Eurockéennes 2025"
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid ""
"Our partner uses Weezevent to sell tickets. Weezevent may collect user info "
"according to its own privacy policy. By clicking the accept button you "
"consent to their terms of services."
msgstr ""
"Notre partenaire utilises Wezevent pour vendre ses billets. Weezevent peut "
"collecter des informations utilisateur conformément à sa propre politique de "
"confidentialité. En cliquant sur le bouton d'acceptation vous consentez à "
"leurs termes de service."
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid "Privacy policy"
msgstr "Politique de confidentialité"
#: eboutic/templates/eboutic/eboutic_main.jinja
#: trombi/templates/trombi/comment_moderation.jinja
msgid "Accept"
msgstr "Accepter"
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid ""
"You must be subscribed to benefit from the partnership with the Eurockéennes."
msgstr ""
"Vous devez être cotisant pour bénéficier du partenariat avec les "
"Eurockéennes."
#: eboutic/templates/eboutic/eboutic_main.jinja
#, python-format
msgid ""
"This partnership offers a discount of up to 33%% on tickets for Friday, "
"Saturday and Sunday, as well as the 3-day package from Friday to Sunday."
msgstr ""
"Ce partenariat permet de profiter d'une réduction jusqu'à 33%% sur les "
"billets du vendredi, du samedi et du dimanche, ainsi qu'au forfait 3 jours, "
"du vendredi au dimanche."
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid "There are no items available for sale"
msgstr "Aucun article n'est disponible à la vente"
@@ -4189,6 +4160,10 @@ msgstr "Message supprimé ou non-visible."
msgid "Order by 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
msgid "View last unread messages"
msgstr "Voir les derniers messages non lus"
@@ -4415,6 +4390,14 @@ msgstr "signaler"
msgid "reporter"
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
#, python-format
msgid "%(display_name)s"
@@ -4707,6 +4690,11 @@ msgstr "Demande de retrait d'image"
msgid "Request removal"
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
msgid "You must be logged in to see the SAS."
msgstr "Vous devez être connecté pour voir les photos."
@@ -5305,6 +5293,10 @@ msgstr "fin"
msgid "Moderate Trombi comments"
msgstr "Modérer les commentaires du Trombi"
#: trombi/templates/trombi/comment_moderation.jinja
msgid "Accept"
msgstr "Accepter"
#: trombi/templates/trombi/comment_moderation.jinja
msgid "Reject"
msgstr "Refuser"

View File

@@ -7,7 +7,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-05-18 12:17+0200\n"
"POT-Creation-Date: 2025-08-23 15:30+0200\n"
"PO-Revision-Date: 2024-09-17 11:54+0200\n"
"Last-Translator: Sli <antoine@bartuccio.fr>\n"
"Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -193,7 +193,7 @@ msgstr "Montrer moins"
msgid "Show more"
msgstr "Montrer plus"
#: core/static/bundled/user/family-graph-index.js
#: core/static/bundled/user/family-graph-index.ts
msgid "family_tree.%(extension)s"
msgstr "arbre_genealogique.%(extension)s"

View File

@@ -57,7 +57,6 @@ nav:
- Accueil: explanation/index.md
- Technologies utilisées: explanation/technos.md
- Conventions: explanation/conventions.md
- Politique IA: explanation/ia.md
- Archives: explanation/archives.md
- Tutoriels:
- Installer le projet: tutorial/install.md

1078
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,7 +10,7 @@
"openapi": "openapi-ts",
"analyse-dev": "vite-bundle-visualizer --mode development",
"analyse-prod": "vite-bundle-visualizer --mode production",
"check": "biome check --write"
"check": "tsc && biome check --write"
},
"keywords": [],
"author": "",
@@ -30,13 +30,14 @@
"@hey-api/openapi-ts": "^0.73.0",
"@rollup/plugin-inject": "^5.0.5",
"@types/alpinejs": "^3.13.10",
"@types/jquery": "^3.5.31",
"@types/cytoscape-cxtmenu": "^3.4.4",
"@types/cytoscape-klay": "^3.1.4",
"@types/jquery": "^3.5.31",
"@types/js-cookie": "^3.0.6",
"typescript": "^5.8.3",
"vite": "^6.2.5",
"vite": "^6.2.6",
"vite-bundle-visualizer": "^1.2.1",
"vite-plugin-static-copy": "^3.0.2"
"vite-plugin-static-copy": "^3.1.2"
},
"dependencies": {
"@alpinejs/sort": "^3.14.7",

View File

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

View File

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

View File

@@ -83,7 +83,6 @@ document.addEventListener("alpine:init", () => {
Alpine.data("pictureUpload", (albumId: number) => ({
errors: [] as string[],
pictures: [],
sending: false,
progress: null as HTMLProgressElement,

View File

@@ -1,3 +1,4 @@
import type { UserAjaxSelect } from "#core:core/components/ajax-select-index";
import { paginated } from "#core:utils/api";
import { exportToHtml } from "#core:utils/globals";
import { History } from "#core:utils/history";
@@ -130,7 +131,7 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
currentPicture: {
// biome-ignore lint/style/useNamingConvention: api is in snake_case
is_moderated: true,
id: null,
id: null as number,
name: "",
// biome-ignore lint/style/useNamingConvention: api is in snake_case
display_name: "",
@@ -142,7 +143,7 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
full_size_url: "",
owner: "",
date: new Date(),
identifications: [],
identifications: [] as IdentifiedUserSchema[],
},
/**
* The picture which will be displayed next if the user press the "next" button
@@ -155,7 +156,7 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
/**
* The select2 component used to identify users
**/
selector: undefined,
selector: undefined as UserAjaxSelect,
/**
* Error message when a moderation operation fails
**/

View File

@@ -8,6 +8,10 @@
{% trans %}SAS{% endtrans %}
{% 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) %}
{% from "sas/macros.jinja" import display_album %}

View File

@@ -99,9 +99,10 @@ INSTALLED_APPS = (
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.sitemaps",
"django.contrib.sites",
"django.contrib.messages",
"staticfiles",
"django.contrib.sites",
"honeypot",
"django_jinja",
"ninja_extra",
@@ -686,8 +687,10 @@ SITH_NOTIFICATIONS = [
# The keys are the notification names as found in SITH_NOTIFICATIONS, and the
# values are the callback function to update the notifs.
# 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 = {
"NEWS_MODERATION": "com.models.news_notification_callback",
"NEWS_MODERATION": None,
"SAS_MODERATION": "sas.models.sas_notification_callback",
}

46
sith/sitemap.py Normal file
View File

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

View File

@@ -18,7 +18,6 @@ from datetime import date, timedelta
from dateutil.relativedelta import relativedelta
from django.conf import settings
from django.contrib.auth.forms import PasswordResetForm
from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
@@ -72,24 +71,22 @@ class Subscription(models.Model):
else:
return f"No user - {self.pk}"
def save(self, *args, **kwargs):
super().save()
def save(self, *args, **kwargs) -> None:
if self.member.was_subscribed:
super().save()
return
from counter.models import Customer
_, account_created = Customer.get_or_create(self.member)
if account_created:
# Someone who subscribed once will be considered forever
# as an old subscriber.
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",
)
customer, _ = Customer.get_or_create(self.member)
# Someone who subscribed once will be considered forever
# as an old subscriber.
self.member.groups.add(settings.SITH_GROUP_OLD_SUBSCRIBERS_ID)
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):
return reverse("core:user_edit", kwargs={"user_id": self.member_id})

View File

@@ -5,6 +5,7 @@ from typing import Callable
import pytest
from dateutil.relativedelta import relativedelta
from django.conf import settings
from django.contrib.auth.models import Permission
from django.test import Client
from django.urls import reverse
@@ -14,8 +15,10 @@ from pytest_django.asserts import assertRedirects
from pytest_django.fixtures import SettingsWrapper
from core.baker_recipes import board_user, old_subscriber_user, subscriber_user
from core.models import User
from core.models import Group, User
from counter.models import Customer
from subscription.forms import SubscriptionExistingUserForm, SubscriptionNewUserForm
from subscription.models import Subscription
@pytest.mark.django_db
@@ -189,3 +192,17 @@ def test_submit_form_new_user(client: Client, settings: SettingsWrapper):
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,6 +14,7 @@
#
from django.conf import settings
from django.contrib.auth.forms import PasswordResetForm
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.core.exceptions import PermissionDenied
from django.urls import reverse, reverse_lazy
@@ -65,11 +66,23 @@ class CreateSubscriptionExistingUserFragment(CreateSubscriptionFragment):
class CreateSubscriptionNewUserFragment(CreateSubscriptionFragment):
"""Create a subscription for a user who already exists."""
"""Create a subscription for a user who doesn't exist yet."""
form_class = SubscriptionNewUserForm
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):
template_name = "subscription/fragments/creation_success.jinja"

View File

@@ -14,6 +14,7 @@
"types": ["jquery", "alpinejs"],
"paths": {
"#openapi": ["./staticfiles/generated/openapi/client/index.ts"],
"#openapi:*": ["./staticfiles/generated/openapi/client/*"],
"#core:*": ["./core/static/bundled/*"],
"#pedagogy:*": ["./pedagogy/static/bundled/*"],
"#counter:*": ["./counter/static/bundled/*"],

122
uv.lock generated
View File

@@ -492,15 +492,15 @@ wheels = [
[[package]]
name = "django-debug-toolbar"
version = "5.2.0"
version = "6.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
{ name = "sqlparse" },
]
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" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]
@@ -776,17 +776,16 @@ wheels = [
[[package]]
name = "ical"
version = "10.0.3"
version = "11.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "pyparsing" },
{ name = "python-dateutil" },
{ name = "tzdata" },
]
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" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]
@@ -921,44 +920,64 @@ wheels = [
[[package]]
name = "lxml"
version = "5.4.0"
version = "6.0.1"
source = { registry = "https://pypi.org/simple" }
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" }
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" }
wheels = [
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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]]
@@ -1506,15 +1525,6 @@ 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" },
]
[[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]]
name = "pytest"
version = "8.4.0"
@@ -1826,7 +1836,7 @@ requires-dist = [
{ name = "django-simple-captcha", specifier = ">=0.6.2,<1.0.0" },
{ name = "environs", extras = ["django"], specifier = ">=14.1.1,<15.0.0" },
{ name = "honcho", specifier = ">=2.0.0" },
{ name = "ical", specifier = ">=10.0.3,<11" },
{ name = "ical", specifier = ">=11,<12" },
{ name = "jinja2", specifier = ">=3.1.6,<4.0.0" },
{ name = "libsass", specifier = ">=0.23.0,<1.0.0" },
{ name = "mistune", specifier = ">=3.1.3,<4.0.0" },
@@ -1835,7 +1845,7 @@ requires-dist = [
{ name = "psutil", specifier = ">=7.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 = "redis", extras = ["hiredis"], specifier = ">=5.3.0,<6.0.0" },
{ name = "redis", extras = ["hiredis"], specifier = ">=5.3.0,<7" },
{ name = "reportlab", specifier = ">=4.3.1,<5.0.0" },
{ name = "requests", specifier = ">=2.32.3" },
{ name = "sentry-sdk", specifier = ">=2.25.1,<3.0.0" },
@@ -1846,7 +1856,7 @@ requires-dist = [
[package.metadata.requires-dev]
dev = [
{ name = "django-debug-toolbar", specifier = ">=5.2.0,<6.0.0" },
{ name = "django-debug-toolbar", specifier = ">=6,<7" },
{ name = "djhtml", specifier = ">=3.0.7,<4.0.0" },
{ name = "faker", specifier = ">=37.0.0,<38.0.0" },
{ name = "ipython", specifier = ">=9.0.2,<10.0.0" },
@@ -1865,7 +1875,7 @@ prod = [{ name = "psycopg", extras = ["c"], specifier = ">=3.2.9,<4.0.0" }]
tests = [
{ name = "beautifulsoup4", specifier = ">=4.13.3,<5" },
{ name = "freezegun", specifier = ">=1.5.1,<2.0.0" },
{ name = "lxml", specifier = ">=5.3.1,<6" },
{ name = "lxml", specifier = ">=6,<7" },
{ name = "model-bakery", specifier = ">=1.20.4,<2.0.0" },
{ name = "pytest", specifier = ">=8.3.5,<9.0.0" },
{ name = "pytest-cov", specifier = ">=6.0.0,<7.0.0" },