From 44071f91f610b6d0652d999cc36c993bff80b247 Mon Sep 17 00:00:00 2001
From: imperosol
{% endif %}
diff --git a/matmat/tests.py b/matmat/tests.py
index d05a81b2..ee6f0262 100644
--- a/matmat/tests.py
+++ b/matmat/tests.py
@@ -58,3 +58,13 @@ class TestMatmatronch(TestCase):
assert list(response.context_data["object_list"]) == []
assert not response.context_data["form"].is_valid()
assert "Recherche vide" in response.context_data["form"].non_field_errors()
+
+ def test_search_many_users(self):
+ """Test that the pagination works when a lot of users are returned."""
+
+ baker.make(User, promo=17, _quantity=40, _bulk_create=True)
+ self.client.force_login(subscriber_user.make())
+ response = self.client.get(reverse("matmat:search", query={"promo": 17}))
+ assert response.status_code == 200
+ assert response.context_data["paginator"].count == 43
+ assert response.context_data["paginator"].num_pages == 3
diff --git a/matmat/views.py b/matmat/views.py
index 711d7e17..4f5590a2 100644
--- a/matmat/views.py
+++ b/matmat/views.py
@@ -20,7 +20,6 @@
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
-
from django.db.models import F
from django.views.generic import ListView
from django.views.generic.edit import FormMixin
From 8175fc415cd8eafc8198ad4f097ea0661336eea1 Mon Sep 17 00:00:00 2001
From: imperosol
+
+ {% endblock %} diff --git a/core/templates/core/register.jinja b/core/templates/core/register.jinja index 249de9bf..71ef7f2c 100644 --- a/core/templates/core/register.jinja +++ b/core/templates/core/register.jinja @@ -18,7 +18,17 @@ {% endblock %} diff --git a/core/tests/test_core.py b/core/tests/test_core.py index aa19befa..bbf2fb48 100644 --- a/core/tests/test_core.py +++ b/core/tests/test_core.py @@ -55,6 +55,7 @@ class TestUserRegistration: "password2": "plop", "captcha_0": "dummy-value", "captcha_1": "PASSED", + "cgu_approved": True, } @pytest.fixture() @@ -92,6 +93,7 @@ class TestUserRegistration: ({"first_name": ""}, "Ce champ est obligatoire."), ({"last_name": ""}, "Ce champ est obligatoire."), ({"captcha_1": "WRONG_CAPTCHA"}, "CAPTCHA invalide"), + ({"cgu_approved": ""}, "Vous devez approuver les conditions générales d'utilisation"), ], ) def test_register_user_form_fail( diff --git a/core/views/forms.py b/core/views/forms.py index cfbeac69..c10a7da6 100644 --- a/core/views/forms.py +++ b/core/views/forms.py @@ -42,6 +42,8 @@ from django.forms import ( TextInput, Widget, ) +from django.urls import reverse +from django.utils.safestring import mark_safe from django.utils.timezone import now from django.utils.translation import gettext_lazy as _ from phonenumber_field.widgets import RegionalPhoneNumberWidget @@ -146,9 +148,25 @@ class RegisteringForm(UserCreationForm): class Meta: model = User - fields = ("first_name", "last_name", "email") + fields = ("first_name", "last_name", "email", "cgu_approved") field_classes = {"email": AntiSpamEmailField} + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["cgu_approved"].required = True + self.fields["cgu_approved"].label_suffix = "" + self.fields["cgu_approved"].label = mark_safe( + _( + "I have read and I approve the " + 'End User License Agreement' + ) + % { + "url": reverse( + "core:download", kwargs={"file_id": settings.SITH_CGU_FILE_ID} + ) + } + ) + class UserProfileForm(forms.ModelForm): """Form handling the user profile, managing the files""" diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index a7c32468..1ed6df60 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-09 07:21+0200\n" +"POT-Creation-Date: 2026-09-15 23:49+0200\n" "PO-Revision-Date: 2016-07-18\n" "Last-Translator: Maréchal-
- - -
- {% trans %}Total subscriptions{% endtrans %} : {{ subscriptions_total.count() }}
- {% trans %}Subscriptions by type{% endtrans %}
- {% for location in locations %}
- {{ location[1] }} : {{ subscriptions_total.filter(location=location[0]).count() }}
+ {% trans %}Total subscriptions{% endtrans %} : {{ subscriptions_total.count() }}
+ {% trans %}Subscriptions by type{% endtrans %}
+ {% for location in locations %}
+ {{ location[1] }} : {{ subscriptions_total.filter(location=location[0]).count() }}
+ {% endfor %}
+
+
+
| {% trans %}Subscription type{% endtrans %} | + {% for location in locations %} +{{ location[1] }} | + {% endfor %} +{% trans %}Total{% endtrans %} | + + {% for type in subscriptions_types %} +
|---|---|---|
| {{ subscriptions_types[type]['name'] }} | + {% set subscriptions_total_type = subscriptions_total.filter(subscription_type=type) %} + {% for location in locations %} +
+ {% set subscriptions_total_type_location = subscriptions_total_type.filter(location=location[0]) %}
+ {% trans %}Total{% endtrans %} : {{ subscriptions_total_type_location.count()}} + {% for p_type in payment_types %} + {{ p_type[1] }} : {{ subscriptions_total_type_location.filter(payment_method=p_type[0]).count()}} + {% endfor %} + |
+ {% endfor %}
+ {{subscriptions_total_type.count()}} | +
| {% trans %}Subscription type{% endtrans %} | - {% for location in locations %} -{{ location[1] }} | - {% endfor %} -{% trans %}Total{% endtrans %} | - {% for type in subscriptions_types %} -
|---|---|---|
| {{ subscriptions_types[type]['name'] }} | - {% set subscriptions_total_type = subscriptions_total.filter(subscription_type=type) %} - {% for location in locations %} -
- {% set subscriptions_total_type_location = subscriptions_total_type.filter(location=location[0]) %}
- {% trans %}Total{% endtrans %} : {{ subscriptions_total_type_location.count()}} - {% for p_type in payment_types %} - {{ p_type[1] }} : {{ subscriptions_total_type_location.filter(payment_method=p_type[0]).count()}} - {% endfor %} - |
- {% endfor %}
- {{subscriptions_total_type.count()}} - |
| {% trans %}Total{% endtrans %} | +||||
|---|---|---|---|---|
| {% trans %}All subscriptions{% endtrans %} | + {% for location in locations %} +{{ total_location[location[0]] }} |
+ {% endfor %}
+ {{ total_location.values()|sum }} | +||
| {{ subscriptions_types[type]['name'] }} | - {% set subscriptions_total_type = subscriptions_total.filter(subscription_type=type) %} +{{ subscriptions_types[type]['name'] }} | {% for location in locations %}
- {% set subscriptions_total_type_location = subscriptions_total_type.filter(location=location[0]) %}
- {% trans %}Total{% endtrans %} : {{ subscriptions_total_type_location.count()}} {% for p_type in payment_types %} - {{ p_type[1] }} : {{ subscriptions_total_type_location.filter(payment_method=p_type[0]).count()}} + {% set subtotal = subscriptions[type][location[0]][p_type[0]] %} + {% if subtotal > 0 %} + {{ p_type[1] }} : {{ subtotal }} + {% endif %} {% endfor %} |
{% endfor %}
- {{subscriptions_total_type.count()}} | +{{ total_type[type] }} |
- - - -
+ {% if date %} ++ - + +
+ {% endif %}
- {% trans trimmed app=third_party_app, cgu_link=third_party_cgu, sith_cgu_link=sith_cgu %}
- The privacy policies of {{ app }}
+ {% trans trimmed app=third_party_app, privacy_link=third_party_cgu, sith_cgu_link=sith_cgu %}
+ The privacy policies of {{ app }}
and of the Students' Association
applies as soon as the form is submitted.
{% endtrans %}
diff --git a/api/tests/test_third_party_auth.py b/api/tests/test_third_party_auth.py
index ad9a6927..39faebce 100644
--- a/api/tests/test_third_party_auth.py
+++ b/api/tests/test_third_party_auth.py
@@ -9,6 +9,7 @@ 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
@@ -34,14 +35,16 @@ class TestThirdPartyAuth(TestCase):
self.query = {
"client_id": self.api_client.id,
"third_party_app": "app",
- "cgu_link": "https://foobar.fr/",
+ "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_id": self.user.id}
+ 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
+ self.api_client.hmac_key, self.callback_data["user"]
)
def test_auth_ok(self):
diff --git a/api/views.py b/api/views.py
index 7d0393cb..30801019 100644
--- a/api/views.py
+++ b/api/views.py
@@ -10,9 +10,7 @@ from django.core.exceptions import PermissionDenied
from django.urls import reverse, reverse_lazy
from django.utils.translation import gettext as _
from django.views.generic import FormView, TemplateView
-from ninja import Schema
from ninja_extra.shortcuts import get_object_or_none
-from pydantic import HttpUrl
from api.forms import ThirdPartyAuthForm
from api.models import ApiClient
@@ -22,15 +20,6 @@ from core.schemas import UserProfileSchema
from core.utils import hmac_hexdigest
-class ThirdPartyAuthParamsSchema(Schema):
- client_id: int
- third_party_app: str
- cgu_link: HttpUrl
- username: str
- callback_url: HttpUrl
- signature: str
-
-
class ThirdPartyAuthView(LoginRequiredMixin, FormView):
form_class = ThirdPartyAuthForm
template_name = "api/third_party/auth.jinja"
@@ -94,7 +83,7 @@ class ThirdPartyAuthView(LoginRequiredMixin, FormView):
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.cgu_link,
+ "third_party_cgu": self.params.privacy_link,
"sith_cgu": Page.objects.get(_full_name=settings.SITH_CGU_PAGE),
}
diff --git a/core/utils.py b/core/utils.py
index 1adf50ec..129764ac 100644
--- a/core/utils.py
+++ b/core/utils.py
@@ -207,14 +207,14 @@ def get_client_ip(request: HttpRequest) -> str | None:
def hmac_hexdigest(
key: str | bytes,
data: Mapping[str, Any] | Sequence[tuple[str, Any]],
- digest: str | Callable[[Buffer], HASH] = "sha256",
+ 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
+ digest: a PEP247 hashing algorithm (by default, sha512)
Examples:
```python
@@ -223,7 +223,7 @@ def hmac_hexdigest(
"bar": "somevalue",
}
hmac_key = secrets.token_hex(64)
- signature = hmac_hexdigest(hmac_key, data, "sha512")
+ signature = hmac_hexdigest(hmac_key, data, "sha256")
```
"""
if isinstance(key, str):
diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md
new file mode 100644
index 00000000..c0108439
--- /dev/null
+++ b/docs/reference/api/schemas.md
@@ -0,0 +1 @@
+::: api.schemas
\ No newline at end of file
diff --git a/docs/reference/api/views.md b/docs/reference/api/views.md
new file mode 100644
index 00000000..2a0daef1
--- /dev/null
+++ b/docs/reference/api/views.md
@@ -0,0 +1 @@
+::: api.views
\ No newline at end of file
diff --git a/docs/tutorial/api/account-link.md b/docs/tutorial/api/account-link.md
new file mode 100644
index 00000000..2a125824
--- /dev/null
+++ b/docs/tutorial/api/account-link.md
@@ -0,0 +1,353 @@
+Le site AE offre des mécanismes permettant aux applications tierces
+de récupérer les informations sur un utilisateur du site AE.
+De cette manière, il devient possible de synchroniser les informations
+qu possède l'application tierce sur l'utilisateur, directement depuis
+le site AE.
+
+## Fonctionnement général
+
+Pour authentifier vos utilisateurs, vous aurez besoin d'un serveur web
+et d'un client d'API (celui auquel est liée votre
+[clef d'API](./connect.md#obtenir-une-clef-dapi)).
+Deux informations vous sont nécessaires, en plus de votre clef d'API :
+
+- l'id du client : vous pouvez l'obtenir soit en le demandant à l'équipe info,
+ soit en appelant la route `GET /client/me` avec votre clef d'API
+ renseignée dans le header [X-APIKey](./connect.md#x-apikey)
+- la clef HMAC du client : vous devez la demander à l'équipe info.
+
+Grâce à ces informations, vous allez pouvoir fournir le contexte nécessaire
+au site AE pour qu'il authentifie vos utilisateurs.
+
+En effet, la démarche d'authentification s'effectue presque entièrement
+sur le site : le travail de l'application tierce consiste uniquement
+à fournir à l'utilisateur une url avec les bons paramètres, puis
+à recevoir la réponse du serveur si tout s'est bien passé.
+
+Comme un dessin vaut parfois mieux que mille mots,
+voici les diagrammes décrivant le processus.
+L'un montre l'entièreté de la démarche ;
+l'autre dans un souci de simplicité, ne montre que ce qui est visible
+directement par l'application tierce.
+
+=== "Intégralité du processus"
+
+ ```mermaid
+ sequenceDiagram
+ actor User
+ participant App
+ User->>+App: Authentifie-moi, stp
+ App-->>-User: url de connexion
avec signature
+ User->>+Sith: GET url
+ opt Utilisateur non-connecté
+ Sith->>+User: Formulaire de connexion
+ User-->>-Sith: Connexion
+ end
+ Sith->>Sith: vérification de la signature
+ Sith->>+User: Formulaire
des conditions
d'utilisation
+ User-->>-Sith: Validation
+ Sith->>+App: URL de retour
avec données utilisateur
+ App->>App: Traitement des
données utilisateur
+ App-->>-Sith: 204 OK, No content
+ Sith-->>-User: Message de succès
+ App--)User: Message de succès
+ ```
+
+=== "Point de vue de l'application tierce"
+
+ ```mermaid
+ sequenceDiagram
+ actor User
+ participant App
+ User->>+App: Authentifie-moi, stp
+ App-->>-User: url de connexion
avec signature
+ opt
+ Sith->>+App: URL de retour
avec données utilisateur
+ App->>App: Traitement des
données utilisateur
+ App-->>-Sith: 204 OK, No content
+ App--)User: Message de succès
+ end
+ ```
+
+## Données attendues
+
+### URL de connexion
+
+L'URL de connexion que vous allez fournir à l'utilisateur doit
+être `https://ae.utbm.fr/api-link/auth/`
+et doit contenir les données décrites dans
+[`ThirdPartyAuthParamsSchema`][api.schemas.ThirdPartyAuthParamsSchema] :
+
+- `client_id` (integer) : l'id de votre client, que vous pouvez obtenir
+ de la manière décrite plus haut
+- `third_party_app`(string) : le nom de la plateforme pour laquelle
+ l'authentification va être réalisée (si votre application est un bot
+ discord, mettez la valeur "discord")
+- `privacy_link`(URL) : l'URL vers la page de politique de confidentialité
+ qui s'appliquera dans le cadre de l'application
+ (s'il s'agit d'un bot discord, donnez le lien vers celles de Discord)
+- `username`(string) : le pseudonyme que l'utilisateur possède sur
+ votre application
+- `callback_url`(URL) : l'URL que le site AE appellera si l'authentification
+ réussit
+- `signature`(string) : la signature des données de la requête.
+
+Ces données doivent être url-encodées et passées dans les paramètres GET.
+
+!!!tip "URL de retour"
+
+ Notre système n'impose aucune contrainte quant à la manière
+ de construire votre URL (hormis le fait que ce doit être une URL HTTPS valide),
+ mais il est tout de même conseillé d'utiliser l'identifiant de votre
+ utilisateur comme paramètre dans l'URL
+ (par exemple `GET /callback/{int:user_id}/`).
+
+???Example
+
+ Supposons que votre client d'API soit utilisé dans le cadre d'un bot Discord,
+ avec les données suivantes :
+
+ - l'id du client est 15
+ - sa clef HMAC est "beb99dd53"
+ (c'est pour l'exemple, une vraie clef sera beaucoup plus longue)
+ - le pseudonyme discord de votre utilisateur est Brian
+ - son id sur discord est 123456789
+ - votre route de callback est `GET /callback/{int:user_id}/`,
+ accessible au domaine `https://bot.ae.utbm.fr`
+
+ Alors les paramètres de votre URL seront :
+
+ | Paramètre | valeur |
+ |-----------------|-----------------------------------------------------------------------|
+ | client_id | 15 |
+ | third_party_app | discord |
+ | privacy_link | `https://discord.com/privacy` |
+ | username | Brian |
+ | callback_url | `https://bot.ae.utbm.fr/callback/123456789/` |
+ | signature | 1a383c51060be64f07772aa42e07
18ae096b8f21f2cdb4061c0834a416d12101 |
+
+ Et l'url fournie à l'utilisateur sera :
+
+ `https://ae.utbm.fr/api-link/auth/?client_id=15&third_party_app=discord
+ &privacy_link=https%3A%2F%2Fdiscord.com%2Fprivacy&username=Brian
+ &callback_url=https%3A%2F%2Fbot.ae.utbm.fr%2Fcallback%2F123456789%2F
+ &signature=1a383c51060be64f07772aa42e0718ae096b8f21f2cdb4061c0834a416d12101`
+
+### Données de retour
+
+Si l'authentification réussit, le site AE enverra une requête HTTP POST
+à l'URL de retour fournie dans l'URL de connexion.
+
+Le corps de la requête de callback et au format JSON
+et contient deux paires clef-valeur :
+
+- `user` : les données utilisateur, telles que décrites
+ par [UserProfileSchema][core.schemas.UserProfileSchema]
+- `signature` : la signature des données utilisateur
+
+???Example
+
+ En reprenant les mêmes paramètres que dans l'exemple précédent,
+ le site AE pourra renvoyer à l'application la requête suivante :
+
+ ```http
+ POST https://bot.ae.utbm.fr/callback/123456789/
+ content-type: application/json
+ body: {
+ "user": {
+ "id": 144131,
+ "nick_name": "inzekitchen",
+ "first_name": "Brian",
+ ...
+ },
+ "signature": "f16955bab6b805f6e1abbb98a86dfee53fed0bf812aa6513ca46cfd461b70020"
+ }
+ ```
+
+L'application doit répondre avec un des codes HTTP suivants :
+
+| Code | Raison |
+|------|--------------------------------------------------------------------------------|
+| 204 | Tout s'est bien passé |
+| 403 | Les données de retour ne sont
pas signées ou sont mal signées |
+| 404 | L'URL de retour ne permet pas
d'identifier un utilisateur de l'application |
+
+!!!note "Code d'erreur par défaut"
+
+ Si l'appel de la route fait face à plusieurs problèmes en même temps
+ (par exemple, l'URL ne permet pas de retrouver votre utilisateur,
+ et en plus les données sont mal signées),
+ le 403 prime et doit être retourné par défaut.
+
+## Signature des données
+
+Les données de l'URL de connexion doivent être signées,
+et la signature de l'URL de retour doit être vérifiée.
+
+Dans le deux cas, la signature est le digest HMAC-SHA512
+des données url-encodées, en utilisant la clef HMAC du client d'API.
+
+???Example "Signature de l'URL de connexion"
+
+ En reprenant le même exemple que les fois précédentes,
+ l'url-encodage des données est :
+
+ `client_id=15&third_party_app=discord
+ &privacy_link=https%3A%2F%2Fdiscord.com%2Fprivacy%2F&username=Brian
+ &callback_url=https%3A%2F%2Fbot.ae.utbm.fr%2Fcallback%2F123456789%2F`
+
+ Notez que la signature n'est pas (encore) dedans.
+ Cette dernière peut-être obtenue avec le code suivant :
+
+ === ":simple-python: Python"
+
+ Dépendances :
+
+ - `environs` (>=14.1)
+
+ ```python
+ import hmac
+ from urllib.parse import urlencode
+
+ from environs import Env
+
+ env = Env()
+ env.read_env()
+
+ key = env.str("HMAC_KEY").encode()
+ data = {
+ "client_id": 15,
+ "third_party_app": "discord",
+ "privacy_link": "https://discord.com/privacy/",
+ "username": "Brian",
+ "callback_url": "https://bot.ae.utbm.fr/callback/123456789/",
+ }
+ urlencoded = urlencode(data)
+ data["signature"] = hmac.digest(key, urlencoded.encode(), "sha512").hex()
+
+ # URL a fournir à l'utilisateur pour son authentification
+ user_url = f"https://ae.ubtm.fr/api-link/auth/?{urlencode(data)}"
+ ```
+
+ === ":simple-rust: Rust"
+
+ Dépendances :
+
+ - `hmac` (>=0.12.1)
+ - `url` (>=2.5.7, features `serde`)
+ - `serde` (>=1.0.228, features `derive`)
+ - `serde_urlencoded` (>="0.7.1)
+ - `sha2` (>=0.10.9)
+ - `dotenvy` (>= 0.15)
+
+ ```rust
+ use hmac::{Mac, SimpleHmac};
+ use serde::Serialize;
+ use sha2::Sha512;
+ use url::Url;
+
+ #[derive(Serialize, Debug)]
+ struct UrlData<'a> {
+ client_id: u32,
+ third_party_app: &'a str,
+ privacy_link: Url,
+ username: &'a str,
+ callback_url: Url,
+ }
+
+ impl<'a> UrlData<'a> {
+ pub fn signature(&self, key: &[u8]) -> CtOutput
{% trans %}Global grade{% endtrans %}
-{% trans %}Utility{% endtrans %}
-{% trans %}Interest{% endtrans %}
-{% trans %}Teaching{% endtrans %}
-{% trans %}Work load{% endtrans %}
-{{ display_star(object.grade_global_average) }}
-{{ display_star(object.grade_utility_average) }}
-{{ display_star(object.grade_interest_average) }}
-{{ display_star(object.grade_teaching_average) }}
-{{ display_star(object.grade_work_load_average) }}
-{% trans %}Objectives{% endtrans %}
-{{ object.objectives|markdown }}
-{% trans %}Program{% endtrans %}
-{{ object.program|markdown }}
-{% trans %}Earned skills{% endtrans %}
-{{ object.skills|markdown }}
-{% trans %}Key concepts{% endtrans %}
-{{ object.key_concepts|markdown }}
-{% trans %}UE manager: {% endtrans %}{{ object.manager }}
-
{{ comment.publish_date.strftime('%d/%m/%Y') }}
{{ user_profile_link(comment.author) }}