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)