32 Commits
Author SHA1 Message Date
klmp200 dfc714ff3d Fix broken ProductAjaxSelect widget 2026-08-28 15:05:17 +02:00
thomas girodandGitHub 7808ca3688 Merge pull request #1464 from ae-utbm/tsc6
Tsc6
2026-08-25 22:28:21 +02:00
imperosol 7e42ad138c fix: deprecation warnings in vite config 2026-08-25 22:23:37 +02:00
imperosol 3c4e7defb8 fix: build error with HeyAPI because of TS7 2026-08-25 21:40:21 +02:00
thomas girodandGitHub 2fab0ae96d Merge pull request #1461 from ae-utbm/update-deps
update deps
2026-08-24 12:49:15 +02:00
imperosol 68c5231138 update JS deps 2026-08-23 19:41:43 +02:00
klmp200andGitHub 17708e19c5 Merge pull request #1460 from ae-utbm/pedagogy
Fix star width and rewrite star widget with nice hovering
2026-08-23 18:00:45 +02:00
imperosol e1be017877 update python deps 2026-08-23 17:14:46 +02:00
klmp200 4a8471983f Apply review comments 2026-08-23 15:17:03 +02:00
thomas girodandGitHub c9523b0730 Merge pull request #1449 from ae-utbm/ts
Typescript strict mode
2026-08-22 11:38:31 +02:00
klmp200 82e71263dd StarList uses proper inheritance and properly passes initial values and attributes + add empty comment clause 2026-08-21 14:22:46 +02:00
klmp200 12135b5c22 Fix star width and rewrite star widget with nice hovering 2026-08-21 00:14:04 +02:00
imperosol 58c2406ac5 fix: Uncaught RangeError: Invalid time value is SAS 2026-08-20 16:18:06 +02:00
imperosol b8178e3534 update doc in js-import-paths.md 2026-08-20 16:17:23 +02:00
imperosol 236860d0f5 adapt typescript to strict mode 2026-08-20 16:17:23 +02:00
imperosol 3291a78d2b split node and bundled TS config 2026-08-20 16:17:23 +02:00
thomas girodandGitHub d6677b1fbd Merge pull request #1459 from ae-utbm/master
Merge back master into taiste
2026-08-20 16:17:02 +02:00
thomas girodandGitHub e52fd32e06 Merge pull request #1445 from ae-utbm/pedagogy
Update pedagogy
2026-08-20 16:14:16 +02:00
thomas girodandGitHub 1ae52b3bc9 Merge pull request #1454 from ae-utbm/sas-click
Change sas picture on picture click
2026-08-19 18:06:29 +02:00
klmp200 1765d70911 Move image click to frame and remove image selection 2026-08-19 18:05:18 +02:00
imperosol 4d07b94fee change sas picture on picture click 2026-08-06 19:33:51 +02:00
thomas girodandGitHub b8c8768479 Merge pull request #1451 from ae-utbm/ruff
bump ruff to 0.16
2026-07-27 09:45:28 +02:00
imperosol 3be4c583f1 bump ruff to 0.16 2026-07-26 19:46:03 +02:00
imperosol 5177427549 adapt pedagogy to new UTBM UE API 2026-07-25 11:45:19 +02:00
thomas girodandGitHub 79ec036b70 Merge pull request #1439 from ae-utbm/taiste
AEMark, max account balance, membership API and bugfixes
2026-06-24 13:13:39 +02:00
thomas girodandGitHub 455b81cff6 Merge pull request #1424 from ae-utbm/taiste
Basket timeout, clic limit, club-election link, CGV, counter barmen and other
2026-06-06 09:46:41 +02:00
thomas girodandGitHub 9ceb51a54e Merge pull request #1404 from ae-utbm/taiste
Club roles, club links, notifications and other
2026-05-22 09:43:08 +02:00
TitouanandGitHub 790a1e15b1 Merge pull request #1383 from ae-utbm/taiste
MAJ cotisation, CI, style et fix
2026-05-11 13:03:22 +02:00
thomas girodandGitHub b2ffcd3a37 Merge pull request #1365 from ae-utbm/taiste
Product prices, club list page rework and bug fixes
2026-05-01 19:14:03 +02:00
TitouanandGitHub ca37996d6a Merge pull request #1332 from ae-utbm/taiste
Stats & Whitelist, Eurockéenne, fix pagination, Vite 8, delete unused settings
2026-03-29 16:35:16 +02:00
TitouanandGitHub 173311c1d5 Merge pull request #1315 from ae-utbm/taiste
Product history, formula management, test election
2026-03-12 11:33:45 +01:00
thomas girodandGitHub 2995823d6e Merge pull request #1293 from ae-utbm/taiste
Refactors, updates and db optimisations
2026-02-13 15:25:04 +01:00
93 changed files with 2468 additions and 2711 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.15.19 rev: v0.16.0
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
-9
View File
@@ -17,15 +17,6 @@ class ApiClientAdmin(admin.ModelAdmin):
"owner__nick_name", "owner__nick_name",
) )
autocomplete_fields = ("owner", "groups", "client_permissions") autocomplete_fields = ("owner", "groups", "client_permissions")
readonly_fields = ("hmac_key",)
actions = ("reset_hmac_key",)
@admin.action(permissions=["change"], description=_("Reset HMAC key"))
def reset_hmac_key(self, _request: HttpRequest, queryset: QuerySet[ApiClient]):
objs = list(queryset)
for obj in objs:
obj.reset_hmac(commit=False)
ApiClient.objects.bulk_update(objs, fields=["hmac_key"])
@admin.register(ApiKey) @admin.register(ApiKey)
-16
View File
@@ -1,16 +0,0 @@
from ninja_extra import ControllerBase, api_controller, route
from api.auth import ApiKeyAuth
from api.schemas import ApiClientSchema
@api_controller("/client")
class ApiClientController(ControllerBase):
@route.get(
"/me",
auth=[ApiKeyAuth()],
response=ApiClientSchema,
url_name="api-client-infos",
)
def get_client_info(self):
return self.context.request.auth
-35
View File
@@ -1,35 +0,0 @@
from django import forms
from django.forms import HiddenInput
from django.utils.translation import gettext_lazy as _
class ThirdPartyAuthForm(forms.Form):
"""Form to complete to authenticate on the sith from a third-party app.
For the form to be valid, the user approve the EULA (french: CGU)
and give its username from the third-party app.
"""
cgu_accepted = forms.BooleanField(
required=True,
label=_("I have read and I accept the terms and conditions of use"),
error_messages={
"required": _("You must approve the terms and conditions of use.")
},
)
is_username_valid = forms.BooleanField(
required=True,
error_messages={"required": _("You must confirm that this is your username.")},
)
client_id = forms.IntegerField(widget=HiddenInput())
third_party_app = forms.CharField(widget=HiddenInput())
privacy_link = forms.URLField(widget=HiddenInput())
username = forms.CharField(widget=HiddenInput())
callback_url = forms.URLField(widget=HiddenInput())
signature = forms.CharField(widget=HiddenInput())
def __init__(self, *args, label_suffix: str = "", initial, **kwargs):
super().__init__(*args, label_suffix=label_suffix, initial=initial, **kwargs)
self.fields["is_username_valid"].label = _(
"I confirm that %(username)s is my username on %(app)s"
) % {"username": initial.get("username"), "app": initial.get("third_party_app")}
-19
View File
@@ -1,19 +0,0 @@
# Generated by Django 5.2.3 on 2025-10-26 10:15
from django.db import migrations, models
import api.models
class Migration(migrations.Migration):
dependencies = [("api", "0001_initial")]
operations = [
migrations.AddField(
model_name="apiclient",
name="hmac_key",
field=models.CharField(
default=api.models.get_hmac_key, max_length=128, verbose_name="HMAC Key"
),
),
]
+22 -33
View File
@@ -1,20 +1,13 @@
import secrets
from typing import Iterable from typing import Iterable
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.db import models from django.db import models
from django.db.models import Q
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.utils.translation import pgettext_lazy from django.utils.translation import pgettext_lazy
from core.models import Group, User from core.models import Group, User
def get_hmac_key():
return secrets.token_hex(64)
class ApiClient(models.Model): class ApiClient(models.Model):
name = models.CharField(_("name"), max_length=64) name = models.CharField(_("name"), max_length=64)
owner = models.ForeignKey( owner = models.ForeignKey(
@@ -33,10 +26,11 @@ class ApiClient(models.Model):
help_text=_("Specific permissions for this api client."), help_text=_("Specific permissions for this api client."),
related_name="clients", related_name="clients",
) )
hmac_key = models.CharField(_("HMAC Key"), max_length=128, default=get_hmac_key)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
_perm_cache: set[str] | None = None
class Meta: class Meta:
verbose_name = _("api client") verbose_name = _("api client")
verbose_name_plural = _("api clients") verbose_name_plural = _("api clients")
@@ -44,38 +38,33 @@ class ApiClient(models.Model):
def __str__(self): def __str__(self):
return self.name return self.name
@cached_property
def all_permissions(self) -> set[str]:
permissions = (
Permission.objects.filter(
Q(group__group__in=self.groups.all()) | Q(clients=self)
)
.values_list("content_type__app_label", "codename")
.order_by()
)
return {f"{content_type}.{name}" for content_type, name in permissions}
def has_perm(self, perm: str): def has_perm(self, perm: str):
"""Return True if the client has the specified permission.""" """Return True if the client has the specified permission."""
return perm in self.all_permissions
def has_perms(self, perm_list: Iterable[str]) -> bool: if self._perm_cache is None:
"""Return True if the client has each of the specified permissions.""" group_permissions = (
Permission.objects.filter(group__group__in=self.groups.all())
.values_list("content_type__app_label", "codename")
.order_by()
)
client_permissions = self.client_permissions.values_list(
"content_type__app_label", "codename"
).order_by()
self._perm_cache = {
f"{content_type}.{name}"
for content_type, name in (*group_permissions, *client_permissions)
}
return perm in self._perm_cache
def has_perms(self, perm_list):
"""
Return True if the client has each of the specified permissions. If
object is passed, check if the client has all required perms for it.
"""
if not isinstance(perm_list, Iterable) or isinstance(perm_list, str): if not isinstance(perm_list, Iterable) or isinstance(perm_list, str):
raise ValueError("perm_list must be an iterable of permissions.") raise ValueError("perm_list must be an iterable of permissions.")
return all(self.has_perm(perm) for perm in perm_list) return all(self.has_perm(perm) for perm in perm_list)
def reset_hmac(self, *, commit: bool = True) -> str:
"""Reset and return the HMAC key for this client.
Args:
commit: if True (the default), persist the new hmac in db.
"""
self.hmac_key = get_hmac_key()
if commit:
self.save()
return self.hmac_key
class ApiKey(models.Model): class ApiKey(models.Model):
PREFIX_LENGTH = 5 PREFIX_LENGTH = 5
-23
View File
@@ -1,23 +0,0 @@
from ninja import ModelSchema, Schema
from pydantic import Field, HttpUrl
from api.models import ApiClient
from core.schemas import SimpleUserSchema
class ApiClientSchema(ModelSchema):
class Meta:
model = ApiClient
fields = ["id", "name"]
owner: SimpleUserSchema
permissions: list[str] = Field(alias="all_permissions")
class ThirdPartyAuthParamsSchema(Schema):
client_id: int
third_party_app: str
privacy_link: HttpUrl
username: str
callback_url: HttpUrl
signature: str
-32
View File
@@ -1,32 +0,0 @@
{% extends "core/base.jinja" %}
{% block content %}
<form method="post">
{% csrf_token %}
<h3>{% trans %}Confidentiality{% endtrans %}</h3>
<p>
{% trans trimmed app=third_party_app %}
By ticking this box and clicking on the send button, you
acknowledge and agree to provide {{ app }} with your
first name, last name, nickname and any other information
that was the third party app was explicitly authorized to fetch
and that it must have acknowledged to you, in a complete and accurate manner.
{% endtrans %}
</p>
<p class="margin-bottom">
{% trans trimmed app=third_party_app, privacy_link=third_party_cgu, sith_cgu_link=sith_cgu %}
The privacy policies of <a href="{{ privacy_link }}">{{ app }}</a>
and of <a href="{{ sith_cgu_link }}">the Students' Association</a>
applies as soon as the form is submitted.
{% endtrans %}
</p>
<div class="row">{{ form.cgu_accepted }} {{ form.cgu_accepted.label_tag() }}</div>
<br>
<h3 class="margin-bottom">{% trans %}Confirmation of identity{% endtrans %}</h3>
<div class="row margin-bottom">
{{ form.is_username_valid }} {{ form.is_username_valid.label_tag() }}
</div>
{% for field in form.hidden_fields() %}{{ field }}{% endfor %}
<input type="submit" class="btn btn-blue">
</form>
{% endblock %}
-24
View File
@@ -1,24 +0,0 @@
import pytest
from django.contrib.admin import AdminSite
from django.http import HttpRequest
from model_bakery import baker
from pytest_django.asserts import assertNumQueries
from api.admin import ApiClientAdmin
from api.models import ApiClient
@pytest.mark.django_db
def test_reset_hmac_action():
client_admin = ApiClientAdmin(ApiClient, AdminSite())
api_clients = baker.make(ApiClient, _quantity=4, _bulk_create=True)
old_hmac_keys = [c.hmac_key for c in api_clients]
with assertNumQueries(2):
qs = ApiClient.objects.filter(id__in=[c.id for c in api_clients[2:4]])
client_admin.reset_hmac_key(HttpRequest(), qs)
for c in api_clients:
c.refresh_from_db()
assert api_clients[0].hmac_key == old_hmac_keys[0]
assert api_clients[1].hmac_key == old_hmac_keys[1]
assert api_clients[2].hmac_key != old_hmac_keys[2]
assert api_clients[3].hmac_key != old_hmac_keys[3]
-18
View File
@@ -1,18 +0,0 @@
import pytest
from django.test import Client
from django.urls import reverse
from model_bakery import baker
from api.hashers import generate_key
from api.models import ApiClient, ApiKey
from api.schemas import ApiClientSchema
@pytest.mark.django_db
def test_api_client_controller(client: Client):
key, hashed = generate_key()
api_client = baker.make(ApiClient)
baker.make(ApiKey, client=api_client, hashed_key=hashed)
res = client.get(reverse("api:api-client-infos"), headers={"X-APIKey": key})
assert res.status_code == 200
assert res.json() == ApiClientSchema.from_orm(api_client).model_dump()
-59
View File
@@ -1,59 +0,0 @@
import pytest
from django.contrib.auth.models import Permission
from django.test import TestCase
from model_bakery import baker
from api.models import ApiClient
from core.models import Group
class TestClientPermissions(TestCase):
@classmethod
def setUpTestData(cls):
cls.api_client = baker.make(ApiClient)
cls.perms = baker.make(Permission, _quantity=10, _bulk_create=True)
cls.api_client.groups.set(
[
baker.make(Group, permissions=cls.perms[0:3]),
baker.make(Group, permissions=cls.perms[3:5]),
]
)
cls.api_client.client_permissions.set(
[cls.perms[3], cls.perms[5], cls.perms[6], cls.perms[7]]
)
def test_all_permissions(self):
assert self.api_client.all_permissions == {
f"{p.content_type.app_label}.{p.codename}" for p in self.perms[0:8]
}
def test_has_perm(self):
assert self.api_client.has_perm(
f"{self.perms[1].content_type.app_label}.{self.perms[1].codename}"
)
assert not self.api_client.has_perm(
f"{self.perms[9].content_type.app_label}.{self.perms[9].codename}"
)
def test_has_perms(self):
assert self.api_client.has_perms(
[
f"{self.perms[1].content_type.app_label}.{self.perms[1].codename}",
f"{self.perms[2].content_type.app_label}.{self.perms[2].codename}",
]
)
assert not self.api_client.has_perms(
[
f"{self.perms[1].content_type.app_label}.{self.perms[1].codename}",
f"{self.perms[9].content_type.app_label}.{self.perms[9].codename}",
],
)
@pytest.mark.django_db
def test_reset_hmac_key():
client = baker.make(ApiClient)
original_key = client.hmac_key
client.reset_hmac(commit=True)
assert len(client.hmac_key) == len(original_key)
assert client.hmac_key != original_key
-140
View File
@@ -1,140 +0,0 @@
from unittest import mock
from unittest.mock import Mock
from django.contrib.messages import Message, get_messages
from django.db.models import Max
from django.test import TestCase
from django.urls import reverse
from model_bakery import baker
from pytest_django.asserts import assertRedirects
from api.models import ApiClient, get_hmac_key
from core.baker_recipes import subscriber_user
from core.schemas import UserProfileSchema
from core.utils import hmac_hexdigest
def mocked_post(*, ok: bool):
class MockedResponse(Mock):
@property
def ok(self):
return ok
def mocked():
return MockedResponse()
return mocked
class TestThirdPartyAuth(TestCase):
@classmethod
def setUpTestData(cls):
cls.user = subscriber_user.make()
cls.api_client = baker.make(ApiClient)
def setUp(self):
self.query = {
"client_id": self.api_client.id,
"third_party_app": "app",
"privacy_link": "https://foobar.fr/",
"username": "bibou",
"callback_url": "https://callback.fr/",
}
self.query["signature"] = hmac_hexdigest(self.api_client.hmac_key, self.query)
self.callback_data = {
"user": UserProfileSchema.from_orm(self.user).model_dump()
}
self.callback_data["signature"] = hmac_hexdigest(
self.api_client.hmac_key, self.callback_data["user"]
)
self.url = reverse("api-link:third-party-auth", query=self.query)
def test_auth_ok(self):
self.client.force_login(self.user)
res = self.client.get(self.url)
assert res.status_code == 200
with mock.patch("requests.post", new_callable=mocked_post(ok=True)) as mocked:
res = self.client.post(
self.url,
data={"cgu_accepted": True, "is_username_valid": True, **self.query},
)
mocked.assert_called_once_with(
self.query["callback_url"], json=self.callback_data
)
assertRedirects(
res,
reverse("api-link:third-party-auth-result", kwargs={"result": "success"}),
)
def test_callback_error(self):
"""Test that the user see the failure page if the callback request failed."""
self.client.force_login(self.user)
with mock.patch("requests.post", new_callable=mocked_post(ok=False)) as mocked:
res = self.client.post(
self.url,
data={"cgu_accepted": True, "is_username_valid": True, **self.query},
)
mocked.assert_called_once_with(
self.query["callback_url"], json=self.callback_data
)
assertRedirects(
res,
reverse("api-link:third-party-auth-result", kwargs={"result": "failure"}),
)
def test_wrong_signature(self):
"""Test that a 403 is raised if the signature of the query is wrong."""
self.client.force_login(subscriber_user.make())
new_key = get_hmac_key()
del self.query["signature"]
self.query["signature"] = hmac_hexdigest(new_key, self.query)
res = self.client.get(reverse("api-link:third-party-auth", query=self.query))
assert list(get_messages(res.wsgi_request)) == [
Message(
level=40,
message=(
"La signature est incorrecte. "
"Nous ne pouvons pas garantir l'authenticité de la requête."
),
)
]
res = self.client.post(self.url, data=self.query)
assert res.status_code == 200
def test_cgu_not_accepted(self):
self.client.force_login(self.user)
res = self.client.get(self.url)
assert res.status_code == 200
res = self.client.post(self.url, data=self.query)
assert res.status_code == 200 # no redirect means invalid form
res = self.client.post(
self.url,
data={"cgu_accepted": False, "is_username_valid": False, **self.query},
)
assert res.status_code == 200
def test_invalid_client(self):
self.client.force_login(self.user)
self.query["client_id"] = ApiClient.objects.aggregate(res=Max("id"))["res"] + 1
res = self.client.get(reverse("api-link:third-party-auth", query=self.query))
assert list(get_messages(res.wsgi_request)) == [
Message(
level=40,
message="Les données fournies pour l'authentification sont incorrectes.",
)
]
def test_missing_parameter(self):
self.client.force_login(self.user)
del self.query["username"]
self.query["signature"] = hmac_hexdigest(self.api_client.hmac_key, self.query)
res = self.client.get(reverse("api-link:third-party-auth", query=self.query))
assert list(get_messages(res.wsgi_request)) == [
Message(
level=40,
message="Les données fournies pour l'authentification sont incorrectes.",
)
]
res = self.client.post(self.url, data=self.query)
assert res.status_code == 200
-15
View File
@@ -1,10 +1,6 @@
from django.urls import path, register_converter
from ninja.security import SessionAuth from ninja.security import SessionAuth
from ninja_extra import NinjaExtraAPI from ninja_extra import NinjaExtraAPI
from api.views import ThirdPartyAuthResultView, ThirdPartyAuthView
from core.converters import ResultConverter
api = NinjaExtraAPI( api = NinjaExtraAPI(
title="PICON", title="PICON",
description="Portail Interactif de Communication avec les Outils Numériques", description="Portail Interactif de Communication avec les Outils Numériques",
@@ -13,14 +9,3 @@ api = NinjaExtraAPI(
auth=[SessionAuth()], auth=[SessionAuth()],
) )
api.auto_discover_controllers() api.auto_discover_controllers()
register_converter(ResultConverter, "res")
urlpatterns = [
path("auth/", ThirdPartyAuthView.as_view(), name="third-party-auth"),
path(
"auth/<res:result>/",
ThirdPartyAuthResultView.as_view(),
name="third-party-auth-result",
),
]
-146
View File
@@ -1,146 +0,0 @@
import hmac
from urllib.parse import unquote
import pydantic
import requests
import sentry_sdk
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import AccessMixin, LoginRequiredMixin
from django.shortcuts import render
from django.urls import reverse, reverse_lazy
from django.utils.translation import gettext as _
from django.views.generic import FormView, TemplateView
from ninja_extra.shortcuts import get_object_or_none
from api.forms import ThirdPartyAuthForm
from api.models import ApiClient
from api.schemas import ThirdPartyAuthParamsSchema
from core.models import SithFile
from core.schemas import UserProfileSchema
from core.utils import hmac_hexdigest
class ThirdPartyAuthView(AccessMixin, FormView):
form_class = ThirdPartyAuthForm
template_name = "api/third_party/auth.jinja"
success_url = reverse_lazy("core:index")
def parse_params(self) -> ThirdPartyAuthParamsSchema | None:
"""Parse and check the authentication parameters.
If parsing fails, messages will be created using the django message
infrastructure.
Returns:
The parses parameters, or None if the parsing failed.
"""
# This is here rather than in ThirdPartyAuthForm because
# the given parameters and their signature are checked during both
# POST (for obvious reasons) and GET (in order not to make
# the user fill a form just to get an error he won't understand)
params = self.request.GET if self.request.method == "GET" else self.request.POST
params = {key: unquote(val) for key, val in params.dict().items()}
try:
params = ThirdPartyAuthParamsSchema(**params)
except pydantic.ValidationError:
messages.error(
self.request, _("The data provided for authentication is incorrect")
)
return None
client: ApiClient | None = get_object_or_none(ApiClient, id=params.client_id)
if not client:
messages.error(
self.request, _("The data provided for authentication is incorrect")
)
return None
if not hmac.compare_digest(
hmac_hexdigest(client.hmac_key, params.model_dump(exclude={"signature"})),
params.signature,
):
messages.error(
self.request,
_(
"The signature is incorrect. "
"We cannot ensure the provenance of the request."
),
)
return None
return params
def dispatch(self, request, *args, **kwargs):
if not request.user.is_authenticated:
return self.handle_no_permission()
if (params := self.parse_params()) is None:
# if parameters parsing failed, shortcut the operation and display
# an empty page with just the error messages.
return render(request, "core/base.jinja")
self.params = params
return super().dispatch(request, *args, **kwargs)
def get(self, *args, **kwargs):
messages.warning(
self.request,
_(
"You are going to link your AE account and your %(app)s account. "
"Continue only if this page was opened from %(app)s."
)
% {"app": self.params.third_party_app},
)
return super().get(*args, **kwargs)
def get_initial(self):
return self.params.model_dump()
def form_valid(self, form):
client = ApiClient.objects.get(id=form.cleaned_data["client_id"])
user = UserProfileSchema.from_orm(self.request.user).model_dump()
data = {"user": user, "signature": hmac_hexdigest(client.hmac_key, user)}
try:
ok = requests.post(form.cleaned_data["callback_url"], json=data).ok
except requests.RequestException as e:
sentry_sdk.capture_exception(e)
ok = False
self.success_url = reverse(
"api-link:third-party-auth-result",
kwargs={"result": "success" if ok else "failure"},
)
return super().form_valid(form)
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {
"third_party_app": self.params.third_party_app,
"third_party_cgu": self.params.privacy_link,
"sith_cgu": SithFile.objects.get(id=settings.SITH_CGU_FILE_ID),
}
class ThirdPartyAuthResultView(LoginRequiredMixin, TemplateView):
"""View that the user will see if its authentication on sith was successful.
This can show either a success or a failure message :
- success : everything is good, the user is successfully authenticated
and can close the page
- failure : the authentication has been processed on the sith side,
but the request to the callback url received an error.
In such a case, there is nothing much we can do but to advice
the user to contact the developers of the third-party app.
"""
template_name = "core/base.jinja"
success_message = _(
"You have been successfully authenticated. You can now close this page."
)
error_message = _(
"Your authentication on the AE website was successful, "
"but an error happened during the interaction "
"with the third-party application. "
"Please contact the managers of the latter."
)
def get(self, request, *args, **kwargs):
if self.kwargs.get("result") == "success":
messages.success(request, self.success_message)
else:
messages.error(request, self.error_message)
return super().get(request, *args, **kwargs)
@@ -1,7 +1,6 @@
import type { TomOption } from "tom-select/dist/types/types"; import type { escape_html } from "tom-select/src/utils";
import type { escape_html } from "tom-select/dist/types/utils"; import { AjaxSelect } from "#core:core/components/ajax-select-base";
import { AjaxSelect } from "#core:core/components/ajax-select-base.ts"; import { registerComponent } from "#core:utils/web-components";
import { registerComponent } from "#core:utils/web-components.ts";
import { type ClubSchema, clubSearchClub } from "#openapi"; import { type ClubSchema, clubSearchClub } from "#openapi";
@registerComponent("club-ajax-select") @registerComponent("club-ajax-select")
@@ -10,7 +9,7 @@ export class ClubAjaxSelect extends AjaxSelect {
protected labelField = "name"; protected labelField = "name";
protected searchField = ["code", "name"]; protected searchField = ["code", "name"];
protected async search(query: string): Promise<TomOption[]> { protected async search(query: string) {
const resp = await clubSearchClub({ query: { search: query } }); const resp = await clubSearchClub({ query: { search: query } });
if (resp.data) { if (resp.data) {
return resp.data.results; return resp.data.results;
@@ -1,4 +1,9 @@
import { Calendar, type EventClickArg, type EventContentArg } from "@fullcalendar/core"; import {
Calendar,
type EventClickArg,
type EventContentArg,
type EventInput,
} from "@fullcalendar/core";
import type { EventImpl } from "@fullcalendar/core/internal"; import type { EventImpl } from "@fullcalendar/core/internal";
import enLocale from "@fullcalendar/core/locales/en-gb"; import enLocale from "@fullcalendar/core/locales/en-gb";
import frLocale from "@fullcalendar/core/locales/fr"; import frLocale from "@fullcalendar/core/locales/fr";
@@ -6,8 +11,8 @@ import dayGridPlugin from "@fullcalendar/daygrid";
import iCalendarPlugin from "@fullcalendar/icalendar"; import iCalendarPlugin from "@fullcalendar/icalendar";
import listPlugin from "@fullcalendar/list"; import listPlugin from "@fullcalendar/list";
import { type HTMLTemplateResult, html, render } from "lit-html"; import { type HTMLTemplateResult, html, render } from "lit-html";
import { makeUrl } from "#core:utils/api.ts"; import { type GenericEndpoint, makeUrl } from "#core:utils/api";
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components.ts"; import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
import { import {
calendarCalendarInternal, calendarCalendarInternal,
calendarCalendarUnpublished, calendarCalendarUnpublished,
@@ -19,11 +24,11 @@ import {
@registerComponent("ics-calendar") @registerComponent("ics-calendar")
export class IcsCalendar extends inheritHtmlElement("div") { export class IcsCalendar extends inheritHtmlElement("div") {
static observedAttributes = ["locale", "can_moderate", "can_delete", "ics-help-url"]; static observedAttributes = ["locale", "can_moderate", "can_delete", "ics-help-url"];
private calendar: Calendar; private calendar!: Calendar;
private locale = "en"; private locale? = "en";
private canModerate = false; private canModerate? = false;
private canDelete = false; private canDelete? = false;
private helpUrl = ""; private helpUrl? = "";
// Hack variable to detect recurring events // Hack variable to detect recurring events
// The underlying ics library doesn't include any info about rrules // The underlying ics library doesn't include any info about rrules
@@ -35,10 +40,12 @@ export class IcsCalendar extends inheritHtmlElement("div") {
this.locale = newValue; this.locale = newValue;
} }
if (name === "can_moderate") { if (name === "can_moderate") {
this.canModerate = newValue.toLowerCase() === "true"; this.canModerate =
typeof newValue === "string" && newValue.toLowerCase() === "true";
} }
if (name === "can_delete") { if (name === "can_delete") {
this.canDelete = newValue.toLowerCase() === "true"; this.canDelete =
typeof newValue === "string" && newValue.toLowerCase() === "true";
} }
if (name === "ics-help-url") { if (name === "ics-help-url") {
@@ -94,7 +101,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
.toString() .toString()
.split("/") .split("/")
.filter((s) => s) // Remove blank characters .filter((s) => s) // Remove blank characters
.pop(), .pop() as string,
10, 10,
); );
} }
@@ -159,7 +166,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
this.refreshEvents(); this.refreshEvents();
} }
async getEventSources() { async getEventSources(): Promise<EventInput[]> {
const tagRecurringEvents = (eventData: EventImpl) => { const tagRecurringEvents = (eventData: EventImpl) => {
// This functions tags events with a similar event url // This functions tags events with a similar event url
// We rely on the fact that the event url is always the same // We rely on the fact that the event url is always the same
@@ -173,14 +180,14 @@ export class IcsCalendar extends inheritHtmlElement("div") {
}; };
return [ return [
{ {
url: `${await makeUrl(calendarCalendarInternal)}`, url: `${await makeUrl(calendarCalendarInternal as GenericEndpoint)}`,
format: "ics", format: "ics",
className: "internal", className: "internal",
cache: false, cache: false,
eventDataTransform: tagRecurringEvents, eventDataTransform: tagRecurringEvents,
}, },
{ {
url: `${await makeUrl(calendarCalendarUnpublished)}`, url: `${await makeUrl(calendarCalendarUnpublished as GenericEndpoint)}`,
format: "ics", format: "ics",
color: "red", color: "red",
className: "unpublished", className: "unpublished",
@@ -213,7 +220,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
${event.title} ${event.title}
</h4> </h4>
<span class="event-details-row-content"> <span class="event-details-row-content">
${this.formatDate(event.start)} - ${this.formatDate(event.end)} ${this.formatDate(event.start as Date)} - ${this.formatDate(event.end as Date)}
</span> </span>
</div> </div>
`; `;
@@ -251,7 +258,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
const buttons = [] as HTMLTemplateResult[]; const buttons = [] as HTMLTemplateResult[];
if (this.canModerate) { if (this.canModerate) {
if (event.source.internalEventSource.ui.classNames.includes("unpublished")) { if (event.source?.internalEventSource.ui.classNames.includes("unpublished")) {
const button = html` const button = html`
<button class="btn btn-green" @click="${() => this.publishNews(newsId)}"> <button class="btn btn-green" @click="${() => this.publishNews(newsId)}">
<i class="fa fa-check"></i>${gettext("Publish")} <i class="fa fa-check"></i>${gettext("Publish")}
@@ -338,9 +345,9 @@ export class IcsCalendar extends inheritHtmlElement("div") {
button.classList.remove("text-copied"); button.classList.remove("text-copied");
} }
button.setAttribute("tooltip", gettext("Link copied")); button.setAttribute("tooltip", gettext("Link copied"));
navigator.clipboard.writeText( await navigator.clipboard.writeText(
new URL( new URL(
await makeUrl(calendarCalendarInternal), await makeUrl(calendarCalendarInternal as GenericEndpoint),
window.location.origin, window.location.origin,
).toString(), ).toString(),
); );
@@ -1,4 +1,9 @@
import { newsDeleteNews, newsFetchNewsDates, newsPublishNews } from "#openapi"; import {
newsDeleteNews,
newsFetchNewsDates,
newsPublishNews,
type PaginatedResponseSchemaNewsDateSchema,
} from "#openapi";
// This will be used in jinja templates, // This will be used in jinja templates,
// so we cannot use real enums as those are purely an abstraction of Typescript // so we cannot use real enums as those are purely an abstraction of Typescript
@@ -64,7 +69,8 @@ document.addEventListener("alpine:init", () => {
// biome-ignore lint/style/useNamingConvention: api is snake-case // biome-ignore lint/style/useNamingConvention: api is snake-case
query: { news_id: this.newsId, page: 1, page_size: 1 }, query: { news_id: this.newsId, page: 1, page_size: 1 },
}); });
return response.data.count;
return (response.data as PaginatedResponseSchemaNewsDateSchema).count;
}, },
weeklyEventWarningMessage(nbEvents: number): string { weeklyEventWarningMessage(nbEvents: number): string {
@@ -1,11 +1,11 @@
import { type NewsDateSchema, newsFetchNewsDates } from "#openapi"; import { type NewsDateSchema, newsFetchNewsDates } from "#openapi";
interface ParsedNewsDateSchema extends Omit<NewsDateSchema, "start_date" | "end_date"> { type ParsedNewsDateSchema = Omit<NewsDateSchema, "start_date" | "end_date"> & {
// biome-ignore lint/style/useNamingConvention: api is snake_case // biome-ignore lint/style/useNamingConvention: api is snake_case
start_date: Date; start_date: Date;
// biome-ignore lint/style/useNamingConvention: api is snake_case // biome-ignore lint/style/useNamingConvention: api is snake_case
end_date: Date; end_date: Date;
} };
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
Alpine.data("upcomingNewsLoader", (startDate: Date, locale: string) => ({ Alpine.data("upcomingNewsLoader", (startDate: Date, locale: string) => ({
@@ -32,6 +32,11 @@ document.addEventListener("alpine:init", () => {
page_size: this.pageSize, page_size: this.pageSize,
}, },
}); });
if (response.response === undefined || response.data === undefined) {
// response may be undefined, because error may be
// from building the request object itself or from a network error
return;
}
if (response.response.status === 404) { if (response.response.status === 404) {
this.hasNext = false; this.hasNext = false;
} else if (response.data.next === null) { } else if (response.data.next === null) {
@@ -44,7 +49,7 @@ document.addEventListener("alpine:init", () => {
this.loading = false; this.loading = false;
}, },
groupedDates(): Record<string, NewsDateSchema[]> { groupedDates(): Record<string, ParsedNewsDateSchema[]> {
return this.newsDates return this.newsDates
.map( .map(
(date: NewsDateSchema): ParsedNewsDateSchema => ({ (date: NewsDateSchema): ParsedNewsDateSchema => ({
+3 -2
View File
@@ -1,4 +1,4 @@
from typing import Annotated, Any, Literal from typing import Annotated, Literal
from annotated_types import Ge, Le, MinLen from annotated_types import Ge, Le, MinLen
from django.conf import settings from django.conf import settings
@@ -26,6 +26,7 @@ from core.schemas import (
UserFilterSchema, UserFilterSchema,
UserProfileSchema, UserProfileSchema,
UserSchema, UserSchema,
ValidationErrorSchema,
) )
from core.templatetags.renderer import markdown from core.templatetags.renderer import markdown
from counter.utils import is_logged_in_counter from counter.utils import is_logged_in_counter
@@ -45,7 +46,7 @@ class UploadController(ControllerBase):
"/image", "/image",
response={ response={
200: UploadedFileSchema, 200: UploadedFileSchema,
422: dict[Literal["detail"], list[dict[str, Any]]], 422: ValidationErrorSchema,
403: dict[Literal["detail"], str], 403: dict[Literal["detail"], str],
}, },
permissions=[HasPerm("core.add_quickuploadimage")], permissions=[HasPerm("core.add_quickuploadimage")],
+8 -11
View File
@@ -1,16 +1,19 @@
from django.urls.converters import IntConverter, StringConverter class FourDigitYearConverter:
class FourDigitYearConverter(IntConverter):
regex = "[0-9]{4}" regex = "[0-9]{4}"
def to_python(self, value):
return int(value)
def to_url(self, value): def to_url(self, value):
return str(value).zfill(4) return str(value).zfill(4)
class TwoDigitMonthConverter(IntConverter): class TwoDigitMonthConverter:
regex = "[0-9]{2}" regex = "[0-9]{2}"
def to_python(self, value):
return int(value)
def to_url(self, value): def to_url(self, value):
return str(value).zfill(2) return str(value).zfill(2)
@@ -25,9 +28,3 @@ class BooleanStringConverter:
def to_url(self, value): def to_url(self, value):
return str(value) return str(value)
class ResultConverter(StringConverter):
"""Converter whose regex match either "success" or "failure"."""
regex = "(success|failure)"
+5 -12
View File
@@ -28,7 +28,6 @@ from typing import ClassVar, NamedTuple
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.contrib.sites.models import Site from django.contrib.sites.models import Site
from django.core.files.base import ContentFile
from django.core.management import call_command from django.core.management import call_command
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.db import connection from django.db import connection
@@ -121,21 +120,15 @@ class Command(BaseCommand):
) )
self.profiles_root = SithFile.objects.create(name="profiles", owner=root) self.profiles_root = SithFile.objects.create(name="profiles", owner=root)
home_root = SithFile.objects.create(name="users", owner=root) home_root = SithFile.objects.create(name="users", owner=root)
# Page needed for club creation
p = Page(name=settings.SITH_CLUB_ROOT_PAGE)
p.save(force_lock=True)
club_root = SithFile.objects.create(name="clubs", owner=root) club_root = SithFile.objects.create(name="clubs", owner=root)
sas = SithFile.objects.create( sas = SithFile.objects.create(
name="SAS", owner=root, id=settings.SITH_SAS_ROOT_DIR_ID name="SAS", owner=root, id=settings.SITH_SAS_ROOT_DIR_ID
) )
SithFile.objects.create(
name="CGU",
is_folder=False,
file=ContentFile(
content="Conditions générales d'utilisation", name="cgu.txt"
),
owner=root,
)
# Page needed for club creation
p = Page(name=settings.SITH_CLUB_ROOT_PAGE)
p.save(force_lock=True)
clubs = self._create_clubs() clubs = self._create_clubs()
self.reset_index("club") self.reset_index("club")
+6 -13
View File
@@ -1,4 +1,3 @@
import math
import random import random
from datetime import date, timedelta from datetime import date, timedelta
from datetime import timezone as tz from datetime import timezone as tz
@@ -36,17 +35,12 @@ class Command(BaseCommand):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.faker = Faker("fr_FR") self.faker = Faker("fr_FR")
def add_arguments(self, parser):
parser.add_argument(
"-n", "--nb-users", help="Number of users to create", type=int, default=600
)
def handle(self, *args, **options): def handle(self, *args, **options):
if not settings.DEBUG: if not settings.DEBUG:
raise Exception("Never call this command in prod. Never.") raise Exception("Never call this command in prod. Never.")
self.stdout.write("Creating users...") self.stdout.write("Creating users...")
users = self.create_users(options["nb_users"]) users = self.create_users()
self.create_bans(random.sample(users, k=len(users) // 200)) # 0.5% of users self.create_bans(random.sample(users, k=len(users) // 200)) # 0.5% of users
subscribers = random.sample(users, k=int(0.8 * len(users))) subscribers = random.sample(users, k=int(0.8 * len(users)))
self.stdout.write("Creating subscriptions...") self.stdout.write("Creating subscriptions...")
@@ -86,7 +80,7 @@ class Command(BaseCommand):
self.stdout.write("Creating products...") self.stdout.write("Creating products...")
self.create_products() self.create_products()
self.stdout.write("Creating sales and refills...") self.stdout.write("Creating sales and refills...")
sellers = random.sample(users, len(users) // 10) sellers = random.sample(list(User.objects.all()), 100)
self.create_sales(sellers) self.create_sales(sellers)
self.stdout.write("Creating permanences...") self.stdout.write("Creating permanences...")
self.create_permanences(sellers) self.create_permanences(sellers)
@@ -95,7 +89,7 @@ class Command(BaseCommand):
self.stdout.write("Done") self.stdout.write("Done")
def create_users(self, nb_users: int = 600) -> list[User]: def create_users(self) -> list[User]:
# Create a single password hash for all users to make it faster. # Create a single password hash for all users to make it faster.
# It's insecure as hell, but it's ok since it's only for dev purposes. # It's insecure as hell, but it's ok since it's only for dev purposes.
password = make_password("plop") password = make_password("plop")
@@ -114,7 +108,7 @@ class Command(BaseCommand):
address=self.faker.address(), address=self.faker.address(),
password=password, password=password,
) )
for _ in range(nb_users) for _ in range(600)
] ]
# there may a duplicate or two # there may a duplicate or two
# Not a problem, we will just have 599 users instead of 600 # Not a problem, we will just have 599 users instead of 600
@@ -421,9 +415,8 @@ class Command(BaseCommand):
Permanency.objects.bulk_create(perms) Permanency.objects.bulk_create(perms)
def create_forums(self): def create_forums(self):
users = list(User.objects.all()) forumers = random.sample(list(User.objects.all()), 100)
forumers = random.sample(users, math.ceil(len(users) / 10)) most_actives = random.sample(forumers, 10)
most_actives = random.sample(forumers, math.ceil(len(forumers) / 6))
categories = list(Forum.objects.filter(is_category=True)) categories = list(Forum.objects.filter(is_category=True))
new_forums = [ new_forums = [
Forum(name=self.faker.text(20), parent=random.choice(categories)) Forum(name=self.faker.text(20), parent=random.choice(categories))
+10
View File
@@ -19,6 +19,16 @@ from core.utils import get_last_promo, is_image
NonEmptyStr = Annotated[str, MinLen(1)] NonEmptyStr = Annotated[str, MinLen(1)]
class ValidationErrorSchema(Schema):
class ValidationErrorItem(Schema):
loc: list[str | int]
msg: str
type: str
ctx: dict[str, str]
detail: list[ValidationErrorItem]
class UploadedImage(UploadedFile): class UploadedImage(UploadedFile):
@classmethod @classmethod
def _validate(cls, v: Any, info: ValidationInfo) -> Any: def _validate(cls, v: Any, info: ValidationInfo) -> Any:
@@ -25,7 +25,7 @@ export function limitedChoices(Alpine: AlpineType) {
Alpine.directive( Alpine.directive(
"limited-choices", "limited-choices",
(el, { expression }, { evaluateLater, effect }) => { (el, { expression }, { evaluateLater, effect }) => {
const getMaxChoices = evaluateLater(expression); const getMaxChoices = evaluateLater<string>(expression);
let maxChoices: number; let maxChoices: number;
const inputs: HTMLInputElement[] = Array.from( const inputs: HTMLInputElement[] = Array.from(
el.querySelectorAll("input[type='checkbox']"), el.querySelectorAll("input[type='checkbox']"),
@@ -54,7 +54,7 @@ export function limitedChoices(Alpine: AlpineType) {
}); });
} }
effect(() => { effect(() => {
getMaxChoices((value: string) => { getMaxChoices((value) => {
const previousValue = maxChoices; const previousValue = maxChoices;
maxChoices = Number.parseInt(value, 10); maxChoices = Number.parseInt(value, 10);
if (maxChoices < previousValue) { if (maxChoices < previousValue) {
+12 -6
View File
@@ -43,13 +43,19 @@ polyfillCountryFlagEmojis();
/** /**
* HTMX * HTMX
*/ */
document.body.addEventListener("htmx:beforeRequest", (event: CustomEvent) => { document.body.addEventListener(
event.detail.target.ariaBusy = true; "htmx:beforeRequest" as keyof HTMLElementEventMap,
}); (event) => {
(event as CustomEvent).detail.target.ariaBusy = true;
},
);
document.body.addEventListener("htmx:beforeSwap", (event: CustomEvent) => { document.body.addEventListener(
event.detail.target.ariaBusy = null; "htmx:beforeSwap" as keyof HTMLElementEventMap,
}); (event) => {
(event as CustomEvent).detail.target.ariaBusy = null;
},
);
Object.assign(window, { htmx }); Object.assign(window, { htmx });
@@ -4,9 +4,9 @@ import type {
TomLoadCallback, TomLoadCallback,
TomOption, TomOption,
TomSettings, TomSettings,
} from "tom-select/dist/types/types"; } from "tom-select/src/types";
import type { escape_html } from "tom-select/dist/types/utils"; import type { escape_html } from "tom-select/src/utils";
import { inheritHtmlElement } from "#core:utils/web-components.ts"; import { inheritHtmlElement } from "#core:utils/web-components";
export class AutoCompleteSelectBase extends inheritHtmlElement("select") { export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
static observedAttributes = [ static observedAttributes = [
@@ -15,7 +15,7 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
"max", "max",
"min-characters-for-search", "min-characters-for-search",
]; ];
public widget: TomSelect; public widget!: TomSelect;
protected minCharNumberForSearch = 0; protected minCharNumberForSearch = 0;
protected delay: number | null = null; protected delay: number | null = null;
@@ -24,8 +24,8 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
protected attributeChangedCallback( protected attributeChangedCallback(
name: string, name: string,
_oldValue?: string, _oldValue: string,
newValue?: string, newValue: string,
) { ) {
switch (name) { switch (name) {
case "delay": { case "delay": {
@@ -73,7 +73,7 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
persist: false, persist: false,
maxItems: this.node.multiple ? this.max : 1, maxItems: this.node.multiple ? this.max : 1,
closeAfterSelect: true, closeAfterSelect: true,
loadThrottle: this.delay, loadThrottle: this.delay ?? undefined,
placeholder: this.placeholder, placeholder: this.placeholder,
shouldLoad: (query: string) => this.shouldLoad(query), // wraps the method to avoid shadowing `this` by the one from tom-select shouldLoad: (query: string) => this.shouldLoad(query), // wraps the method to avoid shadowing `this` by the one from tom-select
render: { render: {
@@ -103,7 +103,7 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
} }
export abstract class AjaxSelect extends AutoCompleteSelectBase { export abstract class AjaxSelect extends AutoCompleteSelectBase {
protected filter?: (items: TomOption[]) => TomOption[] = null; protected filter?: (items: TomOption[]) => TomOption[];
protected minCharNumberForSearch = 2; protected minCharNumberForSearch = 2;
/** /**
* A cache of researches that have been made using this input. * A cache of researches that have been made using this input.
@@ -1,11 +1,12 @@
// @ts-expect-error TS2882
import "tom-select/dist/css/tom-select.default.css"; import "tom-select/dist/css/tom-select.default.css";
import type { TomOption } from "tom-select/dist/types/types"; import type { TomOption } from "tom-select/src/types";
import type { escape_html } from "tom-select/dist/types/utils"; import type { escape_html } from "tom-select/src/utils";
import { import {
AjaxSelect, AjaxSelect,
AutoCompleteSelectBase, AutoCompleteSelectBase,
} from "#core:core/components/ajax-select-base.ts"; } from "#core:core/components/ajax-select-base";
import { registerComponent } from "#core:utils/web-components.ts"; import { registerComponent } from "#core:utils/web-components";
import { import {
type GroupSchema, type GroupSchema,
groupSearchGroup, groupSearchGroup,
@@ -1,11 +1,13 @@
// @ts-expect-error 2307
// biome-ignore lint/correctness/noUndeclaredDependencies: shipped by easymde // biome-ignore lint/correctness/noUndeclaredDependencies: shipped by easymde
import "codemirror/lib/codemirror.css"; import "codemirror/lib/codemirror.css";
// @ts-expect-error 2307
import "easymde/src/css/easymde.css"; import "easymde/src/css/easymde.css";
// biome-ignore lint/correctness/noUndeclaredDependencies: Imported by EasyMDE // biome-ignore lint/correctness/noUndeclaredDependencies: Imported by EasyMDE
import type CodeMirror from "codemirror"; import type CodeMirror from "codemirror";
// biome-ignore lint/style/useNamingConvention: This is how they called their namespace // biome-ignore lint/style/useNamingConvention: This is how they called their namespace
import EasyMDE from "easymde"; import EasyMDE from "easymde";
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components.ts"; import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
import { import {
markdownRenderMarkdown, markdownRenderMarkdown,
type UploadUploadImageErrors, type UploadUploadImageErrors,
@@ -25,11 +27,11 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
file: file, file: file,
}, },
}); });
if (!response.response.ok) { if (response.response !== undefined && !response.response.ok) {
if (response.response.status === 422) { if (response?.response.status === 422) {
onError( onError(
(response.error as UploadUploadImageErrors[422]).detail (response.error as UploadUploadImageErrors[422]).detail
.map((err: Record<"ctx", Record<"error", string>>) => err.ctx.error) .map((err) => err.ctx.error)
.join(" ; "), .join(" ; "),
); );
} else if (response.response.status === 403) { } else if (response.response.status === 403) {
@@ -39,6 +41,10 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
} }
return; return;
} }
if (response.data === undefined) {
// this can't happen, it's just for the type checker to know
return;
}
onSuccess(response.data.href); onSuccess(response.data.href);
// Workaround function to add an image name to uploaded image // Workaround function to add an image name to uploaded image
// Without this, you get ![](url) instead of ![name](url) // Without this, you get ![](url) instead of ![name](url)
@@ -58,21 +64,18 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
}); });
easymde.codemirror.replaceSelection("\n"); easymde.codemirror.replaceSelection("\n");
}, },
previewRender: (plainText: string, preview: MarkdownInput) => { previewRender: (plainText, preview) => {
/* This is wrapped this way to allow time for Alpine to be loaded on the page */ /* This is wrapped this way to allow time for Alpine to be loaded on the page */
return Alpine.debounce((plainText: string, preview: MarkdownInput) => { return Alpine.debounce(() => {
const func = async ( const func = async () => {
plainText: string,
preview: MarkdownInput,
): Promise<null> => {
preview.innerHTML = ( preview.innerHTML = (
await markdownRenderMarkdown({ body: { text: plainText } }) await markdownRenderMarkdown({ body: { text: plainText } })
).data as string; ).data as string;
return null; return null;
}; };
func(plainText, preview); func().then();
return null; return null;
}, 300)(plainText, preview); }, 300)();
}, },
forceSync: true, // Avoid validation error on generic create view forceSync: true, // Avoid validation error on generic create view
imageTexts: { imageTexts: {
@@ -222,9 +225,11 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
}); });
const submits: HTMLInputElement[] = Array.from( const submits: HTMLInputElement[] = Array.from(
textarea.closest("form").querySelectorAll('input[type="submit"]'), (textarea.closest("form") as HTMLFormElement).querySelectorAll(
'input[type="submit"]',
),
); );
const parentDiv = textarea.parentElement.parentElement; const parentDiv = textarea.parentElement?.parentElement as HTMLElement;
function checkMarkdownInput(event: Event) { function checkMarkdownInput(event: Event) {
// an attribute is null if it does not exist, else a string // an attribute is null if it does not exist, else a string
@@ -249,6 +254,7 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
}; };
@registerComponent("markdown-input") @registerComponent("markdown-input")
// biome-ignore lint/correctness/noUnusedVariables: it is used in jinja
class MarkdownInput extends inheritHtmlElement("textarea") { class MarkdownInput extends inheritHtmlElement("textarea") {
connectedCallback() { connectedCallback() {
super.connectedCallback(); super.connectedCallback();
@@ -2,7 +2,7 @@ import {
type InheritedHtmlElement, type InheritedHtmlElement,
inheritHtmlElement, inheritHtmlElement,
registerComponent, registerComponent,
} from "#core:utils/web-components.ts"; } from "#core:utils/web-components";
/** /**
* ElementOnce web components * ElementOnce web components
@@ -28,7 +28,7 @@ export function elementOnce<K extends keyof HTMLElementTagNameMap>(tagName: K) {
clearNode() { clearNode() {
while (this.firstChild) { while (this.firstChild) {
this.removeChild(this.lastChild); this.removeChild(this.lastChild as ChildNode);
} }
} }
@@ -130,7 +130,7 @@ startObserver(observer);
export class LinkOnce extends elementOnce("link") { export class LinkOnce extends elementOnce("link") {
getElementQuerySelector(): string { getElementQuerySelector(): string {
// We get href from node.attributes instead of node.href to avoid getting the domain part // We get href from node.attributes instead of node.href to avoid getting the domain part
return `link[href='${this.node.attributes.getNamedItem("href").nodeValue}']`; return `link[href='${this.node.attributes.getNamedItem("href")?.nodeValue}']`;
} }
} }
@@ -142,6 +142,6 @@ export class LinkOnce extends elementOnce("link") {
export class ScriptOnce extends inheritHtmlElement("script") { export class ScriptOnce extends inheritHtmlElement("script") {
getElementQuerySelector(): string { getElementQuerySelector(): string {
// We get href from node.attributes instead of node.src to avoid getting the domain part // We get href from node.attributes instead of node.src to avoid getting the domain part
return `script[src='${this.node.attributes.getNamedItem("src").nodeValue}']`; return `script[src='${this.node.attributes.getNamedItem("src")?.nodeValue}']`;
} }
} }
@@ -1,4 +1,4 @@
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components.ts"; import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
@registerComponent("nfc-input") @registerComponent("nfc-input")
export class NfcInput extends inheritHtmlElement("input") { export class NfcInput extends inheritHtmlElement("input") {
@@ -26,9 +26,11 @@ export class NfcInput extends inheritHtmlElement("input") {
window.alert(gettext("Unsupported NFC card")); window.alert(gettext("Unsupported NFC card"));
}); });
ndef.addEventListener("reading", (event: NDEFReadingEvent) => { ndef.addEventListener("reading", (event) => {
this.removeAttribute("scan"); this.removeAttribute("scan");
this.node.value = event.serialNumber.replace(/:/g, "").toUpperCase(); this.node.value = (event as NDEFReadingEvent).serialNumber
.replace(/:/g, "")
.toUpperCase();
/* Auto submit form, we need another button to not trigger our previously defined click event */ /* Auto submit form, we need another button to not trigger our previously defined click event */
const submit = document.createElement("button"); const submit = document.createElement("button");
this.node.appendChild(submit); this.node.appendChild(submit);
@@ -1,6 +1,6 @@
import { html, render } from "lit-html"; import { html, render } from "lit-html";
import { unsafeHTML } from "lit-html/directives/unsafe-html.js"; import { unsafeHTML } from "lit-html/directives/unsafe-html.js";
import { registerComponent } from "#core:utils/web-components.ts"; import { registerComponent } from "#core:utils/web-components";
@registerComponent("ui-tab") @registerComponent("ui-tab")
export class Tab extends HTMLElement { export class Tab extends HTMLElement {
@@ -19,7 +19,7 @@ export class Tab extends HTMLElement {
} }
if (name === "title") { if (name === "title") {
this.description = newValue; this.description = newValue ?? "";
} }
this.dispatchEvent(new CustomEvent("ui-tab-updated", { bubbles: true })); this.dispatchEvent(new CustomEvent("ui-tab-updated", { bubbles: true }));
} }
@@ -79,15 +79,15 @@ export class Tab extends HTMLElement {
@registerComponent("ui-tab-group") @registerComponent("ui-tab-group")
export class TabGroup extends HTMLElement { export class TabGroup extends HTMLElement {
private node: HTMLDivElement; private node!: HTMLDivElement;
connectedCallback() { connectedCallback() {
this.node = document.createElement("div"); this.node = document.createElement("div");
this.node.classList.add("tabs", "shadow"); this.node.classList.add("tabs", "shadow");
this.appendChild(this.node); this.appendChild(this.node);
this.addEventListener("ui-tab-activated", (event: CustomEvent) => { this.addEventListener("ui-tab-activated", (event) => {
const target = event.detail as Tab; const target = (event as CustomEvent).detail as Tab;
for (const tab of this.getElementsByTagName("ui-tab") as HTMLCollectionOf<Tab>) { for (const tab of this.getElementsByTagName("ui-tab") as HTMLCollectionOf<Tab>) {
if (tab !== target) { if (tab !== target) {
tab.setActive(false); tab.setActive(false);
@@ -26,19 +26,23 @@ document.addEventListener("alpine:init", () => {
* `counter/templates/counter/product_form.jinja` * `counter/templates/counter/product_form.jinja`
*/ */
Alpine.data("dynamicFormSet", (config?: Config) => ({ Alpine.data("dynamicFormSet", (config?: Config) => ({
formContainer: undefined as unknown as HTMLElement,
nbForms: 0,
template: undefined as unknown as HTMLTemplateElement,
init() { init() {
this.formContainer = this.$refs.formContainer as HTMLElement; this.formContainer = this.$refs.formContainer as HTMLElement;
this.nbForms = this.formContainer.children.length as number; this.nbForms = this.formContainer.children.length as number;
this.template = this.$refs.formTemplate as HTMLTemplateElement; this.template = this.$refs.formTemplate as HTMLTemplateElement;
const prefix = config?.prefix ?? "form"; const prefix = config?.prefix ?? "form";
this.$root (
.querySelector(`#id_${prefix}-TOTAL_FORMS`) this.$root.querySelector(`#id_${prefix}-TOTAL_FORMS`) as HTMLFormElement
.setAttribute(":value", "nbForms"); ).setAttribute(":value", "nbForms");
}, },
addForm() { addForm() {
this.formContainer.appendChild(document.importNode(this.template.content, true)); this.formContainer.appendChild(document.importNode(this.template.content, true));
const newForm = this.formContainer.lastElementChild; const newForm = this.formContainer.lastElementChild as Element;
const inputs: NodeListOf<HTMLFormInputElement> = newForm.querySelectorAll( const inputs: NodeListOf<HTMLFormInputElement> = newForm.querySelectorAll(
"input, select, textarea", "input, select, textarea",
); );
@@ -59,7 +63,7 @@ document.addEventListener("alpine:init", () => {
this.nbForms -= 1; this.nbForms -= 1;
// adjust the id of remaining forms // adjust the id of remaining forms
for (let i = 0; i < this.nbForms; i++) { for (let i = 0; i < this.nbForms; i++) {
const form: HTMLDivElement = this.formContainer.children[i]; const form = this.formContainer.children[i];
const inputs: NodeListOf<HTMLFormInputElement> = form.querySelectorAll( const inputs: NodeListOf<HTMLFormInputElement> = form.querySelectorAll(
"input, select, textarea", "input, select, textarea",
); );
+6 -3
View File
@@ -1,5 +1,5 @@
function showMenu() { function showMenu() {
const navbar = document.getElementById("navbar-content"); const navbar = document.getElementById("navbar-content") as HTMLElement;
const current = navbar.getAttribute("mobile-display"); const current = navbar.getAttribute("mobile-display");
navbar.setAttribute("mobile-display", current === "hidden" ? "revealed" : "hidden"); navbar.setAttribute("mobile-display", current === "hidden" ? "revealed" : "hidden");
} }
@@ -20,9 +20,12 @@ function navbarInit() {
item.removeAttribute("open"); item.removeAttribute("open");
} }
}); });
item.addEventListener("click", (event: MouseEvent) => { item.addEventListener("click", (event) => {
// Don't close when clicking on desktop mode // Don't close when clicking on desktop mode
if ((event.target as HTMLElement).nodeName !== "SUMMARY" || event.detail === 0) { if (
(event.target as HTMLElement).nodeName !== "SUMMARY" ||
(event as MouseEvent).detail === 0
) {
return; return;
} }
@@ -1,3 +1,4 @@
// @ts-expect-error 2307 this dependency does exist, but it's a little bit wacky
import clip from "@arendjr/text-clipper"; import clip from "@arendjr/text-clipper";
/* /*
+8 -7
View File
@@ -70,15 +70,16 @@ function createTooltip(element: HTMLElement) {
function updateTooltip(element: HTMLElement, tooltip: HTMLElement, status: Status) { function updateTooltip(element: HTMLElement, tooltip: HTMLElement, status: Status) {
// Update tooltip status and set it's attributes and content // Update tooltip status and set it's attributes and content
tooltip.setAttribute("tooltip-status", status); tooltip.setAttribute("tooltip-status", status);
tooltip.innerText = element.getAttribute("tooltip"); tooltip.innerText = element.getAttribute("tooltip") as string;
for (const attributes of [ for (const attributes of [
{ src: "tooltip-class", dst: "class", default: ["tooltip"] }, { src: "tooltip-class", dst: "class", default: ["tooltip"] },
{ src: "tooltip-id", dst: "id", default: [] }, { src: "tooltip-id", dst: "id", default: [] },
]) { ]) {
const populated = attributes.default; const populated = attributes.default;
if (element.hasAttribute(attributes.src)) { const attr = element.getAttribute(attributes.src);
populated.push(...element.getAttribute(attributes.src).split(" ")); if (attr !== null) {
populated.push(...attr.split(" "));
} }
tooltip.setAttribute(attributes.dst, populated.join(" ")); tooltip.setAttribute(attributes.dst, populated.join(" "));
} }
@@ -93,7 +94,7 @@ function getTooltip(element: HTMLElement) {
return tooltip; return tooltip;
} }
function tooltipMouseover(event: MouseEvent) { function tooltipMouseover(event: Event) {
// We get the closest tooltip to have a consistent behavior // We get the closest tooltip to have a consistent behavior
// when hovering over a child element of a tooltip marked element // when hovering over a child element of a tooltip marked element
const target = (event.target as HTMLElement).closest("[tooltip]") as HTMLElement; const target = (event.target as HTMLElement).closest("[tooltip]") as HTMLElement;
@@ -111,7 +112,7 @@ function tooltipMouseover(event: MouseEvent) {
}); });
} }
function tooltipMouseout(event: MouseEvent) { function tooltipMouseout(event: Event) {
// We get the closest tooltip to have a consistent behavior // We get the closest tooltip to have a consistent behavior
// when hovering over a child element of a tooltip marked element // when hovering over a child element of a tooltip marked element
const target = (event.target as HTMLElement).closest("[tooltip]") as HTMLElement; const target = (event.target as HTMLElement).closest("[tooltip]") as HTMLElement;
@@ -141,7 +142,7 @@ new MutationObserver((mutations: MutationRecord[]) => {
} }
} else if (tooltips.has(target)) { } else if (tooltips.has(target)) {
// Remove corresponding tooltip // Remove corresponding tooltip
tooltips.get(target).remove(); tooltips.get(target)?.remove();
tooltips.delete(target); tooltips.delete(target);
} }
} }
@@ -163,7 +164,7 @@ new MutationObserver((mutations: MutationRecord[]) => {
continue; continue;
} }
if (tooltips.has(target)) { if (tooltips.has(target)) {
tooltips.get(target).remove(); tooltips.get(target)?.remove();
tooltips.delete(target); tooltips.delete(target);
} }
} }
+18 -23
View File
@@ -1,20 +1,13 @@
import cytoscape, { import cytoscape, { type ElementDefinition, type Singular } from "cytoscape";
type ElementDefinition,
type NodeSingular,
type Singular,
} from "cytoscape";
import cxtmenu from "cytoscape-cxtmenu"; import cxtmenu from "cytoscape-cxtmenu";
import klay, { type KlayLayoutOptions } from "cytoscape-klay"; import klay, { type KlayLayoutOptions } from "cytoscape-klay";
import { History, initialUrlParams, updateQueryString } from "#core:utils/history.ts"; import { History, initialUrlParams, updateQueryString } from "#core:utils/history";
import { familyGetFamilyGraph, type UserProfileSchema } from "#openapi"; import { familyGetFamilyGraph, type UserProfileSchema } from "#openapi";
cytoscape.use(klay); cytoscape.use(klay);
cytoscape.use(cxtmenu); cytoscape.use(cxtmenu);
type GraphData = ( type GraphData = { data: UserProfileSchema | { source: number; target: number } }[];
| { data: UserProfileSchema }
| { data: { source: number; target: number } }
)[];
function isMobile() { function isMobile() {
return window.innerWidth < 500; return window.innerWidth < 500;
@@ -27,10 +20,8 @@ async function getGraphData(
): Promise<GraphData> { ): Promise<GraphData> {
const data = ( const data = (
await familyGetFamilyGraph({ await familyGetFamilyGraph({
path: { // biome-ignore lint/style/useNamingConvention: api is snake_case
// biome-ignore lint/style/useNamingConvention: api is snake_case path: { user_id: userId },
user_id: userId,
},
query: { query: {
// biome-ignore lint/style/useNamingConvention: api is snake_case // biome-ignore lint/style/useNamingConvention: api is snake_case
godfathers_depth: godfathersDepth, godfathers_depth: godfathersDepth,
@@ -39,6 +30,10 @@ async function getGraphData(
}, },
}) })
).data; ).data;
if (data === undefined) {
console.error("Family graph request failed");
return [];
}
return [ return [
...data.users.map((user) => { ...data.users.map((user) => {
return { data: user }; return { data: user };
@@ -155,7 +150,7 @@ function createGraph(container: HTMLDivElement, data: GraphData, activeUserId: n
{ {
content: '<i class="fa fa-external-link fa-2x"></i>', content: '<i class="fa fa-external-link fa-2x"></i>',
select: (el) => { select: (el) => {
window.open(el.data().profile_url, "_blank").focus(); window.open(el.data().profile_url, "_blank")?.focus();
}, },
}, },
@@ -195,12 +190,12 @@ document.addEventListener("alpine:init", () => {
godfathersDepth: 0, godfathersDepth: 0,
godchildrenDepth: 0, godchildrenDepth: 0,
reverse: initialUrlParams.get("reverse")?.toLowerCase?.() === "true", reverse: initialUrlParams.get("reverse")?.toLowerCase?.() === "true",
graph: undefined as cytoscape.Core, graph: undefined as unknown as cytoscape.Core,
graphData: {}, graphData: {} as GraphData,
isZoomEnabled: !isMobile(), isZoomEnabled: !isMobile(),
getInitialDepth(prop: string) { getInitialDepth(prop: string) {
const value = Number.parseInt(initialUrlParams.get(prop), 10); const value = Number.parseInt(initialUrlParams.get(prop) as string, 10);
if (Number.isNaN(value) || value < config.depthMin || value > config.depthMax) { if (Number.isNaN(value) || value < config.depthMin || value > config.depthMax) {
return defaultDepth; return defaultDepth;
} }
@@ -223,8 +218,8 @@ document.addEventListener("alpine:init", () => {
await delayedFetch(); await delayedFetch();
}); });
} }
this.$watch("reverse", async (value: number) => { this.$watch("reverse", async (newValue, _oldValue) => {
updateQueryString("reverse", value.toString(), History.Replace); updateQueryString("reverse", newValue.toString(), History.Replace);
await this.reverseGraph(); await this.reverseGraph();
}); });
this.$watch("graphData", async () => { this.$watch("graphData", async () => {
@@ -259,9 +254,9 @@ document.addEventListener("alpine:init", () => {
}, },
async reverseGraph() { async reverseGraph() {
this.graph.elements((el: NodeSingular) => { this.graph
el.position({ x: -el.position().x, y: -el.position().y }); .elements()
}); .positions((el, _) => ({ x: -el.position().x, y: -el.position().y }));
this.graph.center(this.graph.elements()); this.graph.center(this.graph.elements());
}, },
+2 -2
View File
@@ -5,9 +5,9 @@ interface AlertParams {
export class AlertMessage { export class AlertMessage {
public open: boolean; public open: boolean;
public success: boolean; public success!: boolean;
public content: string; public content: string;
private timeoutId?: number; private timeoutId: number | null;
private readonly defaultDuration: number; private readonly defaultDuration: number;
constructor(params?: { defaultDuration: number }) { constructor(params?: { defaultDuration: number }) {
+8 -5
View File
@@ -37,6 +37,10 @@ export const paginated = async <T>(
queryParams.query.page = 1; queryParams.query.page = 1;
const firstPage = (await endpoint(queryParams)).data; const firstPage = (await endpoint(queryParams)).data;
if (firstPage === undefined) {
console.error(`Request to "${options?.url}" failed`);
return [];
}
const results = firstPage.results; const results = firstPage.results;
const nbElements = firstPage.count; const nbElements = firstPage.count;
@@ -45,9 +49,9 @@ export const paginated = async <T>(
if (nbPages > 1) { if (nbPages > 1) {
const promises: Promise<T[]>[] = []; const promises: Promise<T[]>[] = [];
for (let i = 2; i <= nbPages; i++) { for (let i = 2; i <= nbPages; i++) {
const nextPage = structuredClone(queryParams); const nextPage = structuredClone(queryParams) as Required<PaginatedRequest>;
nextPage.query.page = i; nextPage.query.page = i;
promises.push(endpoint(nextPage).then((res) => res.data.results)); promises.push(endpoint(nextPage).then((res) => res.data?.results ?? []));
} }
results.push(...(await Promise.all(promises)).flat()); results.push(...(await Promise.all(promises)).flat());
} }
@@ -61,8 +65,7 @@ interface Request extends TDataShape {
interface InterceptorOptions { interface InterceptorOptions {
url: string; url: string;
} }
export type GenericEndpoint = <ThrowOnError extends boolean = false>(
type GenericEndpoint = <ThrowOnError extends boolean = false>(
options?: Options<Request, ThrowOnError>, options?: Options<Request, ThrowOnError>,
) => RequestResult<unknown, unknown, ThrowOnError>; ) => RequestResult<unknown, unknown, ThrowOnError>;
@@ -71,7 +74,7 @@ type GenericEndpoint = <ThrowOnError extends boolean = false>(
**/ **/
export const makeUrl = async (endpoint: GenericEndpoint) => { export const makeUrl = async (endpoint: GenericEndpoint) => {
let url = ""; let url = "";
const interceptor = (_request: undefined, options: InterceptorOptions) => { const interceptor = (_request: Request, options: InterceptorOptions) => {
url = options.url; url = options.url;
throw new Error("We don't want to send the request"); throw new Error("We don't want to send the request");
}; };
+12 -9
View File
@@ -7,14 +7,14 @@ interface StringifyOptions<T extends object> {
titleRow?: readonly string[]; titleRow?: readonly string[];
} }
function getNested<T extends object>(obj: T, key: NestedKeyOf<T>) { function getNested<T extends { [key: string]: unknown }>(obj: T, key: NestedKeyOf<T>) {
const path: (keyof object)[] = key.split(".") as (keyof unknown)[]; const path = key.split(".");
let res = obj[path.shift() as keyof T]; let res = obj[path.shift() as string] as { [key: string]: unknown } | undefined;
for (const node of path) { for (const node of path) {
if (res === null) { if (res === undefined) {
break; break;
} }
res = res[node]; res = res[node] as { [key: string]: unknown } | undefined;
} }
return res; return res;
} }
@@ -29,18 +29,21 @@ function sanitizeCell(content: string): string {
} }
export const csv = { export const csv = {
stringify: <T extends object>(objs: T[], options?: StringifyOptions<T>) => { stringify: <T extends { [key: string]: unknown }>(
const columns = options.columns; objs: T[],
options?: StringifyOptions<T>,
) => {
const columns = options?.columns;
const content = objs const content = objs
.map((obj) => { .map((obj) => {
return columns return (columns ?? [])
.map((col) => { .map((col) => {
return sanitizeCell((getNested(obj, col) ?? "").toString()); return sanitizeCell((getNested(obj, col) ?? "").toString());
}) })
.join(","); .join(",");
}) })
.join("\n"); .join("\n");
if (!options.titleRow) { if (!options?.titleRow) {
return content; return content;
} }
const firstRow = options.titleRow.map(sanitizeCell).join(","); const firstRow = options.titleRow.map(sanitizeCell).join(",");
+3 -2
View File
@@ -29,6 +29,7 @@ export function registerComponent(name: string, options?: ElementDefinitionOptio
export interface InheritedHtmlElement<K extends keyof HTMLElementTagNameMap> export interface InheritedHtmlElement<K extends keyof HTMLElementTagNameMap>
extends HTMLElement { extends HTMLElement {
readonly inheritedTagName: K; readonly inheritedTagName: K;
// readonly initializedAttribute: "component-initialized";
node: HTMLElementTagNameMap[K]; node: HTMLElementTagNameMap[K];
} }
@@ -47,8 +48,8 @@ export function inheritHtmlElement<K extends keyof HTMLElementTagNameMap>(tagNam
implements InheritedHtmlElement<K> implements InheritedHtmlElement<K>
{ {
readonly inheritedTagName = tagName; readonly inheritedTagName = tagName;
private readonly initializedAttribute = "component-initialized"; readonly initializedAttribute = "component-initialized";
node: HTMLElementTagNameMap[K]; node!: HTMLElementTagNameMap[K];
connectedCallback(autoAddNode?: boolean) { connectedCallback(autoAddNode?: boolean) {
// When nesting inherited elements, we might trigger the wrapping twice // When nesting inherited elements, we might trigger the wrapping twice
-13
View File
@@ -1,13 +0,0 @@
import contextlib
import os
import pytest
from django.core.management import call_command
@pytest.mark.django_db
def test_populate_more(settings):
"""Just check that populate more doesn't crash"""
settings.DEBUG = True
with open(os.devnull, "w") as devnull, contextlib.redirect_stdout(devnull):
call_command("populate_more", "--nb-users", "50")
+3 -40
View File
@@ -12,31 +12,21 @@
# OR WITHIN THE LOCAL FILE "LICENSE" # OR WITHIN THE LOCAL FILE "LICENSE"
# #
# #
from __future__ import annotations
import hmac
from datetime import date, timedelta from datetime import date, timedelta
# Image utils # Image utils
from io import BytesIO from io import BytesIO
from typing import TYPE_CHECKING from typing import Final
from urllib.parse import urlencode
import PIL import PIL
from django.conf import settings from django.conf import settings
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.core.files.uploadedfile import UploadedFile
from django.http import HttpRequest
from django.utils.timezone import localdate from django.utils.timezone import localdate
from PIL.Image import Image, Resampling from PIL.Image import Image, Resampling
if TYPE_CHECKING:
from _hashlib import HASH
from collections.abc import Buffer, Mapping, Sequence
from typing import Any, Callable, Final
from django.core.files.uploadedfile import UploadedFile
from django.http import HttpRequest
RED_PIXEL_PNG: Final[bytes] = ( RED_PIXEL_PNG: Final[bytes] = (
b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52" b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52"
b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90\x77\x53" b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90\x77\x53"
@@ -198,30 +188,3 @@ def get_client_ip(request: HttpRequest) -> str | None:
return ip return ip
return None return None
def hmac_hexdigest(
key: str | bytes,
data: Mapping[str, Any] | Sequence[tuple[str, Any]],
digest: str | Callable[[Buffer], HASH] = "sha512",
) -> str:
"""Return the hexdigest of the signature of the given data.
Args:
key: the HMAC key used for the signature
data: the data to sign
digest: a PEP247 hashing algorithm (by default, sha512)
Examples:
```python
data = {
"foo": 5,
"bar": "somevalue",
}
hmac_key = secrets.token_hex(64)
signature = hmac_hexdigest(hmac_key, data, "sha256")
```
"""
if isinstance(key, str):
key = key.encode()
return hmac.digest(key, urlencode(data).encode(), digest).hex()
@@ -1,7 +1,7 @@
import type { TomOption } from "tom-select/dist/types/types"; import type { TomOption } from "tom-select/src/types";
import type { escape_html } from "tom-select/dist/types/utils"; import type { escape_html } from "tom-select/src/utils";
import { AjaxSelect } from "#core:core/components/ajax-select-base.ts"; import { AjaxSelect } from "#core:core/components/ajax-select-base";
import { registerComponent } from "#core:utils/web-components.ts"; import { registerComponent } from "#core:utils/web-components";
import { import {
type CounterSchema, type CounterSchema,
counterSearchCounter, counterSearchCounter,
@@ -28,14 +28,23 @@ export class ProductAjaxSelect extends AjaxSelect {
return []; return [];
} }
// In the context in which this method is called, `this` might be shadowed
// We need to call it explicitly from the class itself
private static getName(
item: SimpleProductSchema,
sanitize: typeof escape_html,
): string {
return item.code ? `${sanitize(item.code)} - ${sanitize(item.name)}` : item.name;
}
protected renderOption(item: SimpleProductSchema, sanitize: typeof escape_html) { protected renderOption(item: SimpleProductSchema, sanitize: typeof escape_html) {
return `<div class="select-item"> return `<div class="select-item">
<span class="select-item-text">${sanitize(item.code)} - ${sanitize(item.name)}</span> <span class="select-item-text">${ProductAjaxSelect.getName(item, sanitize)}</span>
</div>`; </div>`;
} }
protected renderItem(item: SimpleProductSchema, sanitize: typeof escape_html) { protected renderItem(item: SimpleProductSchema, sanitize: typeof escape_html) {
return `<span>${sanitize(item.code)} - ${sanitize(item.name)}</span>`; return `<span>${ProductAjaxSelect.getName(item, sanitize)}</span>`;
} }
} }
@@ -44,7 +53,7 @@ export class ProductTypeAjaxSelect extends AjaxSelect {
protected valueField = "id"; protected valueField = "id";
protected labelField = "name"; protected labelField = "name";
protected searchField = ["name"]; protected searchField = ["name"];
private productTypes = null as ProductTypeSchema[]; private productTypes = null as ProductTypeSchema[] | null;
protected async search(query: string): Promise<TomOption[]> { protected async search(query: string): Promise<TomOption[]> {
// The production database has a grand total of 26 product types // The production database has a grand total of 26 product types
@@ -52,7 +61,7 @@ export class ProductTypeAjaxSelect extends AjaxSelect {
// Thus, it's appropriate to fetch all product types during first use, // Thus, it's appropriate to fetch all product types during first use,
// then to reuse the result again and again. // then to reuse the result again and again.
if (this.productTypes === null) { if (this.productTypes === null) {
this.productTypes = (await producttypeFetchAll()).data || null; this.productTypes = (await producttypeFetchAll()).data || [];
} }
return this.productTypes.filter((t) => return this.productTypes.filter((t) =>
t.name.toLowerCase().includes(query.toLowerCase()), t.name.toLowerCase().includes(query.toLowerCase()),
@@ -59,7 +59,7 @@ export class CounterProductSelect extends AutoCompleteSelectBase {
return onOptionSelect.call( return onOptionSelect.call(
this.widget, this.widget,
evt, evt,
this.widget.getOption(value, true), this.widget.getOption(value, true) as HTMLElement,
); );
}, },
); );
@@ -70,12 +70,15 @@ export class CounterProductSelect extends AutoCompleteSelectBase {
...super.tomSelectSettings(), ...super.tomSelectSettings(),
openOnFocus: false, openOnFocus: false,
// We make searching on exact code matching a higher priority // We make searching on exact code matching a higher priority
// We need to manually set weights or it results on an inconsistent // We need to manually set weights, or it results on an inconsistent
// behavior between production and development environment // behavior between production and development environment
//
// SearchField can be an object array (according to the docs),
// but it is unproperly typed, so we must force-cast to trick TS
searchField: [ searchField: [
{ field: "code", weight: 2 }, { field: "code", weight: 2 },
{ field: "text", weight: 0.5 }, { field: "text", weight: 0.5 },
], ] as unknown as string[],
}; };
} }
} }
@@ -1,9 +1,14 @@
import { showSaveFilePicker } from "native-file-system-adapter"; import { showSaveFilePicker } from "native-file-system-adapter";
import type TomSelect from "tom-select"; import type TomSelect from "tom-select";
import type { ClubAjaxSelect } from "#club:club/components/ajax-select-index";
import type { NestedKeyOf } from "#core:types/nested-key"; import type { NestedKeyOf } from "#core:types/nested-key";
import { paginated } from "#core:utils/api"; import { type PaginatedRequest, paginated } from "#core:utils/api";
import { csv } from "#core:utils/csv"; import { csv } from "#core:utils/csv";
import { getCurrentUrlParams, History, updateQueryString } from "#core:utils/history"; import { getCurrentUrlParams, History, updateQueryString } from "#core:utils/history";
import type {
CounterAjaxSelect,
ProductTypeAjaxSelect,
} from "#counter:counter/components/ajax-select-index";
import { import {
type ProductSchema, type ProductSchema,
type ProductSearchProductsDetailedData, type ProductSearchProductsDetailedData,
@@ -51,6 +56,8 @@ const csvColumnTitles = [
gettext("archived"), gettext("archived"),
]; ];
type ProductStatus = "active" | "archived" | "both";
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
Alpine.data("productList", () => ({ Alpine.data("productList", () => ({
loading: false, loading: false,
@@ -59,8 +66,7 @@ document.addEventListener("alpine:init", () => {
/** Total number of elements corresponding to the current query. */ /** Total number of elements corresponding to the current query. */
nbPages: 0, nbPages: 0,
productStatus: "" as ProductStatus,
productStatus: "" as "active" | "archived" | "both",
search: "", search: "",
productTypes: [] as string[], productTypes: [] as string[],
clubs: [] as string[], clubs: [] as string[],
@@ -71,16 +77,18 @@ document.addEventListener("alpine:init", () => {
async init() { async init() {
const url = getCurrentUrlParams(); const url = getCurrentUrlParams();
this.search = url.get("search") || ""; this.search = url.get("search") || "";
this.productStatus = url.get("productStatus") ?? "active"; this.productStatus = (url.get("productStatus") ?? "active") as ProductStatus;
const productTypesWidget = this.$refs.productTypesInput.widget as TomSelect; const productTypesWidget = (this.$refs.productTypesInput as ProductTypeAjaxSelect)
.widget as TomSelect;
productTypesWidget.on("change", (items: string[]) => { productTypesWidget.on("change", (items: string[]) => {
this.productTypes = [...items]; this.productTypes = [...items];
}); });
const clubsWidget = this.$refs.clubsInput.widget as TomSelect; const clubsWidget = (this.$refs.clubsInput as ClubAjaxSelect).widget as TomSelect;
clubsWidget.on("change", (items: string[]) => { clubsWidget.on("change", (items: string[]) => {
this.clubs = [...items]; this.clubs = [...items];
}); });
const countersWidget = this.$refs.countersInput.widget as TomSelect; const countersWidget = (this.$refs.countersInput as CounterAjaxSelect)
.widget as TomSelect;
countersWidget.on("change", (items: string[]) => { countersWidget.on("change", (items: string[]) => {
this.counters = [...items]; this.counters = [...items];
}); });
@@ -127,9 +135,9 @@ document.addEventListener("alpine:init", () => {
// biome-ignore lint/style/useNamingConvention: api is in snake_case // biome-ignore lint/style/useNamingConvention: api is in snake_case
is_archived: isArchived, is_archived: isArchived,
// biome-ignore lint/style/useNamingConvention: api is in snake_case // biome-ignore lint/style/useNamingConvention: api is in snake_case
product_type: [...this.productTypes], product_type: [...this.productTypes.map(Number.parseInt)],
club: [...this.clubs], club: [...this.clubs.map(Number.parseInt)],
counter: [...this.counters], counter: [...this.counters.map(Number.parseInt)],
}, },
}; };
}, },
@@ -141,6 +149,10 @@ document.addEventListener("alpine:init", () => {
this.loading = true; this.loading = true;
const options = this.getQueryParams(); const options = this.getQueryParams();
const resp = await productSearchProductsDetailed(options); const resp = await productSearchProductsDetailed(options);
if (resp.data === undefined) {
console.error("Product search request failed");
return;
}
this.nbPages = Math.ceil(resp.data.count / defaultPageSize); this.nbPages = Math.ceil(resp.data.count / defaultPageSize);
this.products = resp.data.results.reduce<GroupedProducts>( this.products = resp.data.results.reduce<GroupedProducts>(
(acc: GroupedProducts, curr: ProductSchema) => { (acc: GroupedProducts, curr: ProductSchema) => {
@@ -172,7 +184,10 @@ document.addEventListener("alpine:init", () => {
// If not, fetch them. // If not, fetch them.
const products: ProductSchema[] = const products: ProductSchema[] =
this.nbPages > 1 this.nbPages > 1
? await paginated(productSearchProductsDetailed, this.getQueryParams()) ? await paginated(
productSearchProductsDetailed,
this.getQueryParams() as PaginatedRequest,
)
: Object.values<ProductSchema[]>(this.products).flat(); : Object.values<ProductSchema[]>(this.products).flat();
// CSV cannot represent nested data // CSV cannot represent nested data
// so we create a row for each price of each product. // so we create a row for each price of each product.
@@ -1,5 +1,5 @@
import Alpine from "alpinejs"; import Alpine from "alpinejs";
import { AlertMessage } from "#core:utils/alert-message.ts"; import { AlertMessage } from "#core:utils/alert-message";
import { producttypeReorder } from "#openapi"; import { producttypeReorder } from "#openapi";
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
@@ -22,7 +22,7 @@ document.addEventListener("alpine:init", () => {
const productTypes = this.$refs.productTypes const productTypes = this.$refs.productTypes
.childNodes as NodeListOf<HTMLLIElement>; .childNodes as NodeListOf<HTMLLIElement>;
const getId = (elem: HTMLLIElement) => const getId = (elem: HTMLLIElement) =>
Number.parseInt(elem.getAttribute("x-sort:item"), 10); Number.parseInt(elem.getAttribute("x-sort:item") as string, 10);
const query = const query =
newPosition === 0 newPosition === 0
? { above: getId(productTypes.item(1)) } ? { above: getId(productTypes.item(1)) }
@@ -32,6 +32,10 @@ document.addEventListener("alpine:init", () => {
path: { type_id: itemId }, path: { type_id: itemId },
query: query, query: query,
}); });
if (response.response === undefined) {
console.error("Product type reordering request failed");
return;
}
this.openAlertMessage(response.response); this.openAlertMessage(response.response);
this.loading = false; this.loading = false;
}, },
+4 -6
View File
@@ -17,16 +17,14 @@ D'abord, il faut ajouter dans node via `package.json`:
} }
``` ```
Ensuite, pour faire fonctionne l'auto-complétion, il faut configurer `tsconfig.json`: Ensuite, pour faire fonctionne l'auto-complétion, il faut configurer `tsconfig.bundled.json`:
```json ```json
{ {
"compilerOptions": { // ...
"paths": {
// ... // ...
"paths": { "#mon_app:*": ["./mon_app/static/bundled/*"]
// ...
"#mon_app:*": ["./mon_app/static/bundled/*"]
}
} }
} }
``` ```
+8 -4
View File
@@ -61,9 +61,8 @@ 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
] ]
@@ -117,14 +116,16 @@ 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 = [
@@ -164,6 +165,7 @@ 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)
@@ -174,6 +176,7 @@ 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),
@@ -289,6 +292,7 @@ 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")]
+1 -5
View File
@@ -191,7 +191,6 @@ 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
@@ -239,10 +238,7 @@ 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( total_amount = sum(sale.amount * sale.unit_price for sale in foyer.sellings.all())
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.
+1 -2
View File
@@ -27,12 +27,11 @@ 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,6 +26,7 @@ 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")
``` ```
-1
View File
@@ -1 +0,0 @@
::: api.schemas
-1
View File
@@ -1 +0,0 @@
::: api.views
-378
View File
@@ -1,378 +0,0 @@
Le site AE offre des mécanismes permettant aux applications tierces
de récupérer les informations sur un utilisateur du site AE.
De cette manière, il devient possible de synchroniser les informations
qu possède l'application tierce sur l'utilisateur, directement depuis
le site AE.
## Fonctionnement général
Pour authentifier vos utilisateurs, vous aurez besoin d'un serveur web
et d'un client d'API (celui auquel est liée votre
[clef d'API](./connect.md#obtenir-une-clef-dapi)).
Deux informations vous sont nécessaires, en plus de votre clef d'API :
- l'id du client : vous pouvez l'obtenir soit en le demandant à l'équipe info,
soit en appelant la route `GET /api/client/me` avec votre clef d'API
renseignée dans le header [X-APIKey](./connect.md#x-apikey)
- la clef HMAC du client : vous devez la demander à l'équipe info.
Ces deux éléments ont une fonction différente : l'id du client permet
de dire au serveur quelle est l'application qui s'adresse à lui,
tandis que la [clef HMAC](https://fr.wikipedia.org/wiki/HMAC)
servira à créer une signature unique permettant de s'assurer
que les données transmises n'ont pas été falsifiées.
Grâce à ces informations, vous allez pouvoir fournir le contexte nécessaire
au site AE pour qu'il authentifie vos utilisateurs.
En effet, la démarche d'authentification s'effectue presque entièrement
sur le site : le travail de l'application tierce consiste uniquement
à fournir à l'utilisateur une url avec les bons paramètres, puis
à recevoir la réponse du serveur si tout s'est bien passé.
Comme un dessin vaut parfois mieux que mille mots,
voici les diagrammes décrivant le processus.
L'un montre l'entièreté de la démarche ;
l'autre dans un souci de simplicité, ne montre que ce qui est visible
directement par l'application tierce.
=== "Intégralité du processus"
```mermaid
sequenceDiagram
actor User
participant App
User->>+App: Authentifie-moi, stp
App-->>-User: url de connexion<br/>avec signature
User->>+Sith: GET url
opt Utilisateur non-connecté
Sith->>+User: Formulaire de connexion
User-->>-Sith: Connexion
end
Sith->>Sith: vérification de la signature
Sith->>+User: Formulaire<br/>des conditions<br/>d'utilisation
User-->>-Sith: Validation
Sith->>+App: URL de retour<br/>avec données utilisateur
App->>App: Traitement des <br/>données utilisateur
App-->>-Sith: 204 OK, No content
Sith-->>-User: Message de succès
App--)User: Message de succès
```
=== "Point de vue de l'application tierce"
```mermaid
sequenceDiagram
actor User
participant App
User->>+App: Authentifie-moi, stp
App-->>-User: url de connexion<br/>avec signature
opt
Sith->>+App: URL de retour<br/>avec données utilisateur
App->>App: Traitement des <br/>données utilisateur
App-->>-Sith: 204 OK, No content
App--)User: Message de succès
end
```
## Données attendues
### URL de connexion
L'URL de connexion que vous allez fournir à l'utilisateur doit
être `https://ae.utbm.fr/api-link/auth/`
et doit contenir les données décrites dans
[`ThirdPartyAuthParamsSchema`][api.schemas.ThirdPartyAuthParamsSchema] :
- `client_id` (integer) : l'id de votre client, que vous pouvez obtenir
de la manière décrite plus haut
- `third_party_app`(string) : le nom de la plateforme pour laquelle
l'authentification va être réalisée (si votre application est un bot
discord, mettez la valeur "discord")
- `privacy_link`(URL) : l'URL vers la page de politique de confidentialité
qui s'appliquera dans le cadre de l'application
(s'il s'agit d'un bot discord, donnez le lien vers celles de Discord)
- `username`(string) : le pseudonyme que l'utilisateur possède sur
votre application
- `callback_url`(URL) : l'URL que le site AE appellera si l'authentification
réussit
- `signature`(string) : la signature des données de la requête.
Il s'agit d'une signature par clef HMAC dont le fonctionnement
est détaillé plus bas.
Ces données doivent être url-encodées et passées dans les paramètres GET.
!!!warning "URL de retour"
Les URLs fournies doivent être des URLs HTTP valides.
En outre, elles doivent obligatoirement inclure la barre oblique finale.
=== "URL correcte ✔️"
`https://exemple.ae.utbm.fr/foo/`
=== "URL incorrecte ❌"
`https://exemple.ae.utbm.fr/foo`
!!!tip
Inclure l'id de votre utilisateur dans l'URL de retour
peut être un bon moyen de l'identifier lors du callback.
Par exemple : `GET /callback/{int:user_id}/`.
???Example
Supposons que votre client d'API soit utilisé dans le cadre d'un bot Discord,
avec les données suivantes :
- l'id du client est 15
- sa clef HMAC est "beb99dd53"
(c'est pour l'exemple, une vraie clef sera beaucoup plus longue)
- le pseudonyme discord de votre utilisateur est Brian
- son id sur discord est 123456789
- votre route de callback est `GET /callback/{int:user_id}/`,
accessible au domaine `https://bot.ae.utbm.fr`
Alors les paramètres de votre URL seront :
| Paramètre | valeur |
|-----------------|-----------------------------------------------------------------------|
| client_id | 15 |
| third_party_app | discord |
| privacy_link | `https://discord.com/privacy` |
| username | Brian |
| callback_url | `https://bot.ae.utbm.fr/callback/123456789/` |
| signature | 1a383c51060be64f07772aa42e07<br/>18ae096b8f21f2cdb4061c0834a416d12101 |
Et l'url fournie à l'utilisateur sera :
`https://ae.utbm.fr/api-link/auth/?client_id=15&third_party_app=discord
&privacy_link=https%3A%2F%2Fdiscord.com%2Fprivacy&username=Brian
&callback_url=https%3A%2F%2Fbot.ae.utbm.fr%2Fcallback%2F123456789%2F
&signature=1a383c51060be64f07772aa42e0718ae096b8f21f2cdb4061c0834a416d12101`
### Données de retour
Si l'authentification réussit, le site AE enverra une requête HTTP POST
à l'URL de retour fournie dans l'URL de connexion.
Le corps de la requête de callback et au format JSON
et contient deux paires clef-valeur :
- `user` : les données utilisateur, telles que décrites
par [UserProfileSchema][core.schemas.UserProfileSchema]
- `signature` : la signature des données utilisateur
???Example
En reprenant les mêmes paramètres que dans l'exemple précédent,
le site AE pourra renvoyer à l'application la requête suivante :
```http
POST https://bot.ae.utbm.fr/callback/123456789/
content-type: application/json
body: {
"user": {
"id": 144131,
"nick_name": "inzekitchen",
"first_name": "Brian",
...
},
"signature": "f16955bab6b805f6e1abbb98a86dfee53fed0bf812aa6513ca46cfd461b70020"
}
```
L'application doit répondre avec un des codes HTTP suivants :
| Code | Raison |
|------|--------------------------------------------------------------------------------|
| 204 | Tout s'est bien passé |
| 403 | Les données de retour ne sont <br>pas signées ou sont mal signées |
| 404 | L'URL de retour ne permet pas <br>d'identifier un utilisateur de l'application |
!!!note "Code d'erreur par défaut"
Si l'appel de la route fait face à plusieurs problèmes en même temps
(par exemple, l'URL ne permet pas de retrouver votre utilisateur,
et en plus les données sont mal signées),
le 403 prime et doit être retourné par défaut.
## Signature des données
Les données de l'URL de connexion doivent être signées,
et la signature de l'URL de retour doit être vérifiée.
Dans le deux cas, la signature est le digest HMAC-SHA512
des données url-encodées, en utilisant la clef HMAC du client d'API.
L'ordre dans lequel ces données sont placées dans l'encodage URL
doit être strictement le même que celui donné plus haut.
???Example "Signature de l'URL de connexion"
En reprenant le même exemple que les fois précédentes,
l'url-encodage des données est :
`client_id=15&third_party_app=discord
&privacy_link=https%3A%2F%2Fdiscord.com%2Fprivacy%2F&username=Brian
&callback_url=https%3A%2F%2Fbot.ae.utbm.fr%2Fcallback%2F123456789%2F`
Notez que la signature n'est pas (encore) dedans.
Cette dernière peut-être obtenue avec le code suivant :
=== ":simple-python: Python"
Dépendances :
- `environs` (>=14.1)
```python
import hmac
from urllib.parse import urlencode
from environs import Env
env = Env()
env.read_env()
key = env.str("HMAC_KEY").encode()
data = {
"client_id": 15,
"third_party_app": "discord",
"privacy_link": "https://discord.com/privacy/",
"username": "Brian",
"callback_url": "https://bot.ae.utbm.fr/callback/123456789/",
}
urlencoded = urlencode(data)
data["signature"] = hmac.digest(key, urlencoded.encode(), "sha512").hex()
# URL a fournir à l'utilisateur pour son authentification
user_url = f"https://ae.ubtm.fr/api-link/auth/?{urlencode(data)}"
```
=== ":simple-rust: Rust"
Dépendances :
- `hmac` (>=0.12.1)
- `url` (>=2.5.7, features `serde`)
- `serde` (>=1.0.228, features `derive`)
- `serde_urlencoded` (>=0.7.1)
- `sha2` (>=0.10.9)
- `dotenvy` (>= 0.15)
```rust
use hmac::{Mac, SimpleHmac};
use serde::Serialize;
use sha2::Sha512;
use url::Url;
#[derive(Serialize, Debug)]
struct UrlData<'a> {
client_id: u32,
third_party_app: &'a str,
privacy_link: Url,
username: &'a str,
callback_url: Url,
}
impl<'a> UrlData<'a> {
pub fn signature(&self, key: &[u8]) -> CtOutput<SimpleHmac<Sha512>> {
let urlencoded = serde_urlencoded::to_string(self).unwrap();
SimpleHmac::<Sha512>::new_from_slice(key)
.unwrap()
.chain_update(urlencoded.as_bytes())
.finalize()
}
}
impl Into<Url> for UrlData<'_> {
fn into(self) -> Url {
let key = std::env::var("HMAC_KEY").unwrap();
let mut url = Url::parse("http://ae.utbm.fr/api-link/auth/").unwrap();
url.set_query(Some(
format!(
"{}&signature={:x}",
serde_urlencoded::to_string(&self).unwrap(),
self.signature(key.as_bytes()).into_bytes()
)
.as_str(),
));
url
}
}
fn main() {
dotenvy::dotenv().expect("Couldn't load env");
let data = UrlData {
client_id: 1,
third_party_app: "discord",
privacy_link: "https://discord.com/privacy/".parse().unwrap(),
username: "Brian",
callback_url: "https://bot.ae.utbm.fr/callback/123456789/"
.parse()
.unwrap(),
};
let url: Url = data.into();
println!("{:?}", url);
}
```
???Example "Vérification de la signature de la réponse"
Les données utilisateur peuvent ressembler à :
```json
{
"user": {
"display_name": "Matthieu Vincent",
"profile_url": "/user/380/",
"profile_pict": "/static/core/img/unknown.jpg",
"id": 380,
"nick_name": None,
"first_name": "Matthieu",
"last_name": "Vincent",
},
"signature": "3802a280fbb01bd9fetc."
}
```
Vous pouvez vérifier la signature ainsi :
```python
import hmac
from urllib.parse import urlencode
from environs import Env
env = Env()
env.read_env()
def is_signature_valid(user_data: dict, signature: str) -> bool:
key = env.str("HMAC_KEY").encode()
urlencoded = urlencode(user_data)
return hmac.compare_digest(
hmac.digest(key, urlencoded.encode(), "sha512").hex(),
signature,
)
post_data = <récupération des données POST>
print(
"signature valide :",
is_signature_valid(post_data["user"], post_data["signature"])
)
```
!!!Warning
Vous devez impérativement vérifier la signature
des données de la requête de callback !
Ne pas vérifier la signature permet à n'importe quel acteur
tierce malveillant de vous appeler sur votre callback.
Ce serait une faille de sécurité majeure de votre côté.
Si l'équipe informatique se rend compte que vous ne le faites pas,
elle se réserve le droit de suspendre votre application,
immédiatement et sans préavis.
+7 -6
View File
@@ -112,7 +112,7 @@ cf. [HTTP persistant connection (wikipedia)](https://en.wikipedia.org/wiki/HTTP_
Voici quelques exemples : Voici quelques exemples :
=== ":simple-python: Python (requests)" === "Python (requests)"
Dépendances : Dépendances :
@@ -132,7 +132,7 @@ Voici quelques exemples :
print(response.json()) print(response.json())
``` ```
=== ":simple-python: Python (aiohttp)" === "Python (aiohttp)"
Dépendances : Dépendances :
@@ -147,18 +147,19 @@ 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/", base_url="https://ae.utbm.fr/api/", headers={"X-APIKey": env.str("API_KEY")}
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())
``` ```
=== ":simple-javascript: Javascript (axios)" === "Javascript (axios)"
Dépendances : Dépendances :
@@ -178,7 +179,7 @@ Voici quelques exemples :
console.log(await instance.get("club/1").json()); console.log(await instance.get("club/1").json());
``` ```
=== ":simple-rust: Rust (reqwest)" === "Rust (reqwest)"
Dépendances : Dépendances :
+7 -11
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,11 +319,9 @@ 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"
``` ```
@@ -342,9 +340,7 @@ from core.views.mixins import UseFragmentsMixin
class FooCompositeFormView(UseFragmentsMixin, TemplateView): class FooCompositeFormView(UseFragmentsMixin, TemplateView):
fragments = { fragments = {"create_fragment": FooCreateFragment}
"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):
+14 -12
View File
@@ -123,6 +123,7 @@ 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):
# ... # ...
@@ -192,20 +193,21 @@ 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
@@ -219,12 +221,13 @@ 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):
@@ -263,6 +266,7 @@ 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"
@@ -324,8 +328,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"))
@@ -370,6 +374,7 @@ 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"))
@@ -378,7 +383,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,
@@ -386,7 +391,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,
@@ -529,10 +534,7 @@ 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( return self.filter(models.Q(is_moderated=True) | models.Q(author=user))
models.Q(is_moderated=True)
| models.Q(author=user)
)
class News(models.Model): class News(models.Model):
+37
View File
@@ -0,0 +1,37 @@
#
# Copyright 2022
# - Maréchal <thgirod@hotmail.com
#
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
# http://ae.utbm.fr.
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License a published by the Free Software
# Foundation; either version 3 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA.
class PaymentResultConverter:
"""Converter used for url mapping of the `eboutic.views.payment_result` view.
It's meant to build an url that can match
either `/eboutic/pay/success/` or `/eboutic/pay/failure/`
but nothing else.
"""
regex = "(success|failure)"
def to_python(self, value):
return str(value)
def to_url(self, value):
return str(value)
@@ -61,10 +61,10 @@ document.addEventListener("alpine:init", () => {
// biome-ignore lint/style/useNamingConvention: api is in snake_case // biome-ignore lint/style/useNamingConvention: api is in snake_case
path: { basket_id: basket.id }, path: { basket_id: basket.id },
}); });
if (res.response.ok) { if (res.response?.ok) {
this.data = res.data as Record<string, string>; this.data = res.data as Record<string, string>;
this.isCbAvailable = true; this.isCbAvailable = true;
} else if (res.response.status === 410) { } else if (res.response?.status === 410) {
// The basket is expired, so no payment method should be available at all. // The basket is expired, so no payment method should be available at all.
// This shouldn't happen, because we don't send the request // This shouldn't happen, because we don't send the request
// when the timeout is passed, but we are better safe than sorry // when the timeout is passed, but we are better safe than sorry
+2 -2
View File
@@ -24,7 +24,7 @@
from django.urls import path, register_converter from django.urls import path, register_converter
from core.converters import ResultConverter from eboutic.converters import PaymentResultConverter
from eboutic.views import ( from eboutic.views import (
BillingInfoFormFragment, BillingInfoFormFragment,
EbouticCheckout, EbouticCheckout,
@@ -34,7 +34,7 @@ from eboutic.views import (
payment_result, payment_result,
) )
register_converter(ResultConverter, "res") register_converter(PaymentResultConverter, "res")
urlpatterns = [ urlpatterns = [
# Subscription views # Subscription views
+1 -1
View File
@@ -1,6 +1,5 @@
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from cryptography.utils import cached_property
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.mixins import ( from django.contrib.auth.mixins import (
LoginRequiredMixin, LoginRequiredMixin,
@@ -12,6 +11,7 @@ from django.db import transaction
from django.db.models import QuerySet from django.db.models import QuerySet
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView from django.views.generic import DetailView, ListView
from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateView from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateView
+5 -96
View File
@@ -6,7 +6,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-06-10 20:18+0200\n" "POT-Creation-Date: 2026-08-21 14:10+0200\n"
"PO-Revision-Date: 2016-07-18\n" "PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n" "Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -35,10 +35,6 @@ msgstr ""
"True si gardé à jour par le biais d'un fournisseur externe de domains " "True si gardé à jour par le biais d'un fournisseur externe de domains "
"toxics, False sinon" "toxics, False sinon"
#: api/admin.py
msgid "Reset HMAC key"
msgstr "Réinitialiser la clef HMAC"
#: api/admin.py #: api/admin.py
#, python-format #, python-format
msgid "" msgid ""
@@ -52,23 +48,6 @@ msgstr ""
msgid "Revoke selected API keys" msgid "Revoke selected API keys"
msgstr "Révoquer les clefs d'API sélectionnées" msgstr "Révoquer les clefs d'API sélectionnées"
#: api/forms.py
msgid "I have read and I accept the terms and conditions of use"
msgstr "J'ai lu et j'accepte les conditions générales d'utilisation."
#: api/forms.py
msgid "You must approve the terms and conditions of use."
msgstr "Vous devez approuver les conditions générales d'utilisation."
#: api/forms.py
msgid "You must confirm that this is your username."
msgstr "Vous devez confirmer que c'est bien votre nom d'utilisateur."
#: api/forms.py
#, python-format
msgid "I confirm that %(username)s is my username on %(app)s"
msgstr "Je confirme que %(username)s est mon nom d'utilisateur sur %(app)s"
#: api/models.py club/models.py com/models.py counter/models.py forum/models.py #: api/models.py club/models.py com/models.py counter/models.py forum/models.py
msgid "name" msgid "name"
msgstr "nom" msgstr "nom"
@@ -89,10 +68,6 @@ msgstr "permissions du client"
msgid "Specific permissions for this api client." msgid "Specific permissions for this api client."
msgstr "Permissions spécifiques pour ce client d'API" msgstr "Permissions spécifiques pour ce client d'API"
#: api/models.py
msgid "HMAC Key"
msgstr "Clef HMAC"
#: api/models.py #: api/models.py
msgid "api client" msgid "api client"
msgstr "client d'api" msgstr "client d'api"
@@ -122,76 +97,6 @@ msgstr "clef d'api"
msgid "api keys" msgid "api keys"
msgstr "clefs d'api" msgstr "clefs d'api"
#: api/templates/api/third_party/auth.jinja
msgid "Confidentiality"
msgstr "Confidentialité"
#: api/templates/api/third_party/auth.jinja
#, python-format
msgid ""
"By ticking this box and clicking on the send button, you acknowledge and "
"agree to provide %(app)s with your first name, last name, nickname and any "
"other information that was the third party app was explicitly authorized to "
"fetch and that it must have acknowledged to you, in a complete and accurate "
"manner."
msgstr ""
"En cochant cette case et en cliquant sur le bouton « Envoyer », vous "
"reconnaissez et acceptez de fournir à %(app)s votre prénom, nom, pseudonyme "
"et toute autre information que l'application tierce a été explicitement "
"autorisée à récupérer et qu'elle doit vous avoir communiqué de manière "
"complète et exacte."
#: api/templates/api/third_party/auth.jinja
#, python-format
msgid ""
"The privacy policies of <a href=\"%(privacy_link)s\">%(app)s</a> and of <a "
"href=\"%(sith_cgu_link)s\">the Students' Association</a> applies as soon as "
"the form is submitted."
msgstr ""
"Les politiques de confidentialité de <a href=\"%(privacy_link)s\">%(app)s</"
"a> et de <a href=\"%(sith_cgu_link)s\">l'Association des Etudiants</a> "
"s'appliquent dès la soumission du formulaire."
#: api/templates/api/third_party/auth.jinja
msgid "Confirmation of identity"
msgstr "Confirmation d'identité"
#: api/views.py
msgid "The data provided for authentication is incorrect"
msgstr "Les données fournies pour l'authentification sont incorrectes."
#: api/views.py
msgid ""
"The signature is incorrect. We cannot ensure the provenance of the request."
msgstr ""
"La signature est incorrecte. Nous ne pouvons pas garantir l'authenticité de "
"la requête."
#: api/views.py
#, python-format
msgid ""
"You are going to link your AE account and your %(app)s account. Continue "
"only if this page was opened from %(app)s."
msgstr ""
"Vous allez lier votre compte AE et votre compte %(app)s. Poursuivez "
"uniquement si cette page a été ouverte depuis %(app)s."
#: api/views.py
msgid "You have been successfully authenticated. You can now close this page."
msgstr ""
"Vous avez été authentifié avec succès. Vous pouvez maintenant fermer cette "
"page."
#: api/views.py
msgid ""
"Your authentication on the AE website was successful, but an error happened "
"during the interaction with the third-party application. Please contact the "
"managers of the latter."
msgstr ""
"Votre authentification sur le site AE a fonctionné, mais une erreur est "
"arrivée durant l'interaction avec l'application tierce. Veuillez contacter "
"les responsables de cette dernière."
#: club/forms.py #: club/forms.py
msgid "Users to add" msgid "Users to add"
msgstr "Utilisateurs à ajouter" msgstr "Utilisateurs à ajouter"
@@ -5064,6 +4969,10 @@ msgstr "Ne pas voter"
msgid "This user has already commented on this UE" msgid "This user has already commented on this UE"
msgstr "Cet utilisateur a déjà commenté cette UE" msgstr "Cet utilisateur a déjà commenté cette UE"
#: pedagogy/forms.py
msgid "UE comment can't be empty."
msgstr "Un commentaire d'UE ne peut pas être vide."
#: pedagogy/forms.py #: pedagogy/forms.py
msgid "Accepted reports" msgid "Accepted reports"
msgstr "Signalements acceptés" msgstr "Signalements acceptés"
-3
View File
@@ -71,7 +71,6 @@ nav:
- API: - API:
- Développement: tutorial/api/dev.md - Développement: tutorial/api/dev.md
- Connexion à l'API: tutorial/api/connect.md - Connexion à l'API: tutorial/api/connect.md
- Liaison avec le compte AE: tutorial/api/account-link.md
- Etransactions: tutorial/etransaction.md - Etransactions: tutorial/etransaction.md
- How-to: - How-to:
- L'ORM de Django: howto/querysets.md - L'ORM de Django: howto/querysets.md
@@ -95,8 +94,6 @@ nav:
- reference/api/hashers.md - reference/api/hashers.md
- reference/api/models.md - reference/api/models.md
- reference/api/perms.md - reference/api/perms.md
- reference/api/schemas.md
- reference/api/views.md
- club: - club:
- reference/club/models.md - reference/club/models.md
- reference/club/views.md - reference/club/views.md
+859 -448
View File
File diff suppressed because it is too large Load Diff
+16 -13
View File
@@ -33,26 +33,28 @@
"@types/cytoscape-cxtmenu": "^3.4.5", "@types/cytoscape-cxtmenu": "^3.4.5",
"@types/cytoscape-klay": "^3.1.5", "@types/cytoscape-klay": "^3.1.5",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"rollup-plugin-visualizer": "^7.0.1", "@types/node": "^26.2.0",
"typescript": "^6.0.3", "rollup-plugin-visualizer": "^7.1.1",
"vite": "^8.1.0" "@typescript/native": "npm:typescript@^7.0.2",
"typescript": "npm:@typescript/typescript6@^6.0.2",
"vite": "^8.2.2"
}, },
"dependencies": { "dependencies": {
"@alpinejs/sort": "^3.15.12", "@alpinejs/sort": "^3.16.2",
"@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0", "@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0",
"@floating-ui/dom": "^1.7.6", "@floating-ui/dom": "^1.8.0",
"@fortawesome/fontawesome-free": "^7.2.0", "@fortawesome/fontawesome-free": "^7.3.1",
"@fullcalendar/core": "^6.1.21", "@fullcalendar/core": "^6.1.21",
"@fullcalendar/daygrid": "^6.1.21", "@fullcalendar/daygrid": "^6.1.21",
"@fullcalendar/icalendar": "^6.1.21", "@fullcalendar/icalendar": "^6.1.21",
"@fullcalendar/list": "^6.1.21", "@fullcalendar/list": "^6.1.21",
"@sentry/browser": "^10.60.0", "@sentry/browser": "^10.70.0",
"@zip.js/zip.js": "^2.8.26", "@zip.js/zip.js": "^2.8.57",
"3d-force-graph": "^1.80.0", "3d-force-graph": "^1.80.0",
"alpinejs": "^3.15.12", "alpinejs": "^3.16.2",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"country-flag-emoji-polyfill": "^0.1.8", "country-flag-emoji-polyfill": "^0.1.10",
"cytoscape": "^3.34.0", "cytoscape": "^3.34.1",
"cytoscape-cxtmenu": "^3.5.0", "cytoscape-cxtmenu": "^3.5.0",
"cytoscape-klay": "^3.1.4", "cytoscape-klay": "^3.1.4",
"d3-force-3d": "^3.0.6", "d3-force-3d": "^3.0.6",
@@ -63,8 +65,9 @@
"js-cookie": "^3.0.8", "js-cookie": "^3.0.8",
"lit-html": "^3.3.3", "lit-html": "^3.3.3",
"native-file-system-adapter": "^3.0.1", "native-file-system-adapter": "^3.0.1",
"three": "^0.184.0", "temporal-polyfill": "^1.0.4",
"three": "^0.185.1",
"three-spritetext": "^1.10.0", "three-spritetext": "^1.10.0",
"tom-select": "^2.6.1" "tom-select": "^2.6.2"
} }
} }
+29 -10
View File
@@ -22,6 +22,8 @@
# #
from django import forms from django import forms
from django.contrib.staticfiles.storage import staticfiles_storage
from django.forms.fields import ValidationError
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from core.models import User from core.models import User
@@ -30,7 +32,7 @@ from pedagogy.models import UE, UEComment, UECommentReport
class UEForm(forms.ModelForm): class UEForm(forms.ModelForm):
"""Form handeling creation and edit of an UE.""" """Form handling creation and edit of an UE."""
class Meta: class Meta:
model = UE model = UE
@@ -68,22 +70,28 @@ class UEForm(forms.ModelForm):
self.fields["author"].initial = author_id self.fields["author"].initial = author_id
class StarList(forms.NumberInput): class StarList(forms.RadioSelect):
template_name = "pedagogy/starlist.jinja" template_name = "pedagogy/starlist.jinja"
def __init__(self, nubmer_of_stars=0): def __init__(self, number_of_stars=0, attrs=None):
super().__init__(None) super().__init__(
self.number_of_stars = nubmer_of_stars attrs=attrs,
choices=(
(choice, _("Do not vote") if choice == -1 else choice)
for choice in range(-1, number_of_stars)
),
)
def get_context(self, name, value, attrs): def get_context(self, name, value, attrs):
context = super().get_context(name, value, attrs) context = super().get_context(name, value, attrs)
context["number_of_stars"] = range(0, self.number_of_stars) context["statics"] = {
context["translations"] = {"do_not_vote": _("Do not vote")} "css": staticfiles_storage.url("pedagogy/css/starlist.scss"),
}
return context return context
class UECommentForm(forms.ModelForm): class UECommentForm(forms.ModelForm):
"""Form handeling creation and edit of an UEComment.""" """Form handling creation and edit of an UEComment."""
class Meta: class Meta:
model = UEComment model = UEComment
@@ -129,11 +137,22 @@ class UECommentForm(forms.ModelForm):
), ),
) )
# Ensure that at least one value is exists
if (
all(
grade == -1
for key, grade in self.cleaned_data.items()
if key.startswith("grade_")
)
and not self.cleaned_data["comment"]
):
raise ValidationError(message=_("UE comment can't be empty."))
return self.cleaned_data return self.cleaned_data
class UECommentReportForm(forms.ModelForm): class UECommentReportForm(forms.ModelForm):
"""Form handeling creation and edit of an UEReport.""" """Form handling creation and edit of an UEReport."""
class Meta: class Meta:
model = UECommentReport model = UECommentReport
@@ -153,7 +172,7 @@ class UECommentReportForm(forms.ModelForm):
class UECommentModerationForm(forms.Form): class UECommentModerationForm(forms.Form):
"""Form handeling bulk comment deletion.""" """Form handling bulk comment deletion."""
accepted_reports = forms.ModelMultipleChoiceField( accepted_reports = forms.ModelMultipleChoiceField(
UECommentReport.objects.all(), UECommentReport.objects.all(),
@@ -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")
) )
+2 -5
View File
@@ -1,3 +1,4 @@
@import "core/static/core/devices";
@import "core/static/core/colors"; @import "core/static/core/colors";
@@ -7,10 +8,6 @@ $pedagogy-hover-blue: #0e97ce;
$pedagogy-light-blue: #caf0ff; $pedagogy-light-blue: #caf0ff;
$pedagogy-white-text: #f0f0f0; $pedagogy-white-text: #f0f0f0;
$small-devices: 576px;
$medium-devices: 768px;
$large-devices: 992px;
.pedagogy { .pedagogy {
&.star-not-checked { &.star-not-checked {
color: #f7f7f7; color: #f7f7f7;
@@ -256,7 +253,7 @@ $large-devices: 992px;
.ue-details-container { .ue-details-container {
display: grid; display: grid;
grid-template-columns: 150px 100px auto; grid-template-columns: 150px 130px auto;
grid-template-rows: 156px 1fr; grid-template-rows: 156px 1fr;
grid-template-areas: grid-template-areas:
"grade grade-stars ue-infos" "grade grade-stars ue-infos"
@@ -0,0 +1,38 @@
fieldset.star {
border: none;
label {
display: inline;
cursor: pointer;
}
.checked {
color: orange;
}
.unchecked {
color: gray;
}
.unchecked.hovered {
color: #FFD700;
}
.removed {
color: red;
}
input[type="radio"] {
display: none;
}
label:first-child {
margin-right: 10px;
}
&:has(input:required:invalid) {
border: 1px solid;
border-color: #c00000;
}
}
+50 -56
View File
@@ -1,58 +1,52 @@
<div> <link-once rel="stylesheet" type="text/css" href="{{ statics.css }}" defer></link-once>
<style> <fieldset
.checked { class="star"
color : orange; x-data="{ 'value': {{ widget.value.0 }}, 'hover': -1 }"
} @mouseover.outside="hover = -1"
.unchecked { {% include "django/forms/widgets/attrs.html" %}
color : gray; >
} {# Do not vote button #}
.star input[type="radio"] { <label
display : none; @mouseover="hover = -1"
} for="{{ widget.optgroups.0.1.0.attrs.id }}"
.star { >
display: inline; <input
} type="radio"
name="{{ widget.name }}"
</style> value="{{ widget.optgroups.0.1.0.value }}"
@click="value = -1"
{# Do not vote button #} {% with widget=widget.optgroups.0.1.0 %}
<label class="star"> {% include "django/forms/widgets/attrs.html" %}
<input type="radio" name="{{ widget.name }}" value="-1" onclick=' {% endwith %}
var stars = document.getElementsByClassName("{{ widget.name }}"); >
for (var i = 0; i < stars.length; i++){ <i class="fa fa-times-circle"></i>
var attrs = stars[i].getAttribute("class"); {{ widget.optgroups.0.1.0.label }}
attrs = attrs.replace("unchecked", "");
attrs = attrs.replace("checked", "");
stars[i].setAttribute("class", attrs + " unchecked");
}
' checked>
<span class="fa fa-times-circle"> {{ translations.do_not_vote }}</span>
</label>
{# Star widget #}
{% for i in number_of_stars %}
<label class="star">
<input type="radio" name="{{ widget.name }}" value="{{ forloop.counter0 }}" onclick='
var stars = document.getElementsByClassName("{{ widget.name }}");
for (var i = 0; i < stars.length; i++){
var attrs = stars[i].getAttribute("class");
attrs = attrs.replace("unchecked", "");
attrs = attrs.replace("checked", "");
if (i > {{ forloop.counter0 }}){
stars[i].setAttribute("class", attrs + " unchecked");
} else {
stars[i].setAttribute("class", attrs + " checked");
}
}
'>
<i class="{{ widget.name }} fa fa-star unchecked"></i>
</label> </label>
{% endfor %}
{# Restaure previous (-1 is default) #} {# Star widget #}
<script type="text/javascript"> {% for opt in widget.optgroups|slice:"1:" %}
document.querySelector("input[name='{{ widget.name }}'][value='{{ widget.value }}']").click() <label
</script> @mouseover="hover = {{ opt.1.0.value }}"
for="{{ opt.1.0.attrs.id }}"
</div> >
<input
type="radio"
name="{{ widget.name }}"
value="{{ opt.1.0.value }}"
@click="value = {{ opt.1.0.value }}"
{% with widget=opt.1.0 %}
{% include "django/forms/widgets/attrs.html" %}
{% endwith %}
>
<i
class="{{ widget.name }} fa fa-star"
:class="{
'checked': value >= {{ opt.1.0.value }},
'unchecked': value < {{ opt.1.0.value }},
'hovered': hover >= {{ opt.1.0.value }},
'removed': hover >= 0 && value >= {{ opt.1.0.value }} && hover < {{ opt.1.0.value }},
}"
></i>
</label>
{% endfor %}
</fieldset>
+2 -1
View File
@@ -89,13 +89,14 @@
<p>{% trans %}You already posted a comment on this UE. If you want to comment again, please modify or delete your previous comment.{% endtrans %}</p> <p>{% trans %}You already posted a comment on this UE. If you want to comment again, please modify or delete your previous comment.{% endtrans %}</p>
</div> </div>
{% elif user.has_perm("pedagogy.add_uecomment") %} {% elif user.has_perm("pedagogy.add_uecomment") %}
<details class="accordion" id="leave_comment"> <details class="accordion" id="leave_comment" {% if form.errors %}open{%endif%}>
<summary>{% trans %}Leave comment{% endtrans %}</summary> <summary>{% trans %}Leave comment{% endtrans %}</summary>
<div class="accordion-content"> <div class="accordion-content">
<form action="{{ url('pedagogy:ue_detail', ue_id=object.id) }}" method="post" enctype="multipart/form-data"> <form action="{{ url('pedagogy:ue_detail', ue_id=object.id) }}" method="post" enctype="multipart/form-data">
{% csrf_token %} {% csrf_token %}
<div class="leave-comment-grid-container"> <div class="leave-comment-grid-container">
<div class="form-stars"> <div class="form-stars">
{{ form.non_field_errors() }}
{{ form.author.errors }} {{ form.author.errors }}
{{ form.ue.errors }} {{ form.ue.errors }}
+18
View File
@@ -339,6 +339,24 @@ class TestUVCommentCreationAndDisplay(TestCase):
response = self.client.get(self.ue_url) response = self.client.get(self.ue_url)
self.assertContains(response, text="Superbe UE") self.assertContains(response, text="Superbe UE")
def test_create_ue_empty_comment_fail(self):
self.client.force_login(self.tutu)
response = self.client.post(
self.ue_url,
{
"author": self.tutu.id,
"ue": UE.objects.get(code="PA00").id,
"grade_global": -1,
"grade_utility": -1,
"grade_interest": -1,
"grade_teaching": -1,
"grade_work_load": -1,
"comment": "",
},
)
assert response.status_code == 200
self.assertInHTML("Un commentaire d'UE ne peut pas être vide.", response.text)
def test_create_ue_comment_unauthorized_fail(self): def test_create_ue_comment_unauthorized_fail(self):
nb_comments = self.ue.comments.count() nb_comments = self.ue.comments.count()
# Test with anonymous user # Test with anonymous user
+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"),
"",
),
) )
+21 -21
View File
@@ -19,23 +19,23 @@ authors = [
license = { text = "GPL-3.0-only" } license = { text = "GPL-3.0-only" }
requires-python = "<4.0,>=3.12" requires-python = "<4.0,>=3.12"
dependencies = [ dependencies = [
"django>=5.2.15,<6.0.0", "django>=5.2.17,<6.0.0",
"django-ninja>=1.6.2,<2.0.0", "django-ninja>=1.6.3,<2.0.0",
"django-ninja-extra>=0.31.5", "django-ninja-extra>=0.31.7",
"Pillow>=12.2.0,<13.0.0", "Pillow>=12.3.0,<13.0.0",
"aemark>=0.1.1", "aemark>=0.1.1",
"django-jinja<3.0.0,>=2.11.0", "django-jinja<3.0.0,>=2.11.0",
"cryptography>=49.0.0,<50.0.0", "cryptography>=50.0.0,<51.0.0",
"django-phonenumber-field>=8.4.0,<9.0.0", "django-phonenumber-field>=8.5.0,<9.0.0",
"phonenumbers>=9.0.33,<10.0.0", "phonenumbers>=9.0.37,<10.0.0",
"reportlab>=5.0.0,<6.0.0", "reportlab>=5.0.1,<6.0.0",
"django-haystack>=3.4.0,<4.0.0", "django-haystack>=3.4.0,<4.0.0",
"xapian-haystack>=4.0.0,<5.0.0", "xapian-haystack>=4.0.0,<5.0.0",
"libsass>=0.23.0,<1.0.0", "libsass>=0.23.0,<1.0.0",
"django-ordered-model>=3.7.4,<4.0.0", "django-ordered-model>=3.7.4,<4.0.0",
"django-simple-captcha>=0.6.3,<1.0.0", "django-simple-captcha>=0.7.0,<1.0.0",
"python-dateutil>=2.9.0.post0,<3.0.0.0", "python-dateutil>=2.9.0.post0,<3.0.0.0",
"sentry-sdk>=2.63.0,<3.0.0", "sentry-sdk>=2.68.0,<3.0.0",
"jinja2>=3.1.6,<4.0.0", "jinja2>=3.1.6,<4.0.0",
"django-countries>=9.0.0,<10.0.0", "django-countries>=9.0.0,<10.0.0",
"dict2xml>=1.7.8,<2.0.0", "dict2xml>=1.7.8,<2.0.0",
@@ -43,9 +43,9 @@ dependencies = [
"tomli>=2.4.1,<3.0.0", "tomli>=2.4.1,<3.0.0",
"django-honeypot>=1.3.0,<2", "django-honeypot>=1.3.0,<2",
"pydantic-extra-types>=2.11.1,<3.0.0", "pydantic-extra-types>=2.11.1,<3.0.0",
"ical>=12.0.0,<14.0.0", "ical>=12.0.0",
"redis[hiredis]>=6.4.0,<9.0.0", "redis[hiredis]>=6.4.0,<9.0.0",
"environs[django]>=15.0.1,<16", "environs[django]>=15.1.0,<16",
"requests>=2.34.2,<3.0.0", "requests>=2.34.2,<3.0.0",
"honcho>=2.0.0", "honcho>=2.0.0",
"psutil>=7.2.2,<8.0.0", "psutil>=7.2.2,<8.0.0",
@@ -63,28 +63,28 @@ prod = [
"psycopg[c]>=3.3.4,<4.0.0", "psycopg[c]>=3.3.4,<4.0.0",
] ]
dev = [ dev = [
"django-debug-toolbar>=7.0.0,<8", "django-debug-toolbar>=7.1.1,<8",
"ipython>=9.14.1,<10.0.0", "ipython>=9.16.1,<10.0.0",
"pre-commit>=4.6.0,<5.0.0", "pre-commit>=4.6.0,<5.0.0",
"ruff>=0.15.19,<1.0.0", "ruff>=0.16.0,<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.37.0,<41.0.0",
"rjsmin>=1.2.5,<2.0.0", "rjsmin>=1.2.5,<2.0.0",
] ]
tests = [ tests = [
"freezegun>=1.5.5,<2.0.0", "freezegun>=1.5.5,<2.0.0",
"pytest>=9.1.1,<10.0.0", "pytest>=9.1.1,<10.0.0",
"pytest-cov>=7.1.0,<8.0.0", "pytest-cov>=7.1.0,<8.0.0",
"pytest-django>=4.12.0,<5.0.0", "pytest-django>=4.14.0,<5.0.0",
"model-bakery>=1.24.0,<2.0.0", "model-bakery>=1.24.0,<2.0.0",
"beautifulsoup4>=4.15.0,<5", "beautifulsoup4>=4.15.0,<5",
"lxml>=6.1.1,<7", "lxml>=6.1.2,<7",
] ]
docs = [ docs = [
"mkdocs>=1.6.1,<2.0.0", "mkdocs>=1.6.1,<2.0.0",
"mkdocs-material>=9.7.6,<10.0.0", "mkdocs-material>=9.7.7,<10.0.0",
"mkdocstrings>=1.0.4,<2.0.0", "mkdocstrings>=1.0.6,<2.0.0",
"mkdocstrings-python>=2.0.4,<3.0.0", "mkdocstrings-python>=2.0.7,<3.0.0",
"mkdocs-include-markdown-plugin>=7.3.0,<8.0.0", "mkdocs-include-markdown-plugin>=7.3.0,<8.0.0",
] ]
+3 -3
View File
@@ -1,4 +1,4 @@
from typing import Any, Literal from typing import Literal
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
@@ -20,7 +20,7 @@ from api.permissions import (
IsRoot, IsRoot,
) )
from core.models import Notification, User from core.models import Notification, User
from core.schemas import UploadedImage from core.schemas import UploadedImage, ValidationErrorSchema
from sas.models import Album, PeoplePictureRelation, Picture from sas.models import Album, PeoplePictureRelation, Picture
from sas.schemas import ( from sas.schemas import (
AlbumAutocompleteSchema, AlbumAutocompleteSchema,
@@ -106,7 +106,7 @@ class PicturesController(ControllerBase):
response={ response={
200: None, 200: None,
409: dict[Literal["detail"], dict[str, list[str]]], 409: dict[Literal["detail"], dict[str, list[str]]],
422: dict[Literal["detail"], list[dict[str, Any]]], 422: ValidationErrorSchema,
}, },
url_name="upload_picture", url_name="upload_picture",
) )
@@ -1,7 +1,7 @@
import type { TomOption } from "tom-select/dist/types/types"; import type { TomOption } from "tom-select/src/types";
import type { escape_html } from "tom-select/dist/types/utils"; import type { escape_html } from "tom-select/src/utils";
import { AjaxSelect } from "#core:core/components/ajax-select-base.ts"; import { AjaxSelect } from "#core:core/components/ajax-select-base";
import { registerComponent } from "#core:utils/web-components.ts"; import { registerComponent } from "#core:utils/web-components";
import { type AlbumAutocompleteSchema, albumAutocompleteAlbum } from "#openapi"; import { type AlbumAutocompleteSchema, albumAutocompleteAlbum } from "#openapi";
@registerComponent("album-ajax-select") @registerComponent("album-ajax-select")
@@ -9,7 +9,7 @@ document.addEventListener("alpine:init", () => {
async downloadZip() { async downloadZip() {
this.isDownloading = true; this.isDownloading = true;
const bar = this.$refs.progress; const bar = this.$refs.progress as HTMLProgressElement;
bar.value = 0; bar.value = 0;
bar.max = this.downloadPictures.length; bar.max = this.downloadPictures.length;
@@ -31,10 +31,11 @@ document.addEventListener("alpine:init", () => {
await Promise.all( await Promise.all(
this.downloadPictures.map((p: PictureSchema) => { this.downloadPictures.map((p: PictureSchema) => {
const imgName = `${p.album.name}/IMG_${p.id}_${p.date.replace(/[:-]/g, "_")}${p.name.slice(p.name.lastIndexOf("."))}`; const dateStr = (p.date as string).replace(/[:-]/g, "_");
const imgName = `${p.album.name}/IMG_${p.id}_${dateStr}${p.name.slice(p.name.lastIndexOf("."))}`;
return zipWriter.add(imgName, new HttpReader(p.full_size_url), { return zipWriter.add(imgName, new HttpReader(p.full_size_url), {
level: 9, level: 9,
lastModDate: new Date(p.date), lastModDate: new Date(p.date as string),
onstart: incrementProgressBar, onstart: incrementProgressBar,
}); });
}), }),
+10 -8
View File
@@ -57,14 +57,16 @@ document.addEventListener("alpine:init", () => {
async init() { async init() {
const pictures = await this.fetchPictures(); const pictures = await this.fetchPictures();
const groupedAlbums = Object.groupBy(pictures, (i: PictureSchema) => i.album.id); const groupedAlbums = Object.groupBy(pictures, (i) => i.album.id as number);
this.albums = Object.values(groupedAlbums).map((pictures: PictureSchema[]) => { this.albums = Object.values(groupedAlbums as Record<number, PictureSchema[]>).map(
return { (pictures) => {
id: pictures[0].album.id, return {
name: pictures[0].album.name, id: pictures[0].album.id as number,
pictures: pictures, name: pictures[0].album.name,
}; pictures: pictures,
}); };
},
);
this.albums.sort((a: Album, b: Album) => b.id - a.id); this.albums.sort((a: Album, b: Album) => b.id - a.id);
const hash = document.location.hash.replace("#", ""); const hash = document.location.hash.replace("#", "");
if (hash.startsWith("album-")) { if (hash.startsWith("album-")) {
+71 -36
View File
@@ -1,4 +1,5 @@
import type TomSelect from "tom-select"; import type TomSelect from "tom-select";
import type { TomOption } from "tom-select/src/types";
import type { UserAjaxSelect } from "#core:core/components/ajax-select-index"; import type { UserAjaxSelect } from "#core:core/components/ajax-select-index";
import { paginated } from "#core:utils/api"; import { paginated } from "#core:utils/api";
import { History } from "#core:utils/history"; import { History } from "#core:utils/history";
@@ -6,7 +7,6 @@ import {
type IdentifiedUserSchema, type IdentifiedUserSchema,
type ModerationRequestSchema, type ModerationRequestSchema,
type PictureSchema, type PictureSchema,
type PicturesFetchIdentificationsResponse,
type PicturesFetchPicturesData, type PicturesFetchPicturesData,
picturesDeletePicture, picturesDeletePicture,
picturesFetchIdentifications, picturesFetchIdentifications,
@@ -15,6 +15,7 @@ import {
picturesIdentifyUsers, picturesIdentifyUsers,
picturesModeratePicture, picturesModeratePicture,
picturesRotatePicture, picturesRotatePicture,
type SimpleAlbumSchema,
type UserProfileSchema, type UserProfileSchema,
usersidentifiedDeleteRelation, usersidentifiedDeleteRelation,
} from "#openapi"; } from "#openapi";
@@ -23,8 +24,8 @@ import {
* A container for a picture with the users identified on it * A container for a picture with the users identified on it
* able to prefetch its data. * able to prefetch its data.
*/ */
class PictureWithIdentifications { class PictureWithIdentifications implements PictureSchema {
identifications: PicturesFetchIdentificationsResponse = null; identifications?: IdentifiedUserSchema[];
imageLoading = false; imageLoading = false;
identificationsLoading = false; identificationsLoading = false;
moderationLoading = false; moderationLoading = false;
@@ -32,10 +33,43 @@ class PictureWithIdentifications {
compressedUrl: string = ""; compressedUrl: string = "";
thumbUrl: string = ""; thumbUrl: string = "";
fullSizeUrl: string = ""; fullSizeUrl: string = "";
moderationRequests: ModerationRequestSchema[] = null; moderationRequests: ModerationRequestSchema[] = [];
owner: UserProfileSchema;
// biome-ignore-start lint/style/useNamingConvention: PictureSchema has CamelCase properties
sas_url: string;
full_size_url: string;
compressed_url: string;
thumb_url: string;
album: SimpleAlbumSchema;
report_url: string;
edit_url: string;
name: string;
date?: string | undefined;
updated_at: string;
size?: number | undefined;
is_moderated?: boolean | undefined;
asked_for_removal?: boolean | undefined;
// biome-ignore-end lint/style/useNamingConvention: PictureSchema has CamelCase properties
constructor(picture: PictureSchema) { constructor(picture: PictureSchema) {
Object.assign(this, picture); if (picture.id === null || picture.id === undefined) {
throw new Error("picture id expected a value");
}
this.owner = picture.owner;
this.sas_url = picture.sas_url;
this.full_size_url = picture.full_size_url;
this.compressed_url = picture.compressed_url;
this.thumb_url = picture.thumb_url;
this.album = picture.album;
this.report_url = picture.report_url;
this.edit_url = picture.edit_url;
this.name = picture.name;
this.date = picture.date;
this.updated_at = picture.updated_at;
this.size = picture.size;
this.is_moderated = picture.is_moderated;
this.asked_for_removal = picture.asked_for_removal;
this.id = picture.id;
this.compressedUrl = picture.compressed_url; this.compressedUrl = picture.compressed_url;
this.thumbUrl = picture.thumb_url; this.thumbUrl = picture.thumb_url;
this.fullSizeUrl = picture.full_size_url; this.fullSizeUrl = picture.full_size_url;
@@ -69,12 +103,13 @@ class PictureWithIdentifications {
return; return;
} }
this.identificationsLoading = true; this.identificationsLoading = true;
this.identifications = ( this.identifications =
await picturesFetchIdentifications({ (
// biome-ignore lint/style/useNamingConvention: api is in snake_case await picturesFetchIdentifications({
path: { picture_id: this.id }, // biome-ignore lint/style/useNamingConvention: api is in snake_case
}) path: { picture_id: this.id },
).data; })
).data ?? [];
this.identificationsLoading = false; this.identificationsLoading = false;
} }
@@ -88,12 +123,13 @@ class PictureWithIdentifications {
return; return;
} }
this.moderationLoading = true; this.moderationLoading = true;
this.moderationRequests = ( this.moderationRequests =
await picturesFetchModerationRequests({ (
// biome-ignore lint/style/useNamingConvention: api is in snake_case await picturesFetchModerationRequests({
path: { picture_id: this.id }, // biome-ignore lint/style/useNamingConvention: api is in snake_case
}) path: { picture_id: this.id },
).data; })
).data ?? [];
this.moderationLoading = false; this.moderationLoading = false;
} }
@@ -103,6 +139,10 @@ class PictureWithIdentifications {
// biome-ignore lint/style/useNamingConvention: api is snake case // biome-ignore lint/style/useNamingConvention: api is snake case
path: { picture_id: this.id, direction: direction }, path: { picture_id: this.id, direction: direction },
}); });
if (!res.data) {
console.error("The data o the rotated were expected, but nothing was returned");
return;
}
// urls returned by the api include a timestamp for cache busting // urls returned by the api include a timestamp for cache busting
this.fullSizeUrl = res.data.full_size_url; this.fullSizeUrl = res.data.full_size_url;
this.compressedUrl = res.data.compressed_url; this.compressedUrl = res.data.compressed_url;
@@ -157,7 +197,7 @@ document.addEventListener("alpine:init", () => {
currentPicture: { currentPicture: {
// biome-ignore lint/style/useNamingConvention: api is in snake_case // biome-ignore lint/style/useNamingConvention: api is in snake_case
is_moderated: true, is_moderated: true,
id: null as number, id: null as number | null,
name: "", name: "",
// biome-ignore lint/style/useNamingConvention: api is in snake_case // biome-ignore lint/style/useNamingConvention: api is in snake_case
display_name: "", display_name: "",
@@ -171,19 +211,19 @@ document.addEventListener("alpine:init", () => {
// biome-ignore lint/style/useNamingConvention: api is in snake_case // biome-ignore lint/style/useNamingConvention: api is in snake_case
created_at: new Date(), created_at: new Date(),
identifications: [] as IdentifiedUserSchema[], identifications: [] as IdentifiedUserSchema[],
}, } as unknown as PictureWithIdentifications,
/** /**
* The picture which will be displayed next if the user press the "next" button * The picture which will be displayed next if the user press the "next" button
**/ **/
nextPicture: null as PictureWithIdentifications, nextPicture: null as PictureWithIdentifications | null,
/** /**
* The picture which will be displayed next if the user press the "previous" button * The picture which will be displayed next if the user press the "previous" button
**/ **/
previousPicture: null as PictureWithIdentifications, previousPicture: null as PictureWithIdentifications | null,
/** /**
* The select2 component used to identify users * The select2 component used to identify users
**/ **/
selector: undefined as UserAjaxSelect, selector: undefined as UserAjaxSelect | undefined,
/** /**
* Error message when a moderation operation fails * Error message when a moderation operation fails
**/ **/
@@ -201,24 +241,18 @@ document.addEventListener("alpine:init", () => {
query: { album_id: config.albumId }, query: { album_id: config.albumId },
} as PicturesFetchPicturesData) } as PicturesFetchPicturesData)
).map(PictureWithIdentifications.fromPicture); ).map(PictureWithIdentifications.fromPicture);
this.selector = this.$refs.search; this.selector = this.$refs.search as UserAjaxSelect;
this.selector.setFilter((users: UserProfileSchema[]) => { this.selector.setFilter((users: TomOption[]) => {
const resp: UserProfileSchema[] = [];
const ids = [ const ids = [
...(this.currentPicture.identifications || []).map( ...(this.currentPicture.identifications || []).map(
(i: IdentifiedUserSchema) => i.user.id, (i: IdentifiedUserSchema) => i.user.id,
), ),
]; ];
for (const user of users) { return users.filter((user) => !ids.includes(user.id));
if (!ids.includes(user.id)) {
resp.push(user);
}
}
return resp;
}); });
this.currentPicture = this.pictures.find( this.currentPicture = this.pictures.find(
(i: PictureSchema) => i.id === config.firstPictureId, (i) => i.id === config.firstPictureId,
); ) as PictureWithIdentifications;
this.$watch( this.$watch(
"currentPicture", "currentPicture",
(current: PictureSchema, previous: PictureSchema) => { (current: PictureSchema, previous: PictureSchema) => {
@@ -236,7 +270,7 @@ document.addEventListener("alpine:init", () => {
this.pushstate = History.Replace; this.pushstate = History.Replace;
this.currentPicture = this.pictures.find( this.currentPicture = this.pictures.find(
(i: PictureSchema) => i.id === Number.parseInt(event.state.sasPictureId, 10), (i: PictureSchema) => i.id === Number.parseInt(event.state.sasPictureId, 10),
); ) as PictureWithIdentifications;
}); });
this.pushstate = History.Replace; /* Avoid first url push */ this.pushstate = History.Replace; /* Avoid first url push */
await this.updatePicture(); await this.updatePicture();
@@ -311,14 +345,15 @@ document.addEventListener("alpine:init", () => {
// As the album is now empty, go back to the parent page // As the album is now empty, go back to the parent page
document.location.href = config.albumUrl; document.location.href = config.albumUrl;
} }
this.currentPicture = this.nextPicture || this.previousPicture; this.currentPicture = (this.nextPicture ||
this.previousPicture) as PictureWithIdentifications;
}, },
/** /**
* Send the identification request and update the list of identified users. * Send the identification request and update the list of identified users.
*/ */
async submitIdentification(): Promise<void> { async submitIdentification(): Promise<void> {
const widget: TomSelect = this.selector.widget; const widget: TomSelect = (this.selector as UserAjaxSelect).widget;
await picturesIdentifyUsers({ await picturesIdentifyUsers({
// biome-ignore lint/style/useNamingConvention: api is in snake_case // biome-ignore lint/style/useNamingConvention: api is in snake_case
path: { picture_id: this.currentPicture.id }, path: { picture_id: this.currentPicture.id },
+3
View File
@@ -48,6 +48,9 @@
height: 100%; height: 100%;
max-width: 100%; max-width: 100%;
object-fit: contain; object-fit: contain;
// Prevent ugly selection when
// clicking to change the image
user-select: none;
} }
} }
} }
+11 -7
View File
@@ -82,7 +82,11 @@
<div class="container" id="pict"> <div class="container" id="pict">
<div class="main"> <div class="main">
<div class="photo" :aria-busy="currentPicture.imageLoading"> <div
class="photo"
:aria-busy="currentPicture.imageLoading"
@click="currentPicture = (($event.offsetX > $event.target.getBoundingClientRect().width / 2) ? nextPicture : previousPicture) ?? currentPicture"
>
<img <img
:src="currentPicture.compressedUrl" :src="currentPicture.compressedUrl"
:alt="currentPicture.name" :alt="currentPicture.name"
@@ -95,13 +99,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 {# The `currentPicture.date || 0` part is to avoid having
x-text="Intl.DateTimeFormat( a console error while the picture date is not yet fetched #}
'{{ LANGUAGE_CODE }}', {dateStyle: 'long'} <span x-text="formatter.format(new Date(currentPicture.date || 0))">
).format(Date.parse(currentPicture.date))"
>
</span> </span>
</div> </div>
<div> <div>
+1 -3
View File
@@ -417,8 +417,6 @@ SITH_FORUM_PAGE_LENGTH = 30
SITH_SAS_ROOT_DIR_ID = env.int("SITH_SAS_ROOT_DIR_ID", default=4) SITH_SAS_ROOT_DIR_ID = env.int("SITH_SAS_ROOT_DIR_ID", default=4)
SITH_SAS_IMAGES_PER_PAGE = 60 SITH_SAS_IMAGES_PER_PAGE = 60
SITH_CGU_FILE_ID = env.int("SITH_CGU_FILE_ID", default=5)
SITH_PROFILE_DEPARTMENTS = [ SITH_PROFILE_DEPARTMENTS = [
("TC", _("TC")), ("TC", _("TC")),
("IMSI", _("IMSI")), ("IMSI", _("IMSI")),
@@ -495,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
-1
View File
@@ -34,7 +34,6 @@ urlpatterns = [
path("", include(("core.urls", "core"), namespace="core")), path("", include(("core.urls", "core"), namespace="core")),
path("sitemap.xml", cache_page(86400)(sitemap), {"sitemaps": sitemaps}), path("sitemap.xml", cache_page(86400)(sitemap), {"sitemaps": sitemaps}),
path("api/", api.urls), path("api/", api.urls),
path("api-link/", include(("api.urls", "api-link"), namespace="api-link")),
path("rootplace/", include(("rootplace.urls", "rootplace"), namespace="rootplace")), path("rootplace/", include(("rootplace.urls", "rootplace"), namespace="rootplace")),
path( path(
"subscription/", "subscription/",
@@ -1,10 +1,11 @@
import type { UserAjaxSelect } from "#core:core/components/ajax-select-index";
import { userFetchUser } from "#openapi"; import { userFetchUser } from "#openapi";
Alpine.data("existing_user_subscription_form", () => ({ Alpine.data("existing_user_subscription_form", () => ({
loading: false, loading: false,
selectedUser: "", selectedUser: "",
profileFragment: "", profileFragment: "",
dateOfBirth: "", dateOfBirth: "" as string | null,
dateOfBirthHidden: true, dateOfBirthHidden: true,
init() { init() {
@@ -14,7 +15,9 @@ Alpine.data("existing_user_subscription_form", () => ({
this.$nextTick(() => { this.$nextTick(() => {
// Force to detect the initial value // Force to detect the initial value
this.selectedUser = this.$refs.userSelect.widget.getValue(); this.selectedUser = (
this.$refs.userSelect as UserAjaxSelect
).widget.getValue() as string;
}); });
}, },
@@ -31,11 +34,15 @@ Alpine.data("existing_user_subscription_form", () => ({
// biome-ignore lint/style/useNamingConvention: api is snake_case // biome-ignore lint/style/useNamingConvention: api is snake_case
userFetchUser({ path: { user_id: userId } }), userFetchUser({ path: { user_id: userId } }),
]); ]);
if (userInfos.data === undefined) {
console.error("User infos request failed");
return;
}
this.profileFragment = await miniProfile.text(); this.profileFragment = await miniProfile.text();
// If the user has no birthdate yet, show the form input // If the user has no birthdate yet, show the form input
// to fill this info. // to fill this info.
// Else keep the input hidden and change its value to the user birthdate // Else keep the input hidden and change its value to the user birthdate
this.dateOfBirth = userInfos.data.date_of_birth; this.dateOfBirth = userInfos.data.date_of_birth ?? null;
this.dateOfBirthHidden = userInfos.data.date_of_birth !== null; this.dateOfBirthHidden = userInfos.data.date_of_birth !== null;
this.loading = false; this.loading = false;
}, },
@@ -17,7 +17,10 @@ function getRandomColorUniq(list: string[]) {
} }
return color; return color;
} }
function hexToRgb(hex: string) {
type Rgb = { r: number; g: number; b: number };
function hexToRgb(hex: string): Rgb | null {
// Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF") // Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF")
const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i; const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i;
const hexrgb = hex.replace(shorthandRegex, (_m, r, g, b) => { const hexrgb = hex.replace(shorthandRegex, (_m, r, g, b) => {
@@ -37,32 +40,29 @@ function hexToRgb(hex: string) {
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
const ctx = (document.getElementById("statsChart") as HTMLCanvasElement).getContext( const ctx = (document.getElementById("statsChart") as HTMLCanvasElement).getContext(
"2d", "2d",
); ) as CanvasRenderingContext2D;
const labels: string[] = []; const labels: string[] = [];
const total: string[] = []; const total: string[] = [];
const colors: string[] = []; const colorStrings: string[] = [];
const colorsDimmed: string[] = []; const colorsDimmed: string[] = [];
for (const element of Array.from(document.getElementsByClassName("types"))) { for (const element of Array.from(document.getElementsByClassName("types"))) {
labels.push(element.childNodes[0].textContent); labels.push(element.childNodes[0].textContent as string);
} }
for (const element of Array.from(document.getElementsByClassName("total"))) { for (const element of Array.from(document.getElementsByClassName("total"))) {
total.push(element.childNodes[0].childNodes[0].textContent); total.push(element.childNodes[0].childNodes[0].textContent as string);
} }
for (const _ of labels) { for (const _ of labels) {
colors.push(getRandomColorUniq(colors)); colorStrings.push(getRandomColorUniq(colorStrings));
}
const colors = colorStrings.map(hexToRgb) as Rgb[];
for (const color of colors) {
colorsDimmed.push(`rgba(${color.r}, ${color.g}, ${color.b}, 0.2)`);
} }
for (const element of colors) { for (const color of colors) {
const rgbColor = hexToRgb(element); colorsDimmed.push(`rgba(${color.r}, ${color.g}, ${color.b}, 0.2)`);
colorsDimmed.push(`rgba(${rgbColor.r}, ${rgbColor.g}, ${rgbColor.b}, 0.2)`);
}
for (const element of colors) {
const rgbColorDimmed = hexToRgb(element);
colorsDimmed.push(
`rgba(${rgbColorDimmed.r}, ${rgbColorDimmed.g}, ${rgbColorDimmed.b}, 0.2)`,
);
} }
new Chart(ctx, { new Chart(ctx, {
@@ -71,10 +71,11 @@ document.addEventListener("DOMContentLoaded", () => {
labels: labels, labels: labels,
datasets: [ datasets: [
{ {
label: document.getElementById("graphLabel").childNodes[0].textContent, label: document.getElementById("graphLabel")?.childNodes[0]
.textContent as string,
data: total, data: total,
backgroundColor: colorsDimmed, backgroundColor: colorsDimmed,
borderColor: colors, borderColor: colorStrings,
borderWidth: 1, borderWidth: 1,
}, },
], ],
@@ -58,7 +58,7 @@ function parseSlots(s: string): TimetableSlot[] {
.filter((s: string) => s.length > 0) .filter((s: string) => s.length > 0)
.map((row: string) => { .map((row: string) => {
const parsed = TIMETABLE_ROW_RE.exec(row); const parsed = TIMETABLE_ROW_RE.exec(row);
if (!parsed) { if (!parsed?.groups) {
throw new Error(`Couldn't parse row ${row}`); throw new Error(`Couldn't parse row ${row}`);
} }
const [startHour, startMin] = parsed.groups.startHour const [startHour, startMin] = parsed.groups.startHour
@@ -173,7 +173,7 @@ document.addEventListener("alpine:init", () => {
async savePng() { async savePng() {
const elem = document.getElementById("timetable"); const elem = document.getElementById("timetable");
const img = (await html2canvas(elem)).toDataURL(); const img = (await html2canvas(elem as HTMLElement)).toDataURL();
const downloadLink = document.createElement("a"); const downloadLink = document.createElement("a");
downloadLink.href = img; downloadLink.href = img;
downloadLink.download = "edt.png"; downloadLink.download = "edt.png";
+27
View File
@@ -0,0 +1,27 @@
{
"exclude": ["staticfiles/generated/**/*", "*.mts", "node_modules/**/*"],
"compilerOptions": {
"outDir": "./staticfiles/generated/bundled/",
"sourceMap": true,
"noImplicitAny": true,
"module": "esnext",
"target": "es2024",
"allowJs": true,
"moduleResolution": "node",
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"composite": true,
"types": ["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/*"],
"#com:*": ["./com/static/bundled/*"],
"#club:*": ["./club/static/bundled/*"]
}
}
}
+4 -22
View File
@@ -1,24 +1,6 @@
{ {
"compilerOptions": { "references": [
"outDir": "./staticfiles/generated/bundled/", {"path": "tsconfig.bundled.json"},
"sourceMap": true, {"path": "tsconfig.node.json"}
"noImplicitAny": true, ],
"module": "esnext",
"target": "es2024",
"allowJs": true,
"moduleResolution": "node",
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"types": ["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/*"],
"#com:*": ["./com/static/bundled/*"]
}
}
} }
+10
View File
@@ -0,0 +1,10 @@
{
"include": ["vite.config.*"],
"compilerOptions": {
"module": "preserve",
"moduleResolution": "bundler",
"types": ["node"],
"emitDeclarationOnly": true,
"composite": true
}
}
Generated
+752 -640
View File
File diff suppressed because it is too large Load Diff
+5 -7
View File
@@ -8,9 +8,9 @@ import {
type Rollup, type Rollup,
type UserConfig, type UserConfig,
} from "vite"; } from "vite";
import tsconfig from "./tsconfig.json"; import { compilerOptions } from "./tsconfig.bundled.json" with { type: "json" };
const outDir = resolve(__dirname, "./staticfiles/generated/bundled"); const outDir = resolve(import.meta.dirname, "./staticfiles/generated/bundled");
const collectedFiles = glob.sync( const collectedFiles = glob.sync(
"./!(static)/static/bundled/**/*?(-)index.?(m)[j|t]s?(x)", "./!(static)/static/bundled/**/*?(-)index.?(m)[j|t]s?(x)",
); );
@@ -20,8 +20,8 @@ const collectedFiles = glob.sync(
**/ **/
function getAliases(): AliasOptions { function getAliases(): AliasOptions {
const aliases: AliasOptions = {}; const aliases: AliasOptions = {};
for (const [key, value] of Object.entries(tsconfig.compilerOptions.paths)) { for (const [key, value] of Object.entries(compilerOptions.paths)) {
aliases[key] = resolve(__dirname, value[0]); aliases[key] = resolve(import.meta.dirname, value[0]);
} }
return aliases; return aliases;
} }
@@ -59,7 +59,7 @@ export default defineConfig((config: UserConfig) => {
// Mirror architecture of static folders in generated .js and .css // Mirror architecture of static folders in generated .js and .css
entryFileNames: (chunkInfo: Rollup.PreRenderedChunk) => { entryFileNames: (chunkInfo: Rollup.PreRenderedChunk) => {
if (chunkInfo.facadeModuleId !== null) { if (chunkInfo.facadeModuleId !== null) {
return `${getRelativeAssetPath(chunkInfo.facadeModuleId)}.[hash].js`; return `${getRelativeAssetPath(chunkInfo.facadeModuleId as string)}.[hash].js`;
} }
return "[name].[hash].js"; return "[name].[hash].js";
}, },
@@ -80,8 +80,6 @@ export default defineConfig((config: UserConfig) => {
resolve: { resolve: {
alias: getAliases(), alias: getAliases(),
}, },
// biome-ignore lint/style/useNamingConvention: that's how it's called
inject: { Alpine: "alpinejs", htmx: "htmx.org" },
plugins: [visualizer({ filename: ".bundle-size-report.html" }) as PluginOption], plugins: [visualizer({ filename: ".bundle-size-report.html" }) as PluginOption],
} satisfies UserConfig; } satisfies UserConfig;
}); });