diff --git a/core/management/commands/populate.py b/core/management/commands/populate.py index a85da98e..de803d66 100644 --- a/core/management/commands/populate.py +++ b/core/management/commands/populate.py @@ -20,6 +20,7 @@ # Place - Suite 330, Boston, MA 02111-1307, USA. # # +import itertools from datetime import date, datetime, timedelta from io import StringIO from pathlib import Path @@ -120,11 +121,6 @@ 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 @@ -256,6 +252,7 @@ class Command(BaseCommand): date_of_birth="1942-06-12", password="plop", ) + User.objects.all().update(cgu_approved_at=now()) User.groups.through.objects.bulk_create( [ User.groups.through(group=groups.counter_admin, user=counter), @@ -279,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) @@ -577,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( @@ -737,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/migrations/0051_user_cgu_approved_at.py b/core/migrations/0051_user_cgu_approved_at.py new file mode 100644 index 00000000..730b3225 --- /dev/null +++ b/core/migrations/0051_user_cgu_approved_at.py @@ -0,0 +1,15 @@ +# Generated by Django 5.2.17 on 2026-09-25 12:26 + +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_at", + field=models.DateTimeField(null=True, verbose_name="ToS approved at"), + ), + ] diff --git a/core/models.py b/core/models.py index 208f1937..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,6 +292,7 @@ class User(AbstractUser): ), blank=True, ) + cgu_approved_at = models.DateTimeField(_("ToS approved at"), null=True, blank=False) godfathers = models.ManyToManyField("User", related_name="godchildren", blank=True) objects = CustomUserManager() @@ -418,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() @@ -1233,9 +1243,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 @@ -1254,7 +1263,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/static/user/login.scss b/core/static/user/login.scss index 5d506edd..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; @@ -49,67 +48,33 @@ 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%; - margin: 0; - - >label { - width: 100%; - - @media (min-width: 500px) { - width: 300px; - } - } + max-width: 300px; + margin-top: 1em; } - >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; + } + .captcha { + box-sizing: border-box; - >label { - width: 100%; + fieldset { + margin-bottom: unset } >img { width: 70px; object-fit: contain; } - - >input { - width: 200px; - } } } } diff --git a/core/templates/core/cgu_approve.jinja b/core/templates/core/cgu_approve.jinja new file mode 100644 index 00000000..c683c799 --- /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 %}Lost password?{% endtrans %} - - {% trans %}Create account{% endtrans %} -
+ + {% endblock %} diff --git a/core/templates/core/register.jinja b/core/templates/core/register.jinja index 249de9bf..91ad54e5 100644 --- a/core/templates/core/register.jinja +++ b/core/templates/core/register.jinja @@ -18,7 +18,17 @@ {% endblock %} diff --git a/core/tests/test_core.py b/core/tests/test_core.py index aa19befa..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,6 +56,7 @@ class TestUserRegistration: "password2": "plop", "captcha_0": "dummy-value", "captcha_1": "PASSED", + "cgu_approved_at": now(), } @pytest.fixture() @@ -92,6 +94,10 @@ class TestUserRegistration: ({"first_name": ""}, "Ce champ est obligatoire."), ({"last_name": ""}, "Ce champ est obligatoire."), ({"captcha_1": "WRONG_CAPTCHA"}, "CAPTCHA invalide"), + ( + {"cgu_approved_at": False}, + "Vous devez approuver les conditions générales d'utilisation", + ), ], ) def test_register_user_form_fail( @@ -150,7 +156,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_at=now()) @pytest.mark.parametrize( "identifier_getter", @@ -191,10 +197,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_at=None) + 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_at": now(), "next": user_url} + ) + assertRedirects(res, user_url) + user.refresh_from_db() + assert user.cgu_approved_at is not None + + 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_at=now())) + res = client.get(url) + assertRedirects(res, settings.LOGIN_REDIRECT_URL) + res = client.post(url, {"cgu_approved_at": now()}) + assertRedirects(res, settings.LOGIN_REDIRECT_URL) + + @pytest.mark.parametrize( ("md", "html"), [ 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 cfbeac69..c0510c7d 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 ( @@ -42,6 +40,9 @@ from django.forms import ( TextInput, 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 _ from phonenumber_field.widgets import RegionalPhoneNumberWidget @@ -108,6 +109,43 @@ class FutureDateTimeField(forms.DateTimeField): return {"min": widget.format_value(now())} +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 + + 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 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}) + self.__label = mark_safe( + _( + "I have read and I approve the " + 'Terms of Service' + ) + % {"url": url} + ) + return self.__label + + # Forms @@ -146,8 +184,28 @@ class RegisteringForm(UserCreationForm): class Meta: model = User - fields = ("first_name", "last_name", "email") - field_classes = {"email": AntiSpamEmailField} + 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_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 31462551..4fad761f 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.approved_current_cgu: + 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.approved_current_cgu: + 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 a7c32468..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-09 07:21+0200\n" +"POT-Creation-Date: 2026-09-21 13:16+0200\n" "PO-Revision-Date: 2016-07-18\n" "Last-Translator: Maréchal