Compare commits

..
Author SHA1 Message Date
imperosol 8dbb472865 force CGU/EULA approval on account creation 2026-09-16 09:59:23 +02:00
imperosol 246909376f add CGU/EULA to populate command 2026-09-15 23:39:40 +02:00
19 changed files with 106 additions and 172 deletions
@@ -18,7 +18,7 @@ class Migration(migrations.Migration):
"Groups that are automatically given or removed " "Groups that are automatically given or removed "
"to user receiving or losing this club role" "to user receiving or losing this club role"
), ),
related_name="club_roles", related_name="linked_roles",
to="core.group", to="core.group",
verbose_name="Linked groups", verbose_name="Linked groups",
), ),
+4 -55
View File
@@ -1,16 +1,15 @@
from __future__ import annotations from __future__ import annotations
import typing from typing import TYPE_CHECKING
from django.conf import settings from django.conf import settings
from django.contrib.auth.backends import ModelBackend from django.contrib.auth.backends import ModelBackend
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.db.models import Exists, OuterRef, Q, QuerySet
from core.models import Group, User from core.models import Group
if typing.TYPE_CHECKING: if TYPE_CHECKING:
from django.db.models.base import Model from core.models import User
class SithModelBackend(ModelBackend): class SithModelBackend(ModelBackend):
@@ -41,53 +40,3 @@ class SithModelBackend(ModelBackend):
return Permission.objects.filter( return Permission.objects.filter(
group__group__in=groups.values_list("pk", flat=True) group__group__in=groups.values_list("pk", flat=True)
) )
@typing.override
def with_perm(
self,
perm: str | Permission,
is_active: bool | None = True,
include_superusers: bool = False,
obj: Model | None = None,
) -> QuerySet[User]:
"""Return users that have permission "perm".
Contrary to the base django method, superusers aren't included in the
result.
This is because the OR operation to include superusers in the query result
utterly destroy the query performances on postgres
(it makes it like 1000x slower, and I'm not even kidding).
To overcome that, we could use a UNION instead, but then we wouldn't
be able to perform further filter operations on the queryset.
Thus, the `include_superusers` argument is not used at all.
Because of that, it is useless to set `include_superusers`,
as it will be silently ignored.
The only reason it's still there is not to break the interface
of the base class.
"""
if isinstance(perm, str):
try:
app_label, codename = perm.split(".")
except ValueError as e:
raise ValueError(
"Permission name should be in the form "
"app_label.permission_codename."
) from e
permission_q = Q(codename=codename, content_type__app_label=app_label)
elif isinstance(perm, Permission):
permission_q = Q(pk=perm.pk)
else:
raise TypeError(
"The `perm` argument must be a string or a permission instance."
)
user_q = Exists(
Permission.objects.filter(
Q(group__group__users=OuterRef("pk")) | Q(user=OuterRef("pk")),
permission_q,
)
)
if is_active is not None:
user_q &= Q(is_active=is_active)
return User.objects.filter(user_q)
+12 -6
View File
@@ -28,6 +28,7 @@ from typing import ClassVar, NamedTuple
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.contrib.sites.models import Site from django.contrib.sites.models import Site
from django.core.files.base import ContentFile
from django.core.management import call_command from django.core.management import call_command
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.db import connection from django.db import connection
@@ -120,15 +121,21 @@ class Command(BaseCommand):
) )
self.profiles_root = SithFile.objects.create(name="profiles", owner=root) self.profiles_root = SithFile.objects.create(name="profiles", owner=root)
home_root = SithFile.objects.create(name="users", 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) club_root = SithFile.objects.create(name="clubs", owner=root)
sas = SithFile.objects.create( sas = SithFile.objects.create(
name="SAS", owner=root, id=settings.SITH_SAS_ROOT_DIR_ID name="SAS", owner=root, id=settings.SITH_SAS_ROOT_DIR_ID
) )
SithFile.objects.create(
name="CGU",
is_folder=False,
file=ContentFile(
content="Conditions générales d'utilisation", name="cgu.txt"
),
owner=root,
)
# Page needed for club creation
p = Page(name=settings.SITH_CLUB_ROOT_PAGE)
p.save(force_lock=True)
clubs = self._create_clubs() clubs = self._create_clubs()
self.reset_index("club") self.reset_index("club")
@@ -747,7 +754,6 @@ class Command(BaseCommand):
"add_subscription", "add_subscription",
"add_membership", "add_membership",
"view_hidden_user", "view_hidden_user",
"add_refilling",
] ]
) )
) )
@@ -0,0 +1,15 @@
# Generated by Django 5.2.17 on 2026-09-15 11:02
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",
field=models.BooleanField(default=False, verbose_name="EULA approved"),
),
]
+1
View File
@@ -291,6 +291,7 @@ class User(AbstractUser):
), ),
blank=True, blank=True,
) )
cgu_approved = models.BooleanField(_("EULA approved"), default=False)
godfathers = models.ManyToManyField("User", related_name="godchildren", blank=True) godfathers = models.ManyToManyField("User", related_name="godchildren", blank=True)
objects = CustomUserManager() objects = CustomUserManager()
+6 -44
View File
@@ -49,67 +49,29 @@ body {
max-width: 500px; max-width: 500px;
margin-top: 20px; margin-top: 20px;
>p, input[type="submit"] {
>div {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%; width: 100%;
margin: 0;
>label {
width: 100%;
@media (min-width: 500px) {
width: 300px;
}
}
}
>input,
>p>input,
>div>input {
box-sizing: border-box;
width: 100%;
max-width: 500px;
@media (min-width: 500px) {
max-width: 300px; max-width: 300px;
} margin: 0;
} }
>.errorlist { .errorlist {
color: red; color: red;
text-align: center; text-align: center;
margin: 10px 0 0 0; margin: 10px 0 0 0;
list-style-type: none; list-style-type: none;
} }
>.required>.helptext { div, fieldset {
text-align: center;
font-style: italic;
}
>.required:last-of-type {
box-sizing: border-box;
max-width: 300px; max-width: 300px;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-between;
>label {
width: 100%;
} }
.captcha {
box-sizing: border-box;
>img { >img {
width: 70px; width: 70px;
object-fit: contain; object-fit: contain;
} }
>input {
width: 200px;
}
} }
} }
} }
+4 -5
View File
@@ -50,13 +50,12 @@
<input type="hidden" name="next" value="{{ next }}"> <input type="hidden" name="next" value="{{ next }}">
<input type="submit" value="{% trans %}Login{% endtrans %}"> <input type="submit" value="{% trans %}Login{% endtrans %}">
<div>
{# Assumes you setup the password_reset view in your URLconf #}
<p>
<a href="{{ url('core:password_reset') }}">{% trans %}Lost password?{% endtrans %}</a> <a href="{{ url('core:password_reset') }}">{% trans %}Lost password?{% endtrans %}</a>
&nbsp;&nbsp; </div>
<div>
<a href="{{ url('core:register') }}">{% trans %}Create account{% endtrans %}</a> <a href="{{ url('core:register') }}">{% trans %}Create account{% endtrans %}</a>
</p> </div>
</form> </form>
{% endblock %} {% endblock %}
+12 -2
View File
@@ -18,7 +18,17 @@
<form action="{{ url('core:register') }}" method="post"> <form action="{{ url('core:register') }}" method="post">
{% csrf_token %} {% csrf_token %}
{% render_honeypot_field %} {% render_honeypot_field %}
{{ form.as_p() }} {% for field in form %}
<input type="submit" value="{% trans %}Register{% endtrans %}" /> {% if field.name not in ["cgu_approved", "captcha"] %}
<div>{{ field.as_field_group() }}</div>
{% endif %}
{% endfor %}
<div class="captcha">{{ form.captcha.as_field_group() }}</div>
<fieldset>
{{ form.cgu_approved.errors }}
{{ form.cgu_approved }}
{{ form.cgu_approved.label_tag() }}
</fieldset>
<input type="submit" class="btn btn-blue" value="{% trans %}Register{% endtrans %}" />
</form> </form>
{% endblock %} {% endblock %}
-27
View File
@@ -1,27 +0,0 @@
import pytest
from django.contrib.auth.models import Permission
from model_bakery import baker
from core.models import Group, User
@pytest.mark.django_db
def test_with_perm():
"""Test that `SithModelBackend.with_perm` works as intended."""
perms = baker.make(Permission, _quantity=4)
groups = baker.make(Group, _quantity=2)
groups[0].permissions.set(perms[0:2])
groups[1].permissions.set(perms[2:4])
users = [
baker.make(User),
baker.make(User, groups=[groups[0]]),
baker.make(User, groups=[groups[1]]),
baker.make(User, user_permissions=[perms[0]]),
baker.make(User, user_permissions=[perms[2]]),
baker.make(User, groups=[groups[1]], user_permissions=[perms[0]]),
]
expected = [users[1], users[3], users[5]]
assert list(User.objects.with_perm(perms[0])) == expected
str_repr = f"{perms[0].content_type.app_label}.{perms[0].codename}"
assert list(User.objects.with_perm(str_repr)) == expected
+2
View File
@@ -55,6 +55,7 @@ class TestUserRegistration:
"password2": "plop", "password2": "plop",
"captcha_0": "dummy-value", "captcha_0": "dummy-value",
"captcha_1": "PASSED", "captcha_1": "PASSED",
"cgu_approved": True,
} }
@pytest.fixture() @pytest.fixture()
@@ -92,6 +93,7 @@ class TestUserRegistration:
({"first_name": ""}, "Ce champ est obligatoire."), ({"first_name": ""}, "Ce champ est obligatoire."),
({"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": ""}, "Ce champ est obligatoire."),
], ],
) )
def test_register_user_form_fail( def test_register_user_form_fail(
+19 -1
View File
@@ -42,6 +42,8 @@ from django.forms import (
TextInput, TextInput,
Widget, Widget,
) )
from django.urls import reverse
from django.utils.safestring import mark_safe
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from phonenumber_field.widgets import RegionalPhoneNumberWidget from phonenumber_field.widgets import RegionalPhoneNumberWidget
@@ -146,9 +148,25 @@ class RegisteringForm(UserCreationForm):
class Meta: class Meta:
model = User model = User
fields = ("first_name", "last_name", "email") fields = ("first_name", "last_name", "email", "cgu_approved")
field_classes = {"email": AntiSpamEmailField} field_classes = {"email": AntiSpamEmailField}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["cgu_approved"].required = True
self.fields["cgu_approved"].label_suffix = ""
self.fields["cgu_approved"].label = mark_safe(
_(
"I have read and I approve the "
'<a href="%(url)s" target="_blank">End User License Agreement</a>'
)
% {
"url": reverse(
"core:download", kwargs={"file_id": settings.SITH_CGU_FILE_ID}
)
}
)
class UserProfileForm(forms.ModelForm): class UserProfileForm(forms.ModelForm):
"""Form handling the user profile, managing the files""" """Form handling the user profile, managing the files"""
@@ -11,7 +11,7 @@ class Migration(migrations.Migration):
model_name="permanency", model_name="permanency",
name="end", name="end",
field=models.DateTimeField( field=models.DateTimeField(
db_index=True, verbose_name="end date", null=True db_index=True, verbose_name="end date", null=True, blank=True
), ),
) )
] ]
+1 -3
View File
@@ -6,9 +6,7 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [("counter", "0018_producttype_priority")]
("counter", "0018_producttype_priority"),
]
operations = [ operations = [
migrations.AlterModelOptions( migrations.AlterModelOptions(
@@ -6,9 +6,7 @@ import counter.fields
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [("counter", "0019_billinginfo")]
("counter", "0019_billinginfo"),
]
operations = [ operations = [
migrations.AlterField( migrations.AlterField(
+6 -11
View File
@@ -669,18 +669,13 @@ class Counter(models.Model):
"""Update the barman activity to prevent timeout.""" """Update the barman activity to prevent timeout."""
self.permanencies.filter(end=None).update(activity=timezone.now()) self.permanencies.filter(end=None).update(activity=timezone.now())
@cached_property
def can_refill(self) -> bool: def can_refill(self) -> bool:
"""Show if the counter authorize the refilling with physic money. """Show if the counter authorize the refilling with physic money."""
if self.type != "BAR":
Refills are authorized if a user having the required permission return False
is currently logged in. # at least one of the barmen is in the AE board
""" ae = Club.objects.get(id=settings.SITH_MAIN_CLUB_ID)
return self.type == "BAR" and ( return any(ae.get_membership_for(barman) for barman in self.barmen_list)
User.objects.with_perm("counter.add_refilling")
.filter(id__in=[u.id for u in self.barmen_list])
.exists()
)
def get_top_barmen(self) -> QuerySet: def get_top_barmen(self) -> QuerySet:
"""Return a QuerySet querying the office hours stats of all the barmen of all time """Return a QuerySet querying the office hours stats of all the barmen of all time
+1 -8
View File
@@ -108,13 +108,6 @@ class TestFullClickBase(TestCase):
class TestRefilling(TestFullClickBase): class TestRefilling(TestFullClickBase):
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.board_admin.user_permissions.add(
Permission.objects.get(codename="add_refilling")
)
def login_in_bar(self, barmen: User | None = None): def login_in_bar(self, barmen: User | None = None):
used_barman = barmen if barmen is not None else self.board_admin used_barman = barmen if barmen is not None else self.board_admin
self.client.post( self.client.post(
@@ -154,7 +147,7 @@ class TestRefilling(TestFullClickBase):
assert self.updated_amount(self.customer) == 0 assert self.updated_amount(self.customer) == 0
def test_refilling_no_refer_fail(self): def test_refilling_no_refer_fail(self):
"""Check that the refill fails if the HTTP_REFERER header is missing""" """Check that the refill fails is the HTTP_REFERER header is missing"""
def refill(): def refill():
return self.client.post( return self.client.post(
+2 -2
View File
@@ -204,7 +204,7 @@ class CounterClick(
res["student_card_fragment"] = StudentCardFormFragment.as_fragment()( res["student_card_fragment"] = StudentCardFormFragment.as_fragment()(
self.request, customer=self.customer self.request, customer=self.customer
) )
if self.object.can_refill: if self.object.can_refill():
res["refilling_fragment"] = RefillingCreateView.as_fragment()( res["refilling_fragment"] = RefillingCreateView.as_fragment()(
self.request, customer=self.customer, counter=self.object self.request, customer=self.customer, counter=self.object
) )
@@ -250,7 +250,7 @@ class RefillingCreateView(FragmentMixin, CreateView):
if not ( if not (
request.barmen request.barmen
and request.barmen.issubset(self.counter.barmen_list) and request.barmen.issubset(self.counter.barmen_list)
and self.counter.can_refill and self.counter.can_refill()
): ):
raise PermissionDenied raise PermissionDenied
+15 -2
View File
@@ -6,7 +6,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-09 07:21+0200\n" "POT-Creation-Date: 2026-09-15 23:49+0200\n"
"PO-Revision-Date: 2016-07-18\n" "PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n" "Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -1835,6 +1835,10 @@ msgstr ""
"Même si ce profil est caché, les utilisateurs sur cette liste pourront " "Même si ce profil est caché, les utilisateurs sur cette liste pourront "
"toujours le voir." "toujours le voir."
#: core/models.py
msgid "EULA approved"
msgstr "CGU approuvées"
#: core/models.py #: core/models.py
msgid "A user with that username already exists" msgid "A user with that username already exists"
msgstr "Un utilisateur de ce nom d'utilisateur existe déjà" msgstr "Un utilisateur de ce nom d'utilisateur existe déjà"
@@ -3137,6 +3141,15 @@ msgstr "Assurez-vous que cet horodatage est dans le futur"
msgid "Username, email, or account number" msgid "Username, email, or account number"
msgstr "Nom d'utilisateur, email, ou numéro de compte AE" msgstr "Nom d'utilisateur, email, ou numéro de compte AE"
#: core/views/forms.py
#, python-format
msgid ""
"I have read and I approve the <a href=\"%(url)s\" target=\"_blank\">End User "
"License Agreement</a>"
msgstr ""
"J'ai lu et j'approuve les <a href=\"%(url)s\" target=\"_blank\">Conditions "
"Générales d'utilisation</a>"
#: core/views/forms.py #: core/views/forms.py
msgid "" msgid ""
"Profile: you need to be visible on the picture, in order to be recognized " "Profile: you need to be visible on the picture, in order to be recognized "
@@ -3778,8 +3791,8 @@ msgid "Emptied"
msgstr "Coffre vidé" msgstr "Coffre vidé"
#: counter/templates/counter/cash_summary_list.jinja counter/views/cash.py #: counter/templates/counter/cash_summary_list.jinja counter/views/cash.py
#: pedagogy/templates/pedagogy/fragments/ue_comment_form.jinja
#: pedagogy/templates/pedagogy/moderation.jinja #: pedagogy/templates/pedagogy/moderation.jinja
#: pedagogy/templates/pedagogy/ue_detail.jinja
#: trombi/templates/trombi/comment.jinja #: trombi/templates/trombi/comment.jinja
#: trombi/templates/trombi/user_tools.jinja #: trombi/templates/trombi/user_tools.jinja
msgid "Comment" msgid "Comment"
+2
View File
@@ -417,6 +417,8 @@ SITH_FORUM_PAGE_LENGTH = 30
SITH_SAS_ROOT_DIR_ID = env.int("SITH_SAS_ROOT_DIR_ID", default=4) SITH_SAS_ROOT_DIR_ID = env.int("SITH_SAS_ROOT_DIR_ID", default=4)
SITH_SAS_IMAGES_PER_PAGE = 60 SITH_SAS_IMAGES_PER_PAGE = 60
SITH_CGU_FILE_ID = env.int("SITH_CGU_FILE_ID", default=5)
SITH_PROFILE_DEPARTMENTS = [ SITH_PROFILE_DEPARTMENTS = [
("TC", _("TC")), ("TC", _("TC")),
("IMSI", _("IMSI")), ("IMSI", _("IMSI")),