diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4f92247f..668f81d9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,7 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.16.0 + rev: v0.16.10 hooks: - id: ruff-check # just check the code, and print the errors - id: ruff-check # actually fix the fixable errors, but print nothing @@ -12,7 +12,7 @@ repos: rev: v0.6.1 hooks: - id: biome-check - additional_dependencies: ["@biomejs/biome@2.5.1"] + additional_dependencies: ["@biomejs/biome@2.5.15"] - repo: https://github.com/rtts/djhtml rev: 3.0.11 hooks: diff --git a/api/admin.py b/api/admin.py index 5aeed933..40b51b2c 100644 --- a/api/admin.py +++ b/api/admin.py @@ -21,6 +21,15 @@ 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) diff --git a/api/api.py b/api/api.py new file mode 100644 index 00000000..0475822e --- /dev/null +++ b/api/api.py @@ -0,0 +1,16 @@ +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 diff --git a/api/forms.py b/api/forms.py new file mode 100644 index 00000000..6bd3b5f1 --- /dev/null +++ b/api/forms.py @@ -0,0 +1,35 @@ +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")} diff --git a/api/migrations/0002_apiclient_hmac_key.py b/api/migrations/0002_apiclient_hmac_key.py new file mode 100644 index 00000000..d0b3fad4 --- /dev/null +++ b/api/migrations/0002_apiclient_hmac_key.py @@ -0,0 +1,19 @@ +# 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" + ), + ), + ] diff --git a/api/models.py b/api/models.py index 36e20287..98a19d6b 100644 --- a/api/models.py +++ b/api/models.py @@ -1,13 +1,20 @@ +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( @@ -26,11 +33,10 @@ 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") @@ -38,33 +44,38 @@ 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) + ) + .values_list("content_type__app_label", "codename") + .order_by() + ) + return {f"{content_type}.{name}" for content_type, name in permissions} + def has_perm(self, perm: str): """Return True if the client has the specified permission.""" + return perm in self.all_permissions - 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() - ) - client_permissions = self.client_permissions.values_list( - "content_type__app_label", "codename" - ).order_by() - self._perm_cache = { - f"{content_type}.{name}" - for content_type, name in (*group_permissions, *client_permissions) - } - return perm in self._perm_cache - - def has_perms(self, perm_list): - """ - Return True if the client has each of the specified permissions. If - object is passed, check if the client has all required perms for it. - """ + def has_perms(self, perm_list: Iterable[str]) -> bool: + """Return True if the client has each of the specified permissions.""" 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 diff --git a/api/schemas.py b/api/schemas.py new file mode 100644 index 00000000..b39bb5c3 --- /dev/null +++ b/api/schemas.py @@ -0,0 +1,23 @@ +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 diff --git a/api/templates/api/third_party/auth.jinja b/api/templates/api/third_party/auth.jinja new file mode 100644 index 00000000..3e712434 --- /dev/null +++ b/api/templates/api/third_party/auth.jinja @@ -0,0 +1,32 @@ +{% extends "core/base.jinja" %} + +{% block content %} +
+{% endblock %} \ No newline at end of file diff --git a/api/tests/test_admin.py b/api/tests/test_admin.py new file mode 100644 index 00000000..134484ee --- /dev/null +++ b/api/tests/test_admin.py @@ -0,0 +1,24 @@ +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] diff --git a/api/tests/test_api_client_controller.py b/api/tests/test_api_client_controller.py new file mode 100644 index 00000000..a854e3fa --- /dev/null +++ b/api/tests/test_api_client_controller.py @@ -0,0 +1,22 @@ +from typing import TYPE_CHECKING + +import pytest +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 + +if TYPE_CHECKING: + from django.test import Client + + +@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() diff --git a/api/tests/test_client.py b/api/tests/test_client.py new file mode 100644 index 00000000..b813b06b --- /dev/null +++ b/api/tests/test_client.py @@ -0,0 +1,59 @@ +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 diff --git a/api/tests/test_third_party_auth.py b/api/tests/test_third_party_auth.py new file mode 100644 index 00000000..3cd4f6eb --- /dev/null +++ b/api/tests/test_third_party_auth.py @@ -0,0 +1,140 @@ +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 diff --git a/api/urls.py b/api/urls.py index 50300453..f4f7fdd8 100644 --- a/api/urls.py +++ b/api/urls.py @@ -1,6 +1,10 @@ +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", @@ -9,3 +13,14 @@ api = NinjaExtraAPI( auth=[SessionAuth()], ) api.auto_discover_controllers() + +register_converter(ResultConverter, "res") + +urlpatterns = [ + path("auth/", ThirdPartyAuthView.as_view(), name="third-party-auth"), + path( + "auth/- - - -
+ {% if date %} ++ - + +
+ {% endif %}- {% trans %}Lost password?{% endtrans %} - - {% trans %}Create account{% endtrans %} -
+ + {% endblock %} diff --git a/core/templates/core/register.jinja b/core/templates/core/register.jinja index 249de9bf..91ad54e5 100644 --- a/core/templates/core/register.jinja +++ b/core/templates/core/register.jinja @@ -18,7 +18,17 @@ {% endblock %} diff --git a/core/tests/test_commands.py b/core/tests/test_commands.py new file mode 100644 index 00000000..5602a4ca --- /dev/null +++ b/core/tests/test_commands.py @@ -0,0 +1,13 @@ +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") diff --git a/core/tests/test_core.py b/core/tests/test_core.py index aa19befa..8395e8a6 100644 --- a/core/tests/test_core.py +++ b/core/tests/test_core.py @@ -27,6 +27,7 @@ from django.core.exceptions import ValidationError from django.core.mail import EmailMessage from django.test import Client, RequestFactory, TestCase from django.urls import reverse +from django.utils.timezone import now from django.views.generic import View from django.views.generic.base import ContextMixin from model_bakery import baker @@ -55,6 +56,7 @@ class TestUserRegistration: "password2": "plop", "captcha_0": "dummy-value", "captcha_1": "PASSED", + "cgu_approved_at": now(), } @pytest.fixture() @@ -92,6 +94,10 @@ class TestUserRegistration: ({"first_name": ""}, "Ce champ est obligatoire."), ({"last_name": ""}, "Ce champ est obligatoire."), ({"captcha_1": "WRONG_CAPTCHA"}, "CAPTCHA invalide"), + ( + {"cgu_approved_at": False}, + "Vous devez approuver les conditions générales d'utilisation", + ), ], ) def test_register_user_form_fail( @@ -150,7 +156,7 @@ class TestUserRegistration: class TestUserLogin: @pytest.fixture() def user(self) -> User: - return baker.make(User, password=make_password("plop")) + return baker.make(User, password=make_password("plop"), cgu_approved_at=now()) @pytest.mark.parametrize( "identifier_getter", @@ -191,10 +197,40 @@ class TestUserLogin: reverse("core:login"), {"username": identifier_getter(user), "password": "plop"}, ) - assertRedirects(response, reverse("core:index")) + assertRedirects(response, settings.LOGIN_REDIRECT_URL) assert response.wsgi_request.user == user +@pytest.mark.django_db +class TestCGU: + def test_cgu_approval(self, client: Client): + user = baker.make(User, password=make_password("plop"), cgu_approved_at=None) + user_url = user.get_absolute_url() + res = client.post( + reverse("core:login"), + {"username": user.username, "password": "plop", "next": user_url}, + ) + assertRedirects(res, reverse("core:approve_cgu", query={"next": user_url})) + res = client.post( + reverse("core:approve_cgu"), {"cgu_approved_at": now(), "next": user_url} + ) + assertRedirects(res, user_url) + user.refresh_from_db() + assert user.cgu_approved_at is not None + + def test_access_cgu_when_already_approved(self, client: Client): + url = reverse("core:approve_cgu") + + res = client.get(url) + assertRedirects(res, reverse("core:login")) + + client.force_login(baker.make(User, cgu_approved_at=now())) + res = client.get(url) + assertRedirects(res, settings.LOGIN_REDIRECT_URL) + res = client.post(url, {"cgu_approved_at": now()}) + assertRedirects(res, settings.LOGIN_REDIRECT_URL) + + @pytest.mark.parametrize( ("md", "html"), [ diff --git a/core/urls.py b/core/urls.py index d53697a9..0dccdc2e 100644 --- a/core/urls.py +++ b/core/urls.py @@ -31,6 +31,7 @@ from core.converters import ( TwoDigitMonthConverter, ) from core.views import ( + CGUApprovalView, FileDeleteView, FileEditPropView, FileEditView, @@ -125,6 +126,7 @@ urlpatterns = [ name="password_reset_complete", ), path("register/", UserCreationView.as_view(), name="register"), + path("cgu/", CGUApprovalView.as_view(), name="approve_cgu"), # Group handling path("group/", GroupListView.as_view(), name="group_list"), path("group/new/", GroupCreateView.as_view(), name="group_new"), diff --git a/core/utils.py b/core/utils.py index 30c0116d..129764ac 100644 --- a/core/utils.py +++ b/core/utils.py @@ -12,12 +12,15 @@ # 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, Final +from urllib.parse import urlencode import PIL from django.conf import settings @@ -25,6 +28,15 @@ from django.core.files.base import ContentFile 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 + + if TYPE_CHECKING: from django.core.files.uploadedfile import UploadedFile from django.http import HttpRequest @@ -190,3 +202,30 @@ 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() diff --git a/core/views/forms.py b/core/views/forms.py index cfbeac69..c0510c7d 100644 --- a/core/views/forms.py +++ b/core/views/forms.py @@ -30,9 +30,7 @@ from django import forms from django.conf import settings from django.contrib.auth.forms import AuthenticationForm, UserCreationForm from django.contrib.auth.models import Permission -from django.contrib.staticfiles.management.commands.collectstatic import ( - staticfiles_storage, -) +from django.contrib.staticfiles.storage import staticfiles_storage from django.core.exceptions import ValidationError from django.db import transaction from django.forms import ( @@ -42,6 +40,9 @@ from django.forms import ( TextInput, Widget, ) +from django.urls import reverse +from django.utils.functional import lazy +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 @@ -108,6 +109,43 @@ class FutureDateTimeField(forms.DateTimeField): return {"min": widget.format_value(now())} +class CGUApprovalField(forms.BooleanField): + """Form field with a checkbox to approve the CGUs. + + The checkbox must be checked to be valid. + If valid, then the value of the field is the current timestamp. + """ + + default_error_messages = {"required": _("You must approve the terms of service.")} + __label = None + + def __init__(self, *, label_suffix: str | None = "", **kwargs): + # Because the core app of the sith is so huge, + # and because we require a url from the latter, + # putting the reverse into the __init__ will result in it + # being evaluated at server startup time (even with reverse_lazy). + # This will result in a circular import. + # Thus, we must keep the label in its own property and force it to be lazy. + kwargs["label"] = lazy(self.get_label, str) + kwargs["required"] = True + super().__init__(label_suffix=label_suffix, **kwargs) + + def to_python(self, value): + return now() if super().to_python(value) else None + + def get_label(self): + if not self.__label: + url = reverse("core:page", kwargs={"page_name": settings.SITH_CGU_PAGE}) + self.__label = mark_safe( + _( + "I have read and I approve the " + 'Terms of Service' + ) + % {"url": url} + ) + return self.__label + + # Forms @@ -146,8 +184,28 @@ class RegisteringForm(UserCreationForm): class Meta: model = User - fields = ("first_name", "last_name", "email") - field_classes = {"email": AntiSpamEmailField} + fields = ("first_name", "last_name", "email", "cgu_approved_at") + field_classes = { + "email": AntiSpamEmailField, + "cgu_approved_at": CGUApprovalField, + } + + +class CGUApprovalForm(forms.ModelForm): + class Meta: + model = User + fields = ["cgu_approved_at"] + field_classes = {"cgu_approved_at": CGUApprovalField} + + def __init__(self, *args, instance: User | None = None, **kwargs): + if instance: + # If this form is displayed, + # then we want the user to explicitly check the button. + # So we pretend cgu were never approved (even if they were). + # If we didn't do that, the button would be initially checked, + # even if the approval was done before the last CGU version. + instance.cgu_approved_at = False + super().__init__(*args, instance=instance, **kwargs) class UserProfileForm(forms.ModelForm): diff --git a/core/views/user.py b/core/views/user.py index 31462551..4fad761f 100644 --- a/core/views/user.py +++ b/core/views/user.py @@ -27,12 +27,12 @@ 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 typing import TYPE_CHECKING, Any from django.contrib import messages from django.contrib.auth import login, views from django.contrib.auth.decorators import login_required -from django.contrib.auth.forms import PasswordChangeForm, SetPasswordForm +from django.contrib.auth.forms import SetPasswordForm from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin from django.contrib.messages.views import SuccessMessageMixin from django.core.exceptions import PermissionDenied @@ -60,6 +60,7 @@ from honeypot.decorators import check_honeypot from core.auth.mixins import CanEditMixin, CanEditPropMixin, CanViewMixin from core.models import Gift, Preferences, User from core.views.forms import ( + CGUApprovalForm, GiftForm, LoginForm, RegisteringForm, @@ -71,6 +72,7 @@ from core.views.forms import ( from core.views.mixins import FragmentMixin, TabedViewMixin, UseFragmentsMixin from counter.models import Refilling, Selling from eboutic.models import Invoice +from sith import settings from trombi.views import UserTrombiForm if TYPE_CHECKING: @@ -82,9 +84,16 @@ class SithLoginView(views.LoginView): template_name = "core/login.jinja" authentication_form = LoginForm - form_class = PasswordChangeForm redirect_authenticated_user = True + def get_success_url(self) -> str: + redirect_to = self.get_redirect_url() + default_url = self.get_default_redirect_url() + if not self.request.user.approved_current_cgu: + query = {"next": redirect_to} if redirect_to else {} + return reverse("core:approve_cgu", query=query) + return redirect_to or default_url + class SithPasswordChangeView(views.PasswordChangeView): """Allows a user to change its password.""" @@ -188,6 +197,27 @@ class UserCreationView(FormView): return super().form_valid(form) +class CGUApprovalView(views.RedirectURLMixin, UpdateView): + form_class = CGUApprovalForm + next_page = settings.LOGIN_REDIRECT_URL + template_name = "core/cgu_approve.jinja" + + def dispatch(self, request, *args, **kwargs): + if self.request.user.is_anonymous: + return redirect("core:login") + if self.request.user.approved_current_cgu: + return redirect(self.get_success_url()) + return super().dispatch(request, *args, **kwargs) + + def get_object(self, *args, **kwargs): + return self.request.user + + def get_context_data(self, **kwargs) -> dict[str, Any]: + return super().get_context_data(**kwargs) | { + self.redirect_field_name: self.get_redirect_url() + } + + class UserMeRedirect(LoginRequiredMixin, RedirectView): def get_redirect_url(self, *args, **kwargs): if remaining := kwargs.get("remaining_path"): diff --git a/counter/static/bundled/counter/counter-click-index.ts b/counter/static/bundled/counter/counter-click-index.ts index 5504cd12..7ec9abc2 100644 --- a/counter/static/bundled/counter/counter-click-index.ts +++ b/counter/static/bundled/counter/counter-click-index.ts @@ -123,14 +123,14 @@ document.addEventListener("alpine:init", () => { onRefillingSuccess(event: CustomEvent) { if ( - event.type !== "htmx:after-swap" || - event.detail.failed || - event.detail.elt.querySelector(".errorlist") + event.type !== "htmx:after:swap" || + event.detail.ctx.response.status !== 200 || + event.detail.ctx.target.querySelector(".errorlist") ) { return; } this.customerBalance += Number.parseFloat( - (event.detail.target.querySelector("#id_amount") as HTMLInputElement).value, + (event.detail.ctx.target.querySelector("#id_amount") as HTMLInputElement).value, ); document.getElementById("selling-accordion")?.setAttribute("open", ""); this.codeField?.widget.focus(); diff --git a/counter/static/counter/css/counter-click.scss b/counter/static/counter/css/counter-click.scss index 00f882bf..64645cde 100644 --- a/counter/static/counter/css/counter-click.scss +++ b/counter/static/counter/css/counter-click.scss @@ -62,8 +62,7 @@ } form { - margin-top: .5rem; - margin-bottom: .5rem; + margin: 0; } } diff --git a/counter/templates/counter/counter_click.jinja b/counter/templates/counter/counter_click.jinja index d18f025e..bccc6f09 100644 --- a/counter/templates/counter/counter_click.jinja +++ b/counter/templates/counter/counter_click.jinja @@ -186,7 +186,7 @@ {% if refilling_fragment %}
{% trans %}Comments{% endtrans %}
++{% endif %} + +
+ {% trans %}This comment has been reported{% endtrans %} +
+{% trans %}It will be hidden until moderated.{% endtrans %}
+ {% if user.has_perm("pedagogy.view_uecommentreport") %} + {% for report in comment.reports.all() %} ++ {% elif user.has_perm("pedagogy.add_uecomment") %} + {{ add_comment_form }} + {% endif %} +
{% trans %}Comments{% endtrans %}
--{% endif %} - -
{% trans %}Global grade{% endtrans %}
-{% trans %}Utility{% endtrans %}
-{% trans %}Interest{% endtrans %}
-{% trans %}Teaching{% endtrans %}
-{% trans %}Work load{% endtrans %}
-{{ display_star(comment.grade_global) }}
-{{ display_star(comment.grade_utility) }}
-{{ display_star(comment.grade_interest) }}
-{{ display_star(comment.grade_teaching) }}
-{{ display_star(comment.grade_work_load) }}
-- {% trans %}This comment has been reported{% endtrans %} -
- {% endif %} - - {% if comment.author_id == user.id or user.has_perm("pedagogy.change_comment") %} - - {% endif %} - {% if comment.author_id == user.id or user.has_perm("pedagogy.delete_comment") %} - - {% endif %} -- - {% trans %}Report this comment{% endtrans %} - -
-{{ comment.publish_date.strftime('%d/%m/%Y') }}
{{ user_profile_link(comment.author) }}
{% trans %}You already posted a comment on this UE. If you want to comment again, please modify or delete your previous comment.{% endtrans %}
--{% endif %} - -{% if not object.has_user_already_commented(user) and user.has_perm("pedagogy.add_uecomment") %} - {{ add_comment_form }} -{% endif %} diff --git a/pedagogy/templates/pedagogy/fragments/ue_details/grade.jinja b/pedagogy/templates/pedagogy/fragments/ue_details/grade.jinja deleted file mode 100644 index 7f507863..00000000 --- a/pedagogy/templates/pedagogy/fragments/ue_details/grade.jinja +++ /dev/null @@ -1,27 +0,0 @@ -
{% 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 }}
-{{ object.code }} - {{ object.title }}
--
{{ object.code }}
+{{ object.title }}
+