Compare commits

..
1 Commits
Author SHA1 Message Date
imperosol 5177427549 adapt pedagogy to new UTBM UE API 2026-07-25 11:45:19 +02:00
23 changed files with 308 additions and 157 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
repos: repos:
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version. # Ruff version.
rev: v0.16.0 rev: v0.15.19
hooks: hooks:
- id: ruff-check # just check the code, and print the errors - id: ruff-check # just check the code, and print the errors
- id: ruff-check # actually fix the fixable errors, but print nothing - id: ruff-check # actually fix the fixable errors, but print nothing
-22
View File
@@ -886,28 +886,6 @@ class TestBarmanConnection(TestCase):
) )
assert response.status_code == 403 assert response.status_code == 403
def test_logout_then_login(self):
"""Test that the login is successful if it is after a previous logout.
This is a regression test for #1440
"""
self.client.post( # login
self.login_url, {"username": self.barman.username, "password": "plop"}
)
self.client.post( # logout
reverse("counter:logout", kwargs={"counter_id": self.counter.id}),
data={"user_id": self.barman.id},
)
response = self.client.post( # and re-login
self.login_url, {"username": self.barman.username, "password": "plop"}
)
assert response.status_code == 200
assert response.headers["HX-Redirect"] == self.detail_url
response = self.client.get(
self.detail_url, {"username": self.barman.username, "password": "plop"}
)
assert self.barman in response.wsgi_request.barmen
@pytest.mark.django_db @pytest.mark.django_db
def test_barman_timeout(client: Client): def test_barman_timeout(client: Client):
-1
View File
@@ -61,7 +61,6 @@ class CounterLoginFragment(FragmentMixin, SingleObjectMixin, FormView):
user = form.get_user() user = form.get_user()
perm = self.object.permanencies.create(user=user, start=timezone.now()) perm = self.object.permanencies.create(user=user, start=timezone.now())
self.request.session.setdefault(SESSION_PERMANENCES_KEY, []).append(perm.id) self.request.session.setdefault(SESSION_PERMANENCES_KEY, []).append(perm.id)
self.request.session.modified = True
self.success_url = reverse( self.success_url = reverse(
"counter:details", kwargs={"counter_id": self.object.id} "counter:details", kwargs={"counter_id": self.object.id}
) )
+4 -8
View File
@@ -61,8 +61,9 @@ Un fichier de migration ressemble à ça :
```python ```python
from django.db import migrations from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
# liste des autres migrations à appliquer avant celle-ci # liste des autres migrations à appliquer avant celle-ci
] ]
@@ -116,16 +117,14 @@ Par exemple :
```python ```python
from django.db import migrations from django.db import migrations
def forwards_func(apps, schema_editor): def forwards_func(apps, schema_editor):
print("Appplication de la migration") print("Appplication de la migration")
def reverse_func(apps, schema_editor): def reverse_func(apps, schema_editor):
print("Annulation de la migration") print("Annulation de la migration")
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [] dependencies = []
operations = [ operations = [
@@ -165,7 +164,6 @@ On écrirait donc, dans l'application `pedagogy` :
from django.db import models from django.db import models
from core.models import User from core.models import User
class UserUe(models.Model): class UserUe(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE) user = models.ForeignKey(User, on_delete=models.CASCADE)
ue = models.CharField(max_length=10) ue = models.CharField(max_length=10)
@@ -176,7 +174,6 @@ Et nous aurions le fichier de migration suivant :
from django.db import migrations, models from django.db import migrations, models
from django.conf import settings from django.conf import settings
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(settings.AUTH_USER_MODEL),
@@ -292,7 +289,6 @@ La commande vous donnera ceci :
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
replaces = [("pedagogy", "0004_userue"), ("pedagogy", "0005_alter_userue_ue")] replaces = [("pedagogy", "0004_userue"), ("pedagogy", "0005_alter_userue_ue")]
+5 -1
View File
@@ -191,6 +191,7 @@ for user in richest.annotate(amount=F("customer__amount"))[:100]:
On aurait même pu réorganiser ça : On aurait même pu réorganiser ça :
```python ```python
from core.models import User from core.models import User
from django.db.models import F from django.db.models import F
@@ -238,7 +239,10 @@ nous écrivons donc instinctivement :
from counter.models import Counter from counter.models import Counter
foyer = Counter.objects.get(name="Foyer") foyer = Counter.objects.get(name="Foyer")
total_amount = sum(sale.amount * sale.unit_price for sale in foyer.sellings.all()) total_amount = sum(
sale.amount * sale.unit_price
for sale in foyer.sellings.all()
)
``` ```
On pourrait penser qu'il n'y a pas de problème. On pourrait penser qu'il n'y a pas de problème.
+2 -1
View File
@@ -27,11 +27,12 @@ SITH_SUBSCRIPTIONS = {
"cursus-alternant": {"name": _("Alternating cursus"), "price": 30, "duration": 6}, "cursus-alternant": {"name": _("Alternating cursus"), "price": 30, "duration": 6},
"membre-honoraire": {"name": _("Honorary member"), "price": 0, "duration": 666}, "membre-honoraire": {"name": _("Honorary member"), "price": 0, "duration": 666},
"un-jour": {"name": _("One day"), "price": 0, "duration": 0.00555333}, "un-jour": {"name": _("One day"), "price": 0, "duration": 0.00555333},
# On rajoute ici notre cotisation # On rajoute ici notre cotisation
# Elle se nomme "Un mois" # Elle se nomme "Un mois"
# Coûte 6€ # Coûte 6€
# Dure 1 mois (on raisonne en semestre, ici, c'est 1/6 de semestre) # Dure 1 mois (on raisonne en semestre, ici, c'est 1/6 de semestre)
"un-mois": {"name": _("One month"), "price": 6, "duration": 0.166}, "un-mois": {"name": _("One month"), "price": 6, "duration": 0.166}
} }
``` ```
+1 -1
View File
@@ -15,7 +15,7 @@ Si le mot est dans le code Python :
```python ```python
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
help_text = _("Hello") help_text=_("Hello")
``` ```
Si le mot apparaît dans le template Jinja : Si le mot apparaît dans le template Jinja :
-1
View File
@@ -26,7 +26,6 @@ from django.templatetags.static import static
# Sélectionnez le fichier de bannière pour le weekmail de l'automne 2018 # Sélectionnez le fichier de bannière pour le weekmail de l'automne 2018
def get_banner(self): def get_banner(self):
return "http://" + settings.SITH_URL + static("com/img/weekmail_bannerA18.jpg") return "http://" + settings.SITH_URL + static("com/img/weekmail_bannerA18.jpg")
``` ```
+2 -3
View File
@@ -147,15 +147,14 @@ Voici quelques exemples :
env = Env() env = Env()
env.read_env() env.read_env()
async def main(): async def main():
async with aiohttp.ClientSession( async with aiohttp.ClientSession(
base_url="https://ae.utbm.fr/api/", headers={"X-APIKey": env.str("API_KEY")} base_url="https://ae.utbm.fr/api/",
headers={"X-APIKey": env.str("API_KEY")}
) as session: ) as session:
async with session.get("club/1") as res: async with session.get("club/1") as res:
print(await res.json()) print(await res.json())
asyncio.run(main()) asyncio.run(main())
``` ```
+11 -7
View File
@@ -161,8 +161,8 @@ qui seront alors injectés.
```python ```python
from django.views.generic import CreateView, UpdateView, TemplateView from django.views.generic import CreateView, UpdateView, TemplateView
from core.views.mixins import FragmentMixin from core.views.mixins import FragmentMixin
class FooCreateFragment(FragmentMixin, CreateView): class FooCreateFragment(FragmentMixin, CreateView):
model = Foo model = Foo
fields = ["foo", "bar"] fields = ["foo", "bar"]
@@ -182,7 +182,7 @@ qui seront alors injectés.
def get_context_data(**kwargs): def get_context_data(**kwargs):
return super().get_context_data(**kwargs) | { return super().get_context_data(**kwargs) | {
"create_fragment": FooCreateFragment.as_fragment()(), "create_fragment": FooCreateFragment.as_fragment()(),
"update_fragment": FooUpdateFragment.as_fragment()(foo_id=1), "update_fragment": FooUpdateFragment.as_fragment()(foo_id=1)
} }
``` ```
@@ -288,7 +288,7 @@ class FooCreateFragment(FragmentMixin, CreateView):
def render_fragment(self, request, **kwargs): def render_fragment(self, request, **kwargs):
if "foo" in kwargs: if "foo" in kwargs:
kwargs["foo"] += 2 kwargs["foo"] += 2
return super().render_fragment(request, **kwargs) return super().render_fragment(request, **kwargs)
``` ```
@@ -319,9 +319,11 @@ from core.views.mixins import UseFragmentsMixin
class FooCompositeFormView(UseFragmentsMixin, TemplateView): class FooCompositeFormView(UseFragmentsMixin, TemplateView):
fragments = { fragments = {
"create_fragment": FooCreateFragment, "create_fragment": FooCreateFragment,
"update_fragment": FooUpdateFragment, "update_fragment": FooUpdateFragment
}
fragment_data = {
"update_fragment": {"foo": 4}
} }
fragment_data = {"update_fragment": {"foo": 4}}
template_name = "app/foo.jinja" template_name = "app/foo.jinja"
``` ```
@@ -340,7 +342,9 @@ from core.views.mixins import UseFragmentsMixin
class FooCompositeFormView(UseFragmentsMixin, TemplateView): class FooCompositeFormView(UseFragmentsMixin, TemplateView):
fragments = {"create_fragment": FooCreateFragment} fragments = {
"create_fragment": FooCreateFragment
}
template_name = "app/foo.jinja" template_name = "app/foo.jinja"
def get_fragment_context_data(self): def get_fragment_context_data(self):
+12 -14
View File
@@ -123,7 +123,6 @@ Par exemple, prenons le modèle suivant :
```python ```python
from django.db import models from django.db import models
class News(models.Model): class News(models.Model):
# ... # ...
@@ -193,21 +192,20 @@ Pour les vues sous forme de fonction, il y a le décorateur
```python ```python
from com.models import News from com.models import News
from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.auth.mixins import PermissionRequiredMixin
from django.shortcuts import redirect from django.shortcuts import redirect
from django.urls import reverse from django.urls import reverse
from django.views import View from django.views import View
from django.views.generic.detail import SingleObjectMixin from django.views.generic.detail import SingleObjectMixin
class NewsModerateView(PermissionRequiredMixin, SingleObjectMixin, View): class NewsModerateView(PermissionRequiredMixin, SingleObjectMixin, View):
model = News model = News
pk_url_kwarg = "news_id" pk_url_kwarg = "news_id"
permission_required = "com.moderate_news" permission_required = "com.moderate_news"
# On peut aussi fournir plusieurs permissions, par exemple : # On peut aussi fournir plusieurs permissions, par exemple :
# permission_required = ["com.moderate_news", "com.delete_news"] # permission_required = ["com.moderate_news", "com.delete_news"]
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
# Si nous sommes ici, nous pouvons être certains que l'utilisateur # Si nous sommes ici, nous pouvons être certains que l'utilisateur
# a la permission requise # a la permission requise
@@ -221,13 +219,12 @@ Pour les vues sous forme de fonction, il y a le décorateur
```python ```python
from com.models import News from com.models import News
from django.contrib.auth.decorators import permission_required from django.contrib.auth.decorators import permission_required
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse from django.urls import reverse
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST
@permission_required("com.moderate_news") @permission_required("com.moderate_news")
@require_POST @require_POST
def moderate_news(request, news_id: int): def moderate_news(request, news_id: int):
@@ -266,7 +263,6 @@ from core.auth.mixins import PermissionOrAuthorRequiredMixin
from django.views.generic import UpdateView from django.views.generic import UpdateView
class NewsUpdateView(PermissionOrAuthorRequiredMixin, UpdateView): class NewsUpdateView(PermissionOrAuthorRequiredMixin, UpdateView):
model = News model = News
pk_url_kwarg = "news_id" pk_url_kwarg = "news_id"
@@ -328,8 +324,8 @@ Voici un exemple d'implémentation de ce système :
from core.models import User, Group from core.models import User, Group
class Article(models.Model): class Article(models.Model):
title = models.CharField(_("title"), max_length=100) title = models.CharField(_("title"), max_length=100)
content = models.TextField(_("content")) content = models.TextField(_("content"))
@@ -374,7 +370,6 @@ Voici un exemple d'implémentation de ce système :
from core.models import User, Group from core.models import User, Group
class Article(models.Model): class Article(models.Model):
title = models.CharField(_("title"), max_length=100) title = models.CharField(_("title"), max_length=100)
content = models.TextField(_("content")) content = models.TextField(_("content"))
@@ -383,7 +378,7 @@ Voici un exemple d'implémentation de ce système :
owner_group = models.ForeignKey( # (1)! owner_group = models.ForeignKey( # (1)!
Group, related_name="owned_articles", default=settings.SITH_GROUP_ROOT_ID Group, related_name="owned_articles", default=settings.SITH_GROUP_ROOT_ID
) )
# relation many-to-many # relation many-to-many
edit_groups = models.ManyToManyField( # (2)! edit_groups = models.ManyToManyField( # (2)!
Group, Group,
@@ -391,7 +386,7 @@ Voici un exemple d'implémentation de ce système :
verbose_name=_("edit groups"), verbose_name=_("edit groups"),
blank=True, blank=True,
) )
# relation many-to-many # relation many-to-many
view_groups = models.ManyToManyField( # (3)! view_groups = models.ManyToManyField( # (3)!
Group, Group,
@@ -534,7 +529,10 @@ class NewsQuerySet(models.QuerySet): # (1)!
return self return self
# sinon, on retourne les nouvelles modérées ou dont l'utilisateur # sinon, on retourne les nouvelles modérées ou dont l'utilisateur
# est l'auteur # est l'auteur
return self.filter(models.Q(is_moderated=True) | models.Q(author=user)) return self.filter(
models.Q(is_moderated=True)
| models.Q(author=user)
)
class News(models.Model): class News(models.Model):
@@ -148,6 +148,56 @@
</span> </span>
</div> </div>
{% endif %} {% endif %}
<section>
<div class="category-header">
<h3 class="margin-bottom">{% trans %}Eurockéennes 2025 partnership{% endtrans %}</h3>
{% if user.is_subscribed %}
<div id="eurock-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:eurock") }}"
hx-target="#eurock-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 prices in categories %} {% for prices in categories %}
{% set category = prices[0].product.product_type %} {% set category = prices[0].product.product_type %}
<section> <section>
@@ -0,0 +1,16 @@
<a title="Logiciel billetterie en ligne"
href="https://www.weezevent.com?c=sys_widget"
class="weezevent-widget-integration"
target="_blank"
data-src="https://widget.weezevent.com/ticket/8aaba226-f7a3-4192-a64e-72ff8f5b35b7?id_evenement=1419869&locale=fr-FR&code=28747"
data-width="650"
data-height="600"
data-resize="1"
data-nopb="0"
data-type="neo"
data-width_auto="1"
data-noscroll="0"
data-id="1419869">
Billetterie Weezevent
</a>
<script type="text/javascript" src="https://widget.weezevent.com/weez.js" async defer></script>
+2
View File
@@ -31,6 +31,7 @@ from eboutic.views import (
EbouticMainView, EbouticMainView,
EbouticPayWithSith, EbouticPayWithSith,
EtransactionAutoAnswer, EtransactionAutoAnswer,
EurockPartnerFragment,
payment_result, payment_result,
) )
@@ -50,4 +51,5 @@ urlpatterns = [
EtransactionAutoAnswer.as_view(), EtransactionAutoAnswer.as_view(),
name="etransation_autoanswer", name="etransation_autoanswer",
), ),
path("eurock/", EurockPartnerFragment.as_view(), name="eurock"),
] ]
+6 -2
View File
@@ -43,11 +43,11 @@ from django.utils.formats import localize
from django.utils.timezone import localtime from django.utils.timezone import localtime
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.decorators.http import require_GET from django.views.decorators.http import require_GET
from django.views.generic import DetailView, FormView, UpdateView, View from django.views.generic import DetailView, FormView, TemplateView, UpdateView, View
from django.views.generic.edit import SingleObjectMixin from django.views.generic.edit import SingleObjectMixin
from django_countries.fields import Country from django_countries.fields import Country
from core.auth.mixins import CanViewMixin from core.auth.mixins import CanViewMixin, IsSubscriberMixin
from core.views.mixins import FragmentMixin, UseFragmentsMixin from core.views.mixins import FragmentMixin, UseFragmentsMixin
from counter.forms import BaseBasketForm, BasketItemForm, BillingInfoForm from counter.forms import BaseBasketForm, BasketItemForm, BillingInfoForm
from counter.models import ( from counter.models import (
@@ -359,3 +359,7 @@ class EtransactionAutoAnswer(View):
return HttpResponse( return HttpResponse(
"Payment failed with error: " + request.GET["Error"], status=202 "Payment failed with error: " + request.GET["Error"], status=202
) )
class EurockPartnerFragment(IsSubscriberMixin, TemplateView):
template_name = "eboutic/eurock_fragment.jinja"
+41 -4
View File
@@ -4465,6 +4465,47 @@ msgstr ""
msgid "this page" msgid "this page"
msgstr "cette 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 #: eboutic/templates/eboutic/eboutic_main.jinja
msgid "Product sold out" msgid "Product sold out"
msgstr "Produit épuisé" msgstr "Produit épuisé"
@@ -5908,10 +5949,6 @@ msgstr "fin"
msgid "Moderate Trombi comments" msgid "Moderate Trombi comments"
msgstr "Modérer les commentaires du Trombi" msgstr "Modérer les commentaires du Trombi"
#: trombi/templates/trombi/comment_moderation.jinja
msgid "Accept"
msgstr "Accepter"
#: trombi/templates/trombi/comment_moderation.jinja #: trombi/templates/trombi/comment_moderation.jinja
msgid "Reject" msgid "Reject"
msgstr "Refuser" msgstr "Refuser"
@@ -18,7 +18,12 @@ class Command(BaseCommand):
"Fetching UEs from the UTBM API.\n" "Fetching UEs from the UTBM API.\n"
"This may take a few minutes to complete." "This may take a few minutes to complete."
) )
i = 0
for ue in client.fetch_ues(): for ue in client.fetch_ues():
if (i := (i + 1)) % 10 == 0:
self.stdout.write(f"{i} UEs processed")
# this is a N+1 query, but it isn't a problem,
# as fetching a UE from the UTBM API is taking most of the time anyway
db_ue = UE.objects.filter(code=ue.code).first() db_ue = UE.objects.filter(code=ue.code).first()
if db_ue is None: if db_ue is None:
db_ue = UE(code=ue.code, author=root_user) db_ue = UE(code=ue.code, author=root_user)
+34 -18
View File
@@ -5,12 +5,22 @@ from django.urls import reverse
from django.utils import html from django.utils import html
from haystack.query import SearchQuerySet from haystack.query import SearchQuerySet
from ninja import FilterLookup, FilterSchema, ModelSchema, Schema from ninja import FilterLookup, FilterSchema, ModelSchema, Schema
from pydantic import AliasPath, ConfigDict, Field, TypeAdapter from pydantic import AliasPath, BeforeValidator, ConfigDict, Field, TypeAdapter
from pydantic.alias_generators import to_camel from pydantic.alias_generators import to_camel
from pedagogy.models import UE from pedagogy.models import UE
class FormationSchema(Schema):
model_config = ConfigDict(validate_by_name=True, validate_by_alias=True)
code: str
label: str = Field(alias="libelle")
FormationListSchema = TypeAdapter(list[FormationSchema])
class UtbmShortUeSchema(Schema): class UtbmShortUeSchema(Schema):
"""Short representation of an UE in the UTBM API. """Short representation of an UE in the UTBM API.
@@ -19,19 +29,17 @@ class UtbmShortUeSchema(Schema):
The UTBM API returns more data than that. The UTBM API returns more data than that.
""" """
model_config = ConfigDict(alias_generator=to_camel) model_config = ConfigDict(validate_by_name=True)
code: str code: str
code_formation: str category: str = Field(alias="codeCategorie")
code_categorie: str | None formation: FormationSchema
code_langue: str lang: str = Field(alias="codeLangue")
ouvert_automne: bool open_autumn: bool = Field(alias="ouvertAutomne")
ouvert_printemps: bool open_spring: bool = Field(alias="ouvertPrintemps")
class WorkloadSchema(Schema): class WorkloadSchema(Schema):
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
code: Literal["TD", "TP", "CM", "THE", "TE"] code: Literal["TD", "TP", "CM", "THE", "TE"]
nbh: int nbh: int
@@ -48,22 +56,30 @@ class SemesterUeState(Schema):
ShortUeList = TypeAdapter(list[UtbmShortUeSchema]) ShortUeList = TypeAdapter(list[UtbmShortUeSchema])
class SyllabusItemSchema(Schema):
model_config = ConfigDict(validate_by_name=True)
label: str = Field(alias="libelle")
# We want only strings, but the UTBM API can return null,
# so convert null values to empty strings
value: Annotated[str, BeforeValidator(lambda x: x or "")] = Field(alias="valeur")
class UtbmFullUeSchema(Schema): class UtbmFullUeSchema(Schema):
"""Long representation of an UE in the UTBM API.""" """Long representation of an UE in the UTBM API."""
model_config = ConfigDict(alias_generator=to_camel) model_config = ConfigDict(validate_by_name=True, alias_generator=to_camel)
code: str code: str
departement: str = "NA" departement: str = "NA"
libelle: str | None label: str = Field(alias="libelle")
objectifs: str | None syllabus: list[SyllabusItemSchema] = Field(
programme: str | None default=[],
acquisition_competences: str | None validation_alias=AliasPath("listeElementConstitutif", 0, "listeSaisieSyllabus"),
acquisition_notions: str | None )
langue: str lang: str = Field(None, validation_alias=AliasPath("langueEnseignement", "code"))
code_langue: str
credits_ects: int credits_ects: int
activites: list[WorkloadSchema] activites: list[WorkloadSchema] = Field(alias="listeActivite")
respo_automne: str | None = Field( respo_automne: str | None = Field(
None, validation_alias=AliasPath("automne", "responsable") None, validation_alias=AliasPath("automne", "responsable")
) )
+81 -36
View File
@@ -5,8 +5,16 @@ from typing import Iterator
import requests import requests
from django.conf import settings from django.conf import settings
from django.utils.functional import cached_property from django.utils.functional import cached_property
from pydantic import TypeAdapter
from pedagogy.schemas import ShortUeList, UeSchema, UtbmFullUeSchema, UtbmShortUeSchema from pedagogy.schemas import (
FormationSchema,
UeSchema,
UtbmFullUeSchema,
UtbmShortUeSchema,
)
FormationListSchema = TypeAdapter(list[FormationSchema])
class UtbmApiClient(requests.Session): class UtbmApiClient(requests.Session):
@@ -18,28 +26,44 @@ class UtbmApiClient(requests.Session):
@cached_property @cached_property
def current_year(self) -> int: def current_year(self) -> int:
"""Fetch from the API the latest existing year""" """Fetch from the API the latest existing year"""
url = f"{self.BASE_URL}/guides/fr" url = f"{self.BASE_URL}/guide/"
response = self.get(url) response = self.get(url, {"langue": "fr"})
return response.json()[-1]["annee"] return max(i["annee"] for i in response.json())
def fetch_short_ues( @cached_property
self, lang: str = "fr", year: int | None = None def formations(self) -> list[FormationSchema]:
) -> list[UtbmShortUeSchema]: response = self.get(
f"{self.BASE_URL}/formation/",
{"langue": "fr", "annee_univ": self.current_year, "typeFormation": "ING"},
)
return FormationListSchema.validate_json(response.text, by_alias=True)
def fetch_short_ues(self, year: int | None = None) -> list[UtbmShortUeSchema]:
"""Get the list of UEs in their short format from the UTBM API""" """Get the list of UEs in their short format from the UTBM API"""
if year is None: if year is None:
year = self.current_year year = self.current_year
if lang not in self._cache["short_ues"]: if year not in self._cache["short_ues"]:
self._cache["short_ues"][lang] = {} ues = []
if year not in self._cache["short_ues"][lang]: for formation in self.formations:
url = f"{self.BASE_URL}/uvs/{lang}/{year}" response = self.get(
response = self.get(url) f"{self.BASE_URL}/ue/",
ues = ShortUeList.validate_json(response.content) {
self._cache["short_ues"][lang][year] = ues "langue": "fr",
return self._cache["short_ues"][lang][year] "annee_univ": year,
"codeFormation": formation.code,
},
)
ues.extend(
[
UtbmShortUeSchema.model_validate({**ue, "formation": formation})
for ue in response.json()
if ue["codeCategorie"] is not None
]
)
self._cache["short_ues"][year] = ues
return self._cache["short_ues"][year]
def fetch_ues( def fetch_ues(self, year: int | None = None) -> Iterator[UeSchema]:
self, lang: str = "fr", year: int | None = None
) -> Iterator[UeSchema]:
"""Fetch all UEs from the UTBM API, parsed in a format that we can use. """Fetch all UEs from the UTBM API, parsed in a format that we can use.
Warning: Warning:
@@ -52,7 +76,7 @@ class UtbmApiClient(requests.Session):
""" """
if year is None: if year is None:
year = self.current_year year = self.current_year
shorts_ues = self.fetch_short_ues(lang, year) shorts_ues = self.fetch_short_ues(year)
# When UEs are common to multiple branches (like most HUMA) # When UEs are common to multiple branches (like most HUMA)
# the UTBM API duplicates them for every branch. # the UTBM API duplicates them for every branch.
# We have no way in our db to link a UE to multiple formations, # We have no way in our db to link a UE to multiple formations,
@@ -65,27 +89,40 @@ class UtbmApiClient(requests.Session):
if ue.code not in unique_short_ues: if ue.code not in unique_short_ues:
unique_short_ues[ue.code] = ue unique_short_ues[ue.code] = ue
for ue in unique_short_ues.values(): for ue in unique_short_ues.values():
ue_url = f"{self.BASE_URL}/uv/{lang}/{year}/{ue.code}/{ue.code_formation}" if ue.lang.upper() != "FR":
response = requests.get(ue_url) continue
response = requests.get(
f"{self.BASE_URL}/ue/{ue.code}",
{
"langue": ue.lang,
"annee_univ": year,
"codeFormation": ue.formation.code,
},
)
full_ue = UtbmFullUeSchema.model_validate_json(response.content) full_ue = UtbmFullUeSchema.model_validate_json(response.content)
yield make_clean_ue(ue, full_ue) yield make_clean_ue(ue, full_ue)
def find_uu(self, lang: str, code: str, year: int | None = None) -> UeSchema | None: def find_ue(self, code: str, year: int | None = None) -> UeSchema | None:
"""Find an UE from the UTBM API.""" """Find a UE from the UTBM API."""
# query the UE list
if not year: if not year:
year = self.current_year year = self.current_year
# the UTBM API has no way to fetch a single short ue, # the UTBM API has no way to fetch a single short ue,
# and short ues contain infos that we need and are not # and short ues contain infos that we need and are not
# in the full ue schema, so we must fetch everything. # in the full ue schema, so we must fetch everything.
short_ues = self.fetch_short_ues(lang, year) short_ues = self.fetch_short_ues(year)
short_ue = next((ue for ue in short_ues if ue.code == code), None) short_ue = next((ue for ue in short_ues if ue.code == code), None)
if short_ue is None: if short_ue is None:
return None return None
# get detailed information about the UE # get detailed information about the UE
ue_url = f"{self.BASE_URL}/uv/{lang}/{year}/{code}/{short_ue.code_formation}" response = requests.get(
response = requests.get(ue_url) f"{self.BASE_URL}/ue/{code}",
{
"langue": "fr",
"annee_univ": year,
"codeFormation": short_ue.formation.code,
},
)
full_ue = UtbmFullUeSchema.model_validate_json(response.content) full_ue = UtbmFullUeSchema.model_validate_json(response.content)
return make_clean_ue(short_ue, full_ue) return make_clean_ue(short_ue, full_ue)
@@ -112,9 +149,9 @@ def make_clean_ue(short_ue: UtbmShortUeSchema, full_ue: UtbmFullUeSchema) -> UeS
"ED": "EDIM", "ED": "EDIM",
"AI": "GI", "AI": "GI",
"AM": "MC", "AM": "MC",
}.get(short_ue.code_formation, "NA") }.get(short_ue.formation.code, "NA")
match short_ue.ouvert_printemps, short_ue.ouvert_automne: match short_ue.open_spring, short_ue.open_autumn:
case True, True: case True, True:
semester = "AUTUMN_AND_SPRING" semester = "AUTUMN_AND_SPRING"
case True, False: case True, False:
@@ -125,11 +162,11 @@ def make_clean_ue(short_ue: UtbmShortUeSchema, full_ue: UtbmFullUeSchema) -> UeS
semester = "CLOSED" semester = "CLOSED"
return UeSchema( return UeSchema(
title=full_ue.libelle or "", title=full_ue.label or "",
code=full_ue.code, code=full_ue.code,
credit_type=short_ue.code_categorie or "FREE", credit_type=short_ue.category or "FREE",
semester=semester, semester=semester,
language=short_ue.code_langue.upper(), language=short_ue.lang.upper(),
credits=full_ue.credits_ects, credits=full_ue.credits_ects,
department=department, department=department,
hours_THE=next((i.nbh for i in full_ue.activites if i.code == "THE"), 0) // 60, hours_THE=next((i.nbh for i in full_ue.activites if i.code == "THE"), 0) // 60,
@@ -138,8 +175,16 @@ def make_clean_ue(short_ue: UtbmShortUeSchema, full_ue: UtbmFullUeSchema) -> UeS
hours_TE=next((i.nbh for i in full_ue.activites if i.code == "TE"), 0) // 60, hours_TE=next((i.nbh for i in full_ue.activites if i.code == "TE"), 0) // 60,
hours_CM=next((i.nbh for i in full_ue.activites if i.code == "CM"), 0) // 60, hours_CM=next((i.nbh for i in full_ue.activites if i.code == "CM"), 0) // 60,
manager=full_ue.respo_automne or full_ue.respo_printemps or "", manager=full_ue.respo_automne or full_ue.respo_printemps or "",
objectives=full_ue.objectifs or "", objectives=next(
program=full_ue.programme or "", (i.value for i in full_ue.syllabus if i.label == "Objectifs"), ""
skills=full_ue.acquisition_competences or "", ),
key_concepts=full_ue.acquisition_notions or "", program=next((i.value for i in full_ue.syllabus if i.label == "Programme"), ""),
skills=next(
(i.value for i in full_ue.syllabus if i.label == "Acquis d'apprentissage"),
"",
),
key_concepts=next(
(i.value for i in full_ue.syllabus if i.label == "Notions-clés"),
"",
),
) )
+1 -1
View File
@@ -66,7 +66,7 @@ dev = [
"django-debug-toolbar>=7.0.0,<8", "django-debug-toolbar>=7.0.0,<8",
"ipython>=9.14.1,<10.0.0", "ipython>=9.14.1,<10.0.0",
"pre-commit>=4.6.0,<5.0.0", "pre-commit>=4.6.0,<5.0.0",
"ruff>=0.16.0,<1.0.0", "ruff>=0.15.19,<1.0.0",
"djhtml>=3.0.11,<4.0.0", "djhtml>=3.0.11,<4.0.0",
"faker>=40.23.0,<41.0.0", "faker>=40.23.0,<41.0.0",
"rjsmin>=1.2.5,<2.0.0", "rjsmin>=1.2.5,<2.0.0",
+7 -9
View File
@@ -82,11 +82,7 @@
<div class="container" id="pict"> <div class="container" id="pict">
<div class="main"> <div class="main">
<div <div class="photo" :aria-busy="currentPicture.imageLoading">
class="photo"
:aria-busy="currentPicture.imageLoading"
@click="currentPicture = (($event.offsetX > $el.offsetWidth / 2) ? nextPicture : previousPicture) ?? currentPicture"
>
<img <img
:src="currentPicture.compressedUrl" :src="currentPicture.compressedUrl"
:alt="currentPicture.name" :alt="currentPicture.name"
@@ -99,11 +95,13 @@
<div class="infos"> <div class="infos">
<h5>{% trans %}Infos{% endtrans %}</h5> <h5>{% trans %}Infos{% endtrans %}</h5>
<div> <div>
<div <div>
x-data="{formatter: Intl.DateTimeFormat('{{ LANGUAGE_CODE }}', {dateStyle: 'long'})}"
>
<span>{% trans %}Date: {% endtrans %}</span> <span>{% trans %}Date: {% endtrans %}</span>
<span x-text="formatter.format(new Date(currentPicture.date))"> <span
x-text="Intl.DateTimeFormat(
'{{ LANGUAGE_CODE }}', {dateStyle: 'long'}
).format(Date.parse(currentPicture.date))"
>
</span> </span>
</div> </div>
<div> <div>
+1 -1
View File
@@ -493,7 +493,7 @@ SITH_LOG_OPERATION_TYPE = [
("REFILLING_DELETION", _("Refilling deletion")), ("REFILLING_DELETION", _("Refilling deletion")),
] ]
SITH_PEDAGOGY_UTBM_API = "https://extranet1.utbm.fr/gpedago/api/guide" SITH_PEDAGOGY_UTBM_API = "https://api.utbm.fr/offre-formation/public"
# Defines pagination for cash summary # Defines pagination for cash summary
SITH_COUNTER_CASH_SUMMARY_LENGTH = 50 SITH_COUNTER_CASH_SUMMARY_LENGTH = 50
Generated
+26 -26
View File
@@ -972,9 +972,9 @@ resolution-markers = [
"python_full_version < '3.13'", "python_full_version < '3.13'",
] ]
dependencies = [ dependencies = [
{ name = "pydantic" }, { name = "pydantic", marker = "python_full_version < '3.13'" },
{ name = "python-dateutil" }, { name = "python-dateutil", marker = "python_full_version < '3.13'" },
{ name = "tzdata" }, { name = "tzdata", marker = "python_full_version < '3.13'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/0c/a3/506fe8d9416472588ff30d4c97a7e477f680a098c74a9f2050bd84182a75/ical-12.0.0.tar.gz", hash = "sha256:2f1c7450bd1eff586322273360f44931eb0919dc048c7284f1647037624be7c4", size = 125412, upload-time = "2025-11-13T06:00:04.95Z" } sdist = { url = "https://files.pythonhosted.org/packages/0c/a3/506fe8d9416472588ff30d4c97a7e477f680a098c74a9f2050bd84182a75/ical-12.0.0.tar.gz", hash = "sha256:2f1c7450bd1eff586322273360f44931eb0919dc048c7284f1647037624be7c4", size = 125412, upload-time = "2025-11-13T06:00:04.95Z" }
wheels = [ wheels = [
@@ -989,9 +989,9 @@ resolution-markers = [
"python_full_version >= '3.13'", "python_full_version >= '3.13'",
] ]
dependencies = [ dependencies = [
{ name = "pydantic" }, { name = "pydantic", marker = "python_full_version >= '3.13'" },
{ name = "python-dateutil" }, { name = "python-dateutil", marker = "python_full_version >= '3.13'" },
{ name = "tzdata" }, { name = "tzdata", marker = "python_full_version >= '3.13'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/f5/1c/dc7378c5ba63e8b0f2987bd827a1e40bd27196d37a7082be0593551e8ed8/ical-13.2.5.tar.gz", hash = "sha256:1cc3116e6f522eeeaa694b3e20b58dbfbe4b281954a8d3d28892cfb61f03fd40", size = 131405, upload-time = "2026-05-24T16:51:56.017Z" } sdist = { url = "https://files.pythonhosted.org/packages/f5/1c/dc7378c5ba63e8b0f2987bd827a1e40bd27196d37a7082be0593551e8ed8/ical-13.2.5.tar.gz", hash = "sha256:1cc3116e6f522eeeaa694b3e20b58dbfbe4b281954a8d3d28892cfb61f03fd40", size = 131405, upload-time = "2026-05-24T16:51:56.017Z" }
wheels = [ wheels = [
@@ -2074,27 +2074,27 @@ wheels = [
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.16.0" version = "0.15.19"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4d/94/1e5e4967626faf12fa56999cd6222dff6992ceb086ad7945756baf70c7a7/ruff-0.16.0.tar.gz", hash = "sha256:e460aafd5495ec89efaa6ced2e4a9a581116451e1c88b9d37ef497e0f8e93982", size = 4790557, upload-time = "2026-07-23T19:11:30.981Z" } sdist = { url = "https://files.pythonhosted.org/packages/d5/e6/15800dfde183a1a106594016c912b4c12d050a301989d1aca6cb63759fe8/ruff-0.15.19.tar.gz", hash = "sha256:edc27f7172a93b32b102687009d6a588508815072141543ae603a8b9b0823063", size = 4772071, upload-time = "2026-06-24T01:10:46.942Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/4b/81/1c8818fee7ce1a04cd7d1b3172e0a8f8e4f1dc4feb7fc390e16daa8af323/ruff-0.16.0-py3-none-linux_armv6l.whl", hash = "sha256:e5115729eb08c585e5121978ba5d5b60caeae394ce21b9fb5e6cd33a1c6c9b1e", size = 10754633, upload-time = "2026-07-23T19:10:46.415Z" }, { url = "https://files.pythonhosted.org/packages/88/4c/9ded7626c39a0440c575bf69e2bf500d443388272c842662c59852ee7fcd/ruff-0.15.19-py3-none-linux_armv6l.whl", hash = "sha256:922d1eb283161564759bd49f507e91dc6112c15da8bd5b84ed714e086243cf86", size = 10950859, upload-time = "2026-06-24T01:10:38.491Z" },
{ url = "https://files.pythonhosted.org/packages/23/df/beaf59c09d68db84304d555f188b276a77132a5d5b0b67a5c762aa143628/ruff-0.16.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3c954b1d580bfa035b41654f7858cc7e71d5fc3ac5b723dd62bd9133830ed522", size = 10969164, upload-time = "2026-07-23T19:10:50.271Z" }, { url = "https://files.pythonhosted.org/packages/fb/ef/c211505ece1d00ef493d58e54e3b6383c946a21e9874774eb531f2512cf3/ruff-0.15.19-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4d190d8f62a0b94aba8f721116538a9ee29b1e74d26650846ba9b99f0ae21c40", size = 11294529, upload-time = "2026-06-24T01:10:36.481Z" },
{ url = "https://files.pythonhosted.org/packages/42/ce/741cd197496a1abbf51352710fd15ed995d2a2be87189c1da26a450d6e83/ruff-0.16.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e01c21d10eb1b29f47b7454e1f4056db9a3f0260c646aa88457c610291db9f81", size = 10488846, upload-time = "2026-07-23T19:10:52.639Z" }, { url = "https://files.pythonhosted.org/packages/fe/93/78d462e7d39968e58094dc57be7d09ffb14ce37da5b68ed70338a35a1f21/ruff-0.15.19-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5a2c86ba6870dd415a9d9eb8be94d7924ebec6a26ffc7958ec7ca29d4bff967d", size = 10641416, upload-time = "2026-06-24T01:10:48.923Z" },
{ url = "https://files.pythonhosted.org/packages/52/2a/a2db8e88cade358f5cdcb05674a917751074109315d014eb6352d9a893f7/ruff-0.16.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e364e5ed22ed8dc05082fd78e35308618260907ac2d3c1d637b2e682415b6c9", size = 10889729, upload-time = "2026-07-23T19:10:54.89Z" }, { url = "https://files.pythonhosted.org/packages/76/c4/5cb66cfd1f865d5cca908b86c93ac785e7f572193d3c7426079ca6643e24/ruff-0.15.19-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82b432bc087264aea70fd25ac198918b70bd9e2aa0db4297b0bb91bbfbbc63ce", size = 11015582, upload-time = "2026-06-24T01:10:30.089Z" },
{ url = "https://files.pythonhosted.org/packages/42/65/62a771694ebd63029dc953e27dbad40e1588bd4860ff9fe881018fddaa49/ruff-0.16.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d327b8fc113a1d4421a04f3839d3752057c8dd1ee320223a6f3f52d04ada462a", size = 10568275, upload-time = "2026-07-23T19:10:56.993Z" }, { url = "https://files.pythonhosted.org/packages/51/9f/8ecfaec10cf5eecd28fbc00ff4fb867db90a1be54bf3d39ebf93f893cd52/ruff-0.15.19-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8530a09d03b3a8c994f8b559a7dcdabc690bcd3f78ef276c38c83166798ebf56", size = 10744059, upload-time = "2026-06-24T01:10:32.48Z" },
{ url = "https://files.pythonhosted.org/packages/3f/e2/ced249fe8af5f086c5c58cc21cc3356d50f32f7401c5df87050c999620a7/ruff-0.16.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9b50c55e263103586b3dcf5f73d479eb8cb5fdb6098fec59a62891dab653717", size = 11385112, upload-time = "2026-07-23T19:10:59.615Z" }, { url = "https://files.pythonhosted.org/packages/35/6b/983249d04562bc2d590edd75f32455cdb473affb3ba4bc8d883e939c697d/ruff-0.15.19-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:87bf21fb3875fe69f0eacc825411657e2e85589cce633c35c0adf1113649c62b", size = 11568461, upload-time = "2026-06-24T01:10:17.435Z" },
{ url = "https://files.pythonhosted.org/packages/87/0b/05154977a8fd69eeb6c103271f55403bfd8711f5c0f8ed07489d95a504e7/ruff-0.16.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ff4a79ce3ec0172f3241943835de1c4cb4e2dcd07f0f8c2d02603dbbbee4b17", size = 12207008, upload-time = "2026-07-23T19:11:02.154Z" }, { url = "https://files.pythonhosted.org/packages/eb/39/bc7794f127b18f492a3b4ee82bba5a900c985ff13b72b46f46e3c171ba34/ruff-0.15.19-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f9b229cb3ef56ecc2c1c8ebeca64b7a7740ccaef40a9eb097e78dde5a8560b83", size = 12429690, upload-time = "2026-06-24T01:10:40.638Z" },
{ url = "https://files.pythonhosted.org/packages/fb/29/98225831a3a1eab0e02f4acc6ca6559a98611dcc68b6965ff4b7234627c1/ruff-0.16.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e95c448fca1fb2a18372a9440926c5a6ee789639bb975c72e7ae6d0b04218ab4", size = 11650842, upload-time = "2026-07-23T19:11:04.557Z" }, { url = "https://files.pythonhosted.org/packages/0a/3b/0de6859e698ed11c8a49e765196c8d333599b6a546c0715df39b6ba1aa2e/ruff-0.15.19-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6c754515be7b76afe6e7e62df7776709571bcfc1631183828afcf3bafa869e3", size = 11693067, upload-time = "2026-06-24T01:10:25.681Z" },
{ url = "https://files.pythonhosted.org/packages/91/66/6bd3cf90500653d55dc0ffc8507aa8300bd49d0214b2e8cb4d3fef2943ba/ruff-0.16.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f11a8d11010301d0a398a2fdef67691feca7294da6aef55e2150e8fa2cd520b", size = 11400718, upload-time = "2026-07-23T19:11:09.233Z" }, { url = "https://files.pythonhosted.org/packages/89/3d/0b1f30f84bee9ae6ae8d349c2ba8b6f4b040966744efdd3acc804ae7c024/ruff-0.15.19-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6a498f82e0f4d8904c4e0aea5139cdfac1f39d19a3c51d491292f63a36e83b2e", size = 11616911, upload-time = "2026-06-24T01:10:44.809Z" },
{ url = "https://files.pythonhosted.org/packages/8e/a2/a54eb4eae05d66364050a5d3b8a9c5ef88196531b3cbe7109d873f87f819/ruff-0.16.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:48044c678e9cb8698246c99b14aaccfa6601dea7379eb48a6f8f73f7a6d86cd0", size = 11426177, upload-time = "2026-07-23T19:11:11.994Z" }, { url = "https://files.pythonhosted.org/packages/4d/eb/c90bd3dfc12eed9032c2c1bfe05105b93a1b2c8bce555db6308315b853ce/ruff-0.15.19-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:d48caa34488fb521fd0ef4aea2b0e8fe758298df044138f0d67b687a6a0d07ed", size = 11649343, upload-time = "2026-06-24T01:10:23.472Z" },
{ url = "https://files.pythonhosted.org/packages/1a/be/16e3eea4b2a478a496919f5e36f17c4559e54620bd3bbac5d6affa068006/ruff-0.16.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7aa0959bad8eb8bef50340154fc9b58678dae31fa4293afa38b44b6e552c0213", size = 10856126, upload-time = "2026-07-23T19:11:14.221Z" }, { url = "https://files.pythonhosted.org/packages/82/91/01caa13602a2f12fae5edbe8caf78b3c1e6db1293132aee6959eecce095c/ruff-0.15.19-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4171b6613effa9363cd46dd4f75bd1827b6d1b946b5e278ed0c600d305379445", size = 10977610, upload-time = "2026-06-24T01:10:50.892Z" },
{ url = "https://files.pythonhosted.org/packages/a2/84/252eb8b868a16eec7257c14f504f77537e734b2d69c762e639e588e304a3/ruff-0.16.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:28ea2b7df8ebf7f9da6b7d47b230ab48f387c0a29be3b474c4d0740e197bb9af", size = 10571208, upload-time = "2026-07-23T19:11:16.378Z" }, { url = "https://files.pythonhosted.org/packages/3c/51/acb817922feab9ecbb3201377d4dbe7a25f1395e46545820061973f03468/ruff-0.15.19-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:27c15b2a241dd4d995557949a094fe78b8ad99122a38ccae1595849bcc947b3f", size = 10744900, upload-time = "2026-06-24T01:10:42.726Z" },
{ url = "https://files.pythonhosted.org/packages/21/09/817a482f542f7570cbb4554b26e896610c7114f539b1d9e2d2145bf6bef6/ruff-0.16.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:33a3dfac8c35f81498dea9181bccc2f4c4bc8f1521a1dd9406e77643e0f0fb09", size = 11063329, upload-time = "2026-07-23T19:11:19.173Z" }, { url = "https://files.pythonhosted.org/packages/84/bc/5c8ca46b8a7a3f2b16cfbec88721d772b1c93912904e8f8c2e49470fea63/ruff-0.15.19-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ed03b7862d68f0a8771d50ee129980cbf1b113f96e250b73954bc292f689e0bb", size = 11293560, upload-time = "2026-06-24T01:10:21.262Z" },
{ url = "https://files.pythonhosted.org/packages/2e/23/9403c180ca1cb9b1f7335f5c3e5305c09d49ea5b345196682a36028bde4a/ruff-0.16.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a5237a0bda500d30d81b8e07a6973a5cbc772864cbf746ae2f4e8a2e01c9f4ed", size = 11489751, upload-time = "2026-07-23T19:11:21.74Z" }, { url = "https://files.pythonhosted.org/packages/81/e0/4a888cbe4d5523b3f77a2b1fa043f46cfeba1b32eac35dcfadee0578fa8a/ruff-0.15.19-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:08143f0685ae278b30727ea72e90c61e5bd9c31b91aac4f5bb989538f73d24b8", size = 11696533, upload-time = "2026-06-24T01:10:53.046Z" },
{ url = "https://files.pythonhosted.org/packages/b2/1d/1b2ef7bcde851c78d7f17f1cca13fd6dc695fc4b3d6197941e72cae5b132/ruff-0.16.0-py3-none-win32.whl", hash = "sha256:7fab76fa065c873f41ff744347c6e77bcc3dfec4bcc754dc26b63d23c0f7f5fb", size = 10785885, upload-time = "2026-07-23T19:11:23.947Z" }, { url = "https://files.pythonhosted.org/packages/98/43/c34b2fcd79262a85161764a97aaca89c3e4f574340ab61430cefa2bdd2c1/ruff-0.15.19-py3-none-win32.whl", hash = "sha256:8f47f0f92952af2557212bb10cf3e695cd4cf28b2c6e42cdb18ec6c9ebfa19da", size = 10986299, upload-time = "2026-06-24T01:10:55.185Z" },
{ url = "https://files.pythonhosted.org/packages/b2/a3/d5e4ef7a56be3f928ffb90b94c25ba7d3cb9c7fe0736aeaaedf361770712/ruff-0.16.0-py3-none-win_amd64.whl", hash = "sha256:429c117f022bf481fabd9d551e7a3952b24c65e6ef44337ea09d90bebef14472", size = 11923141, upload-time = "2026-07-23T19:11:26.409Z" }, { url = "https://files.pythonhosted.org/packages/22/e8/15fd23e02b2442b56b2026b455977bc3057aa34b26e6323d1e99e8531a9f/ruff-0.15.19-py3-none-win_amd64.whl", hash = "sha256:efeca47ee3f9d4a7162655a3b8e6ee4a878646044233978d4d2c1ff8cdd914f0", size = 12123473, upload-time = "2026-06-24T01:10:27.74Z" },
{ url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407, upload-time = "2026-07-23T19:11:28.705Z" }, { url = "https://files.pythonhosted.org/packages/30/66/9a73695e31eaee04f35d8475998bf8ab354465f9c638936d76111603dcc5/ruff-0.15.19-py3-none-win_arm64.whl", hash = "sha256:6c6b607466e47349332eb1d9be52fb1467423fc07c217341af41cd0f3f0573be", size = 11376779, upload-time = "2026-06-24T01:10:34.465Z" },
] ]
[[package]] [[package]]
@@ -2226,7 +2226,7 @@ dev = [
{ name = "ipython", specifier = ">=9.14.1,<10.0.0" }, { name = "ipython", specifier = ">=9.14.1,<10.0.0" },
{ name = "pre-commit", specifier = ">=4.6.0,<5.0.0" }, { name = "pre-commit", specifier = ">=4.6.0,<5.0.0" },
{ name = "rjsmin", specifier = ">=1.2.5,<2.0.0" }, { name = "rjsmin", specifier = ">=1.2.5,<2.0.0" },
{ name = "ruff", specifier = ">=0.16.0,<1.0.0" }, { name = "ruff", specifier = ">=0.15.19,<1.0.0" },
] ]
docs = [ docs = [
{ name = "mkdocs", specifier = ">=1.6.1,<2.0.0" }, { name = "mkdocs", specifier = ">=1.6.1,<2.0.0" },