Compare commits

..
Author SHA1 Message Date
imperosol db44e4bd02 fix user picture delete confirmation page 2026-09-09 22:48:34 +02:00
klmp200 8de4d9a4d0 Merge pull request #1481 from ae-utbm/htmx4
Fix bad hx-swap on club list and moderation view
2026-09-09 22:10:25 +02:00
thomas girod e960625933 Merge pull request #1478 from ae-utbm/timetable-og
add og tags to timetable generator
2026-09-09 19:31:27 +02:00
klmp200 5f3e2d13ba Merge pull request #1482 from ae-utbm/htmx4-aria-busy-plugin
Fix aria-busy during swap and make it a plugin
2026-09-09 08:18:05 +02:00
imperosol 80f113eaf6 add og tags to timetable generator 2026-09-09 07:22:13 +02:00
thomas girod ff72939503 Merge pull request #1484 from ae-utbm/edt-msg
show the broken line on timetable parsing error
2026-09-09 07:19:13 +02:00
imperosol ecaef644b3 show the broken line on timetable parsing error
Certains utilisateurs font remonter régulièrement que le générateur ne marche pas. C'est normal, on n'a pas forcément prévu tous les cas et on n'est pas à l'abri des changements de format de l'UTBM.

Ce qui est très embêtant, dans ces cas-là, c'est qu'on donne très peu d'informations sur ce qui n'a pas marché, et que les utilisateurs peuvent difficilement faire remonter plus que ça.

