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", date_of_birth="1942-06-12",
password="plop", 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.objects.bulk_create(
[ [
User.groups.through(group=groups.counter_admin, user=counter), 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 from django.db import migrations, models
@@ -9,7 +9,7 @@ class Migration(migrations.Migration):
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name="user", model_name="user",
name="cgu_approved", name="cgu_approved_at",
field=models.BooleanField(default=False, verbose_name="ToS approved"), 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.core.mail import send_mail
from django.db import models, transaction from django.db import models, transaction
from django.db.models import Exists, F, OuterRef, Q from django.db.models import Exists, F, OuterRef, Q
from django.db.models.aggregates import Max
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.functional import cached_property from django.utils.functional import cached_property
@@ -291,7 +292,7 @@ class User(AbstractUser):
), ),
blank=True, 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) godfathers = models.ManyToManyField("User", related_name="godchildren", blank=True)
objects = CustomUserManager() objects = CustomUserManager()
@@ -419,6 +420,14 @@ class User(AbstractUser):
) )
return age 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): def make_home(self):
if self.home is None: if self.home is None:
home_root = SithFile.objects.filter(parent=None, name="users").first() home_root = SithFile.objects.filter(parent=None, name="users").first()
+3 -3
View File
@@ -27,9 +27,9 @@
{% endtrans %} {% endtrans %}
</div> </div>
<div class="form-group"> <div class="form-group">
{{ form.cgu_approved.errors }} {{ form.cgu_approved_at.errors }}
{{ form.cgu_approved }} {{ form.cgu_approved_at }}
{{ form.cgu_approved.label_tag() }} {{ form.cgu_approved_at.label_tag() }}
</div> </div>
<input type="hidden" name="next" value="{{ next }}"> <input type="hidden" name="next" value="{{ next }}">
+4 -4
View File
@@ -19,15 +19,15 @@
{% csrf_token %} {% csrf_token %}
{% render_honeypot_field %} {% render_honeypot_field %}
{% for field in form %} {% 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> <div>{{ field.as_field_group() }}</div>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
<div class="captcha">{{ form.captcha.as_field_group() }}</div> <div class="captcha">{{ form.captcha.as_field_group() }}</div>
<div class="form-group"> <div class="form-group">
{{ form.cgu_approved.errors }} {{ form.cgu_approved_at.errors }}
{{ form.cgu_approved }} {{ form.cgu_approved_at }}
{{ form.cgu_approved.label_tag() }} {{ form.cgu_approved_at.label_tag() }}
</div> </div>
<input type="submit" class="btn btn-blue" value="{% trans %}Register{% endtrans %}" /> <input type="submit" class="btn btn-blue" value="{% trans %}Register{% endtrans %}" />
</form> </form>
+9 -8
View File
@@ -27,6 +27,7 @@ from django.core.exceptions import ValidationError
from django.core.mail import EmailMessage from django.core.mail import EmailMessage
from django.test import Client, RequestFactory, TestCase from django.test import Client, RequestFactory, TestCase
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import now
from django.views.generic import View from django.views.generic import View
from django.views.generic.base import ContextMixin from django.views.generic.base import ContextMixin
from model_bakery import baker from model_bakery import baker
@@ -55,7 +56,7 @@ class TestUserRegistration:
"password2": "plop", "password2": "plop",
"captcha_0": "dummy-value", "captcha_0": "dummy-value",
"captcha_1": "PASSED", "captcha_1": "PASSED",
"cgu_approved": True, "cgu_approved_at": now(),
} }
@pytest.fixture() @pytest.fixture()
@@ -94,7 +95,7 @@ class TestUserRegistration:
({"last_name": ""}, "Ce champ est obligatoire."), ({"last_name": ""}, "Ce champ est obligatoire."),
({"captcha_1": "WRONG_CAPTCHA"}, "CAPTCHA invalide"), ({"captcha_1": "WRONG_CAPTCHA"}, "CAPTCHA invalide"),
( (
{"cgu_approved": ""}, {"cgu_approved_at": False},
"Vous devez approuver les conditions générales d'utilisation", "Vous devez approuver les conditions générales d'utilisation",
), ),
], ],
@@ -155,7 +156,7 @@ class TestUserRegistration:
class TestUserLogin: class TestUserLogin:
@pytest.fixture() @pytest.fixture()
def user(self) -> User: 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( @pytest.mark.parametrize(
"identifier_getter", "identifier_getter",
@@ -203,7 +204,7 @@ class TestUserLogin:
@pytest.mark.django_db @pytest.mark.django_db
class TestCGU: class TestCGU:
def test_cgu_approval(self, client: Client): 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() user_url = user.get_absolute_url()
res = client.post( res = client.post(
reverse("core:login"), reverse("core:login"),
@@ -211,11 +212,11 @@ class TestCGU:
) )
assertRedirects(res, reverse("core:approve_cgu", query={"next": user_url})) assertRedirects(res, reverse("core:approve_cgu", query={"next": user_url}))
res = client.post( 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) assertRedirects(res, user_url)
user.refresh_from_db() 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): def test_access_cgu_when_already_approved(self, client: Client):
url = reverse("core:approve_cgu") url = reverse("core:approve_cgu")
@@ -223,10 +224,10 @@ class TestCGU:
res = client.get(url) res = client.get(url)
assertRedirects(res, reverse("core:login")) 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) res = client.get(url)
assertRedirects(res, settings.LOGIN_REDIRECT_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) assertRedirects(res, settings.LOGIN_REDIRECT_URL)
+26 -4
View File
@@ -110,6 +110,12 @@ class FutureDateTimeField(forms.DateTimeField):
class CGUApprovalField(forms.BooleanField): 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.")} default_error_messages = {"required": _("You must approve the terms of service.")}
__label = None __label = None
@@ -124,6 +130,9 @@ class CGUApprovalField(forms.BooleanField):
kwargs["required"] = True kwargs["required"] = True
super().__init__(label_suffix=label_suffix, **kwargs) 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): def get_label(self):
if not self.__label: if not self.__label:
url = reverse("core:page", kwargs={"page_name": settings.SITH_CGU_PAGE}) url = reverse("core:page", kwargs={"page_name": settings.SITH_CGU_PAGE})
@@ -175,15 +184,28 @@ class RegisteringForm(UserCreationForm):
class Meta: class Meta:
model = User model = User
fields = ("first_name", "last_name", "email", "cgu_approved") fields = ("first_name", "last_name", "email", "cgu_approved_at")
field_classes = {"email": AntiSpamEmailField, "cgu_approved": CGUApprovalField} field_classes = {
"email": AntiSpamEmailField,
"cgu_approved_at": CGUApprovalField,
}
class CGUApprovalForm(forms.ModelForm): class CGUApprovalForm(forms.ModelForm):
class Meta: class Meta:
model = User model = User
fields = ["cgu_approved"] fields = ["cgu_approved_at"]
field_classes = {"cgu_approved": CGUApprovalField} 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): class UserProfileForm(forms.ModelForm):
+2 -2
View File
@@ -89,7 +89,7 @@ class SithLoginView(views.LoginView):
def get_success_url(self) -> str: def get_success_url(self) -> str:
redirect_to = self.get_redirect_url() redirect_to = self.get_redirect_url()
default_url = self.get_default_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 {} query = {"next": redirect_to} if redirect_to else {}
return reverse("core:approve_cgu", query=query) return reverse("core:approve_cgu", query=query)
return redirect_to or default_url return redirect_to or default_url
@@ -205,7 +205,7 @@ class CGUApprovalView(views.RedirectURLMixin, UpdateView):
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
if self.request.user.is_anonymous: if self.request.user.is_anonymous:
return redirect("core:login") return redirect("core:login")
if self.request.user.cgu_approved: if self.request.user.approved_current_cgu:
return redirect(self.get_success_url()) return redirect(self.get_success_url())
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)