From 5b02bc2e3bd3718acc1994b546523cc7ee13df09 Mon Sep 17 00:00:00 2001 From: imperosol Date: Sun, 26 Oct 2025 16:45:24 +0100 Subject: [PATCH 1/8] add CGU/EULA to populate command --- core/management/commands/populate.py | 18 +++++++++++++----- sith/settings.py | 2 ++ 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/core/management/commands/populate.py b/core/management/commands/populate.py index a85da98e..af26410b 100644 --- a/core/management/commands/populate.py +++ b/core/management/commands/populate.py @@ -28,6 +28,7 @@ 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 @@ -120,15 +121,22 @@ 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 ) + s = SithFile.objects.create( + name="CGU", + is_folder=False, + file=ContentFile( + content="Conditions générales d'utilisation", name="cgu.txt" + ), + owner=root, + ) + s.view_groups.add(settings.SITH_GROUP_PUBLIC_ID) + # 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") diff --git a/sith/settings.py b/sith/settings.py index 6cde6fda..56e578f4 100644 --- a/sith/settings.py +++ b/sith/settings.py @@ -417,6 +417,8 @@ SITH_FORUM_PAGE_LENGTH = 30 SITH_SAS_ROOT_DIR_ID = env.int("SITH_SAS_ROOT_DIR_ID", default=4) SITH_SAS_IMAGES_PER_PAGE = 60 +SITH_CGU_FILE_ID = env.int("SITH_CGU_FILE_ID", default=5) + SITH_PROFILE_DEPARTMENTS = [ ("TC", _("TC")), ("IMSI", _("IMSI")), From 4e3ed3212e6335a9fc10526b1e59bb81747751e0 Mon Sep 17 00:00:00 2001 From: imperosol Date: Wed, 16 Sep 2026 00:06:24 +0200 Subject: [PATCH 2/8] force CGU/EULA approval on account creation --- core/migrations/0051_user_eula_approved.py | 15 +++++++ core/models.py | 1 + core/static/user/login.scss | 52 +++------------------- core/templates/core/login.jinja | 9 ++-- core/templates/core/register.jinja | 14 +++++- core/tests/test_core.py | 2 + core/views/forms.py | 20 ++++++++- locale/fr/LC_MESSAGES/django.po | 17 ++++++- 8 files changed, 75 insertions(+), 55 deletions(-) create mode 100644 core/migrations/0051_user_eula_approved.py diff --git a/core/migrations/0051_user_eula_approved.py b/core/migrations/0051_user_eula_approved.py new file mode 100644 index 00000000..6264436a --- /dev/null +++ b/core/migrations/0051_user_eula_approved.py @@ -0,0 +1,15 @@ +# Generated by Django 5.2.17 on 2026-09-15 11:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [("core", "0050_alter_sithfile_moderator")] + + operations = [ + migrations.AddField( + model_name="user", + name="cgu_approved", + field=models.BooleanField(default=False, verbose_name="ToS approved"), + ), + ] diff --git a/core/models.py b/core/models.py index 208f1937..63427a27 100644 --- a/core/models.py +++ b/core/models.py @@ -291,6 +291,7 @@ class User(AbstractUser): ), blank=True, ) + cgu_approved = models.BooleanField(_("ToS approved"), default=False) godfathers = models.ManyToManyField("User", related_name="godchildren", blank=True) objects = CustomUserManager() diff --git a/core/static/user/login.scss b/core/static/user/login.scss index 5d506edd..8e135413 100644 --- a/core/static/user/login.scss +++ b/core/static/user/login.scss @@ -49,67 +49,29 @@ body { max-width: 500px; margin-top: 20px; - >p, - >div { - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; + input[type="submit"] { width: 100%; + max-width: 300px; margin: 0; - - >label { - width: 100%; - - @media (min-width: 500px) { - width: 300px; - } - } } - >input, - >p>input, - >div>input { - box-sizing: border-box; - width: 100%; - max-width: 500px; - - @media (min-width: 500px) { - max-width: 300px; - } - } - - >.errorlist { + .errorlist { color: red; text-align: center; margin: 10px 0 0 0; list-style-type: none; } - >.required>.helptext { - text-align: center; - font-style: italic; - } - - >.required:last-of-type { - box-sizing: border-box; + div, fieldset { max-width: 300px; - flex-direction: row; - flex-wrap: wrap; - justify-content: space-between; - - >label { - width: 100%; - } + } + .captcha { + box-sizing: border-box; >img { width: 70px; object-fit: contain; } - - >input { - width: 200px; - } } } } diff --git a/core/templates/core/login.jinja b/core/templates/core/login.jinja index a6c3332d..9506ffdd 100644 --- a/core/templates/core/login.jinja +++ b/core/templates/core/login.jinja @@ -50,13 +50,12 @@ - - {# Assumes you setup the password_reset view in your URLconf #} -

+