En décrivant la ligne qui n'a pas pu être parsée, ça devrait déjà rendre la gestion de ce genre de problème un peu plus facile, tout en étant plus transparent pour l'utilisateur
2026-09-08 23:49:04 +02:00
klmp200 35a55ff95a Fix animation bug on club list
Also, due to some weird jinja bug, css block is applied even if supposedly
in a conditional statement
2026-09-08 23:17:35 +02:00
thomas girod 564baf3b56 Merge pull request #1471 from ae-utbm/linked-role-groups
Linked role groups
2026-09-08 12:11:37 +02:00
imperosol d4432a4963 add translations 2026-09-08 11:52:18 +02:00
imperosol 43eedb274a Show linked groups on club role form page 2026-09-08 11:52:18 +02:00
imperosol c4cbcc3560 Allow club role-group m2m links 2026-09-08 11:52:18 +02:00
klmp200 11206fae90 Merge pull request #1483 from ae-utbm/xapian
Upgrade xapian to 2.1.0
2026-09-08 11:47:14 +02:00
klmp200 bacf100f6d Upgrade xapian to 2.1.0 2026-09-08 09:17:04 +02:00
klmp200 00cae33a33 Fix moderation view 2026-09-07 17:36:53 +02:00
klmp200 4bfcb6a267 Fix club-list pagination 2026-09-07 17:36:50 +02:00
klmp200 cbd80c17a2 Fix aria-busy during swap and make it a plugin 2026-09-07 17:23:23 +02:00
thomas girod 4f885b4ba7 Merge pull request #1479 from ae-utbm/bump-python
python 3.14
2026-09-07 17:03:57 +02:00
imperosol 13ee70afe9 update python deps 2026-09-07 16:16:55 +02:00
imperosol 7bd79d1d55 re-apply ruff TCH rules 2026-09-07 13:32:13 +02:00
imperosol d93afffbc3 python 3.14 2026-09-07 11:48:02 +02:00
thomas girod d2c259c4f9 Merge pull request #1474 from ae-utbm/404-msg
funny 404 message
2026-09-04 16:12:37 +02:00
thomas girod f57fe290d6 Merge pull request #1475 from ae-utbm/fix-timetable-regex
fix: timetable
2026-09-04 15:55:37 +02:00
imperosol e7b5330f4f make timetable generator available to everyone 2026-09-04 15:50:21 +02:00
imperosol 3cdbb9605c fix: timetable regex 2026-09-04 15:47:46 +02:00
imperosol c17f2feefc funny 404 message 2026-09-04 11:25:06 +02:00
thomas girod 22ecd01898 Merge pull request #1473 from ae-utbm/js-markdown
fix: use good version of aemark
2026-09-03 16:31:48 +02:00
imperosol 1fa4ccd710 fix: use good version of aemark 2026-09-03 16:30:05 +02:00
thomas girod 2a71708ccd Merge pull request #1472 from ae-utbm/js-markdown
Js markdown
2026-09-03 16:22:43 +02:00
imperosol 549d8c67e7 doc: ae markdown 2026-09-03 16:10:18 +02:00
imperosol ff69741c82 remove /api/markdown route 2026-09-03 12:03:52 +02:00
imperosol b5b837498f Use client-side md parser for easymde 2026-09-03 11:50:55 +02:00
imperosol 8ef3054888 add @ae_utbm/aemark to JS deps 2026-09-03 11:50:55 +02:00
thomas girod 362f1a6a62 Merge pull request #1470 from ae-utbm/subscription-creation-date
add created_at column to Subscription
2026-09-01 23:35:33 +02:00
imperosol b6347829c6 add translations 2026-09-01 18:34:03 +02:00
imperosol 2099e1bd36 add created_at column to Subscription 2026-09-01 15:45:51 +02:00
thomas girod 7fca5d8c75 Merge pull request #1468 from ae-utbm/fix-duplicate-user-search
fix: duplicate user search when a whitelist exists
2026-08-31 18:03:19 +02:00
imperosol 3467aad846 fix: duplicate user search when a whitelist exists
Quand un utilisateur possède une whitelist d'utilisateurs, qu'il effectue une recherche et qu'il apparait dans les résultats, son profil apparait plusieurs fois.
2026-08-31 16:52:42 +02:00
klmp200 ebfac638de Merge pull request #1465 from ae-utbm/hotfix
Fix broken product widget
2026-08-30 22:40:08 +02:00
klmp200 839536661b Merge pull request #1466 from ae-utbm/htmx4
Migrate to HTMX4
2026-08-30 22:39:52 +02:00
thomas girod b1b3639dc9 Merge pull request #1467 from ae-utbm/pedagogy-style
Pedagogy style
2026-08-30 22:38:34 +02:00
klmp200 56c832ba06 Migrate to HTMX4 2026-08-30 22:07:53 +02:00
imperosol 884019d803 style: standardize UE search style 2026-08-30 21:53:00 +02:00
klmp200 dfc714ff3d Fix broken ProductAjaxSelect widget 2026-08-28 15:05:17 +02:00
thomas girod 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 girod 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
klmp200 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 girod 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 girod d6677b1fbd Merge pull request #1459 from ae-utbm/master
Merge back master into taiste
2026-08-20 16:17:02 +02:00
thomas girod e52fd32e06 Merge pull request #1445 from ae-utbm/pedagogy
Update pedagogy
2026-08-20 16:14:16 +02:00
thomas girod 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 girod 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 girod 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 girod 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 girod 9ceb51a54e Merge pull request #1404 from ae-utbm/taiste
Club roles, club links, notifications and other
2026-05-22 09:43:08 +02:00
Titouan 790a1e15b1 Merge pull request #1383 from ae-utbm/taiste
MAJ cotisation, CI, style et fix
2026-05-11 13:03:22 +02:00
thomas girod 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
Titouan 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
Titouan 173311c1d5 Merge pull request #1315 from ae-utbm/taiste
Product history, formula management, test election
2026-03-12 11:33:45 +01:00
thomas girod 2995823d6e Merge pull request #1293 from ae-utbm/taiste
Refactors, updates and db optimisations
2026-02-13 15:25:04 +01:00
178 changed files with 3107 additions and 3288 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.15.19
rev: v0.16.0
hooks:
- id: ruff-check # just check the code, and print the errors
- id: ruff-check # actually fix the fixable errors, but print nothing
+1 -1
View File
@@ -1 +1 @@
3.12
3.14
+6 -11
View File
@@ -1,11 +1,15 @@
from typing import TYPE_CHECKING
from django.contrib import admin, messages
from django.db.models import QuerySet
from django.http import HttpRequest
from django.utils.translation import gettext_lazy as _
from api.hashers import generate_key
from api.models import ApiClient, ApiKey
if TYPE_CHECKING:
from django.db.models import QuerySet
from django.http import HttpRequest
@admin.register(ApiClient)
class ApiClientAdmin(admin.ModelAdmin):
@@ -17,15 +21,6 @@ class ApiClientAdmin(admin.ModelAdmin):
"owner__nick_name",
)
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)
-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
+5 -1
View File
@@ -1,9 +1,13 @@
from django.http import HttpRequest
from typing import TYPE_CHECKING
from ninja.security import APIKeyHeader
from api.hashers import get_hasher
from api.models import ApiClient, ApiKey
if TYPE_CHECKING:
from django.http import HttpRequest
class ApiKeyAuth(APIKeyHeader):
"""Authentication through client api keys."""
-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"
),
),
]
+21 -32
View File
@@ -1,20 +1,13 @@
import secrets
from typing import Iterable
from django.contrib.auth.models import Permission
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 pgettext_lazy
from core.models import Group, User
def get_hmac_key():
return secrets.token_hex(64)
class ApiClient(models.Model):
name = models.CharField(_("name"), max_length=64)
owner = models.ForeignKey(
@@ -33,10 +26,11 @@ class ApiClient(models.Model):
help_text=_("Specific permissions for this api client."),
related_name="clients",
)
hmac_key = models.CharField(_("HMAC Key"), max_length=128, default=get_hmac_key)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
_perm_cache: set[str] | None = None
class Meta:
verbose_name = _("api client")
verbose_name_plural = _("api clients")
@@ -44,38 +38,33 @@ class ApiClient(models.Model):
def __str__(self):
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)
)
def has_perm(self, perm: str):
"""Return True if the client has the specified permission."""
if self._perm_cache is None:
group_permissions = (
Permission.objects.filter(group__group__in=self.groups.all())
.values_list("content_type__app_label", "codename")
.order_by()
)
return {f"{content_type}.{name}" for content_type, name in permissions}
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_perm(self, perm: str):
"""Return True if the client has the specified permission."""
return perm in self.all_permissions
def has_perms(self, perm_list: Iterable[str]) -> bool:
"""Return True if the client has each of the specified permissions."""
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):
raise ValueError("perm_list must be an iterable of permissions.")
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):
PREFIX_LENGTH = 5
+6 -4
View File
@@ -39,15 +39,17 @@ Example:
import operator
from functools import reduce
from typing import Any, Callable
from typing import TYPE_CHECKING, Any, Callable
from django.contrib.auth.models import Permission
from django.http import HttpRequest
from ninja_extra import ControllerBase
from ninja_extra.permissions import BasePermission
from counter.utils import is_logged_in_counter
if TYPE_CHECKING:
from django.contrib.auth.models import Permission
from django.http import HttpRequest
from ninja_extra import ControllerBase
class IsInGroup(BasePermission):
"""Check that the user is in the group whose primary key is given."""
-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_extra import NinjaExtraAPI
from api.views import ThirdPartyAuthResultView, ThirdPartyAuthView
from core.converters import ResultConverter
api = NinjaExtraAPI(
title="PICON",
description="Portail Interactif de Communication avec les Outils Numériques",
@@ -13,14 +9,3 @@ api = NinjaExtraAPI(
auth=[SessionAuth()],
)
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)
+8 -4
View File
@@ -12,12 +12,16 @@
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
from typing import TYPE_CHECKING
from django.contrib import admin
from django.forms.models import ModelForm
from django.http import HttpRequest
from club.models import Club, ClubLink, ClubRole, LinkType, Membership
if TYPE_CHECKING:
from django.forms.models import ModelForm
from django.http import HttpRequest
@admin.register(Club)
class ClubAdmin(admin.ModelAdmin):
@@ -46,8 +50,8 @@ class ClubAdmin(admin.ModelAdmin):
@admin.register(ClubRole)
class ClubRoleAdmin(admin.ModelAdmin):
list_display = ("name", "club", "is_board", "is_presidency")
search_fields = ("name",)
autocomplete_fields = ("club",)
search_fields = ("name", "club__name")
autocomplete_fields = ("club", "linked_groups")
list_select_related = ("club",)
list_filter = (
"is_board",
+7
View File
@@ -479,6 +479,13 @@ class ClubRoleCreateForm(forms.ModelForm):
class ClubRoleBaseFormSet(forms.BaseInlineFormSet):
ordering_widget = forms.HiddenInput()
def __init__(self, *args, queryset=None, **kwargs):
if queryset is None:
queryset = self.model._default_manager
super().__init__(
*args, queryset=queryset.prefetch_related("linked_groups"), **kwargs
)
ClubRoleFormSet = forms.inlineformset_factory(
Club,
@@ -1,12 +1,16 @@
# Generated by Django 4.2.16 on 2024-11-20 17:08
from typing import TYPE_CHECKING
import django.db.models.deletion
import django.db.models.functions.datetime
from django.db import migrations, models
from django.db.migrations.state import StateApps
from django.db.models import Q
from django.utils.timezone import localdate
if TYPE_CHECKING:
from django.db.migrations.state import StateApps
# Before the club role rework, the maximum free role
# was the hardcoded highest non-board role
MAXIMUM_FREE_ROLE = 1
@@ -1,10 +1,14 @@
# Generated by Django 5.2.3 on 2025-06-21 21:59
from typing import TYPE_CHECKING
import django.db.models.deletion
from django.db import migrations, models
from django.db.migrations.state import StateApps
from django.db.models import Case, When
if TYPE_CHECKING:
from django.db.migrations.state import StateApps
PRESIDENCY_ROLES = [10, 9]
MAXIMUM_FREE_ROLE = 1
SITH_CLUB_ROLES = {
@@ -0,0 +1,26 @@
# Generated by Django 5.2.17 on 2026-09-01 14:36
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("club", "0017_linktype_clublink"),
("core", "0050_alter_sithfile_moderator"),
]
operations = [
migrations.AddField(
model_name="clubrole",
name="linked_groups",
field=models.ManyToManyField(
help_text=(
"Groups that are automatically given or removed "
"to user receiving or losing this club role"
),
related_name="club_roles",
to="core.group",
verbose_name="Linked groups",
),
),
]
+32 -11
View File
@@ -23,6 +23,8 @@
#
from __future__ import annotations
import operator
from functools import reduce
from typing import Iterable, Self
from django.conf import settings
@@ -282,6 +284,15 @@ class ClubRole(OrderedModel):
"If the role is inactive, people joining the club won't be able to get it."
),
)
linked_groups = models.ManyToManyField(
Group,
verbose_name=_("Linked groups"),
help_text=_(
"Groups that are automatically given or removed "
"to user receiving or losing this club role"
),
related_name="linked_roles",
)
order_with_respect_to = "club"
@@ -534,7 +545,7 @@ class Membership(models.Model):
def _remove_club_groups(
memberships: Iterable[Membership],
) -> tuple[int, dict[str, int]]:
"""Remove users of those memberships from the club groups.
"""Remove users of those memberships from the club and club role groups.
For example, if a user is in the Troll club board,
he is in the board group and the members group of the Troll.
@@ -553,15 +564,19 @@ class Membership(models.Model):
clubs = {m.club_id for m in memberships}
users = {m.user_id for m in memberships}
groups = Group.objects.filter(Q(club__in=clubs) | Q(club_board__in=clubs))
role_groups = [
Q(user_id=m.user_id, group__linked_roles=m.role_id) for m in memberships
]
return User.groups.through.objects.filter(
Q(group__in=groups) & Q(user__in=users)
(Q(group__in=groups) & Q(user__in=users))
| reduce(operator.or_, role_groups)
).delete()
@staticmethod
def _add_club_groups(
memberships: Iterable[Membership],
) -> list[User.groups.through]:
"""Add users of those memberships to the club groups.
"""Add users of those memberships to the club and club role groups.
For example, if a user just joined the Troll club board,
he will be added in both the members group and the board group
@@ -582,34 +597,40 @@ class Membership(models.Model):
memberships = [m for m in memberships if m.end_date is None]
if not memberships:
return []
if sum(1 for m in memberships if not hasattr(m, "club")) > 1:
nb_prefetched = sum(
1 for m in memberships if not hasattr(m, "club") or not hasattr(m, "role")
)
if nb_prefetched > 1:
# if more than one membership hasn't its `club` attribute set
# it's less expensive to reload the whole query with
# a select_related than perform a distinct query
# to fetch each club.
ids = {m.id for m in memberships}
memberships = list(
Membership.objects.filter(id__in=ids).select_related("club")
Membership.objects.filter(id__in=ids)
.select_related("club", "role")
.prefetch_related("role__linked_groups")
)
club_groups = []
groups = []
for membership in memberships:
club_groups.append(
groups.append(
User.groups.through(
user_id=membership.user_id,
group_id=membership.club.members_group_id,
)
)
if membership.role.is_board:
club_groups.append(
groups.append(
User.groups.through(
user_id=membership.user_id,
group_id=membership.club.board_group_id,
)
)
return User.groups.through.objects.bulk_create(
club_groups, ignore_conflicts=True
groups.extend(
User.groups.through(user_id=membership.user_id, group_id=g.id)
for g in membership.role.linked_groups.all()
)
return User.groups.through.objects.bulk_create(groups, ignore_conflicts=True)
class Mailing(models.Model):
@@ -1,7 +1,6 @@
import type { TomOption } from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils";
import { AjaxSelect } from "#core:core/components/ajax-select-base.ts";
import { registerComponent } from "#core:utils/web-components.ts";
import type { escape_html } from "tom-select/src/utils";
import { AjaxSelect } from "#core:core/components/ajax-select-base";
import { registerComponent } from "#core:utils/web-components";
import { type ClubSchema, clubSearchClub } from "#openapi";
@registerComponent("club-ajax-select")
@@ -10,7 +9,7 @@ export class ClubAjaxSelect extends AjaxSelect {
protected labelField = "name";
protected searchField = ["code", "name"];
protected async search(query: string): Promise<TomOption[]> {
protected async search(query: string) {
const resp = await clubSearchClub({ query: { search: query } });
if (resp.data) {
return resp.data.results;
+11 -6
View File
@@ -15,9 +15,6 @@
{% endblock %}
{% else %}
{% extends "core/base.jinja" %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static("club/list.scss") }}">
{% endblock %}
{% block description -%}
{% trans %}The list of all clubs existing at UTBM.{% endtrans %}
{%- endblock %}
@@ -26,6 +23,12 @@
{%- endblock %}
{% endif %}
{% block additional_css %}
{% if not is_fragment %}
<link rel="stylesheet" href="{{ static("club/list.scss") }}">
{% endif %}
{% endblock %}
{% from "core/macros.jinja" import paginate_htmx %}
{% block content %}
@@ -33,15 +36,17 @@
<h3>{% trans %}Filters{% endtrans %}</h3>
<form
id="club-list-filters"
hx-get="{{ url("club:club_list") }}"
method="GET"
hx-action="{{ url("club:club_list") }}"
hx-target="#content"
hx-swap="outerHtml"
hx-swap="innerHTML"
hx-push-url="true"
hx-disable="find input, find button"
>
<div class="row gap-4x">
{{ form }}
</div>
<button type="submit" class="btn btn-blue margin-bottom">
<button class="btn btn-blue margin-bottom">
<i class="fa fa-magnifying-glass"></i>{% trans %}Search{% endtrans %}
</button>
</form>
+14
View File
@@ -49,6 +49,20 @@
{{ subform.is_active.help_text }}
</span>
</div>
{% set groups = subform.instance.linked_groups.all()|list %}
{% if groups %}
<div>
<p>
<strong>{% trans %}Linked groups : {% endtrans %}</strong>
{{ groups|map(attribute="name")|join(", ") }}
</p>
<p class="helptext">
{% trans trimmed %}
Users receiving this role will also be assigned to those groups
{% endtrans %}
</p>
</div>
{% endif %}
</div>
</details>
</div>
@@ -7,7 +7,7 @@
<form
hx-post="{{ url('club:club_new_members', club_id=club.id) }}"
hx-disabled-elt="find input[type='submit']"
hx-disable="find input[type='submit']"
hx-swap="outerHTML"
hx-target="#member-fragment-container"
id="add_club_members_form"
+4 -1
View File
@@ -1,9 +1,9 @@
from datetime import timedelta
from typing import TYPE_CHECKING
import pytest
from django.conf import settings
from django.db import ProgrammingError
from django.test import Client
from django.urls import reverse
from django.utils.timezone import localdate
from model_bakery import baker
@@ -14,6 +14,9 @@ from club.models import Club, ClubRole, Membership
from core.baker_recipes import subscriber_user
from core.models import User
if TYPE_CHECKING:
from django.test import Client
@pytest.mark.django_db
def test_club_queryset_having_board_member():
+4 -1
View File
@@ -1,4 +1,4 @@
from collections.abc import Callable
from typing import TYPE_CHECKING
import pytest
from django.contrib.auth.models import Permission
@@ -14,6 +14,9 @@ from club.models import Club, ClubRole, Membership
from core.baker_recipes import subscriber_user
from core.models import AnonymousUser, User
if TYPE_CHECKING:
from collections.abc import Callable
def make_club():
# unittest-style tests cannot use fixture, so we create a function
+5 -1
View File
@@ -1,5 +1,6 @@
from typing import TYPE_CHECKING
import pytest
from django.test import Client
from django.urls import reverse
from model_bakery import baker
from pytest_django.asserts import assertRedirects
@@ -7,6 +8,9 @@ from pytest_django.asserts import assertRedirects
from club.models import Club, ClubRole, Membership
from core.baker_recipes import subscriber_user
if TYPE_CHECKING:
from django.test import Client
@pytest.mark.django_db
def test_club_board_member_cannot_edit_club_properties(client: Client):
+28 -2
View File
@@ -1,6 +1,6 @@
import itertools
from collections.abc import Callable
from datetime import timedelta
from typing import TYPE_CHECKING
import pytest
from bs4 import BeautifulSoup
@@ -17,7 +17,10 @@ from club.forms import ClubAddMemberForm, JoinClubForm
from club.models import Club, ClubRole, Membership
from club.tests.base import TestClub
from core.baker_recipes import subscriber_user
from core.models import AnonymousUser, User
from core.models import AnonymousUser, Group, User
if TYPE_CHECKING:
from collections.abc import Callable
class TestMembershipQuerySet(TestClub):
@@ -500,6 +503,29 @@ class TestMembership(TestClub):
assert self.subscriber.groups.contains(self.club.members_group)
assert self.subscriber.groups.contains(self.club.board_group)
def test_add_to_club_role_group(self):
groups = baker.make(Group, _quantity=4)
self.subscriber.groups.set(groups[:1])
self.board_role.linked_groups.set(groups[1:3])
baker.make(
Membership, club=self.club, user=self.subscriber, role=self.board_role
)
assert set(self.subscriber.groups.all()) == {
*groups[:3],
self.club.board_group,
self.club.members_group,
}
def test_remove_from_club_role_group(self):
groups = baker.make(Group, _quantity=3)
baker.make(
Membership, club=self.club, user=self.subscriber, role=self.board_role
)
self.subscriber.groups.set(groups[:1])
self.board_role.linked_groups.set(groups[1:])
self.subscriber.memberships.update(end_date=localdate())
assert set(self.subscriber.groups.all()) == {groups[0]}
def test_change_position_in_club(self):
"""Test that when moving from board to members, club group change"""
membership = baker.make(
+5 -1
View File
@@ -1,7 +1,8 @@
from typing import TYPE_CHECKING
import pytest
from aemark import markdown
from bs4 import BeautifulSoup
from django.test import Client
from django.urls import reverse
from model_bakery import baker
from pytest_django.asserts import assertHTMLEqual, assertRedirects
@@ -10,6 +11,9 @@ from club.models import Club, ClubRole, Membership
from core.baker_recipes import subscriber_user
from core.models import PageRev, User
if TYPE_CHECKING:
from django.test import Client
@pytest.mark.django_db
def test_page_display_on_club_main_page(client: Client):
+5 -1
View File
@@ -1,5 +1,6 @@
from typing import TYPE_CHECKING
import pytest
from django.test import Client
from django.urls import reverse
from model_bakery import baker
@@ -7,6 +8,9 @@ from club.models import Club
from com.models import Poster
from core.baker_recipes import subscriber_user
if TYPE_CHECKING:
from django.test import Client
@pytest.mark.django_db
@pytest.mark.parametrize("route_url", ["club:poster_list", "club:poster_create"])
+4 -1
View File
@@ -1,8 +1,8 @@
import csv
import itertools
from typing import TYPE_CHECKING
import pytest
from django.test import Client
from django.urls import reverse
from model_bakery import baker
@@ -12,6 +12,9 @@ from core.models import User
from counter.baker_recipes import product_recipe, sale_recipe
from counter.models import Counter, Customer, Product, Selling
if TYPE_CHECKING:
from django.test import Client
@pytest.mark.django_db
def test_sales_page_doesnt_crash(client: Client):
+4 -1
View File
@@ -1,4 +1,5 @@
from datetime import date
from typing import TYPE_CHECKING
from dateutil.relativedelta import relativedelta
from django import forms
@@ -9,11 +10,13 @@ from django.utils.translation import gettext_lazy as _
from club.models import Club
from club.widgets.ajax_select import AutoCompleteSelectClub
from com.models import News, NewsDate, Poster
from core.models import User
from core.utils import get_end_of_semester
from core.views.forms import SelectDateTime
from core.views.widgets.markdown import MarkdownInput
if TYPE_CHECKING:
from core.models import User
class PosterForm(forms.ModelForm):
class Meta:
+7 -2
View File
@@ -1,11 +1,10 @@
from pathlib import Path
from typing import TYPE_CHECKING
from dateutil.relativedelta import relativedelta
from django.conf import settings
from django.contrib.sites.models import Site
from django.contrib.syndication.views import add_domain
from django.db.models import Count, OuterRef, QuerySet, Subquery
from django.http import HttpRequest
from django.urls import reverse
from django.utils import timezone
from ical.calendar import Calendar
@@ -14,6 +13,12 @@ from ical.event import Event
from ical.types import Frequency, Recur
from com.models import News, NewsDate
if TYPE_CHECKING:
from pathlib import Path
from django.http import HttpRequest
from core.models import User
@@ -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 enLocale from "@fullcalendar/core/locales/en-gb";
import frLocale from "@fullcalendar/core/locales/fr";
@@ -6,8 +11,8 @@ import dayGridPlugin from "@fullcalendar/daygrid";
import iCalendarPlugin from "@fullcalendar/icalendar";
import listPlugin from "@fullcalendar/list";
import { type HTMLTemplateResult, html, render } from "lit-html";
import { makeUrl } from "#core:utils/api.ts";
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components.ts";
import { type GenericEndpoint, makeUrl } from "#core:utils/api";
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
import {
calendarCalendarInternal,
calendarCalendarUnpublished,
@@ -19,11 +24,11 @@ import {
@registerComponent("ics-calendar")
export class IcsCalendar extends inheritHtmlElement("div") {
static observedAttributes = ["locale", "can_moderate", "can_delete", "ics-help-url"];
private calendar: Calendar;
private locale = "en";
private canModerate = false;
private canDelete = false;
private helpUrl = "";
private calendar!: Calendar;
private locale? = "en";
private canModerate? = false;
private canDelete? = false;
private helpUrl? = "";
// Hack variable to detect recurring events
// The underlying ics library doesn't include any info about rrules
@@ -35,10 +40,12 @@ export class IcsCalendar extends inheritHtmlElement("div") {
this.locale = newValue;
}
if (name === "can_moderate") {
this.canModerate = newValue.toLowerCase() === "true";
this.canModerate =
typeof newValue === "string" && newValue.toLowerCase() === "true";
}
if (name === "can_delete") {
this.canDelete = newValue.toLowerCase() === "true";
this.canDelete =
typeof newValue === "string" && newValue.toLowerCase() === "true";
}
if (name === "ics-help-url") {
@@ -94,7 +101,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
.toString()
.split("/")
.filter((s) => s) // Remove blank characters
.pop(),
.pop() as string,
10,
);
}
@@ -159,7 +166,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
this.refreshEvents();
}
async getEventSources() {
async getEventSources(): Promise<EventInput[]> {
const tagRecurringEvents = (eventData: EventImpl) => {
// This functions tags events with a similar event url
// We rely on the fact that the event url is always the same
@@ -173,14 +180,14 @@ export class IcsCalendar extends inheritHtmlElement("div") {
};
return [
{
url: `${await makeUrl(calendarCalendarInternal)}`,
url: `${await makeUrl(calendarCalendarInternal as GenericEndpoint)}`,
format: "ics",
className: "internal",
cache: false,
eventDataTransform: tagRecurringEvents,
},
{
url: `${await makeUrl(calendarCalendarUnpublished)}`,
url: `${await makeUrl(calendarCalendarUnpublished as GenericEndpoint)}`,
format: "ics",
color: "red",
className: "unpublished",
@@ -213,7 +220,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
${event.title}
</h4>
<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>
</div>
`;
@@ -251,7 +258,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
const buttons = [] as HTMLTemplateResult[];
if (this.canModerate) {
if (event.source.internalEventSource.ui.classNames.includes("unpublished")) {
if (event.source?.internalEventSource.ui.classNames.includes("unpublished")) {
const button = html`
<button class="btn btn-green" @click="${() => this.publishNews(newsId)}">
<i class="fa fa-check"></i>${gettext("Publish")}
@@ -338,9 +345,9 @@ export class IcsCalendar extends inheritHtmlElement("div") {
button.classList.remove("text-copied");
}
button.setAttribute("tooltip", gettext("Link copied"));
navigator.clipboard.writeText(
await navigator.clipboard.writeText(
new URL(
await makeUrl(calendarCalendarInternal),
await makeUrl(calendarCalendarInternal as GenericEndpoint),
window.location.origin,
).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,
// 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
query: { news_id: this.newsId, page: 1, page_size: 1 },
});
return response.data.count;
return (response.data as PaginatedResponseSchemaNewsDateSchema).count;
},
weeklyEventWarningMessage(nbEvents: number): string {
@@ -1,11 +1,11 @@
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
start_date: Date;
// biome-ignore lint/style/useNamingConvention: api is snake_case
end_date: Date;
}
};
document.addEventListener("alpine:init", () => {
Alpine.data("upcomingNewsLoader", (startDate: Date, locale: string) => ({
@@ -32,6 +32,11 @@ document.addEventListener("alpine:init", () => {
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) {
this.hasNext = false;
} else if (response.data.next === null) {
@@ -44,7 +49,7 @@ document.addEventListener("alpine:init", () => {
this.loading = false;
},
groupedDates(): Record<string, NewsDateSchema[]> {
groupedDates(): Record<string, ParsedNewsDateSchema[]> {
return this.newsDates
.map(
(date: NewsDateSchema): ParsedNewsDateSchema => ({
+4 -1
View File
@@ -1,11 +1,11 @@
from datetime import timedelta
from pathlib import Path
from typing import TYPE_CHECKING
import pytest
from aemark import markdown
from django.conf import settings
from django.contrib.auth.models import Permission
from django.http import HttpResponse
from django.test import Client, TestCase
from django.urls import reverse
from django.utils.timezone import now
@@ -16,6 +16,9 @@ from com.ics_calendar import IcsCalendar
from com.models import News, NewsDate
from core.models import User
if TYPE_CHECKING:
from django.http import HttpResponse
def accel_redirect_to_file(response: HttpResponse) -> Path | None:
redirect = Path(response.headers.get("X-Accel-Redirect", ""))
+3 -13
View File
@@ -1,9 +1,8 @@
from typing import Annotated, Any, Literal
from typing import Annotated, Literal
from annotated_types import Ge, Le, MinLen
from django.conf import settings
from django.db.models import F
from django.http import HttpResponse
from ninja import File, Query
from ninja.security import SessionAuth
from ninja_extra import ControllerBase, api_controller, paginate, route
@@ -18,7 +17,6 @@ from core.models import Group, QuickUploadImage, SithFile, User
from core.schemas import (
FamilyGodfatherSchema,
GroupSchema,
MarkdownSchema,
SithFileSchema,
UploadedFileSchema,
UploadedImage,
@@ -26,26 +24,18 @@ from core.schemas import (
UserFilterSchema,
UserProfileSchema,
UserSchema,
ValidationErrorSchema,
)
from core.templatetags.renderer import markdown
from counter.utils import is_logged_in_counter
@api_controller("/markdown")
class MarkdownController(ControllerBase):
@route.post("", url_name="markdown")
def render_markdown(self, body: MarkdownSchema):
"""Convert the markdown text into html."""
return HttpResponse(markdown(body.text), content_type="text/html")
@api_controller("/upload")
class UploadController(ControllerBase):
@route.post(
"/image",
response={
200: UploadedFileSchema,
422: dict[Literal["detail"], list[dict[str, Any]]],
422: ValidationErrorSchema,
403: dict[Literal["detail"], str],
},
permissions=[HasPerm("core.add_quickuploadimage")],
+8 -11
View File
@@ -1,16 +1,19 @@
from django.urls.converters import IntConverter, StringConverter
class FourDigitYearConverter(IntConverter):
class FourDigitYearConverter:
regex = "[0-9]{4}"
def to_python(self, value):
return int(value)
def to_url(self, value):
return str(value).zfill(4)
class TwoDigitMonthConverter(IntConverter):
class TwoDigitMonthConverter:
regex = "[0-9]{2}"
def to_python(self, value):
return int(value)
def to_url(self, value):
return str(value).zfill(2)
@@ -25,9 +28,3 @@ class BooleanStringConverter:
def to_url(self, 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.contrib.auth.models import Permission
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.base import BaseCommand
from django.db import connection
@@ -121,21 +120,15 @@ class Command(BaseCommand):
)
self.profiles_root = SithFile.objects.create(name="profiles", 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)
sas = SithFile.objects.create(
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()
self.reset_index("club")
+6 -13
View File
@@ -1,4 +1,3 @@
import math
import random
from datetime import date, timedelta
from datetime import timezone as tz
@@ -36,17 +35,12 @@ class Command(BaseCommand):
super().__init__(*args, **kwargs)
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):
if not settings.DEBUG:
raise Exception("Never call this command in prod. Never.")
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
subscribers = random.sample(users, k=int(0.8 * len(users)))
self.stdout.write("Creating subscriptions...")
@@ -86,7 +80,7 @@ class Command(BaseCommand):
self.stdout.write("Creating products...")
self.create_products()
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.stdout.write("Creating permanences...")
self.create_permanences(sellers)
@@ -95,7 +89,7 @@ class Command(BaseCommand):
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.
# It's insecure as hell, but it's ok since it's only for dev purposes.
password = make_password("plop")
@@ -114,7 +108,7 @@ class Command(BaseCommand):
address=self.faker.address(),
password=password,
)
for _ in range(nb_users)
for _ in range(600)
]
# there may a duplicate or two
# Not a problem, we will just have 599 users instead of 600
@@ -421,9 +415,8 @@ class Command(BaseCommand):
Permanency.objects.bulk_create(perms)
def create_forums(self):
users = list(User.objects.all())
forumers = random.sample(users, math.ceil(len(users) / 10))
most_actives = random.sample(forumers, math.ceil(len(forumers) / 6))
forumers = random.sample(list(User.objects.all()), 100)
most_actives = random.sample(forumers, 10)
categories = list(Forum.objects.filter(is_category=True))
new_forums = [
Forum(name=self.faker.text(20), parent=random.choice(categories))
@@ -1,9 +1,13 @@
# Generated by Django 4.2.17 on 2025-01-04 16:42
from typing import TYPE_CHECKING
from django.db import migrations
from django.db.migrations.state import StateApps
from django.db.models import F
if TYPE_CHECKING:
from django.db.migrations.state import StateApps
def invert_is_manually_manageable(apps: StateApps, schema_editor):
"""Invert `is_manually_manageable`.
@@ -1,9 +1,13 @@
# Generated by Django 4.2.17 on 2024-12-31 13:30
from typing import TYPE_CHECKING
import django.contrib.auth.models
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
if TYPE_CHECKING:
from django.db.migrations.state import StateApps
@@ -1,11 +1,15 @@
# Generated by Django 5.2.12 on 2026-05-01 08:59
from typing import TYPE_CHECKING
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
from django.db.migrations.state import StateApps
from django.db.models import F
if TYPE_CHECKING:
from django.db.migrations.state import StateApps
def set_updated_at(apps: StateApps, schema_editor):
SithFile = apps.get_model("core", "SithFile")
+10 -4
View File
@@ -19,6 +19,16 @@ from core.utils import get_last_promo, is_image
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):
@classmethod
def _validate(cls, v: Any, info: ValidationInfo) -> Any:
@@ -151,10 +161,6 @@ class UserFilterSchema(FilterSchema):
return value
class MarkdownSchema(Schema):
text: str
class FamilyGodfatherSchema(Schema):
godfather: int
godchild: int
@@ -25,7 +25,7 @@ export function limitedChoices(Alpine: AlpineType) {
Alpine.directive(
"limited-choices",
(el, { expression }, { evaluateLater, effect }) => {
const getMaxChoices = evaluateLater(expression);
const getMaxChoices = evaluateLater<string>(expression);
let maxChoices: number;
const inputs: HTMLInputElement[] = Array.from(
el.querySelectorAll("input[type='checkbox']"),
@@ -54,7 +54,7 @@ export function limitedChoices(Alpine: AlpineType) {
});
}
effect(() => {
getMaxChoices((value: string) => {
getMaxChoices((value) => {
const previousValue = maxChoices;
maxChoices = Number.parseInt(value, 10);
if (maxChoices < previousValue) {
+26 -7
View File
@@ -6,10 +6,15 @@
* for more efficient tree-shaking and gzip compression.
*/
// Must be loaded before Apline
import htmx from "htmx.org";
import "htmx.org/dist/ext/hx-alpine-compat.js";
import "htmx.org/dist/ext/hx-prompt.js";
import "htmx.org/dist/ext/hx-download.js";
import sort from "@alpinejs/sort";
import Alpine from "alpinejs";
import { polyfillCountryFlagEmojis } from "country-flag-emoji-polyfill";
import htmx from "htmx.org";
import { limitedChoices } from "#core:alpine/limited-choices";
import { expireOldStorage } from "#core:core/localstorage";
import { default as navbar } from "#core:core/navbar";
@@ -43,14 +48,28 @@ polyfillCountryFlagEmojis();
/**
* HTMX
*/
document.body.addEventListener("htmx:beforeRequest", (event: CustomEvent) => {
event.detail.target.ariaBusy = true;
});
document.body.addEventListener("htmx:beforeSwap", (event: CustomEvent) => {
event.detail.target.ariaBusy = null;
htmx.registerExtension("aria-busy", {
// biome-ignore lint/style/useNamingConvention: api's name
htmx_before_request: (
_: HTMLElement,
detail: { ctx: { target: HTMLElement | undefined } },
) => {
if (detail.ctx.target !== undefined) {
(detail.ctx.target as HTMLElement).ariaBusy = "true";
}
},
// biome-ignore lint/style/useNamingConvention: api's name
htmx_after_swap: (
_: HTMLElement,
detail: { ctx: { target: HTMLElement | undefined } },
) => {
if (detail.ctx.target !== undefined) {
(detail.ctx.target as HTMLElement).ariaBusy = null;
}
},
});
htmx.config.transitions = true;
Object.assign(window, { htmx });
/**
@@ -4,9 +4,9 @@ import type {
TomLoadCallback,
TomOption,
TomSettings,
} from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils";
import { inheritHtmlElement } from "#core:utils/web-components.ts";
} from "tom-select/src/types";
import type { escape_html } from "tom-select/src/utils";
import { inheritHtmlElement } from "#core:utils/web-components";
export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
static observedAttributes = [
@@ -15,7 +15,7 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
"max",
"min-characters-for-search",
];
public widget: TomSelect;
public widget!: TomSelect;
protected minCharNumberForSearch = 0;
protected delay: number | null = null;
@@ -24,8 +24,8 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
protected attributeChangedCallback(
name: string,
_oldValue?: string,
newValue?: string,
_oldValue: string,
newValue: string,
) {
switch (name) {
case "delay": {
@@ -73,7 +73,7 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
persist: false,
maxItems: this.node.multiple ? this.max : 1,
closeAfterSelect: true,
loadThrottle: this.delay,
loadThrottle: this.delay ?? undefined,
placeholder: this.placeholder,
shouldLoad: (query: string) => this.shouldLoad(query), // wraps the method to avoid shadowing `this` by the one from tom-select
render: {
@@ -103,7 +103,7 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
}
export abstract class AjaxSelect extends AutoCompleteSelectBase {
protected filter?: (items: TomOption[]) => TomOption[] = null;
protected filter?: (items: TomOption[]) => TomOption[];
protected minCharNumberForSearch = 2;
/**
* 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 type { TomOption } from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils";
import type { TomOption } from "tom-select/src/types";
import type { escape_html } from "tom-select/src/utils";
import {
AjaxSelect,
AutoCompleteSelectBase,
} from "#core:core/components/ajax-select-base.ts";
import { registerComponent } from "#core:utils/web-components.ts";
} from "#core:core/components/ajax-select-base";
import { registerComponent } from "#core:utils/web-components";
import {
type GroupSchema,
groupSearchGroup,
@@ -1,16 +1,14 @@
// @ts-expect-error 2307
// biome-ignore lint/correctness/noUndeclaredDependencies: shipped by easymde
import "codemirror/lib/codemirror.css";
// @ts-expect-error 2307
import "easymde/src/css/easymde.css";
import { markdown } from "@ae_utbm/aemark";
// biome-ignore lint/correctness/noUndeclaredDependencies: Imported by EasyMDE
import type CodeMirror from "codemirror";
// biome-ignore lint/style/useNamingConvention: This is how they called their namespace
import type CodeMirror from "codemirror"; // biome-ignore lint/style/useNamingConvention: This is how they called their namespace
import EasyMDE from "easymde";
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components.ts";
import {
markdownRenderMarkdown,
type UploadUploadImageErrors,
uploadUploadImage,
} from "#openapi";
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
import { type UploadUploadImageErrors, uploadUploadImage } from "#openapi";
const loadEasyMde = (textarea: HTMLTextAreaElement) => {
const easymde = new EasyMDE({
@@ -25,11 +23,11 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
file: file,
},
});
if (!response.response.ok) {
if (response.response.status === 422) {
if (response.response !== undefined && !response.response.ok) {
if (response?.response.status === 422) {
onError(
(response.error as UploadUploadImageErrors[422]).detail
.map((err: Record<"ctx", Record<"error", string>>) => err.ctx.error)
.map((err) => err.ctx.error)
.join(" ; "),
);
} else if (response.response.status === 403) {
@@ -39,6 +37,10 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
}
return;
}
if (response.data === undefined) {
// this can't happen, it's just for the type checker to know
return;
}
onSuccess(response.data.href);
// Workaround function to add an image name to uploaded image
// Without this, you get ![](url) instead of ![name](url)
@@ -58,22 +60,7 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
});
easymde.codemirror.replaceSelection("\n");
},
previewRender: (plainText: string, preview: MarkdownInput) => {
/* This is wrapped this way to allow time for Alpine to be loaded on the page */
return Alpine.debounce((plainText: string, preview: MarkdownInput) => {
const func = async (
plainText: string,
preview: MarkdownInput,
): Promise<null> => {
preview.innerHTML = (
await markdownRenderMarkdown({ body: { text: plainText } })
).data as string;
return null;
};
func(plainText, preview);
return null;
}, 300)(plainText, preview);
},
previewRender: (plainText) => markdown(plainText),
forceSync: true, // Avoid validation error on generic create view
imageTexts: {
sbInit: gettext("Attach files by drag and dropping or pasting from clipboard."),
@@ -222,9 +209,11 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
});
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) {
// an attribute is null if it does not exist, else a string
@@ -249,6 +238,7 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
};
@registerComponent("markdown-input")
// biome-ignore lint/correctness/noUnusedVariables: it is used in jinja
class MarkdownInput extends inheritHtmlElement("textarea") {
connectedCallback() {
super.connectedCallback();
@@ -2,7 +2,7 @@ import {
type InheritedHtmlElement,
inheritHtmlElement,
registerComponent,
} from "#core:utils/web-components.ts";
} from "#core:utils/web-components";
/**
* ElementOnce web components
@@ -28,7 +28,7 @@ export function elementOnce<K extends keyof HTMLElementTagNameMap>(tagName: K) {
clearNode() {
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") {
getElementQuerySelector(): string {
// 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") {
getElementQuerySelector(): string {
// 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")
export class NfcInput extends inheritHtmlElement("input") {
@@ -26,9 +26,11 @@ export class NfcInput extends inheritHtmlElement("input") {
window.alert(gettext("Unsupported NFC card"));
});
ndef.addEventListener("reading", (event: NDEFReadingEvent) => {
ndef.addEventListener("reading", (event) => {
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 */
const submit = document.createElement("button");
this.node.appendChild(submit);
@@ -1,6 +1,6 @@
import { html, render } from "lit-html";
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")
export class Tab extends HTMLElement {
@@ -19,7 +19,7 @@ export class Tab extends HTMLElement {
}
if (name === "title") {
this.description = newValue;
this.description = newValue ?? "";
}
this.dispatchEvent(new CustomEvent("ui-tab-updated", { bubbles: true }));
}
@@ -79,15 +79,15 @@ export class Tab extends HTMLElement {
@registerComponent("ui-tab-group")
export class TabGroup extends HTMLElement {
private node: HTMLDivElement;
private node!: HTMLDivElement;
connectedCallback() {
this.node = document.createElement("div");
this.node.classList.add("tabs", "shadow");
this.appendChild(this.node);
this.addEventListener("ui-tab-activated", (event: CustomEvent) => {
const target = event.detail as Tab;
this.addEventListener("ui-tab-activated", (event) => {
const target = (event as CustomEvent).detail as Tab;
for (const tab of this.getElementsByTagName("ui-tab") as HTMLCollectionOf<Tab>) {
if (tab !== target) {
tab.setActive(false);
@@ -26,19 +26,23 @@ document.addEventListener("alpine:init", () => {
* `counter/templates/counter/product_form.jinja`
*/
Alpine.data("dynamicFormSet", (config?: Config) => ({
formContainer: undefined as unknown as HTMLElement,
nbForms: 0,
template: undefined as unknown as HTMLTemplateElement,
init() {
this.formContainer = this.$refs.formContainer as HTMLElement;
this.nbForms = this.formContainer.children.length as number;
this.template = this.$refs.formTemplate as HTMLTemplateElement;
const prefix = config?.prefix ?? "form";
this.$root
.querySelector(`#id_${prefix}-TOTAL_FORMS`)
.setAttribute(":value", "nbForms");
(
this.$root.querySelector(`#id_${prefix}-TOTAL_FORMS`) as HTMLFormElement
).setAttribute(":value", "nbForms");
},
addForm() {
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(
"input, select, textarea",
);
@@ -59,7 +63,7 @@ document.addEventListener("alpine:init", () => {
this.nbForms -= 1;
// adjust the id of remaining forms
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(
"input, select, textarea",
);
+6 -3
View File
@@ -1,5 +1,5 @@
function showMenu() {
const navbar = document.getElementById("navbar-content");
const navbar = document.getElementById("navbar-content") as HTMLElement;
const current = navbar.getAttribute("mobile-display");
navbar.setAttribute("mobile-display", current === "hidden" ? "revealed" : "hidden");
}
@@ -20,9 +20,12 @@ function navbarInit() {
item.removeAttribute("open");
}
});
item.addEventListener("click", (event: MouseEvent) => {
item.addEventListener("click", (event) => {
// 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;
}
@@ -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";
/*
+8 -7
View File
@@ -70,15 +70,16 @@ function createTooltip(element: HTMLElement) {
function updateTooltip(element: HTMLElement, tooltip: HTMLElement, status: Status) {
// Update tooltip status and set it's attributes and content
tooltip.setAttribute("tooltip-status", status);
tooltip.innerText = element.getAttribute("tooltip");
tooltip.innerText = element.getAttribute("tooltip") as string;
for (const attributes of [
{ src: "tooltip-class", dst: "class", default: ["tooltip"] },
{ src: "tooltip-id", dst: "id", default: [] },
]) {
const populated = attributes.default;
if (element.hasAttribute(attributes.src)) {
populated.push(...element.getAttribute(attributes.src).split(" "));
const attr = element.getAttribute(attributes.src);
if (attr !== null) {
populated.push(...attr.split(" "));
}
tooltip.setAttribute(attributes.dst, populated.join(" "));
}
@@ -93,7 +94,7 @@ function getTooltip(element: HTMLElement) {
return tooltip;
}
function tooltipMouseover(event: MouseEvent) {
function tooltipMouseover(event: Event) {
// We get the closest tooltip to have a consistent behavior
// when hovering over a child element of a tooltip marked element
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
// when hovering over a child element of a tooltip marked element
const target = (event.target as HTMLElement).closest("[tooltip]") as HTMLElement;
@@ -141,7 +142,7 @@ new MutationObserver((mutations: MutationRecord[]) => {
}
} else if (tooltips.has(target)) {
// Remove corresponding tooltip
tooltips.get(target).remove();
tooltips.get(target)?.remove();
tooltips.delete(target);
}
}
@@ -163,7 +164,7 @@ new MutationObserver((mutations: MutationRecord[]) => {
continue;
}
if (tooltips.has(target)) {
tooltips.get(target).remove();
tooltips.get(target)?.remove();
tooltips.delete(target);
}
}
+17 -22
View File
@@ -1,20 +1,13 @@
import cytoscape, {
type ElementDefinition,
type NodeSingular,
type Singular,
} from "cytoscape";
import cytoscape, { type ElementDefinition, type Singular } from "cytoscape";
import cxtmenu from "cytoscape-cxtmenu";
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";
cytoscape.use(klay);
cytoscape.use(cxtmenu);
type GraphData = (
| { data: UserProfileSchema }
| { data: { source: number; target: number } }
)[];
type GraphData = { data: UserProfileSchema | { source: number; target: number } }[];
function isMobile() {
return window.innerWidth < 500;
@@ -27,10 +20,8 @@ async function getGraphData(
): Promise<GraphData> {
const data = (
await familyGetFamilyGraph({
path: {
// biome-ignore lint/style/useNamingConvention: api is snake_case
user_id: userId,
},
path: { user_id: userId },
query: {
// biome-ignore lint/style/useNamingConvention: api is snake_case
godfathers_depth: godfathersDepth,
@@ -39,6 +30,10 @@ async function getGraphData(
},
})
).data;
if (data === undefined) {
console.error("Family graph request failed");
return [];
}
return [
...data.users.map((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>',
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,
godchildrenDepth: 0,
reverse: initialUrlParams.get("reverse")?.toLowerCase?.() === "true",
graph: undefined as cytoscape.Core,
graphData: {},
graph: undefined as unknown as cytoscape.Core,
graphData: {} as GraphData,
isZoomEnabled: !isMobile(),
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) {
return defaultDepth;
}
@@ -223,8 +218,8 @@ document.addEventListener("alpine:init", () => {
await delayedFetch();
});
}
this.$watch("reverse", async (value: number) => {
updateQueryString("reverse", value.toString(), History.Replace);
this.$watch("reverse", async (newValue, _oldValue) => {
updateQueryString("reverse", newValue.toString(), History.Replace);
await this.reverseGraph();
});
this.$watch("graphData", async () => {
@@ -259,9 +254,9 @@ document.addEventListener("alpine:init", () => {
},
async reverseGraph() {
this.graph.elements((el: NodeSingular) => {
el.position({ x: -el.position().x, y: -el.position().y });
});
this.graph
.elements()
.positions((el, _) => ({ x: -el.position().x, y: -el.position().y }));
this.graph.center(this.graph.elements());
},
+2 -2
View File
@@ -5,9 +5,9 @@ interface AlertParams {
export class AlertMessage {
public open: boolean;
public success: boolean;
public success!: boolean;
public content: string;
private timeoutId?: number;
private timeoutId: number | null;
private readonly defaultDuration: number;
constructor(params?: { defaultDuration: number }) {
+8 -5
View File
@@ -37,6 +37,10 @@ export const paginated = async <T>(
queryParams.query.page = 1;
const firstPage = (await endpoint(queryParams)).data;
if (firstPage === undefined) {
console.error(`Request to "${options?.url}" failed`);
return [];
}
const results = firstPage.results;
const nbElements = firstPage.count;
@@ -45,9 +49,9 @@ export const paginated = async <T>(
if (nbPages > 1) {
const promises: Promise<T[]>[] = [];
for (let i = 2; i <= nbPages; i++) {
const nextPage = structuredClone(queryParams);
const nextPage = structuredClone(queryParams) as Required<PaginatedRequest>;
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());
}
@@ -61,8 +65,7 @@ interface Request extends TDataShape {
interface InterceptorOptions {
url: string;
}
type GenericEndpoint = <ThrowOnError extends boolean = false>(
export type GenericEndpoint = <ThrowOnError extends boolean = false>(
options?: Options<Request, ThrowOnError>,
) => RequestResult<unknown, unknown, ThrowOnError>;
@@ -71,7 +74,7 @@ type GenericEndpoint = <ThrowOnError extends boolean = false>(
**/
export const makeUrl = async (endpoint: GenericEndpoint) => {
let url = "";
const interceptor = (_request: undefined, options: InterceptorOptions) => {
const interceptor = (_request: Request, options: InterceptorOptions) => {
url = options.url;
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[];
}
function getNested<T extends object>(obj: T, key: NestedKeyOf<T>) {
const path: (keyof object)[] = key.split(".") as (keyof unknown)[];
let res = obj[path.shift() as keyof T];
function getNested<T extends { [key: string]: unknown }>(obj: T, key: NestedKeyOf<T>) {
const path = key.split(".");
let res = obj[path.shift() as string] as { [key: string]: unknown } | undefined;
for (const node of path) {
if (res === null) {
if (res === undefined) {
break;
}
res = res[node];
res = res[node] as { [key: string]: unknown } | undefined;
}
return res;
}
@@ -29,18 +29,21 @@ function sanitizeCell(content: string): string {
}
export const csv = {
stringify: <T extends object>(objs: T[], options?: StringifyOptions<T>) => {
const columns = options.columns;
stringify: <T extends { [key: string]: unknown }>(
objs: T[],
options?: StringifyOptions<T>,
) => {
const columns = options?.columns;
const content = objs
.map((obj) => {
return columns
return (columns ?? [])
.map((col) => {
return sanitizeCell((getNested(obj, col) ?? "").toString());
})
.join(",");
})
.join("\n");
if (!options.titleRow) {
if (!options?.titleRow) {
return content;
}
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>
extends HTMLElement {
readonly inheritedTagName: K;
// readonly initializedAttribute: "component-initialized";
node: HTMLElementTagNameMap[K];
}
@@ -47,8 +48,8 @@ export function inheritHtmlElement<K extends keyof HTMLElementTagNameMap>(tagNam
implements InheritedHtmlElement<K>
{
readonly inheritedTagName = tagName;
private readonly initializedAttribute = "component-initialized";
node: HTMLElementTagNameMap[K];
readonly initializedAttribute = "component-initialized";
node!: HTMLElementTagNameMap[K];
connectedCallback(autoAddNode?: boolean) {
// When nesting inherited elements, we might trigger the wrapping twice
+4
View File
@@ -4,6 +4,10 @@
<div id="page">
<h3>{% trans %}404, Not Found{% endtrans %}</h3>
<blockquote>
{% trans %}Impossible, perhaps the archives are incomplete{% endtrans %}
</blockquote>
</div>
{% endblock %}
+2
View File
@@ -14,6 +14,8 @@
{%- endblock %}"
>
<meta property="og:site_name" content="Association des Étudiants de l'UTBM" />
<meta property="og:locale" content="fr_FR" />
<meta property="og:locale:alternate" content="en_GB" />
{% block metatags %}
<meta property="og:url" content="{{ request.build_absolute_uri() }}" />
<meta property="og:type" content="website" />
+20 -22
View File
@@ -4,16 +4,12 @@
{% trans %}Delete confirmation{% endtrans %}
{% endblock %}
{% if is_fragment %}
{# Don't display tabs and errors #}
{% block tabs %}
{% endblock %}
{% block errors %}
{% endblock %}
{% endif %}
{% block file %}
<h2>{% trans %}Delete confirmation{% endtrans %}</h2>
@@ -23,30 +19,32 @@
{% set action = current %}
{% endif %}
<form action="{{ action }}" method="post">
{% csrf_token %}
<p>{% trans obj=object %}Are you sure you want to delete "{{ obj }}"?{% endtrans %}</p>
<button
<form
method="post"
{% if is_fragment %}
hx-post="{{ action }}"
hx-action="{{ action }}"
hx-target="#content"
hx-swap="outerHtml"
{% endif %}
>{% trans %}Confirm{% endtrans %}</button>
<button
{% if is_fragment %}
hx-get="{{ previous }}"
hx-target="#content"
hx-swap="outerHtml"
hx-swap="innerHTML"
{% else %}
action="window.history.back()"
action="{{ action }}"
{% endif %}
>{% trans %}Cancel{% endtrans %}</button>
>
{% csrf_token %}
<input type="submit" value="{% trans %}Confirm{% endtrans %}" />
</form>
<form
method="get"
{% if is_fragment %}
hx-action="{{ previous }}"
hx-target="#content"
hx-swap="innerHTML"
{% else %}
action="javascript:history.back();"
{% endif %}
>
<input type="submit" name="cancel" value="{% trans %}Cancel{% endtrans %}" />
</form>
{% endblock %}
+2 -2
View File
@@ -39,13 +39,13 @@
<p><button
hx-get="{{ url('core:file_moderate', file_id=f.id) }}"
hx-target="#content"
hx-swap="outerHtml"
hx-swap="innerHTML"
>{% trans %}Moderate{% endtrans %}</button> -
{% set current_page = url('core:file_moderation') + "?page=" + page_obj.number | string %}
<button
hx-get="{{ url('core:file_delete', file_id=f.id) }}?next={{ current_page | urlencode }}&previous={{ current_page | urlencode }}"
hx-target="#file-{{ loop.index }}"
hx-swap="outerHtml"
hx-swap="innerHTML"
>{% trans %}Delete{% endtrans %}</button></p>
</div>
{% endfor %}
@@ -1,6 +1,6 @@
<form
hx-post="{{ url("core:user_visibility_fragment", user_id=form.instance.id) }}"
hx-disabled-elt="find input[type='submit']"
hx-disable="find input[type='submit']"
hx-swap="outerHTML" x-data="{ isViewable: {{ form.is_viewable.value()|tojson }} }"
>
{% for message in messages %}
+3 -1
View File
@@ -20,12 +20,14 @@
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
from typing import Callable
from typing import TYPE_CHECKING, Callable
import honeypot.templatetags.honeypot as honeypot_filters
from django.template.loader import render_to_string
from jinja2 import Environment, nodes
from jinja2.ext import Extension
if TYPE_CHECKING:
from jinja2.parser import Parser
+4 -1
View File
@@ -23,15 +23,18 @@
#
import datetime
from typing import TYPE_CHECKING
import phonenumbers
from aemark import markdown as md
from django import template
from django.forms import BoundField
from django.template.defaultfilters import stringfilter
from django.utils.safestring import mark_safe
from django.utils.translation import ngettext
if TYPE_CHECKING:
from django.forms import BoundField
register = template.Library()
-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")
+4 -1
View File
@@ -1,4 +1,5 @@
from datetime import timedelta
from typing import TYPE_CHECKING
import freezegun
import pytest
@@ -6,7 +7,6 @@ from aemark import markdown
from bs4 import BeautifulSoup
from django.conf import settings
from django.contrib.auth.models import Permission
from django.test import Client
from django.urls import reverse
from django.utils.timezone import now
from model_bakery import baker
@@ -16,6 +16,9 @@ from club.models import Club, Membership
from core.baker_recipes import board_user, subscriber_user
from core.models import AnonymousUser, Page, PageRev, User
if TYPE_CHECKING:
from django.test import Client
@pytest.mark.django_db
class TestEditPage:
+16
View File
@@ -141,6 +141,22 @@ class TestSearchUsersView(TestSearchUsers):
response = self.client.get(reverse("core:search"))
assert response.status_code == 200
def test_search_with_whitelist_unique(self):
"""Test that when a user has a whitelist and appears in the results,
it appears only once.
This is a regression test (cf #1463)
"""
user = subscriber_user.make(is_viewable=False)
user.whitelisted_users.add(
*subscriber_user.make(_quantity=4, _bulk_create=True)
)
self.client.force_login(user)
response = self.client.get(
reverse("core:search", query={"query": user.last_name})
)
assert response.context_data["users"] == [user]
@pytest.mark.django_db
def test_user_account_not_found(client: Client):
+6 -22
View File
@@ -21,7 +21,7 @@
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
from django.urls import path, re_path, register_converter
from django.urls import path, register_converter
from django.views.generic import RedirectView
from com.views import NewsListView
@@ -193,27 +193,11 @@ urlpatterns = [
name="user_gift_delete",
),
# File views
re_path(r"^file/$", FileListView.as_view(), name="file_list"),
re_path(
r"^file/(?P<file_id>[0-9]+)/$",
FileView.as_view(),
name="file_detail",
),
re_path(
r"^file/(?P<file_id>[0-9]+)/edit/$",
FileEditView.as_view(),
name="file_edit",
),
re_path(
r"^file/(?P<file_id>[0-9]+)/prop/$",
FileEditPropView.as_view(),
name="file_prop",
),
re_path(
r"^file/(?P<file_id>[0-9]+)/delete/$",
FileDeleteView.as_view(),
name="file_delete",
),
path("file/", FileListView.as_view(), name="file_list"),
path("file/<int:file_id>/", FileView.as_view(), name="file_detail"),
path("file/<int:file_id>/edit/", FileEditView.as_view(), name="file_edit"),
path("file/<int:file_id>/prop/", FileEditPropView.as_view(), name="file_prop"),
path("file/<int:file_id>/delete/", FileDeleteView.as_view(), name="file_delete"),
path("file/moderation/", FileModerationView.as_view(), name="file_moderation"),
path(
"file/<int:file_id>/moderate/", FileModerateView.as_view(), name="file_moderate"
+1 -36
View File
@@ -12,15 +12,12 @@
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
from __future__ import annotations
import hmac
from datetime import date, timedelta
# Image utils
from io import BytesIO
from typing import TYPE_CHECKING
from urllib.parse import urlencode
from typing import TYPE_CHECKING, Final
import PIL
from django.conf import settings
@@ -29,14 +26,9 @@ from django.utils.timezone import localdate
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] = (
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"
@@ -198,30 +190,3 @@ def get_client_ip(request: HttpRequest) -> str | None:
return ip
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()
+6 -10
View File
@@ -13,7 +13,7 @@
#
#
import mimetypes
from pathlib import Path
from typing import TYPE_CHECKING
from urllib.parse import quote, urljoin
# This file contains all the views that concern the page model
@@ -48,6 +48,9 @@ from core.views.widgets.ajax_select import (
)
from counter.utils import is_logged_in_counter
if TYPE_CHECKING:
from pathlib import Path
def send_raw_file(path: Path) -> HttpResponse:
"""Send a file located in the MEDIA_ROOT
@@ -353,15 +356,8 @@ class FileDeleteView(AllowFragment, CanEditPropMixin, DeleteView):
if "next" in self.request.GET:
return self.request.GET["next"]
if self.object.parent is None:
return reverse(
"core:file_list",
)
return reverse(
"core:file_detail",
kwargs={
"file_id": self.object.parent.id,
},
)
return reverse("core:file_list")
return reverse("core:file_detail", kwargs={"file_id": self.object.parent.id})
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
+4 -1
View File
@@ -22,8 +22,8 @@
#
import re
from copy import copy
from datetime import date, datetime
from io import BytesIO
from typing import TYPE_CHECKING
from captcha.fields import CaptchaField
from django import forms
@@ -59,6 +59,9 @@ from core.views.widgets.ajax_select import (
)
from core.views.widgets.markdown import MarkdownInput
if TYPE_CHECKING:
from datetime import date, datetime
# Widgets
+7 -2
View File
@@ -22,11 +22,11 @@
#
#
from typing import TYPE_CHECKING
from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import F
from django.db.models.query import QuerySet
from django.http import HttpRequest
from django.shortcuts import get_object_or_404, redirect
from django.views.generic import ListView, TemplateView
@@ -34,6 +34,10 @@ from club.models import Club
from core.models import Notification, User
from core.schemas import UserFilterSchema
if TYPE_CHECKING:
from django.db.models.query import QuerySet
from django.http import HttpRequest
class NotificationList(LoginRequiredMixin, ListView):
model = Notification
@@ -65,6 +69,7 @@ class SearchView(LoginRequiredMixin, TemplateView):
UserFilterSchema(search=query)
.filter(User.objects.viewable_by(self.request.user))
.order_by(F("last_login").desc(nulls_last=True))
.distinct()
)
clubs = list(Club.objects.filter(name__icontains=query)[:5])
return super().get_context_data(**kwargs) | {"users": users, "clubs": clubs}
+5 -3
View File
@@ -1,14 +1,16 @@
import copy
import inspect
from typing import Any, ClassVar, LiteralString, Protocol, Unpack
from typing import TYPE_CHECKING, Any, ClassVar, LiteralString, Protocol, Unpack
from django.core.exceptions import ImproperlyConfigured
from django.http import HttpRequest, HttpResponse
from django.template.loader import render_to_string
from django.utils.safestring import SafeString
from django.views import View
from django.views.generic.base import ContextMixin, TemplateResponseMixin
if TYPE_CHECKING:
from django.http import HttpRequest, HttpResponse
from django.utils.safestring import SafeString
class TabedViewMixin(View):
"""Basic functions for displaying tabs in the template."""
+4 -1
View File
@@ -27,6 +27,7 @@ from datetime import timedelta
# This file contains all the views that concern the user model
from operator import itemgetter
from smtplib import SMTPException
from typing import TYPE_CHECKING
from django.contrib import messages
from django.contrib.auth import login, views
@@ -43,7 +44,6 @@ from django.shortcuts import get_object_or_404, redirect
from django.template.loader import render_to_string
from django.urls import reverse, reverse_lazy
from django.utils.decorators import method_decorator
from django.utils.safestring import SafeString
from django.utils.translation import gettext as _
from django.views.decorators.http import require_POST
from django.views.generic import (
@@ -73,6 +73,9 @@ from counter.models import Refilling, Selling
from eboutic.models import Invoice
from trombi.views import UserTrombiForm
if TYPE_CHECKING:
from django.utils.safestring import SafeString
class SithLoginView(views.LoginView):
"""The login View."""
+7 -4
View File
@@ -1,15 +1,18 @@
from collections.abc import Collection
from typing import Any
from typing import TYPE_CHECKING, Any
from django.contrib.staticfiles.storage import staticfiles_storage
from django.db.models import Model, QuerySet
from django.forms import Select, SelectMultiple
from ninja import ModelSchema
from pydantic import TypeAdapter
from core.models import Group, SithFile, User
from core.schemas import GroupSchema, SithFileSchema, UserProfileSchema
if TYPE_CHECKING:
from collections.abc import Collection
from django.db.models import Model, QuerySet
from ninja import ModelSchema
class AutoCompleteSelectMixin:
component_name = "autocomplete-select"
+4 -2
View File
@@ -3,7 +3,7 @@ import math
import uuid
from collections import defaultdict
from datetime import date, datetime, timezone
from typing import ClassVar
from typing import TYPE_CHECKING, ClassVar
from dateutil.relativedelta import relativedelta
from django import forms
@@ -11,7 +11,6 @@ from django.conf import settings
from django.core.exceptions import ValidationError
from django.db.models import Exists, OuterRef, Q
from django.forms import BaseModelFormSet
from django.http import HttpRequest
from django.utils.functional import cached_property
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
@@ -59,6 +58,9 @@ from counter.widgets.ajax_select import (
AutoCompleteSelectProduct,
)
if TYPE_CHECKING:
from django.http import HttpRequest
class BillingInfoForm(forms.ModelForm):
class Meta:
+4 -1
View File
@@ -1,5 +1,5 @@
from collections.abc import Iterable
from operator import attrgetter
from typing import TYPE_CHECKING
from django.conf import settings
from django.core.mail import send_mass_mail
@@ -13,6 +13,9 @@ from django.utils.translation import gettext as _
from core.models import User, UserQuerySet
from counter.models import AccountDump, Counter, Customer, Product, Selling
if TYPE_CHECKING:
from collections.abc import Iterable
class Command(BaseCommand):
"""Effectively dump the inactive users.
+3 -2
View File
@@ -1,13 +1,14 @@
from typing import TYPE_CHECKING, Callable
from django.http import HttpRequest, HttpResponse
from django.utils.functional import SimpleLazyObject
from core.models import User
from counter.models import Permanency
if TYPE_CHECKING:
from django.contrib.sessions.backends.base import SessionBase
from django.http import HttpRequest, HttpResponse
from core.models import User
SESSION_PERMANENCES_KEY = "permanence_ids"
@@ -1,11 +1,14 @@
# Generated by Django 4.2.17 on 2024-12-08 13:30
from operator import attrgetter
from typing import TYPE_CHECKING
import django.db.models.deletion
from django.db import migrations, models
from django.db.migrations.state import StateApps
from django.db.models import Count
if TYPE_CHECKING:
from django.db.migrations.state import StateApps
def delete_duplicates(apps: StateApps, schema_editor):
"""Delete cards of users with more than one student cards.
@@ -1,6 +1,10 @@
# Generated by Django 4.2.17 on 2024-12-15 17:53
from typing import TYPE_CHECKING
from django.db import migrations, models
if TYPE_CHECKING:
from django.db.migrations.state import StateApps
@@ -1,9 +1,13 @@
# Generated by Django 5.2.8 on 2025-11-19 17:59
from typing import TYPE_CHECKING
from django.db import migrations, models
from django.db.migrations.state import StateApps
from django.db.models import Case, When
if TYPE_CHECKING:
from django.db.migrations.state import StateApps
def migrate_selling_payment_method(apps: StateApps, schema_editor):
# 0 <=> SITH_ACCOUNT is the default value, so no need to migrate it
@@ -1,13 +1,16 @@
# Generated by Django 5.2.8 on 2026-02-10 15:40
from operator import attrgetter
from typing import TYPE_CHECKING
import django.utils.timezone
from django.db import migrations, models
from django.db.migrations.state import StateApps
from django.db.models import OuterRef, Subquery
from counter.models import Selling
if TYPE_CHECKING:
from django.db.migrations.state import StateApps
def apply_product_history_dates(apps: StateApps, schema_editor):
"""Approximate a posteriori the value of created_at and updated_at."""
+5 -1
View File
@@ -1,11 +1,15 @@
# Generated by Django 5.2.11 on 2026-02-18 13:30
from typing import TYPE_CHECKING
import django.db.models.deletion
from django.db import migrations, models
from django.db.migrations.state import StateApps
import counter.fields
if TYPE_CHECKING:
from django.db.migrations.state import StateApps
def migrate_prices(apps: StateApps, schema_editor):
Product = apps.get_model("counter", "Product")
@@ -1,7 +1,7 @@
import type { TomOption } from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils";
import { AjaxSelect } from "#core:core/components/ajax-select-base.ts";
import { registerComponent } from "#core:utils/web-components.ts";
import type { TomOption } from "tom-select/src/types";
import type { escape_html } from "tom-select/src/utils";
import { AjaxSelect } from "#core:core/components/ajax-select-base";
import { registerComponent } from "#core:utils/web-components";
import {
type CounterSchema,
counterSearchCounter,
@@ -28,14 +28,23 @@ export class ProductAjaxSelect extends AjaxSelect {
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) {
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>`;
}
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 labelField = "name";
protected searchField = ["name"];
private productTypes = null as ProductTypeSchema[];
private productTypes = null as ProductTypeSchema[] | null;
protected async search(query: string): Promise<TomOption[]> {
// 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,
// then to reuse the result again and again.
if (this.productTypes === null) {
this.productTypes = (await producttypeFetchAll()).data || null;
this.productTypes = (await producttypeFetchAll()).data || [];
}
return this.productTypes.filter((t) =>
t.name.toLowerCase().includes(query.toLowerCase()),
@@ -59,7 +59,7 @@ export class CounterProductSelect extends AutoCompleteSelectBase {
return onOptionSelect.call(
this.widget,
evt,
this.widget.getOption(value, true),
this.widget.getOption(value, true) as HTMLElement,
);
},
);
@@ -70,12 +70,15 @@ export class CounterProductSelect extends AutoCompleteSelectBase {
...super.tomSelectSettings(),
openOnFocus: false,
// 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
//
// SearchField can be an object array (according to the docs),
// but it is unproperly typed, so we must force-cast to trick TS
searchField: [
{ field: "code", weight: 2 },
{ field: "text", weight: 0.5 },
],
] as unknown as string[],
};
}
}
@@ -1,9 +1,14 @@
import { showSaveFilePicker } from "native-file-system-adapter";
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 { paginated } from "#core:utils/api";
import { type PaginatedRequest, paginated } from "#core:utils/api";
import { csv } from "#core:utils/csv";
import { getCurrentUrlParams, History, updateQueryString } from "#core:utils/history";
import type {
CounterAjaxSelect,
ProductTypeAjaxSelect,
} from "#counter:counter/components/ajax-select-index";
import {
type ProductSchema,
type ProductSearchProductsDetailedData,
@@ -51,6 +56,8 @@ const csvColumnTitles = [
gettext("archived"),
];
type ProductStatus = "active" | "archived" | "both";
document.addEventListener("alpine:init", () => {
Alpine.data("productList", () => ({
loading: false,
@@ -59,8 +66,7 @@ document.addEventListener("alpine:init", () => {
/** Total number of elements corresponding to the current query. */
nbPages: 0,
productStatus: "" as "active" | "archived" | "both",
productStatus: "" as ProductStatus,
search: "",
productTypes: [] as string[],
clubs: [] as string[],
@@ -71,16 +77,18 @@ document.addEventListener("alpine:init", () => {
async init() {
const url = getCurrentUrlParams();
this.search = url.get("search") || "";
this.productStatus = url.get("productStatus") ?? "active";
const productTypesWidget = this.$refs.productTypesInput.widget as TomSelect;
this.productStatus = (url.get("productStatus") ?? "active") as ProductStatus;
const productTypesWidget = (this.$refs.productTypesInput as ProductTypeAjaxSelect)
.widget as TomSelect;
productTypesWidget.on("change", (items: string[]) => {
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[]) => {
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[]) => {
this.counters = [...items];
});
@@ -127,9 +135,9 @@ document.addEventListener("alpine:init", () => {
// biome-ignore lint/style/useNamingConvention: api is in snake_case
is_archived: isArchived,
// biome-ignore lint/style/useNamingConvention: api is in snake_case
product_type: [...this.productTypes],
club: [...this.clubs],
counter: [...this.counters],
product_type: [...this.productTypes.map(Number.parseInt)],
club: [...this.clubs.map(Number.parseInt)],
counter: [...this.counters.map(Number.parseInt)],
},
};
},
@@ -141,6 +149,10 @@ document.addEventListener("alpine:init", () => {
this.loading = true;
const options = this.getQueryParams();
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.products = resp.data.results.reduce<GroupedProducts>(
(acc: GroupedProducts, curr: ProductSchema) => {
@@ -172,7 +184,10 @@ document.addEventListener("alpine:init", () => {
// If not, fetch them.
const products: ProductSchema[] =
this.nbPages > 1
? await paginated(productSearchProductsDetailed, this.getQueryParams())
? await paginated(
productSearchProductsDetailed,
this.getQueryParams() as PaginatedRequest,
)
: Object.values<ProductSchema[]>(this.products).flat();
// CSV cannot represent nested data
// so we create a row for each price of each product.
@@ -1,5 +1,5 @@
import Alpine from "alpinejs";
import { AlertMessage } from "#core:utils/alert-message.ts";
import { AlertMessage } from "#core:utils/alert-message";
import { producttypeReorder } from "#openapi";
document.addEventListener("alpine:init", () => {
@@ -22,7 +22,7 @@ document.addEventListener("alpine:init", () => {
const productTypes = this.$refs.productTypes
.childNodes as NodeListOf<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 =
newPosition === 0
? { above: getId(productTypes.item(1)) }
@@ -32,6 +32,10 @@ document.addEventListener("alpine:init", () => {
path: { type_id: itemId },
query: query,
});
if (response.response === undefined) {
console.error("Product type reordering request failed");
return;
}
this.openAlertMessage(response.response);
this.loading = false;
},
@@ -1,5 +1,10 @@
<div id="student_card_form">
<form hx-post="{{ action }}" hx-swap="outerHTML" hx-target="#student_card_form">
<form
hx-post="{{ action }}"
hx-swap="outerHTML"
hx-target="#student_card_form"
hx-disable="input[type='submit']"
>
{% csrf_token %}
<p>{% trans obj=object %}Are you sure you want to delete "{{ obj }}"?{% endtrans %}</p>
<input type="submit" value="{% trans %}Confirm{% endtrans %}" />
+4 -1
View File
@@ -1,5 +1,5 @@
from collections.abc import Iterable
from datetime import timedelta
from typing import TYPE_CHECKING
import freezegun
import pytest
@@ -17,6 +17,9 @@ from counter.management.commands.dump_warning_mail import Command as WarningComm
from counter.models import AccountDump, Customer, Refilling, Selling
from subscription.models import Subscription
if TYPE_CHECKING:
from collections.abc import Iterable
class TestAccountDump(TestCase):
@classmethod
+4 -1
View File
@@ -1,9 +1,9 @@
import json
from datetime import timedelta
from typing import TYPE_CHECKING
import pytest
from django.conf import settings
from django.test import Client
from django.urls import reverse
from django.utils.timezone import now
from django_celery_beat.models import ClockedSchedule
@@ -18,6 +18,9 @@ from counter.forms import (
)
from counter.models import Product, ProductType, ScheduledProductAction
if TYPE_CHECKING:
from django.test import Client
@pytest.mark.django_db
def test_edit_product(client: Client):
+4 -1
View File
@@ -15,6 +15,7 @@
from dataclasses import asdict, dataclass
from datetime import timedelta
from decimal import Decimal
from typing import TYPE_CHECKING
import pytest
from bs4 import BeautifulSoup
@@ -22,7 +23,6 @@ from dateutil.relativedelta import relativedelta
from django.conf import settings
from django.contrib.auth.models import Permission, make_password
from django.contrib.messages import DEFAULT_LEVELS, get_messages
from django.http import HttpResponse
from django.shortcuts import resolve_url
from django.test import Client, TestCase
from django.urls import reverse
@@ -48,6 +48,9 @@ from counter.models import (
Selling,
)
if TYPE_CHECKING:
from django.http import HttpResponse
def set_age(user: User, age: int):
user.date_of_birth = localdate().replace(year=localdate().year - age)
+4 -1
View File
@@ -1,10 +1,10 @@
from datetime import date, datetime
from typing import TYPE_CHECKING
import pytest
from dateutil.relativedelta import relativedelta
from django.contrib.auth.models import Permission
from django.core.exceptions import ValidationError
from django.test import Client
from django.urls import reverse
from django.utils.timezone import now
from model_bakery import baker
@@ -16,6 +16,9 @@ from counter.baker_recipes import sale_recipe
from counter.forms import InvoiceCallForm
from counter.models import Customer, InvoiceCall, Selling
if TYPE_CHECKING:
from django.test import Client
@pytest.mark.django_db
@pytest.mark.parametrize(

Some files were not shown because too many files have changed in this diff Show More