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)