{% 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..71ef7f2c 100644 --- a/core/templates/core/register.jinja +++ b/core/templates/core/register.jinja @@ -18,7 +18,17 @@
{% csrf_token %} {% render_honeypot_field %} - {{ form.as_p() }} - + {% for field in form %} + {% if field.name not in ["cgu_approved", "captcha"] %} +
{{ field.as_field_group() }}
+ {% endif %} + {% endfor %} +
{{ form.captcha.as_field_group() }}
+
+ {{ form.cgu_approved.errors }} + {{ form.cgu_approved }} + {{ form.cgu_approved.label_tag() }} +
+
{% 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 \n" @@ -1835,6 +1835,10 @@ msgstr "" "Même si ce profil est caché, les utilisateurs sur cette liste pourront " "toujours le voir." +#: core/models.py +msgid "ToS approved" +msgstr "CGU approuvées" + #: core/models.py msgid "A user with that username already exists" msgstr "Un utilisateur de ce nom d'utilisateur existe déjà" @@ -3137,6 +3141,15 @@ msgstr "Assurez-vous que cet horodatage est dans le futur" msgid "Username, email, or account number" msgstr "Nom d'utilisateur, email, ou numéro de compte AE" +#: core/views/forms.py +#, python-format +msgid "" +"I have read and I approve the End User " +"License Agreement" +msgstr "" +"J'ai lu et j'approuve les Conditions " +"Générales d'utilisation" + #: core/views/forms.py msgid "" "Profile: you need to be visible on the picture, in order to be recognized " @@ -3778,8 +3791,8 @@ msgid "Emptied" msgstr "Coffre vidé" #: counter/templates/counter/cash_summary_list.jinja counter/views/cash.py +#: pedagogy/templates/pedagogy/fragments/ue_comment_form.jinja #: pedagogy/templates/pedagogy/moderation.jinja -#: pedagogy/templates/pedagogy/ue_detail.jinja #: trombi/templates/trombi/comment.jinja #: trombi/templates/trombi/user_tools.jinja msgid "Comment" From 03514e4d97b6d8bcf8430425b217cdb9df47b9fe Mon Sep 17 00:00:00 2001 From: imperosol Date: Mon, 21 Sep 2026 13:10:45 +0200 Subject: [PATCH 3/8] refactor login.jinja --- core/static/user/login.scss | 9 ++++++--- core/templates/core/login.jinja | 18 ++++-------------- core/templates/core/register.jinja | 4 ++-- 3 files changed, 12 insertions(+), 19 deletions(-) diff --git a/core/static/user/login.scss b/core/static/user/login.scss index 8e135413..97814e12 100644 --- a/core/static/user/login.scss +++ b/core/static/user/login.scss @@ -37,8 +37,7 @@ body { margin: 0; } - >div, - >form { + form { box-sizing: border-box; display: flex; flex-direction: column; @@ -52,7 +51,7 @@ body { input[type="submit"] { width: 100%; max-width: 300px; - margin: 0; + margin-top: 1em; } .errorlist { @@ -68,6 +67,10 @@ body { .captcha { box-sizing: border-box; + fieldset { + margin-bottom: unset + } + >img { width: 70px; object-fit: contain; diff --git a/core/templates/core/login.jinja b/core/templates/core/login.jinja index 9506ffdd..75a6bbde 100644 --- a/core/templates/core/login.jinja +++ b/core/templates/core/login.jinja @@ -35,26 +35,16 @@ {% csrf_token %} -
- - {{ form.username }} - {{ form.username.errors }} -
- -
- - {{ form.password }} - {{ form.password.errors }} -
+ {{ form }} - +
- {% trans %}Lost password?{% endtrans %} + {% trans %}Lost password?{% endtrans %}
- {% trans %}Create account{% endtrans %} + {% trans %}Create account{% endtrans %}
{% endblock %} diff --git a/core/templates/core/register.jinja b/core/templates/core/register.jinja index 71ef7f2c..de97524e 100644 --- a/core/templates/core/register.jinja +++ b/core/templates/core/register.jinja @@ -24,11 +24,11 @@ {% endif %} {% endfor %}
{{ form.captcha.as_field_group() }}
-
+
{{ form.cgu_approved.errors }} {{ form.cgu_approved }} {{ form.cgu_approved.label_tag() }} -
+ {% endblock %} From c8813f708a3a201c8792161ba4e11ee3bbf754b4 Mon Sep 17 00:00:00 2001 From: imperosol Date: Mon, 21 Sep 2026 13:18:48 +0200 Subject: [PATCH 4/8] make existing users approve CGU after login --- core/templates/core/cgu_approve.jinja | 39 ++++++ core/urls.py | 2 + core/views/forms.py | 56 +++++--- core/views/user.py | 36 ++++- locale/fr/LC_MESSAGES/django.po | 184 +++++++++++++++----------- 5 files changed, 220 insertions(+), 97 deletions(-) create mode 100644 core/templates/core/cgu_approve.jinja diff --git a/core/templates/core/cgu_approve.jinja b/core/templates/core/cgu_approve.jinja new file mode 100644 index 00000000..a152ef27 --- /dev/null +++ b/core/templates/core/cgu_approve.jinja @@ -0,0 +1,39 @@ +{% extends "core/base.jinja" %} + +{%- block additional_css -%} + +{%- endblock -%} + +{% block title %} + {% trans %}Login{% endtrans %} +{% endblock %} + +{% block info_boxes %} +{% endblock %} + +{% block nav %} +{% endblock %} + +{% block content %} +

{% trans %}Terms of Service{% endtrans %}

+ +
+ {% csrf_token %} + +
+ {% trans trimmed %} + To continue using our services, + please read and approve the AE website's terms of service + {% endtrans %} +
+
+ {{ form.cgu_approved.errors }} + {{ form.cgu_approved }} + {{ form.cgu_approved.label_tag() }} +
+ + + +
+{% endblock %} + 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/views/forms.py b/core/views/forms.py index c10a7da6..40afa426 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 ( @@ -43,6 +41,7 @@ from django.forms import ( 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 _ @@ -110,6 +109,34 @@ class FutureDateTimeField(forms.DateTimeField): return {"min": widget.format_value(now())} +class CGUApprovalField(forms.BooleanField): + cgu_file_id = settings.SITH_CGU_FILE_ID + 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 get_label(self): + if not self.__label: + self.__label = mark_safe( + _( + "I have read and I approve the " + 'Terms of Service' + ) + % {"url": reverse("core:page", kwargs={"page_name": self.cgu_file_id})} + ) + return self.__label + + # Forms @@ -149,23 +176,14 @@ class RegisteringForm(UserCreationForm): class Meta: model = User fields = ("first_name", "last_name", "email", "cgu_approved") - field_classes = {"email": AntiSpamEmailField} + field_classes = {"email": AntiSpamEmailField, "cgu_approved": CGUApprovalField} - 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 CGUApprovalForm(forms.ModelForm): + class Meta: + model = User + fields = ["cgu_approved"] + field_classes = {"cgu_approved": CGUApprovalField} class UserProfileForm(forms.ModelForm): diff --git a/core/views/user.py b/core/views/user.py index 31462551..4e16bf64 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.cgu_approved: + 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.cgu_approved: + 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/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 1ed6df60..31520888 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-15 23:49+0200\n" +"POT-Creation-Date: 2026-09-21 13:16+0200\n" "PO-Revision-Date: 2016-07-18\n" "Last-Translator: Maréchal \n" @@ -736,8 +736,9 @@ msgstr "Méthode de paiement" #: counter/templates/counter/fragments/create_student_card.jinja #: counter/templates/counter/last_ops.jinja #: election/templates/election/election_detail.jinja -#: forum/templates/forum/macros.jinja pedagogy/templates/pedagogy/guide.jinja -#: pedagogy/templates/pedagogy/ue_detail.jinja sas/templates/sas/album.jinja +#: forum/templates/forum/macros.jinja +#: pedagogy/templates/pedagogy/fragments/ue_details/comments.jinja +#: pedagogy/templates/pedagogy/guide.jinja sas/templates/sas/album.jinja #: sas/templates/sas/moderation.jinja sas/templates/sas/picture.jinja #: trombi/templates/trombi/detail.jinja #: trombi/templates/trombi/edit_profile.jinja @@ -931,8 +932,9 @@ msgstr "Outils" #: counter/templates/counter/cash_summary_list.jinja #: counter/templates/counter/counter_list.jinja #: election/templates/election/election_detail.jinja -#: forum/templates/forum/macros.jinja pedagogy/templates/pedagogy/guide.jinja -#: pedagogy/templates/pedagogy/ue_detail.jinja sas/templates/sas/album.jinja +#: forum/templates/forum/macros.jinja +#: pedagogy/templates/pedagogy/fragments/ue_details/comments.jinja +#: pedagogy/templates/pedagogy/guide.jinja sas/templates/sas/album.jinja #: trombi/templates/trombi/detail.jinja #: trombi/templates/trombi/edit_profile.jinja msgid "Edit" @@ -2113,7 +2115,8 @@ msgstr "R&D" msgid "Site created by the IT Department of the AE" msgstr "Site réalisé par le Pôle Informatique de l'AE" -#: core/templates/core/base/header.jinja core/templates/core/login.jinja +#: core/templates/core/base/header.jinja core/templates/core/cgu_approve.jinja +#: core/templates/core/login.jinja #: core/templates/core/password_reset_complete.jinja msgid "Login" msgstr "Connexion" @@ -2211,6 +2214,18 @@ msgstr "FAQ" msgid "Wiki" msgstr "Wiki" +#: core/templates/core/cgu_approve.jinja +msgid "Terms of Service" +msgstr "Conditions générales d'utilisation" + +#: core/templates/core/cgu_approve.jinja +msgid "" +"To continue using our services, please read and approve the AE website's " +"terms of service" +msgstr "" +"Pour continuer à utiliser nos services, veuillez lire et approuver les " +"conditions générales d'utilisation du site AE." + #: core/templates/core/create.jinja #, python-format msgid "Create %(name)s" @@ -2237,6 +2252,7 @@ msgstr "Confirmation" #: core/templates/core/file_delete_confirm.jinja #: counter/templates/counter/counter_click.jinja #: counter/templates/counter/fragments/delete_student_card.jinja +#: pedagogy/templates/pedagogy/fragments/comment_report.jinja #: sas/templates/sas/ask_picture_removal.jinja msgid "Cancel" msgstr "Annuler" @@ -2263,6 +2279,7 @@ msgstr "Propriétés" #: core/templates/core/file_delete_confirm.jinja #: counter/templates/counter/fragments/delete_student_card.jinja +#: pedagogy/templates/pedagogy/fragments/ue_details/comments.jinja #, python-format msgid "Are you sure you want to delete \"%(obj)s\"?" msgstr "Êtes-vous sûr de vouloir supprimer \"%(obj)s\" ?" @@ -3138,18 +3155,22 @@ msgid "Ensure this timestamp is set in the future" msgstr "Assurez-vous que cet horodatage est dans le futur" #: core/views/forms.py -msgid "Username, email, or account number" -msgstr "Nom d'utilisateur, email, ou numéro de compte AE" +msgid "You must approve the terms of service." +msgstr "Vous devez approuver les conditions générales d'utilisation" #: core/views/forms.py #, python-format msgid "" -"I have read and I approve the End User " -"License Agreement" +"I have read and I approve the Terms of " +"Service" msgstr "" "J'ai lu et j'approuve les Conditions " "Générales d'utilisation" +#: core/views/forms.py +msgid "Username, email, or account number" +msgstr "Nom d'utilisateur, email, ou numéro de compte AE" + #: core/views/forms.py msgid "" "Profile: you need to be visible on the picture, in order to be recognized " @@ -5122,6 +5143,80 @@ msgstr "signaler" msgid "reporter" msgstr "signalant" +#: pedagogy/templates/pedagogy/fragments/comment_report.jinja +msgid "Report" +msgstr "Signaler" + +#: pedagogy/templates/pedagogy/fragments/ue_comment_form.jinja +msgid "Leave comment" +msgstr "Laisser un commentaire" + +#: pedagogy/templates/pedagogy/fragments/ue_details/comments.jinja +#: trombi/templates/trombi/export.jinja +msgid "Comments" +msgstr "Commentaires" + +#: pedagogy/templates/pedagogy/fragments/ue_details/comments.jinja +#: pedagogy/templates/pedagogy/fragments/ue_details/grade.jinja +msgid "Global grade" +msgstr "Note globale" + +#: pedagogy/templates/pedagogy/fragments/ue_details/comments.jinja +#: pedagogy/templates/pedagogy/fragments/ue_details/grade.jinja +msgid "Utility" +msgstr "Utilité" + +#: pedagogy/templates/pedagogy/fragments/ue_details/comments.jinja +#: pedagogy/templates/pedagogy/fragments/ue_details/grade.jinja +msgid "Interest" +msgstr "Intérêt" + +#: pedagogy/templates/pedagogy/fragments/ue_details/comments.jinja +#: pedagogy/templates/pedagogy/fragments/ue_details/grade.jinja +msgid "Teaching" +msgstr "Enseignement" + +#: pedagogy/templates/pedagogy/fragments/ue_details/comments.jinja +#: pedagogy/templates/pedagogy/fragments/ue_details/grade.jinja +msgid "Work load" +msgstr "Charge de travail" + +#: pedagogy/templates/pedagogy/fragments/ue_details/comments.jinja +msgid "This comment has been reported" +msgstr "Ce commentaire a été signalé" + +#: pedagogy/templates/pedagogy/fragments/ue_details/comments.jinja +msgid "Report this comment" +msgstr "Signaler ce commentaire" + +#: pedagogy/templates/pedagogy/fragments/ue_details/form.jinja +msgid "" +"You already posted a comment on this UE. If you want to comment again, " +"please modify or delete your previous comment." +msgstr "" +"Vous avez déjà commenté cette UE. Si vous voulez de nouveau commenter, " +"veuillez modifier ou supprimer votre commentaire précédent." + +#: pedagogy/templates/pedagogy/fragments/ue_details/grade.jinja +msgid "Objectives" +msgstr "Objectifs" + +#: pedagogy/templates/pedagogy/fragments/ue_details/grade.jinja +msgid "Program" +msgstr "Programme" + +#: pedagogy/templates/pedagogy/fragments/ue_details/grade.jinja +msgid "Earned skills" +msgstr "Compétences acquises" + +#: pedagogy/templates/pedagogy/fragments/ue_details/grade.jinja +msgid "Key concepts" +msgstr "Concepts clefs" + +#: pedagogy/templates/pedagogy/fragments/ue_details/grade.jinja +msgid "UE manager: " +msgstr "Gestionnaire d'UE : " + #: pedagogy/templates/pedagogy/guide.jinja msgid "A guide of courses available at UTBM." msgstr "Un guide de tous les cours disponibles à l'UTBM." @@ -5130,6 +5225,10 @@ msgstr "Un guide de tous les cours disponibles à l'UTBM." msgid "Search UE" msgstr "Recherche d'UE" +#: pedagogy/templates/pedagogy/guide.jinja +msgid "Hide closed UEs" +msgstr "Cacher les UEs fermées" + #: pedagogy/templates/pedagogy/guide.jinja #, python-format msgid "%(display_name)s" @@ -5202,71 +5301,6 @@ msgstr "TE : " msgid "THE: " msgstr "THE : " -#: pedagogy/templates/pedagogy/ue_detail.jinja -msgid "Global grade" -msgstr "Note globale" - -#: pedagogy/templates/pedagogy/ue_detail.jinja -msgid "Utility" -msgstr "Utilité" - -#: pedagogy/templates/pedagogy/ue_detail.jinja -msgid "Interest" -msgstr "Intérêt" - -#: pedagogy/templates/pedagogy/ue_detail.jinja -msgid "Teaching" -msgstr "Enseignement" - -#: pedagogy/templates/pedagogy/ue_detail.jinja -msgid "Work load" -msgstr "Charge de travail" - -#: pedagogy/templates/pedagogy/ue_detail.jinja -msgid "Objectives" -msgstr "Objectifs" - -#: pedagogy/templates/pedagogy/ue_detail.jinja -msgid "Program" -msgstr "Programme" - -#: pedagogy/templates/pedagogy/ue_detail.jinja -msgid "Earned skills" -msgstr "Compétences acquises" - -#: pedagogy/templates/pedagogy/ue_detail.jinja -msgid "Key concepts" -msgstr "Concepts clefs" - -#: pedagogy/templates/pedagogy/ue_detail.jinja -msgid "UE manager: " -msgstr "Gestionnaire d'UE : " - -#: pedagogy/templates/pedagogy/ue_detail.jinja pedagogy/tests/tests.py -msgid "" -"You already posted a comment on this UE. If you want to comment again, " -"please modify or delete your previous comment." -msgstr "" -"Vous avez déjà commenté cette UE. Si vous voulez de nouveau commenter, " -"veuillez modifier ou supprimer votre commentaire précédent." - -#: pedagogy/templates/pedagogy/ue_detail.jinja -msgid "Leave comment" -msgstr "Laisser un commentaire" - -#: pedagogy/templates/pedagogy/ue_detail.jinja -#: trombi/templates/trombi/export.jinja -msgid "Comments" -msgstr "Commentaires" - -#: pedagogy/templates/pedagogy/ue_detail.jinja -msgid "This comment has been reported" -msgstr "Ce commentaire a été signalé" - -#: pedagogy/templates/pedagogy/ue_detail.jinja -msgid "Report this comment" -msgstr "Signaler ce commentaire" - #: pedagogy/templates/pedagogy/ue_edit.jinja msgid "Edit UE" msgstr "Éditer l'UE" From 0c6a96a7ab8e40f128b6917fcd4266a5abc9b399 Mon Sep 17 00:00:00 2001 From: imperosol Date: Mon, 21 Sep 2026 13:57:16 +0200 Subject: [PATCH 5/8] add tests --- core/tests/test_core.py | 39 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/core/tests/test_core.py b/core/tests/test_core.py index bbf2fb48..deb23f74 100644 --- a/core/tests/test_core.py +++ b/core/tests/test_core.py @@ -93,7 +93,10 @@ 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"), + ( + {"cgu_approved": ""}, + "Vous devez approuver les conditions générales d'utilisation", + ), ], ) def test_register_user_form_fail( @@ -152,7 +155,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=True) @pytest.mark.parametrize( "identifier_getter", @@ -193,10 +196,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=False) + 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": True, "next": user_url} + ) + assertRedirects(res, user_url) + user.refresh_from_db() + assert user.cgu_approved + + 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=True)) + res = client.get(url) + assertRedirects(res, settings.LOGIN_REDIRECT_URL) + res = client.post(url, {"cgu_approved": True}) + assertRedirects(res, settings.LOGIN_REDIRECT_URL) + + @pytest.mark.parametrize( ("md", "html"), [ From b607c86ab7bc30dc667a525e501356a2a4cfbba7 Mon Sep 17 00:00:00 2001 From: imperosol Date: Fri, 25 Sep 2026 12:11:04 +0200 Subject: [PATCH 6/8] make CGU a Page instead of a File --- core/management/commands/populate.py | 88 +++++++++++++++------------- core/models.py | 6 +- core/views/forms.py | 4 +- sith/settings.py | 5 +- 4 files changed, 55 insertions(+), 48 deletions(-) diff --git a/core/management/commands/populate.py b/core/management/commands/populate.py index af26410b..1d40e472 100644 --- a/core/management/commands/populate.py +++ b/core/management/commands/populate.py @@ -25,6 +25,7 @@ from io import StringIO from pathlib import Path from typing import ClassVar, NamedTuple +import itertools from django.conf import settings from django.contrib.auth.models import Permission from django.contrib.sites.models import Site @@ -125,18 +126,6 @@ class Command(BaseCommand): sas = SithFile.objects.create( name="SAS", owner=root, id=settings.SITH_SAS_ROOT_DIR_ID ) - s = SithFile.objects.create( - name="CGU", - is_folder=False, - file=ContentFile( - content="Conditions générales d'utilisation", name="cgu.txt" - ), - owner=root, - ) - s.view_groups.add(settings.SITH_GROUP_PUBLIC_ID) - # 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") @@ -287,34 +276,7 @@ class Command(BaseCommand): ] ) - # Adding syntax help page - syntax_page = Page(name="Aide_sur_la_syntaxe") - syntax_page.save(force_lock=True) - PageRev.objects.create( - page=syntax_page, - title="Aide sur la syntaxe", - author=skia, - content=(self.ROOT_PATH / "core" / "fixtures" / "SYNTAX.md").read_text(), - ) - services_page = Page(name="Services") - services_page.save(force_lock=True) - PageRev.objects.create( - page=services_page, - title="Services", - author=skia, - content="- [Eboutic](/eboutic)\n- Matmat\n- SAS\n- Weekmail\n- Forum", - ) - - index_page = Page(name="Index") - index_page.save(force_lock=True) - PageRev.objects.create( - page=index_page, - title="Wiki index", - author=root, - content="Welcome to the wiki page!", - ) - - groups.public.viewable_page.set([syntax_page, services_page, index_page]) + self._create_pages(groups) self._create_subscription(root) self._create_subscription(skia) @@ -585,6 +547,48 @@ class Command(BaseCommand): ] ) + def _create_pages(self, groups: PopulatedGroups): + pages = Page.objects.bulk_create( + [Page(name=s, _full_name=s) for s in settings.SITH_CGU_PAGE.split("/")] + ) + for parent, son in itertools.pairwise(pages): + son.parent = parent + son.save(force_lock=True) + cgu_page = pages[-1] + + syntax_page = Page(name="Aide_sur_la_syntaxe") + syntax_page.save(force_lock=True) + services_page = Page(name="Services") + services_page.save(force_lock=True) + index_page = Page(name="Index") + index_page.save(force_lock=True) + + page_revs = [ + PageRev(page=cgu_page, title="Règlement informatique", content=""), + PageRev( + page=syntax_page, + title="Aide sur la syntaxe", + content=( + self.ROOT_PATH / "core" / "fixtures" / "SYNTAX.md" + ).read_text(), + ), + PageRev( + page=services_page, + title="Services", + content="- [Eboutic](/eboutic)\n- Matmat\n- SAS\n- Weekmail\n- Forum", + ), + PageRev( + page=index_page, title="Wiki index", content="Welcome to the wiki page!" + ), + ] + for rev in page_revs: + rev.author_id = settings.SITH_ROOT_USER_ID + rev.revision = 1 + PageRev.objects.bulk_create(page_revs) + groups.public.viewable_page.set( + [syntax_page, services_page, index_page, cgu_page] + ) + def _create_products(self, groups: PopulatedGroups, clubs: PopulatedClubs): beers_type, cotis_type, refill_type, verre_type = ( ProductType.objects.bulk_create( @@ -745,6 +749,10 @@ class Command(BaseCommand): s.save() def _create_clubs(self) -> PopulatedClubs: + # Page needed for club creation + p = Page(name=settings.SITH_CLUB_ROOT_PAGE) + p.save(force_lock=True) + ae = Club.objects.create( id=1, name="AE", address="6 Boulevard Anatole France, 90000 Belfort" ) diff --git a/core/models.py b/core/models.py index 63427a27..148bda9a 100644 --- a/core/models.py +++ b/core/models.py @@ -1234,9 +1234,8 @@ class Page(models.Model): raise NotLocked("The page is not locked and thus can not be saved") self.full_clean() if not self.id: - super().save( - *args, **kwargs - ) # Save a first time to correctly set _full_name + # Save a first time to correctly set _full_name + super().save(*args, **kwargs) # This reset the _full_name just before saving to maintain a coherent field quicker for queries than the # recursive method # It also update all the children to maintain correct names @@ -1255,7 +1254,6 @@ class Page(models.Model): return Page.objects.filter(_full_name=name).first() def clean(self): - """Cleans up only the name for the moment, but this can be used to make any treatment before saving the object.""" if "/" in self.name: self.name = self.name.split("/")[-1] if ( diff --git a/core/views/forms.py b/core/views/forms.py index 40afa426..276995e6 100644 --- a/core/views/forms.py +++ b/core/views/forms.py @@ -110,7 +110,6 @@ class FutureDateTimeField(forms.DateTimeField): class CGUApprovalField(forms.BooleanField): - cgu_file_id = settings.SITH_CGU_FILE_ID default_error_messages = {"required": _("You must approve the terms of service.")} __label = None @@ -127,12 +126,13 @@ class CGUApprovalField(forms.BooleanField): 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": reverse("core:page", kwargs={"page_name": self.cgu_file_id})} + % {"url": url} ) return self.__label diff --git a/sith/settings.py b/sith/settings.py index 56e578f4..1b81a5c5 100644 --- a/sith/settings.py +++ b/sith/settings.py @@ -372,6 +372,9 @@ SITH_PDF_CLUB_ID = env.int("SITH_PDF_CLUB_ID", default=2) # Main root for club pages SITH_CLUB_ROOT_PAGE = "clubs" +SITH_CGU_PAGE = env.str("SITH_CGU_PAGE", default="legals/ri") + + # Define the date in the year serving as # reference for the subscriptions calendar (month, day) SITH_SEMESTER_START_AUTUMN = (8, 15) # 15 August @@ -417,8 +420,6 @@ SITH_FORUM_PAGE_LENGTH = 30 SITH_SAS_ROOT_DIR_ID = env.int("SITH_SAS_ROOT_DIR_ID", default=4) SITH_SAS_IMAGES_PER_PAGE = 60 -SITH_CGU_FILE_ID = env.int("SITH_CGU_FILE_ID", default=5) - SITH_PROFILE_DEPARTMENTS = [ ("TC", _("TC")), ("IMSI", _("IMSI")), From 6f701a9579253e4b01699a5b43f9479347f475a4 Mon Sep 17 00:00:00 2001 From: imperosol Date: Fri, 25 Sep 2026 12:14:14 +0200 Subject: [PATCH 7/8] default `cgu_approved=True` for populated users --- core/management/commands/populate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/management/commands/populate.py b/core/management/commands/populate.py index 1d40e472..09ec8180 100644 --- a/core/management/commands/populate.py +++ b/core/management/commands/populate.py @@ -20,16 +20,15 @@ # Place - Suite 330, Boston, MA 02111-1307, USA. # # +import itertools from datetime import date, datetime, timedelta from io import StringIO from pathlib import Path from typing import ClassVar, NamedTuple -import itertools 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 @@ -253,6 +252,7 @@ class Command(BaseCommand): date_of_birth="1942-06-12", password="plop", ) + User.objects.all().update(cgu_approved=True) User.groups.through.objects.bulk_create( [ User.groups.through(group=groups.counter_admin, user=counter), From 19f0f23a051164e3800d124c36173f8bace51915 Mon Sep 17 00:00:00 2001 From: imperosol Date: Fri, 25 Sep 2026 15:41:17 +0200 Subject: [PATCH 8/8] use DatetimeField to store cgu approval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Au lieu de dire si l'utilisateur a approuvé les CGUs ou non, on dit quand est-ce qu'il les a approuvées. Si la date de la dernière approbation est antérieure à la date de la dernière révision des CGUs, alors on demande à nouveau de les approuver. --- core/management/commands/populate.py | 2 +- ...proved.py => 0051_user_cgu_approved_at.py} | 6 ++-- core/models.py | 11 ++++++- core/templates/core/cgu_approve.jinja | 6 ++-- core/templates/core/register.jinja | 8 ++--- core/tests/test_core.py | 17 ++++++----- core/views/forms.py | 30 ++++++++++++++++--- core/views/user.py | 4 +-- 8 files changed, 58 insertions(+), 26 deletions(-) rename core/migrations/{0051_user_eula_approved.py => 0051_user_cgu_approved_at.py} (59%) diff --git a/core/management/commands/populate.py b/core/management/commands/populate.py index 09ec8180..de803d66 100644 --- a/core/management/commands/populate.py +++ b/core/management/commands/populate.py @@ -252,7 +252,7 @@ class Command(BaseCommand): date_of_birth="1942-06-12", password="plop", ) - User.objects.all().update(cgu_approved=True) + User.objects.all().update(cgu_approved_at=now()) User.groups.through.objects.bulk_create( [ User.groups.through(group=groups.counter_admin, user=counter), diff --git a/core/migrations/0051_user_eula_approved.py b/core/migrations/0051_user_cgu_approved_at.py similarity index 59% rename from core/migrations/0051_user_eula_approved.py rename to core/migrations/0051_user_cgu_approved_at.py index 6264436a..730b3225 100644 --- a/core/migrations/0051_user_eula_approved.py +++ b/core/migrations/0051_user_cgu_approved_at.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.17 on 2026-09-15 11:02 +# Generated by Django 5.2.17 on 2026-09-25 12:26 from django.db import migrations, models @@ -9,7 +9,7 @@ class Migration(migrations.Migration): operations = [ migrations.AddField( model_name="user", - name="cgu_approved", - field=models.BooleanField(default=False, verbose_name="ToS approved"), + name="cgu_approved_at", + field=models.DateTimeField(null=True, verbose_name="ToS approved at"), ), ] diff --git a/core/models.py b/core/models.py index 148bda9a..8b057a03 100644 --- a/core/models.py +++ b/core/models.py @@ -44,6 +44,7 @@ from django.core.files.base import ContentFile from django.core.mail import send_mail from django.db import models, transaction from django.db.models import Exists, F, OuterRef, Q +from django.db.models.aggregates import Max from django.urls import reverse from django.utils import timezone from django.utils.functional import cached_property @@ -291,7 +292,7 @@ class User(AbstractUser): ), blank=True, ) - cgu_approved = models.BooleanField(_("ToS approved"), default=False) + cgu_approved_at = models.DateTimeField(_("ToS approved at"), null=True, blank=False) godfathers = models.ManyToManyField("User", related_name="godchildren", blank=True) objects = CustomUserManager() @@ -419,6 +420,14 @@ class User(AbstractUser): ) return age + @cached_property + def approved_current_cgu(self) -> bool: + qs = PageRev.objects.filter(page___full_name=settings.SITH_CGU_PAGE) + return ( + self.cgu_approved_at is not None + and self.cgu_approved_at > qs.aggregate(date=Max("date"))["date"] + ) + def make_home(self): if self.home is None: home_root = SithFile.objects.filter(parent=None, name="users").first() diff --git a/core/templates/core/cgu_approve.jinja b/core/templates/core/cgu_approve.jinja index a152ef27..c683c799 100644 --- a/core/templates/core/cgu_approve.jinja +++ b/core/templates/core/cgu_approve.jinja @@ -27,9 +27,9 @@ {% endtrans %}
- {{ form.cgu_approved.errors }} - {{ form.cgu_approved }} - {{ form.cgu_approved.label_tag() }} + {{ form.cgu_approved_at.errors }} + {{ form.cgu_approved_at }} + {{ form.cgu_approved_at.label_tag() }}
diff --git a/core/templates/core/register.jinja b/core/templates/core/register.jinja index de97524e..91ad54e5 100644 --- a/core/templates/core/register.jinja +++ b/core/templates/core/register.jinja @@ -19,15 +19,15 @@ {% csrf_token %} {% render_honeypot_field %} {% for field in form %} - {% if field.name not in ["cgu_approved", "captcha"] %} + {% if field.name not in ["cgu_approved_at", "captcha"] %}
{{ field.as_field_group() }}
{% endif %} {% endfor %}
{{ form.captcha.as_field_group() }}
- {{ form.cgu_approved.errors }} - {{ form.cgu_approved }} - {{ form.cgu_approved.label_tag() }} + {{ form.cgu_approved_at.errors }} + {{ form.cgu_approved_at }} + {{ form.cgu_approved_at.label_tag() }}
diff --git a/core/tests/test_core.py b/core/tests/test_core.py index deb23f74..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,7 +56,7 @@ class TestUserRegistration: "password2": "plop", "captcha_0": "dummy-value", "captcha_1": "PASSED", - "cgu_approved": True, + "cgu_approved_at": now(), } @pytest.fixture() @@ -94,7 +95,7 @@ class TestUserRegistration: ({"last_name": ""}, "Ce champ est obligatoire."), ({"captcha_1": "WRONG_CAPTCHA"}, "CAPTCHA invalide"), ( - {"cgu_approved": ""}, + {"cgu_approved_at": False}, "Vous devez approuver les conditions générales d'utilisation", ), ], @@ -155,7 +156,7 @@ class TestUserRegistration: class TestUserLogin: @pytest.fixture() def user(self) -> User: - return baker.make(User, password=make_password("plop"), cgu_approved=True) + return baker.make(User, password=make_password("plop"), cgu_approved_at=now()) @pytest.mark.parametrize( "identifier_getter", @@ -203,7 +204,7 @@ class TestUserLogin: @pytest.mark.django_db class TestCGU: def test_cgu_approval(self, client: Client): - user = baker.make(User, password=make_password("plop"), cgu_approved=False) + user = baker.make(User, password=make_password("plop"), cgu_approved_at=None) user_url = user.get_absolute_url() res = client.post( reverse("core:login"), @@ -211,11 +212,11 @@ class TestCGU: ) assertRedirects(res, reverse("core:approve_cgu", query={"next": user_url})) res = client.post( - reverse("core:approve_cgu"), {"cgu_approved": True, "next": user_url} + reverse("core:approve_cgu"), {"cgu_approved_at": now(), "next": user_url} ) assertRedirects(res, user_url) user.refresh_from_db() - assert user.cgu_approved + assert user.cgu_approved_at is not None def test_access_cgu_when_already_approved(self, client: Client): url = reverse("core:approve_cgu") @@ -223,10 +224,10 @@ class TestCGU: res = client.get(url) assertRedirects(res, reverse("core:login")) - client.force_login(baker.make(User, cgu_approved=True)) + 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": True}) + res = client.post(url, {"cgu_approved_at": now()}) assertRedirects(res, settings.LOGIN_REDIRECT_URL) diff --git a/core/views/forms.py b/core/views/forms.py index 276995e6..c0510c7d 100644 --- a/core/views/forms.py +++ b/core/views/forms.py @@ -110,6 +110,12 @@ class FutureDateTimeField(forms.DateTimeField): 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 @@ -124,6 +130,9 @@ class CGUApprovalField(forms.BooleanField): 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}) @@ -175,15 +184,28 @@ class RegisteringForm(UserCreationForm): class Meta: model = User - fields = ("first_name", "last_name", "email", "cgu_approved") - field_classes = {"email": AntiSpamEmailField, "cgu_approved": CGUApprovalField} + 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"] - field_classes = {"cgu_approved": CGUApprovalField} + 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 4e16bf64..4fad761f 100644 --- a/core/views/user.py +++ b/core/views/user.py @@ -89,7 +89,7 @@ class SithLoginView(views.LoginView): def get_success_url(self) -> str: redirect_to = self.get_redirect_url() default_url = self.get_default_redirect_url() - if not self.request.user.cgu_approved: + 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 @@ -205,7 +205,7 @@ class CGUApprovalView(views.RedirectURLMixin, UpdateView): def dispatch(self, request, *args, **kwargs): if self.request.user.is_anonymous: return redirect("core:login") - if self.request.user.cgu_approved: + if self.request.user.approved_current_cgu: return redirect(self.get_success_url()) return super().dispatch(request, *args, **kwargs)