use DatetimeField to store cgu approval

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.
This commit is contained in:
imperosol
2026-09-25 15:43:36 +02:00
parent 6f701a9579
commit 19f0f23a05
8 changed files with 58 additions and 26 deletions
+1 -1
View File
@@ -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),
@@ -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"),
),
]
+10 -1
View File
@@ -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()
+3 -3
View File
@@ -27,9 +27,9 @@
{% endtrans %}
</div>
<div class="form-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() }}
</div>
<input type="hidden" name="next" value="{{ next }}">
+4 -4
View File
@@ -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"] %}
<div>{{ field.as_field_group() }}</div>
{% endif %}
{% endfor %}
<div class="captcha">{{ form.captcha.as_field_group() }}</div>
<div class="form-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() }}
</div>
<input type="submit" class="btn btn-blue" value="{% trans %}Register{% endtrans %}" />
</form>
+9 -8
View File
@@ -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)
+26 -4
View File
@@ -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):
+2 -2
View File
@@ -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)