diff --git a/club/admin.py b/club/admin.py index c2444c17..04265245 100644 --- a/club/admin.py +++ b/club/admin.py @@ -20,6 +20,14 @@ from club.models import Club, Membership @admin.register(Club) class ClubAdmin(admin.ModelAdmin): list_display = ("name", "unix_name", "parent", "is_active") + search_fields = ("name", "unix_name") + autocomplete_fields = ( + "parent", + "board_group", + "members_group", + "home", + "page", + ) @admin.register(Membership) diff --git a/club/migrations/0012_club_board_group_club_members_group.py b/club/migrations/0012_club_board_group_club_members_group.py new file mode 100644 index 00000000..2a93dd38 --- /dev/null +++ b/club/migrations/0012_club_board_group_club_members_group.py @@ -0,0 +1,129 @@ +# Generated by Django 4.2.16 on 2024-11-20 17:08 + +import django.db.models.deletion +import django.db.models.functions.datetime +from django.conf import settings +from django.db import migrations, models +from django.db.migrations.state import StateApps +from django.db.models import Q +from django.utils.timezone import localdate + + +def migrate_meta_groups(apps: StateApps, schema_editor): + """Attach the existing meta groups to the clubs. + + Until now, the meta groups were not attached to the clubs, + nor to the users. + This creates actual foreign relationships between the clubs + and theirs groups and the users and theirs groups. + + Warnings: + When the meta groups associated with the clubs aren't found, + they are created. + Thus the migration shouldn't fail, and all the clubs will + have their groups. + However, there will probably be some groups that have + not been found but exist nonetheless, + so there will be duplicates and dangling groups. + There must be a manual cleanup after this migration. + """ + Group = apps.get_model("core", "Group") + Club = apps.get_model("club", "Club") + + meta_groups = Group.objects.filter(is_meta=True) + clubs = list(Club.objects.all()) + for club in clubs: + club.board_group = meta_groups.get_or_create( + name=club.unix_name + settings.SITH_BOARD_SUFFIX + )[0] + club.members_group = meta_groups.get_or_create( + name=club.unix_name + settings.SITH_MEMBER_SUFFIX + )[0] + club.save() + club.refresh_from_db() + memberships = club.members.filter( + Q(end_date=None) | Q(end_date__gt=localdate()) + ).select_related("user") + club.members_group.users.set([m.user for m in memberships]) + club.board_group.users.set( + [ + m.user + for m in memberships.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE) + ] + ) + + +# steps of the migration : +# - Create a nullable field for the board group and the member group +# - Edit those new fields to make them point to currently existing meta groups +# - When this data migration is done, make the fields non-nullable +class Migration(migrations.Migration): + dependencies = [ + ("core", "0040_alter_user_options_user_user_permissions_and_more"), + ("club", "0011_auto_20180426_2013"), + ] + + operations = [ + migrations.RemoveField( + model_name="club", + name="edit_groups", + ), + migrations.RemoveField( + model_name="club", + name="owner_group", + ), + migrations.RemoveField( + model_name="club", + name="view_groups", + ), + migrations.AddField( + model_name="club", + name="board_group", + field=models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="club_board", + to="core.group", + ), + ), + migrations.AddField( + model_name="club", + name="members_group", + field=models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="club", + to="core.group", + ), + ), + migrations.RunPython( + migrate_meta_groups, reverse_code=migrations.RunPython.noop, elidable=True + ), + migrations.AlterField( + model_name="club", + name="board_group", + field=models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="club_board", + to="core.group", + ), + ), + migrations.AlterField( + model_name="club", + name="members_group", + field=models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="club", + to="core.group", + ), + ), + migrations.AddConstraint( + model_name="membership", + constraint=models.CheckConstraint( + check=models.Q(("end_date__gte", models.F("start_date"))), + name="end_after_start", + ), + ), + ] diff --git a/club/models.py b/club/models.py index 5300057d..4184715a 100644 --- a/club/models.py +++ b/club/models.py @@ -23,7 +23,7 @@ # from __future__ import annotations -from typing import Self +from typing import Iterable, Self from django.conf import settings from django.core import validators @@ -31,14 +31,14 @@ from django.core.cache import cache from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.core.validators import RegexValidator, validate_email from django.db import models, transaction -from django.db.models import Exists, OuterRef, Q +from django.db.models import Exists, F, OuterRef, Q from django.urls import reverse from django.utils import timezone from django.utils.functional import cached_property from django.utils.timezone import localdate from django.utils.translation import gettext_lazy as _ -from core.models import Group, MetaGroup, Notification, Page, SithFile, User +from core.models import Group, Notification, Page, SithFile, User # Create your models here. @@ -79,19 +79,6 @@ class Club(models.Model): _("short description"), max_length=1000, default="", blank=True, null=True ) address = models.CharField(_("address"), max_length=254) - - owner_group = models.ForeignKey( - Group, - related_name="owned_club", - default=get_default_owner_group, - on_delete=models.CASCADE, - ) - edit_groups = models.ManyToManyField( - Group, related_name="editable_club", blank=True - ) - view_groups = models.ManyToManyField( - Group, related_name="viewable_club", blank=True - ) home = models.OneToOneField( SithFile, related_name="home_of_club", @@ -103,6 +90,12 @@ class Club(models.Model): page = models.OneToOneField( Page, related_name="club", blank=True, null=True, on_delete=models.CASCADE ) + members_group = models.OneToOneField( + Group, related_name="club", on_delete=models.PROTECT + ) + board_group = models.OneToOneField( + Group, related_name="club_board", on_delete=models.PROTECT + ) class Meta: ordering = ["name", "unix_name"] @@ -112,23 +105,27 @@ class Club(models.Model): @transaction.atomic() def save(self, *args, **kwargs): - old = Club.objects.filter(id=self.id).first() - creation = old is None - if not creation and old.unix_name != self.unix_name: - self._change_unixname(self.unix_name) + creation = self._state.adding + if not creation: + db_club = Club.objects.get(id=self.id) + if self.unix_name != db_club.unix_name: + self.home.name = self.unix_name + self.home.save() + if self.name != db_club.name: + self.board_group.name = f"{self.name} - Bureau" + self.board_group.save() + self.members_group.name = f"{self.name} - Membres" + self.members_group.save() + if creation: + self.board_group = Group.objects.create( + name=f"{self.name} - Bureau", is_manually_manageable=False + ) + self.members_group = Group.objects.create( + name=f"{self.name} - Membres", is_manually_manageable=False + ) super().save(*args, **kwargs) if creation: - board = MetaGroup(name=self.unix_name + settings.SITH_BOARD_SUFFIX) - board.save() - member = MetaGroup(name=self.unix_name + settings.SITH_MEMBER_SUFFIX) - member.save() - subscribers = Group.objects.filter( - name=settings.SITH_MAIN_MEMBERS_GROUP - ).first() self.make_home() - self.home.edit_groups.set([board]) - self.home.view_groups.set([member, subscribers]) - self.home.save() self.make_page() cache.set(f"sith_club_{self.unix_name}", self) @@ -136,7 +133,8 @@ class Club(models.Model): return reverse("club:club_view", kwargs={"club_id": self.id}) @cached_property - def president(self): + def president(self) -> Membership | None: + """Fetch the membership of the current president of this club.""" return self.members.filter( role=settings.SITH_CLUB_ROLES_ID["President"], end_date=None ).first() @@ -154,36 +152,18 @@ class Club(models.Model): def clean(self): self.check_loop() - def _change_unixname(self, old_name, new_name): - c = Club.objects.filter(unix_name=new_name).first() - if c is None: - # Update all the groups names - Group.objects.filter(name=old_name).update(name=new_name) - Group.objects.filter(name=old_name + settings.SITH_BOARD_SUFFIX).update( - name=new_name + settings.SITH_BOARD_SUFFIX - ) - Group.objects.filter(name=old_name + settings.SITH_MEMBER_SUFFIX).update( - name=new_name + settings.SITH_MEMBER_SUFFIX - ) + def make_home(self) -> None: + if self.home: + return + home_root = SithFile.objects.filter(parent=None, name="clubs").first() + root = User.objects.filter(username="root").first() + if home_root and root: + home = SithFile(parent=home_root, name=self.unix_name, owner=root) + home.save() + self.home = home + self.save() - if self.home: - self.home.name = new_name - self.home.save() - - else: - raise ValidationError(_("A club with that unix_name already exists")) - - def make_home(self): - if not self.home: - home_root = SithFile.objects.filter(parent=None, name="clubs").first() - root = User.objects.filter(username="root").first() - if home_root and root: - home = SithFile(parent=home_root, name=self.unix_name, owner=root) - home.save() - self.home = home - self.save() - - def make_page(self): + def make_page(self) -> None: root = User.objects.filter(username="root").first() if not self.page: club_root = Page.objects.filter(name=settings.SITH_CLUB_ROOT_PAGE).first() @@ -213,35 +193,34 @@ class Club(models.Model): self.page.parent = self.parent.page self.page.save(force_lock=True) - def delete(self, *args, **kwargs): + def delete(self, *args, **kwargs) -> tuple[int, dict[str, int]]: # Invalidate the cache of this club and of its memberships for membership in self.members.ongoing().select_related("user"): cache.delete(f"membership_{self.id}_{membership.user.id}") cache.delete(f"sith_club_{self.unix_name}") - super().delete(*args, **kwargs) + self.board_group.delete() + self.members_group.delete() + return super().delete(*args, **kwargs) - def get_display_name(self): + def get_display_name(self) -> str: return self.name - def is_owned_by(self, user): + def is_owned_by(self, user: User) -> bool: """Method to see if that object can be super edited by the given user.""" if user.is_anonymous: return False - return user.is_board_member + return user.is_root or user.is_board_member - def get_full_logo_url(self): - return "https://%s%s" % (settings.SITH_URL, self.logo.url) + def get_full_logo_url(self) -> str: + return f"https://{settings.SITH_URL}{self.logo.url}" - def can_be_edited_by(self, user): + def can_be_edited_by(self, user: User) -> bool: """Method to see if that object can be edited by the given user.""" return self.has_rights_in_club(user) - def can_be_viewed_by(self, user): + def can_be_viewed_by(self, user: User) -> bool: """Method to see if that object can be seen by the given user.""" - sub = User.objects.filter(pk=user.pk).first() - if sub is None: - return False - return sub.was_subscribed + return user.was_subscribed def get_membership_for(self, user: User) -> Membership | None: """Return the current membership the given user. @@ -262,9 +241,8 @@ class Club(models.Model): cache.set(f"membership_{self.id}_{user.id}", membership) return membership - def has_rights_in_club(self, user): - m = self.get_membership_for(user) - return m is not None and m.role > settings.SITH_MAXIMUM_FREE_ROLE + def has_rights_in_club(self, user: User) -> bool: + return user.is_in_group(pk=self.board_group_id) class MembershipQuerySet(models.QuerySet): @@ -283,42 +261,65 @@ class MembershipQuerySet(models.QuerySet): """ return self.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE) - def update(self, **kwargs): - """Refresh the cache for the elements of the queryset. + def update(self, **kwargs) -> int: + """Refresh the cache and edit group ownership. - Besides that, does the same job as a regular update method. + Update the cache, when necessary, remove + users from club groups they are no more in + and add them in the club groups they should be in. - Be aware that this adds a db query to retrieve the updated objects + Be aware that this adds three db queries : + one to retrieve the updated memberships, + one to perform group removal and one to perform + group attribution. """ nb_rows = super().update(**kwargs) - if nb_rows > 0: - # if at least a row was affected, refresh the cache - for membership in self.all(): - if membership.end_date is not None: - cache.set( - f"membership_{membership.club_id}_{membership.user_id}", - "not_member", - ) - else: - cache.set( - f"membership_{membership.club_id}_{membership.user_id}", - membership, - ) + if nb_rows == 0: + # if no row was affected, no need to refresh the cache + return 0 - def delete(self): + cache_memberships = {} + memberships = set(self.select_related("club")) + # delete all User-Group relations and recreate the necessary ones + # It's more concise to write and more reliable + Membership._remove_club_groups(memberships) + Membership._add_club_groups(memberships) + for member in memberships: + cache_key = f"membership_{member.club_id}_{member.user_id}" + if member.end_date is None: + cache_memberships[cache_key] = member + else: + cache_memberships[cache_key] = "not_member" + cache.set_many(cache_memberships) + return nb_rows + + def delete(self) -> tuple[int, dict[str, int]]: """Work just like the default Django's delete() method, but add a cache invalidation for the elements of the queryset - before the deletion. + before the deletion, + and a removal of the user from the club groups. - Be aware that this adds a db query to retrieve the deleted element. - As this first query take place before the deletion operation, - it will be performed even if the deletion fails. + Be aware that this adds some db queries : + + - 1 to retrieve the deleted elements in order to perform + post-delete operations. + As we can't know if a delete will affect rows or not, + this query will always happen + - 1 query to remove the users from the club groups. + If the delete operation affected no row, + this query won't happen. """ - ids = list(self.values_list("club_id", "user_id")) - nb_rows, _ = super().delete() + memberships = set(self.all()) + nb_rows, rows_counts = super().delete() if nb_rows > 0: - for club_id, user_id in ids: - cache.set(f"membership_{club_id}_{user_id}", "not_member") + Membership._remove_club_groups(memberships) + cache.set_many( + { + f"membership_{m.club_id}_{m.user_id}": "not_member" + for m in memberships + } + ) + return nb_rows, rows_counts class Membership(models.Model): @@ -361,6 +362,13 @@ class Membership(models.Model): objects = MembershipQuerySet.as_manager() + class Meta: + constraints = [ + models.CheckConstraint( + check=Q(end_date__gte=F("start_date")), name="end_after_start" + ), + ] + def __str__(self): return ( f"{self.club.name} - {self.user.username} " @@ -370,7 +378,14 @@ class Membership(models.Model): def save(self, *args, **kwargs): super().save(*args, **kwargs) + # a save may either be an update or a creation + # and may result in either an ongoing or an ended membership. + # It could also be a retrogradation from the board to being a simple member. + # To avoid problems, the user is removed from the club groups beforehand ; + # he will be added back if necessary + self._remove_club_groups([self]) if self.end_date is None: + self._add_club_groups([self]) cache.set(f"membership_{self.club_id}_{self.user_id}", self) else: cache.set(f"membership_{self.club_id}_{self.user_id}", "not_member") @@ -378,11 +393,11 @@ class Membership(models.Model): def get_absolute_url(self): return reverse("club:club_members", kwargs={"club_id": self.club_id}) - def is_owned_by(self, user): + def is_owned_by(self, user: User) -> bool: """Method to see if that object can be super edited by the given user.""" if user.is_anonymous: return False - return user.is_board_member + return user.is_root or user.is_board_member def can_be_edited_by(self, user: User) -> bool: """Check if that object can be edited by the given user.""" @@ -392,9 +407,91 @@ class Membership(models.Model): return membership is not None and membership.role >= self.role def delete(self, *args, **kwargs): + self._remove_club_groups([self]) super().delete(*args, **kwargs) cache.delete(f"membership_{self.club_id}_{self.user_id}") + @staticmethod + def _remove_club_groups( + memberships: Iterable[Membership], + ) -> tuple[int, dict[str, int]]: + """Remove users of those memberships from the club groups. + + For example, if a user is in the Troll club board, + he is in the board group and the members group of the Troll. + After calling this function, he will be in neither. + + Returns: + The result of the deletion queryset. + + Warnings: + If this function isn't used in combination + with an actual deletion of the memberships, + it will result in an inconsistent state, + where users will be in the clubs, without + having the associated rights. + """ + clubs = {m.club_id for m in memberships} + users = {m.user_id for m in memberships} + groups = Group.objects.filter(Q(club__in=clubs) | Q(club_board__in=clubs)) + return User.groups.through.objects.filter( + Q(group__in=groups) & Q(user__in=users) + ).delete() + + @staticmethod + def _add_club_groups( + memberships: Iterable[Membership], + ) -> list[User.groups.through]: + """Add users of those memberships to the club groups. + + For example, if a user just joined the Troll club board, + he will be added in both the members group and the board group + of the club. + + Returns: + The created User-Group relations. + + Warnings: + If this function isn't used in combination + with an actual update/creation of the memberships, + it will result in an inconsistent state, + where users will have the rights associated to the + club, without actually being part of it. + """ + # only active membership (i.e. `end_date=None`) + # grant the attribution of club groups. + memberships = [m for m in memberships if m.end_date is None] + if not memberships: + return [] + + if sum(1 for m in memberships if not hasattr(m, "club")) > 1: + # if more than one membership hasn't its `club` attribute set + # it's less expensive to reload the whole query with + # a select_related than perform a distinct query + # to fetch each club. + ids = {m.id for m in memberships} + memberships = list( + Membership.objects.filter(id__in=ids).select_related("club") + ) + club_groups = [] + for membership in memberships: + club_groups.append( + User.groups.through( + user_id=membership.user_id, + group_id=membership.club.members_group_id, + ) + ) + if membership.role > settings.SITH_MAXIMUM_FREE_ROLE: + club_groups.append( + User.groups.through( + user_id=membership.user_id, + group_id=membership.club.board_group_id, + ) + ) + return User.groups.through.objects.bulk_create( + club_groups, ignore_conflicts=True + ) + class Mailing(models.Model): """A Mailing list for a club. diff --git a/club/tests.py b/club/tests.py index ae055bd0..b81aa38d 100644 --- a/club/tests.py +++ b/club/tests.py @@ -21,6 +21,7 @@ from django.urls import reverse from django.utils import timezone from django.utils.timezone import localdate, localtime, now from django.utils.translation import gettext as _ +from model_bakery import baker from club.forms import MailingForm from club.models import Club, Mailing, Membership @@ -164,6 +165,27 @@ class TestMembershipQuerySet(TestClub): assert new_mem != "not_member" assert new_mem.role == 5 + def test_update_change_club_groups(self): + """Test that `update` set the user groups accordingly.""" + user = baker.make(User) + membership = baker.make(Membership, end_date=None, user=user, role=5) + members_group = membership.club.members_group + board_group = membership.club.board_group + assert user.groups.contains(members_group) + assert user.groups.contains(board_group) + + user.memberships.update(role=1) # from board to simple member + assert user.groups.contains(members_group) + assert not user.groups.contains(board_group) + + user.memberships.update(role=5) # from member to board + assert user.groups.contains(members_group) + assert user.groups.contains(board_group) + + user.memberships.update(end_date=localdate()) # end the membership + assert not user.groups.contains(members_group) + assert not user.groups.contains(board_group) + def test_delete_invalidate_cache(self): """Test that the `delete` queryset properly invalidate cache.""" mem_skia = self.skia.memberships.get(club=self.club) @@ -182,6 +204,19 @@ class TestMembershipQuerySet(TestClub): ) assert cached_mem == "not_member" + def test_delete_remove_from_groups(self): + """Test that `delete` removes from club groups""" + user = baker.make(User) + memberships = baker.make(Membership, role=iter([1, 5]), user=user, _quantity=2) + club_groups = { + memberships[0].club.members_group, + memberships[1].club.members_group, + memberships[1].club.board_group, + } + assert set(user.groups.all()) == club_groups + user.memberships.all().delete() + assert user.groups.all().count() == 0 + class TestClubModel(TestClub): def assert_membership_started_today(self, user: User, role: int): @@ -192,10 +227,8 @@ class TestClubModel(TestClub): assert membership.end_date is None assert membership.role == role assert membership.club.get_membership_for(user) == membership - member_group = self.club.unix_name + settings.SITH_MEMBER_SUFFIX - board_group = self.club.unix_name + settings.SITH_BOARD_SUFFIX - assert user.is_in_group(name=member_group) - assert user.is_in_group(name=board_group) + assert user.is_in_group(pk=self.club.members_group_id) + assert user.is_in_group(pk=self.club.board_group_id) def assert_membership_ended_today(self, user: User): """Assert that the given user have a membership which ended today.""" @@ -474,37 +507,35 @@ class TestClubModel(TestClub): assert self.club.members.count() == nb_memberships assert membership == new_mem - def test_delete_remove_from_meta_group(self): - """Test that when a club is deleted, all its members are removed from the - associated metagroup. - """ - memberships = self.club.members.select_related("user") - users = [membership.user for membership in memberships] - meta_group = self.club.unix_name + settings.SITH_MEMBER_SUFFIX + def test_remove_from_club_group(self): + """Test that when a membership ends, the user is removed from club groups.""" + user = baker.make(User) + baker.make(Membership, user=user, club=self.club, end_date=None, role=3) + assert user.groups.contains(self.club.members_group) + assert user.groups.contains(self.club.board_group) + user.memberships.update(end_date=localdate()) + assert not user.groups.contains(self.club.members_group) + assert not user.groups.contains(self.club.board_group) - self.club.delete() - for user in users: - assert not user.is_in_group(name=meta_group) + def test_add_to_club_group(self): + """Test that when a membership begins, the user is added to the club group.""" + assert not self.subscriber.groups.contains(self.club.members_group) + assert not self.subscriber.groups.contains(self.club.board_group) + baker.make(Membership, club=self.club, user=self.subscriber, role=3) + assert self.subscriber.groups.contains(self.club.members_group) + assert self.subscriber.groups.contains(self.club.board_group) - def test_add_to_meta_group(self): - """Test that when a membership begins, the user is added to the meta group.""" - group_members = self.club.unix_name + settings.SITH_MEMBER_SUFFIX - board_members = self.club.unix_name + settings.SITH_BOARD_SUFFIX - assert not self.subscriber.is_in_group(name=group_members) - assert not self.subscriber.is_in_group(name=board_members) - Membership.objects.create(club=self.club, user=self.subscriber, role=3) - assert self.subscriber.is_in_group(name=group_members) - assert self.subscriber.is_in_group(name=board_members) - - def test_remove_from_meta_group(self): - """Test that when a membership ends, the user is removed from meta group.""" - group_members = self.club.unix_name + settings.SITH_MEMBER_SUFFIX - board_members = self.club.unix_name + settings.SITH_BOARD_SUFFIX - assert self.comptable.is_in_group(name=group_members) - assert self.comptable.is_in_group(name=board_members) - self.comptable.memberships.update(end_date=localtime(now())) - assert not self.comptable.is_in_group(name=group_members) - assert not self.comptable.is_in_group(name=board_members) + def test_change_position_in_club(self): + """Test that when moving from board to members, club group change""" + membership = baker.make( + Membership, club=self.club, user=self.subscriber, role=3 + ) + assert self.subscriber.groups.contains(self.club.members_group) + assert self.subscriber.groups.contains(self.club.board_group) + membership.role = 1 + membership.save() + assert self.subscriber.groups.contains(self.club.members_group) + assert not self.subscriber.groups.contains(self.club.board_group) def test_club_owner(self): """Test that a club is owned only by board members of the main club.""" @@ -517,6 +548,26 @@ class TestClubModel(TestClub): Membership(club=self.ae, user=self.sli, role=3).save() assert self.club.is_owned_by(self.sli) + def test_change_club_name(self): + """Test that changing the club name doesn't break things.""" + members_group = self.club.members_group + board_group = self.club.board_group + initial_members = set(members_group.users.values_list("id", flat=True)) + initial_board = set(board_group.users.values_list("id", flat=True)) + self.club.name = "something else" + self.club.save() + self.club.refresh_from_db() + + # The names should have changed, but not the ids nor the group members + assert self.club.members_group.name == "something else - Membres" + assert self.club.board_group.name == "something else - Bureau" + assert self.club.members_group.id == members_group.id + assert self.club.board_group.id == board_group.id + new_members = set(self.club.members_group.users.values_list("id", flat=True)) + new_board = set(self.club.board_group.users.values_list("id", flat=True)) + assert new_members == initial_members + assert new_board == initial_board + class TestMailingForm(TestCase): """Perform validation tests for MailingForm.""" diff --git a/club/views.py b/club/views.py index e1f3367e..d713a1cc 100644 --- a/club/views.py +++ b/club/views.py @@ -71,14 +71,13 @@ class ClubTabsMixin(TabedViewMixin): return self.object.get_display_name() def get_list_of_tabs(self): - tab_list = [] - tab_list.append( + tab_list = [ { "url": reverse("club:club_view", kwargs={"club_id": self.object.id}), "slug": "infos", "name": _("Infos"), } - ) + ] if self.request.user.can_view(self.object): tab_list.append( { diff --git a/core/admin.py b/core/admin.py index 367c056a..601ba636 100644 --- a/core/admin.py +++ b/core/admin.py @@ -15,6 +15,7 @@ from django.contrib import admin from django.contrib.auth.models import Group as AuthGroup +from django.contrib.auth.models import Permission from core.models import Group, OperationLog, Page, SithFile, User @@ -23,9 +24,10 @@ admin.site.unregister(AuthGroup) @admin.register(Group) class GroupAdmin(admin.ModelAdmin): - list_display = ("name", "description", "is_meta") - list_filter = ("is_meta",) + list_display = ("name", "description", "is_manually_manageable") + list_filter = ("is_manually_manageable",) search_fields = ("name",) + autocomplete_fields = ("permissions",) @admin.register(User) @@ -37,10 +39,17 @@ class UserAdmin(admin.ModelAdmin): "profile_pict", "avatar_pict", "scrub_pict", + "user_permissions", + "groups", ) search_fields = ["first_name", "last_name", "username"] +@admin.register(Permission) +class PermissionAdmin(admin.ModelAdmin): + search_fields = ("codename",) + + @admin.register(Page) class PageAdmin(admin.ModelAdmin): list_display = ("name", "_full_name", "owner_group") diff --git a/core/baker_recipes.py b/core/baker_recipes.py index 0abd83e0..4b873b0f 100644 --- a/core/baker_recipes.py +++ b/core/baker_recipes.py @@ -7,7 +7,7 @@ from model_bakery import seq from model_bakery.recipe import Recipe, related from club.models import Membership -from core.models import User +from core.models import Group, User from subscription.models import Subscription active_subscription = Recipe( @@ -60,5 +60,6 @@ board_user = Recipe( first_name="AE", last_name=seq("member "), memberships=related(ae_board_membership), + groups=lambda: [Group.objects.get(club_board=settings.SITH_MAIN_CLUB_ID)], ) """A user which is in the board of the AE.""" diff --git a/core/management/commands/populate.py b/core/management/commands/populate.py index 0a26b4b8..9cf9c59b 100644 --- a/core/management/commands/populate.py +++ b/core/management/commands/populate.py @@ -47,7 +47,7 @@ from accounting.models import ( ) from club.models import Club, Membership from com.models import News, NewsDate, Sith, Weekmail -from core.models import Group, Page, PageRev, RealGroup, SithFile, User +from core.models import Group, Page, PageRev, SithFile, User from core.utils import resize_image from counter.models import Counter, Product, ProductType, StudentCard from election.models import Candidature, Election, ElectionList, Role @@ -143,7 +143,9 @@ class Command(BaseCommand): Counter.objects.bulk_create(counters) bar_groups = [] for bar_id, bar_name in settings.SITH_COUNTER_BARS: - group = RealGroup.objects.create(name=f"{bar_name} admin") + group = Group.objects.create( + name=f"{bar_name} admin", is_manually_manageable=True + ) bar_groups.append( Counter.edit_groups.through(counter_id=bar_id, group=group) ) @@ -366,46 +368,42 @@ Welcome to the wiki page! parent=main_club, ) - Membership.objects.bulk_create( - [ - Membership(user=skia, club=main_club, role=3), - Membership( - user=comunity, - club=bar_club, - start_date=localdate(), - role=settings.SITH_CLUB_ROLES_ID["Board member"], - ), - Membership( - user=sli, - club=troll, - role=9, - description="Padawan Troll", - start_date=localdate() - timedelta(days=17), - ), - Membership( - user=krophil, - club=troll, - role=10, - description="Maitre Troll", - start_date=localdate() - timedelta(days=200), - ), - Membership( - user=skia, - club=troll, - role=2, - description="Grand Ancien Troll", - start_date=localdate() - timedelta(days=400), - end_date=localdate() - timedelta(days=86), - ), - Membership( - user=richard, - club=troll, - role=2, - description="", - start_date=localdate() - timedelta(days=200), - end_date=localdate() - timedelta(days=100), - ), - ] + Membership.objects.create(user=skia, club=main_club, role=3) + Membership.objects.create( + user=comunity, + club=bar_club, + start_date=localdate(), + role=settings.SITH_CLUB_ROLES_ID["Board member"], + ) + Membership.objects.create( + user=sli, + club=troll, + role=9, + description="Padawan Troll", + start_date=localdate() - timedelta(days=17), + ) + Membership.objects.create( + user=krophil, + club=troll, + role=10, + description="Maitre Troll", + start_date=localdate() - timedelta(days=200), + ) + Membership.objects.create( + user=skia, + club=troll, + role=2, + description="Grand Ancien Troll", + start_date=localdate() - timedelta(days=400), + end_date=localdate() - timedelta(days=86), + ) + Membership.objects.create( + user=richard, + club=troll, + role=2, + description="", + start_date=localdate() - timedelta(days=200), + end_date=localdate() - timedelta(days=100), ) p = ProductType.objects.create(name="Bières bouteilles") @@ -594,7 +592,6 @@ Welcome to the wiki page! ) # Create an election - ae_board_group = Group.objects.get(name=settings.SITH_MAIN_BOARD_GROUP) el = Election.objects.create( title="Élection 2017", description="La roue tourne", @@ -604,7 +601,7 @@ Welcome to the wiki page! end_date="7942-06-12 10:28:45+01", ) el.view_groups.add(groups.public) - el.edit_groups.add(ae_board_group) + el.edit_groups.add(main_club.board_group) el.candidature_groups.add(groups.subscribers) el.vote_groups.add(groups.subscribers) liste = ElectionList.objects.create(title="Candidature Libre", election=el) @@ -889,7 +886,7 @@ Welcome to the wiki page! def _create_groups(self) -> PopulatedGroups: perms = Permission.objects.all() - root_group = Group.objects.create(name="Root") + root_group = Group.objects.create(name="Root", is_manually_manageable=True) root_group.permissions.add(*list(perms.values_list("pk", flat=True))) # public has no permission. # Its purpose is not to link users to permissions, @@ -911,7 +908,9 @@ Welcome to the wiki page! ) ) ) - accounting_admin = Group.objects.create(name="Accounting admin") + accounting_admin = Group.objects.create( + name="Accounting admin", is_manually_manageable=True + ) accounting_admin.permissions.add( *list( perms.filter( @@ -931,13 +930,17 @@ Welcome to the wiki page! ).values_list("pk", flat=True) ) ) - com_admin = Group.objects.create(name="Communication admin") + com_admin = Group.objects.create( + name="Communication admin", is_manually_manageable=True + ) com_admin.permissions.add( *list( perms.filter(content_type__app_label="com").values_list("pk", flat=True) ) ) - counter_admin = Group.objects.create(name="Counter admin") + counter_admin = Group.objects.create( + name="Counter admin", is_manually_manageable=True + ) counter_admin.permissions.add( *list( perms.filter( @@ -946,16 +949,20 @@ Welcome to the wiki page! ) ) ) - Group.objects.create(name="Banned from buying alcohol") - Group.objects.create(name="Banned from counters") - Group.objects.create(name="Banned to subscribe") - sas_admin = Group.objects.create(name="SAS admin") + Group.objects.create( + name="Banned from buying alcohol", is_manually_manageable=True + ) + Group.objects.create(name="Banned from counters", is_manually_manageable=True) + Group.objects.create(name="Banned to subscribe", is_manually_manageable=True) + sas_admin = Group.objects.create(name="SAS admin", is_manually_manageable=True) sas_admin.permissions.add( *list( perms.filter(content_type__app_label="sas").values_list("pk", flat=True) ) ) - forum_admin = Group.objects.create(name="Forum admin") + forum_admin = Group.objects.create( + name="Forum admin", is_manually_manageable=True + ) forum_admin.permissions.add( *list( perms.filter(content_type__app_label="forum").values_list( @@ -963,7 +970,9 @@ Welcome to the wiki page! ) ) ) - pedagogy_admin = Group.objects.create(name="Pedagogy admin") + pedagogy_admin = Group.objects.create( + name="Pedagogy admin", is_manually_manageable=True + ) pedagogy_admin.permissions.add( *list( perms.filter(content_type__app_label="pedagogy").values_list( diff --git a/core/management/commands/populate_more.py b/core/management/commands/populate_more.py index f8ac1cef..fbef3ec2 100644 --- a/core/management/commands/populate_more.py +++ b/core/management/commands/populate_more.py @@ -173,7 +173,8 @@ class Command(BaseCommand): club=club, ) ) - Membership.objects.bulk_create(memberships) + memberships = Membership.objects.bulk_create(memberships) + Membership._add_club_groups(memberships) def create_uvs(self): root = User.objects.get(username="root") diff --git a/core/migrations/0001_initial.py b/core/migrations/0001_initial.py index 47cad8ad..23af8d06 100644 --- a/core/migrations/0001_initial.py +++ b/core/migrations/0001_initial.py @@ -563,14 +563,21 @@ class Migration(migrations.Migration): fields=[], options={"proxy": True}, bases=("core.group",), - managers=[("objects", core.models.MetaGroupManager())], + managers=[("objects", django.contrib.auth.models.GroupManager())], ), + # at first, there existed a RealGroupManager and a RealGroupManager, + # which have been since been removed. + # However, this removal broke the migrations because it caused an ImportError. + # Thus, the managers MetaGroupManager (above) and RealGroupManager (below) + # have been replaced by the base django GroupManager to fix the import. + # As those managers aren't actually used in migrations, + # this replacement doesn't break anything. migrations.CreateModel( name="RealGroup", fields=[], options={"proxy": True}, bases=("core.group",), - managers=[("objects", core.models.RealGroupManager())], + managers=[("objects", django.contrib.auth.models.GroupManager())], ), migrations.AlterUniqueTogether( name="page", unique_together={("name", "parent")} diff --git a/core/migrations/0041_delete_metagroup_alter_group_options_and_more.py b/core/migrations/0041_delete_metagroup_alter_group_options_and_more.py new file mode 100644 index 00000000..2a88b8c1 --- /dev/null +++ b/core/migrations/0041_delete_metagroup_alter_group_options_and_more.py @@ -0,0 +1,51 @@ +# Generated by Django 4.2.16 on 2024-11-30 13:16 + +from django.db import migrations, models +from django.db.migrations.state import StateApps +from django.db.models import F + + +def invert_is_manually_manageable(apps: StateApps, schema_editor): + """Invert `is_manually_manageable`. + + This field is a renaming of `is_meta`. + However, the meaning has been inverted : the groups + which were meta are not manually manageable and vice versa. + Thus, the value must be inverted. + """ + Group = apps.get_model("core", "Group") + Group.objects.all().update(is_manually_manageable=~F("is_manually_manageable")) + + +class Migration(migrations.Migration): + dependencies = [("core", "0040_alter_user_options_user_user_permissions_and_more")] + + operations = [ + migrations.DeleteModel( + name="MetaGroup", + ), + migrations.DeleteModel( + name="RealGroup", + ), + migrations.AlterModelOptions( + name="group", + options={}, + ), + migrations.RenameField( + model_name="group", + old_name="is_meta", + new_name="is_manually_manageable", + ), + migrations.AlterField( + model_name="group", + name="is_manually_manageable", + field=models.BooleanField( + default=False, + help_text="If False, this shouldn't be shown on group management pages", + verbose_name="Is manually manageable", + ), + ), + migrations.RunPython( + invert_is_manually_manageable, reverse_code=invert_is_manually_manageable + ), + ] diff --git a/core/models.py b/core/models.py index c8375727..945fd7d0 100644 --- a/core/models.py +++ b/core/models.py @@ -36,7 +36,6 @@ from django.conf import settings from django.contrib.auth.models import AbstractUser, UserManager from django.contrib.auth.models import AnonymousUser as AuthAnonymousUser from django.contrib.auth.models import Group as AuthGroup -from django.contrib.auth.models import GroupManager as AuthGroupManager from django.contrib.staticfiles.storage import staticfiles_storage from django.core import validators from django.core.cache import cache @@ -58,34 +57,17 @@ if TYPE_CHECKING: from club.models import Club -class RealGroupManager(AuthGroupManager): - def get_queryset(self): - return super().get_queryset().filter(is_meta=False) - - -class MetaGroupManager(AuthGroupManager): - def get_queryset(self): - return super().get_queryset().filter(is_meta=True) - - class Group(AuthGroup): - """Implement both RealGroups and Meta groups. + """Wrapper around django.auth.Group""" - Groups are sorted by their is_meta property - """ - - #: If False, this is a RealGroup - is_meta = models.BooleanField( - _("meta group status"), + is_manually_manageable = models.BooleanField( + _("Is manually manageable"), default=False, - help_text=_("Whether a group is a meta group or not"), + help_text=_("If False, this shouldn't be shown on group management pages"), ) #: Description of the group description = models.CharField(_("description"), max_length=60) - class Meta: - ordering = ["name"] - def get_absolute_url(self) -> str: return reverse("core:group_list") @@ -100,65 +82,6 @@ class Group(AuthGroup): cache.delete(f"sith_group_{self.name.replace(' ', '_')}") -class MetaGroup(Group): - """MetaGroups are dynamically created groups. - - Generally used with clubs where creating a club creates two groups: - - * club-SITH_BOARD_SUFFIX - * club-SITH_MEMBER_SUFFIX - """ - - #: Assign a manager in a way that MetaGroup.objects only return groups with is_meta=False - objects = MetaGroupManager() - - class Meta: - proxy = True - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.is_meta = True - - @cached_property - def associated_club(self) -> Club | None: - """Return the group associated with this meta group. - - The result of this function is cached - - - Returns: - The associated club if it exists, else None - """ - from club.models import Club - - if self.name.endswith(settings.SITH_BOARD_SUFFIX): - # replace this with str.removesuffix as soon as Python - # is upgraded to 3.10 - club_name = self.name[: -len(settings.SITH_BOARD_SUFFIX)] - elif self.name.endswith(settings.SITH_MEMBER_SUFFIX): - club_name = self.name[: -len(settings.SITH_MEMBER_SUFFIX)] - else: - return None - club = cache.get(f"sith_club_{club_name}") - if club is None: - club = Club.objects.filter(unix_name=club_name).first() - cache.set(f"sith_club_{club_name}", club) - return club - - -class RealGroup(Group): - """RealGroups are created by the developer. - - Most of the time they match a number in settings to be easily used for permissions. - """ - - #: Assign a manager in a way that MetaGroup.objects only return groups with is_meta=True - objects = RealGroupManager() - - class Meta: - proxy = True - - def validate_promo(value: int) -> None: start_year = settings.SITH_SCHOOL_START_YEAR delta = (localdate() + timedelta(days=180)).year - start_year @@ -204,8 +127,8 @@ def get_group(*, pk: int | None = None, name: str | None = None) -> Group | None else: group = Group.objects.filter(name=name).first() if group is not None: - cache.set(f"sith_group_{group.id}", group) - cache.set(f"sith_group_{group.name.replace(' ', '_')}", group) + name = group.name.replace(" ", "_") + cache.set_many({f"sith_group_{group.id}": group, f"sith_group_{name}": group}) else: cache.set(f"sith_group_{pk_or_name}", "not_found") return group @@ -438,18 +361,6 @@ class User(AbstractUser): return self.was_subscribed if group.id == settings.SITH_GROUP_ROOT_ID: return self.is_root - if group.is_meta: - # check if this group is associated with a club - group.__class__ = MetaGroup - club = group.associated_club - if club is None: - return False - membership = club.get_membership_for(self) - if membership is None: - return False - if group.name.endswith(settings.SITH_MEMBER_SUFFIX): - return True - return membership.role > settings.SITH_MAXIMUM_FREE_ROLE return group in self.cached_groups @property @@ -474,12 +385,11 @@ class User(AbstractUser): return any(g.id == root_id for g in self.cached_groups) @cached_property - def is_board_member(self): - main_club = settings.SITH_MAIN_CLUB["unix_name"] - return self.is_in_group(name=main_club + settings.SITH_BOARD_SUFFIX) + def is_board_member(self) -> bool: + return self.groups.filter(club_board=settings.SITH_MAIN_CLUB_ID).exists() @cached_property - def can_read_subscription_history(self): + def can_read_subscription_history(self) -> bool: if self.is_root or self.is_board_member: return True @@ -943,7 +853,7 @@ class SithFile(models.Model): param="1", ).save() - def is_owned_by(self, user): + def is_owned_by(self, user: User) -> bool: if user.is_anonymous: return False if user.is_root: @@ -958,7 +868,7 @@ class SithFile(models.Model): return True return user.id == self.owner_id - def can_be_viewed_by(self, user): + def can_be_viewed_by(self, user: User) -> bool: if hasattr(self, "profile_of"): return user.can_view(self.profile_of) if hasattr(self, "avatar_of"): diff --git a/core/tests/test_core.py b/core/tests/test_core.py index a33a8705..a152b579 100644 --- a/core/tests/test_core.py +++ b/core/tests/test_core.py @@ -18,6 +18,7 @@ from smtplib import SMTPException import freezegun import pytest +from django.contrib.auth.hashers import make_password from django.core import mail from django.core.cache import cache from django.core.mail import EmailMessage @@ -30,7 +31,7 @@ from model_bakery import baker from pytest_django.asserts import assertInHTML, assertRedirects from antispam.models import ToxicDomain -from club.models import Membership +from club.models import Club, Membership from core.markdown import markdown from core.models import AnonymousUser, Group, Page, User from core.utils import get_semester_code, get_start_of_semester @@ -145,7 +146,7 @@ class TestUserRegistration: class TestUserLogin: @pytest.fixture() def user(self) -> User: - return User.objects.first() + return baker.make(User, password=make_password("plop")) def test_login_fail(self, client, user): """Should not login a user correctly.""" @@ -349,14 +350,9 @@ class TestUserIsInGroup(TestCase): @classmethod def setUpTestData(cls): - from club.models import Club - cls.root_group = Group.objects.get(name="Root") - cls.public = Group.objects.get(name="Public") - cls.skia = User.objects.get(username="skia") - cls.toto = User.objects.create( - username="toto", first_name="a", last_name="b", email="a.b@toto.fr" - ) + cls.public_group = Group.objects.get(name="Public") + cls.public_user = baker.make(User) cls.subscribers = Group.objects.get(name="Subscribers") cls.old_subscribers = Group.objects.get(name="Old subscribers") cls.accounting_admin = Group.objects.get(name="Accounting admin") @@ -366,22 +362,12 @@ class TestUserIsInGroup(TestCase): cls.banned_counters = Group.objects.get(name="Banned from counters") cls.banned_subscription = Group.objects.get(name="Banned to subscribe") cls.sas_admin = Group.objects.get(name="SAS admin") - cls.club = Club.objects.create( - name="Fake Club", - unix_name="fake-club", - address="Fake address", - ) + cls.club = baker.make(Club) cls.main_club = Club.objects.get(id=1) def assert_in_public_group(self, user): - assert user.is_in_group(pk=self.public.id) - assert user.is_in_group(name=self.public.name) - - def assert_in_club_metagroups(self, user, club): - meta_groups_board = club.unix_name + settings.SITH_BOARD_SUFFIX - meta_groups_members = club.unix_name + settings.SITH_MEMBER_SUFFIX - assert user.is_in_group(name=meta_groups_board) is False - assert user.is_in_group(name=meta_groups_members) is False + assert user.is_in_group(pk=self.public_group.id) + assert user.is_in_group(name=self.public_group.name) def assert_only_in_public_group(self, user): self.assert_in_public_group(user) @@ -392,13 +378,11 @@ class TestUserIsInGroup(TestCase): self.sas_admin, self.subscribers, self.old_subscribers, + self.club.members_group, + self.club.board_group, ): assert not user.is_in_group(pk=group.pk) assert not user.is_in_group(name=group.name) - meta_groups_board = self.club.unix_name + settings.SITH_BOARD_SUFFIX - meta_groups_members = self.club.unix_name + settings.SITH_MEMBER_SUFFIX - assert user.is_in_group(name=meta_groups_board) is False - assert user.is_in_group(name=meta_groups_members) is False def test_anonymous_user(self): """Test that anonymous users are only in the public group.""" @@ -407,80 +391,80 @@ class TestUserIsInGroup(TestCase): def test_not_subscribed_user(self): """Test that users who never subscribed are only in the public group.""" - self.assert_only_in_public_group(self.toto) + self.assert_only_in_public_group(self.public_user) def test_wrong_parameter_fail(self): """Test that when neither the pk nor the name argument is given, the function raises a ValueError. """ with self.assertRaises(ValueError): - self.toto.is_in_group() + self.public_user.is_in_group() def test_number_queries(self): """Test that the number of db queries is stable and that less queries are made when making a new call. """ # make sure Skia is in at least one group - self.skia.groups.add(Group.objects.first().pk) - skia_groups = self.skia.groups.all() + group_in = baker.make(Group) + self.public_user.groups.add(group_in) - group_in = skia_groups.first() cache.clear() # Test when the user is in the group with self.assertNumQueries(2): - self.skia.is_in_group(pk=group_in.id) + self.public_user.is_in_group(pk=group_in.id) with self.assertNumQueries(0): - self.skia.is_in_group(pk=group_in.id) + self.public_user.is_in_group(pk=group_in.id) - ids = skia_groups.values_list("pk", flat=True) - group_not_in = Group.objects.exclude(pk__in=ids).first() + group_not_in = baker.make(Group) cache.clear() # Test when the user is not in the group with self.assertNumQueries(2): - self.skia.is_in_group(pk=group_not_in.id) + self.public_user.is_in_group(pk=group_not_in.id) with self.assertNumQueries(0): - self.skia.is_in_group(pk=group_not_in.id) + self.public_user.is_in_group(pk=group_not_in.id) def test_cache_properly_cleared_membership(self): """Test that when the membership of a user end, the cache is properly invalidated. """ - membership = Membership.objects.create( - club=self.club, user=self.toto, end_date=None - ) - meta_groups_members = self.club.unix_name + settings.SITH_MEMBER_SUFFIX + membership = baker.make(Membership, club=self.club, user=self.public_user) cache.clear() - assert self.toto.is_in_group(name=meta_groups_members) is True - assert membership == cache.get(f"membership_{self.club.id}_{self.toto.id}") + self.club.get_membership_for(self.public_user) # this should populate the cache + assert membership == cache.get( + f"membership_{self.club.id}_{self.public_user.id}" + ) membership.end_date = now() - timedelta(minutes=5) membership.save() - cached_membership = cache.get(f"membership_{self.club.id}_{self.toto.id}") + cached_membership = cache.get( + f"membership_{self.club.id}_{self.public_user.id}" + ) assert cached_membership == "not_member" - assert self.toto.is_in_group(name=meta_groups_members) is False def test_cache_properly_cleared_group(self): """Test that when a user is removed from a group, the is_in_group_method return False when calling it again. """ # testing with pk - self.toto.groups.add(self.com_admin.pk) - assert self.toto.is_in_group(pk=self.com_admin.pk) is True + self.public_user.groups.add(self.com_admin.pk) + assert self.public_user.is_in_group(pk=self.com_admin.pk) is True - self.toto.groups.remove(self.com_admin.pk) - assert self.toto.is_in_group(pk=self.com_admin.pk) is False + self.public_user.groups.remove(self.com_admin.pk) + assert self.public_user.is_in_group(pk=self.com_admin.pk) is False # testing with name - self.toto.groups.add(self.sas_admin.pk) - assert self.toto.is_in_group(name="SAS admin") is True + self.public_user.groups.add(self.sas_admin.pk) + assert self.public_user.is_in_group(name="SAS admin") is True - self.toto.groups.remove(self.sas_admin.pk) - assert self.toto.is_in_group(name="SAS admin") is False + self.public_user.groups.remove(self.sas_admin.pk) + assert self.public_user.is_in_group(name="SAS admin") is False def test_not_existing_group(self): """Test that searching for a not existing group returns False. """ - assert self.skia.is_in_group(name="This doesn't exist") is False + user = baker.make(User) + user.groups.set(list(Group.objects.all())) + assert not user.is_in_group(name="This doesn't exist") class TestDateUtils(TestCase): diff --git a/core/views/forms.py b/core/views/forms.py index bda33ec0..5dbf8f3e 100644 --- a/core/views/forms.py +++ b/core/views/forms.py @@ -32,19 +32,14 @@ from django.contrib.staticfiles.management.commands.collectstatic import ( ) from django.core.exceptions import ValidationError from django.db import transaction -from django.forms import ( - CheckboxSelectMultiple, - DateInput, - DateTimeInput, - TextInput, -) +from django.forms import CheckboxSelectMultiple, DateInput, DateTimeInput, TextInput from django.utils.translation import gettext from django.utils.translation import gettext_lazy as _ from phonenumber_field.widgets import RegionalPhoneNumberWidget from PIL import Image from antispam.forms import AntiSpamEmailField -from core.models import Gift, Page, RealGroup, SithFile, User +from core.models import Gift, Group, Page, SithFile, User from core.utils import resize_image from core.views.widgets.select import ( AutoCompleteSelect, @@ -293,7 +288,7 @@ class UserGroupsForm(forms.ModelForm): required_css_class = "required" groups = forms.ModelMultipleChoiceField( - queryset=RealGroup.objects.all(), + queryset=Group.objects.filter(is_manually_manageable=True), widget=CheckboxSelectMultiple, label=_("Groups"), required=False, diff --git a/core/views/group.py b/core/views/group.py index b6e77b54..978fe686 100644 --- a/core/views/group.py +++ b/core/views/group.py @@ -21,7 +21,7 @@ from django.utils.translation import gettext_lazy as _ from django.views.generic import ListView from django.views.generic.edit import CreateView, DeleteView, UpdateView -from core.models import RealGroup, User +from core.models import Group, User from core.views import CanCreateMixin, CanEditMixin, DetailFormView from core.views.widgets.select import AutoCompleteSelectMultipleUser @@ -57,7 +57,8 @@ class EditMembersForm(forms.Form): class GroupListView(CanEditMixin, ListView): """Displays the Group list.""" - model = RealGroup + model = Group + queryset = Group.objects.filter(is_manually_manageable=True) ordering = ["name"] template_name = "core/group_list.jinja" @@ -65,7 +66,8 @@ class GroupListView(CanEditMixin, ListView): class GroupEditView(CanEditMixin, UpdateView): """Edit infos of a Group.""" - model = RealGroup + model = Group + queryset = Group.objects.filter(is_manually_manageable=True) pk_url_kwarg = "group_id" template_name = "core/group_edit.jinja" fields = ["name", "description"] @@ -74,7 +76,8 @@ class GroupEditView(CanEditMixin, UpdateView): class GroupCreateView(CanCreateMixin, CreateView): """Add a new Group.""" - model = RealGroup + model = Group + queryset = Group.objects.filter(is_manually_manageable=True) template_name = "core/create.jinja" fields = ["name", "description"] @@ -84,7 +87,8 @@ class GroupTemplateView(CanEditMixin, DetailFormView): Allow adding and removing users from it. """ - model = RealGroup + model = Group + queryset = Group.objects.filter(is_manually_manageable=True) form_class = EditMembersForm pk_url_kwarg = "group_id" template_name = "core/group_detail.jinja" @@ -118,7 +122,8 @@ class GroupTemplateView(CanEditMixin, DetailFormView): class GroupDeleteView(CanEditMixin, DeleteView): """Delete a Group.""" - model = RealGroup + model = Group + queryset = Group.objects.filter(is_manually_manageable=True) pk_url_kwarg = "group_id" template_name = "core/delete_confirm.jinja" success_url = reverse_lazy("core:group_list") diff --git a/counter/models.py b/counter/models.py index 6668e520..9fade666 100644 --- a/counter/models.py +++ b/counter/models.py @@ -532,9 +532,12 @@ class Counter(models.Model): return user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID) def can_be_viewed_by(self, user: User) -> bool: - if self.type == "BAR": - return True - return user.is_board_member or user in self.sellers.all() + return ( + self.type == "BAR" + or user.is_root + or user.is_in_group(pk=self.club.board_group_id) + or user in self.sellers.all() + ) def gen_token(self) -> None: """Generate a new token for this counter.""" diff --git a/docs/tutorial/groups.md b/docs/tutorial/groups.md index b8603d16..fbb58aab 100644 --- a/docs/tutorial/groups.md +++ b/docs/tutorial/groups.md @@ -1,54 +1,165 @@ -Il existe deux types de groupes sur le site AE : +## Un peu d'histoire + +Par défaut, Django met à disposition un modèle `Group`, +lié par clef étrangère au modèle `User`. +Pour créer un système de gestion des groupes qui semblait plus +approprié aux développeurs initiaux, un nouveau +modèle [core.models.Group][] +a été crée, et la relation de clef étrangère a été modifiée +pour lier [core.models.User][] à ce dernier. + +L'ancien modèle `Group` était implicitement +divisé en deux catégories : + +- les *méta-groupes* : groupes liés aux clubs et créés à la volée. + Ces groupes n'étaient liés par clef étrangère à aucun utilisateur. + Ils étaient récupérés à partir de leur nom uniquement + et étaient plus une indirection pour désigner l'appartenance à un club + que des groupes à proprement parler. +- les *groupes réels* : groupes créés à la main + et souvent hardcodés dans la configuration. + +Cependant, ce nouveau système s'éloignait trop du cadre de Django +et a fini par devenir une gêne. +La vérification des droits lors des opérations est devenue +une opération complexe et coûteuse en temps. + +La gestion des groupes a donc été modifiée pour recoller un +peu plus au cadre de Django. +Toutefois, il n'a pas été tenté de revenir à 100% +sur l'architecture prônée par Django. + +D'une part, cela représentait un risque pour le succès de l'application +de la migration sur la base de données de production. + +D'autre part, si une autre architecture a été tentée au début, +ce n'était pas sans raison : +ce que nous voulons modéliser sur le site AE n'est pas +complètement modélisable avec ce qu'offre Django. +Il faut donc bien garder une surcouche au-dessus de l'authentification +de Django. +Tout le défi est de réussir à maintenir cette surcouche aussi fine +que possible sans limiter ce que nous voulons faire. + +## Représentation en base de données + +Le modèle [core.models.Group][] a donc été légèrement remanié +et la distinction entre groupes méta et groupes réels a été plus ou moins +supprimée. +La liaison de clef étrangère se fait toujours entre [core.models.User][] +et [core.models.Group][]. + +Cependant, il y a une subtilité. +Depuis le début, le modèle `Group` de django n'a jamais disparu. +En effet, lorsqu'un modèle hérite d'un modèle qui n'est pas +abstrait, Django garde les deux tables et les lie +par une clef étrangère unique de clef primaire à clef primaire +(pour plus de détail, lire +[la doc de django sur l'héritage de modèle](https://docs.djangoproject.com/fr/stable/topics/db/models/#model-inheritance)) + +L'organisation réelle de notre système de groupes +est donc la suivante : + + +```mermaid +--- +title: Représentation des groupes +--- +erDiagram + core_user }o..o{ core_group: core_user_groups + auth_group }o..o{ auth_permission: auth_group_permissions + core_group ||--|| auth_group: "" + core_user }o..o{ auth_permission :"core_user_user_permissions" + + core_user { + int id PK + string username + string email + string first_name + etc etc + } + core_group { + int group_ptr_id PK,FK + string description + bool is_manually_manageable + } + auth_group { + int id PK + name string + } + auth_permission { + int id PK + string name + } +``` + +Cette organisation, rajoute une certaine complexité, +mais celle-ci est presque entièrement gérée par django, +ce qui fait que la gestion n'est pas tellement plus compliquée +du point de vue du développeur. + +Chaque fois qu'un queryset implique notre `Group` +ou le `Group` de django, l'autre modèle est automatiquement +ajouté à la requête par jointure. +De cette façon, on peut manipuler l'un ou l'autre, +sans même se rendre que les tables sont dans des tables séparées. + +Par exemple : + +=== "python" + + ```python + from core.models import Group + + Group.objects.all() + ``` + +=== "SQL généré" + + ```sql + SELECT "auth_group"."id", + "auth_group"."name", + "core_group"."group_ptr_id", + "core_group"."is_manually_manageable", + "core_group"."description" + FROM "core_group" + INNER JOIN "auth_group" ON ("core_group"."group_ptr_id" = "auth_group"."id") + ``` + +!!!warning + + Django réussit à abstraire assez bien la logique relationnelle. + Cependant, gardez bien en mémoire que ce n'est pas quelque chose + de magique et que cette manière de faire a des limitations. + Par exemple, il devient impossible de `bulk_create` + des groupes. -- l'un se base sur des groupes enregistrés en base de données pendant le développement, - c'est le système de groupes réels. -- L'autre est plus dynamique et comprend tous les groupes générés - pendant l'exécution et l'utilisation du programme. - Cela correspond généralement aux groupes liés aux clubs. - Ce sont les méta-groupes. ## La définition d'un groupe -Les deux types de groupes sont stockés dans la même table -en base de données, et ne sont différenciés que par un attribut `is_meta`. +Un groupe est constitué des informations suivantes : -### Les groupes réels +- son nom : `name` +- sa description : `description` (optionnelle) +- si on autorise sa gestion par les utilisateurs du site : `is_manually_manageable` -Pour plus différencier l'utilisation de ces deux types de groupe, -il a été créé une classe proxy -(c'est-à-dire qu'elle ne correspond pas à une vraie table en base de donnée) -qui encapsule leur utilisation. -`RealGroup` peut être utilisé pour créer des groupes réels dans le code -et pour faire une recherche sur ceux-ci -(dans le cadre d'une vérification de permissions par exemple). +Si un groupe est gérable manuellement, alors les administrateurs du site +auront le droit d'assigner des utilisateurs à ce groupe depuis l'interface dédiée. -### Les méta-groupes +S'il n'est pas gérable manuellement, on cache aux utilisateurs du site +la gestion des membres de ce groupe. +La gestion se fait alors uniquement "sous le capot", +de manière automatique lors de certains évènements. +Par exemple, lorsqu'un utilisateur rejoint un club, +il est automatiquement ajouté au groupe des membres +du club. +Lorsqu'il quitte le club, il est retiré du groupe. -Les méta-groupes, comme expliqué précédemment, -sont utilisés dans les contextes où il est nécessaire de créer un groupe dynamiquement. -Les objets `MetaGroup`, bien que dynamiques, doivent tout de même s'enregistrer -en base de données comme des vrais groupes afin de pouvoir être affectés -dans les permissions d'autres objets, comme un forum ou une page de wiki par exemple. -C'est principalement utilisé au travers des clubs, -qui génèrent automatiquement deux groupes à leur création : +## Les principaux groupes utilisés -- club-bureau : contient tous les membres d'un club **au dessus** - du grade défini dans `settings.SITH_MAXIMUM_FREE_ROLE`. -- club-membres : contient tous les membres d'un club - **en dessous** du grade défini dans `settings.SITH_MAXIMUM_FREE_ROLE`. - - -## Les groupes réels utilisés - -Les groupes réels que l'on utilise dans le site sont les suivants : - -Groupes gérés automatiquement par le site : - -- `Public` : tous les utilisateurs du site -- `Subscribers` : tous les cotisants du site -- `Old subscribers` : tous les anciens cotisants - -Groupes gérés par les administrateurs (à appliquer à la main sur un utilisateur) : +Les groupes les plus notables gérables par les administrateurs du site sont : - `Root` : administrateur global du site - `Accounting admin` : les administrateurs de la comptabilité @@ -62,3 +173,10 @@ Groupes gérés par les administrateurs (à appliquer à la main sur un utilisat - `Banned to subscribe` : les utilisateurs interdits de cotisation +En plus de ces groupes, on peut noter : + +- `Public` : tous les utilisateurs du site +- `Subscribers` : tous les cotisants du site +- `Old subscribers` : tous les anciens cotisants + + diff --git a/docs/tutorial/perms.md b/docs/tutorial/perms.md index 3377b808..c78292ab 100644 --- a/docs/tutorial/perms.md +++ b/docs/tutorial/perms.md @@ -4,7 +4,7 @@ Le fonctionnement de l'AE ne permet pas d'utiliser le système de permissions intégré à Django tel quel. Lors de la conception du Sith, ce qui paraissait le plus simple à l'époque était de concevoir un système maison afin de se calquer -sur ce que faisais l'ancien site. +sur ce que faisait l'ancien site. ### Protéger un modèle diff --git a/election/tests.py b/election/tests.py index 5152cc3d..f4cc380e 100644 --- a/election/tests.py +++ b/election/tests.py @@ -11,8 +11,6 @@ class TestElection(TestCase): def setUpTestData(cls): cls.election = Election.objects.first() cls.public_group = Group.objects.get(id=settings.SITH_GROUP_PUBLIC_ID) - cls.subscriber_group = Group.objects.get(name=settings.SITH_MAIN_MEMBERS_GROUP) - cls.ae_board_group = Group.objects.get(name=settings.SITH_MAIN_BOARD_GROUP) cls.sli = User.objects.get(username="sli") cls.subscriber = User.objects.get(username="subscriber") cls.public = User.objects.get(username="public") diff --git a/galaxy/management/commands/generate_galaxy_test_data.py b/galaxy/management/commands/generate_galaxy_test_data.py index 39334ddd..d0dea4a5 100644 --- a/galaxy/management/commands/generate_galaxy_test_data.py +++ b/galaxy/management/commands/generate_galaxy_test_data.py @@ -118,18 +118,26 @@ class Command(BaseCommand): self.make_important_citizen(u) def make_clubs(self): - """Create all the clubs (:class:`club.models.Club`). + """Create all the clubs [club.models.Club][]. After creation, the clubs are stored in `self.clubs` for fast access later. - Don't create the meta groups (:class:`core.models.MetaGroup`) - nor the pages of the clubs (:class:`core.models.Page`). + Don't create the pages of the clubs ([core.models.Page][]). """ - self.clubs = [] - for i in range(self.NB_CLUBS): - self.clubs.append(Club(unix_name=f"galaxy-club-{i}", name=f"club-{i}")) - # We don't need to create corresponding groups here, as the Galaxy doesn't care about them - Club.objects.bulk_create(self.clubs) - self.clubs = list(Club.objects.filter(unix_name__startswith="galaxy-").all()) + # dummy groups. + # the galaxy doesn't care about the club groups, + # but it's necessary to add them nonetheless in order + # not to break the integrity constraints + self.clubs = Club.objects.bulk_create( + [ + Club( + unix_name=f"galaxy-club-{i}", + name=f"club-{i}", + board_group=Group.objects.create(name=f"board {i}"), + members_group=Group.objects.create(name=f"members {i}"), + ) + for i in range(self.NB_CLUBS) + ] + ) def make_users(self): """Create all the users and store them in `self.users` for fast access later. diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index d6c2e813..4b44dcf2 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: 2024-12-23 02:37+0100\n" +"POT-Creation-Date: 2024-12-28 12:50+0100\n" "PO-Revision-Date: 2016-07-18\n" "Last-Translator: Maréchal \n" @@ -40,7 +40,7 @@ msgstr "code postal" msgid "country" msgstr "pays" -#: accounting/models.py:67 core/models.py:356 +#: accounting/models.py:67 core/models.py:279 msgid "phone" msgstr "téléphone" @@ -64,7 +64,7 @@ msgstr "IBAN" msgid "account number" msgstr "numéro de compte" -#: accounting/models.py:107 accounting/models.py:136 club/models.py:345 +#: accounting/models.py:107 accounting/models.py:136 club/models.py:356 #: com/models.py:87 com/models.py:272 com/models.py:312 counter/models.py:361 #: counter/models.py:485 trombi/models.py:209 msgid "club" @@ -87,12 +87,12 @@ msgstr "Compte club" msgid "%(club_account)s on %(bank_account)s" msgstr "%(club_account)s sur %(bank_account)s" -#: accounting/models.py:188 club/models.py:351 counter/models.py:997 +#: accounting/models.py:188 club/models.py:362 counter/models.py:1000 #: election/models.py:16 launderette/models.py:165 msgid "start date" msgstr "date de début" -#: accounting/models.py:189 club/models.py:352 counter/models.py:998 +#: accounting/models.py:189 club/models.py:363 counter/models.py:1001 #: election/models.py:17 msgid "end date" msgstr "date de fin" @@ -106,7 +106,7 @@ msgid "club account" msgstr "compte club" #: accounting/models.py:199 accounting/models.py:255 counter/models.py:93 -#: counter/models.py:714 +#: counter/models.py:717 msgid "amount" msgstr "montant" @@ -126,20 +126,20 @@ msgstr "numéro" msgid "journal" msgstr "classeur" -#: accounting/models.py:256 core/models.py:905 core/models.py:1414 -#: core/models.py:1459 core/models.py:1488 core/models.py:1512 -#: counter/models.py:724 counter/models.py:829 counter/models.py:1033 +#: accounting/models.py:256 core/models.py:815 core/models.py:1324 +#: core/models.py:1369 core/models.py:1398 core/models.py:1422 +#: counter/models.py:727 counter/models.py:832 counter/models.py:1036 #: eboutic/models.py:57 eboutic/models.py:193 forum/models.py:312 #: forum/models.py:413 msgid "date" msgstr "date" -#: accounting/models.py:257 counter/models.py:302 counter/models.py:1034 +#: accounting/models.py:257 counter/models.py:302 counter/models.py:1037 #: pedagogy/models.py:208 msgid "comment" msgstr "commentaire" -#: accounting/models.py:259 counter/models.py:726 counter/models.py:831 +#: accounting/models.py:259 counter/models.py:729 counter/models.py:834 #: subscription/models.py:56 msgid "payment method" msgstr "méthode de paiement" @@ -165,8 +165,8 @@ msgid "accounting type" msgstr "type comptable" #: accounting/models.py:294 accounting/models.py:429 accounting/models.py:460 -#: accounting/models.py:492 core/models.py:1487 core/models.py:1513 -#: counter/models.py:795 +#: accounting/models.py:492 core/models.py:1397 core/models.py:1423 +#: counter/models.py:798 msgid "label" msgstr "étiquette" @@ -174,7 +174,7 @@ msgstr "étiquette" msgid "target type" msgstr "type de cible" -#: accounting/models.py:303 club/models.py:504 +#: accounting/models.py:303 club/models.py:611 #: club/templates/club/club_members.jinja:17 #: club/templates/club/club_old_members.jinja:8 #: club/templates/club/mailing.jinja:41 @@ -186,7 +186,7 @@ msgstr "type de cible" msgid "User" msgstr "Utilisateur" -#: accounting/models.py:304 club/models.py:408 +#: accounting/models.py:304 club/models.py:515 #: club/templates/club/club_detail.jinja:12 #: com/templates/com/mailing_admin.jinja:11 #: com/templates/com/news_admin_list.jinja:23 @@ -218,7 +218,7 @@ msgstr "Compte" msgid "Company" msgstr "Entreprise" -#: accounting/models.py:307 core/models.py:303 sith/settings.py:424 +#: accounting/models.py:307 core/models.py:226 sith/settings.py:424 msgid "Other" msgstr "Autre" @@ -385,7 +385,7 @@ msgid "Delete" msgstr "Supprimer" #: accounting/templates/accounting/bank_account_details.jinja:18 -#: club/views.py:79 core/views/user.py:201 sas/templates/sas/picture.jinja:91 +#: club/views.py:78 core/views/user.py:201 sas/templates/sas/picture.jinja:91 msgid "Infos" msgstr "Infos" @@ -404,7 +404,7 @@ msgstr "Nouveau compte club" #: accounting/templates/accounting/bank_account_details.jinja:27 #: accounting/templates/accounting/bank_account_list.jinja:22 #: accounting/templates/accounting/club_account_details.jinja:58 -#: accounting/templates/accounting/journal_details.jinja:92 club/views.py:125 +#: accounting/templates/accounting/journal_details.jinja:92 club/views.py:124 #: com/templates/com/news_admin_list.jinja:39 #: com/templates/com/news_admin_list.jinja:68 #: com/templates/com/news_admin_list.jinja:115 @@ -586,7 +586,7 @@ msgstr "Classeur : " #: accounting/templates/accounting/journal_statement_accounting.jinja:30 #: core/templates/core/user_account.jinja:39 #: core/templates/core/user_account_detail.jinja:9 -#: counter/templates/counter/counter_click.jinja:46 +#: counter/templates/counter/counter_click.jinja:39 msgid "Amount: " msgstr "Montant : " @@ -934,11 +934,11 @@ msgstr "Retirer" msgid "Action" msgstr "Action" -#: club/forms.py:109 club/tests.py:711 +#: club/forms.py:109 club/tests.py:742 msgid "This field is required" msgstr "Ce champ est obligatoire" -#: club/forms.py:118 club/tests.py:742 +#: club/forms.py:118 club/tests.py:773 msgid "One of the selected users doesn't have an email address" msgstr "Un des utilisateurs sélectionnés n'a pas d'adresse email" @@ -946,15 +946,15 @@ msgstr "Un des utilisateurs sélectionnés n'a pas d'adresse email" msgid "An action is required" msgstr "Une action est requise" -#: club/forms.py:140 club/tests.py:698 club/tests.py:724 +#: club/forms.py:140 club/tests.py:729 club/tests.py:755 msgid "You must specify at least an user or an email address" msgstr "vous devez spécifier au moins un utilisateur ou une adresse email" -#: club/forms.py:149 counter/forms.py:207 +#: club/forms.py:149 counter/forms.py:209 msgid "Begin date" msgstr "Date de début" -#: club/forms.py:152 com/views.py:84 com/views.py:202 counter/forms.py:210 +#: club/forms.py:152 com/views.py:84 com/views.py:202 counter/forms.py:212 #: election/views.py:170 subscription/forms.py:21 msgid "End date" msgstr "Date de fin" @@ -1025,74 +1025,70 @@ msgstr "actif" msgid "short description" msgstr "description courte" -#: club/models.py:81 core/models.py:358 +#: club/models.py:81 core/models.py:281 msgid "address" msgstr "Adresse" -#: club/models.py:98 core/models.py:269 +#: club/models.py:98 core/models.py:192 msgid "home" msgstr "home" -#: club/models.py:150 +#: club/models.py:159 msgid "You can not make loops in clubs" msgstr "Vous ne pouvez pas faire de boucles dans les clubs" -#: club/models.py:174 -msgid "A club with that unix_name already exists" -msgstr "Un club avec ce nom UNIX existe déjà." - -#: club/models.py:337 counter/models.py:988 counter/models.py:1024 +#: club/models.py:348 counter/models.py:991 counter/models.py:1027 #: eboutic/models.py:53 eboutic/models.py:189 election/models.py:183 #: launderette/models.py:130 launderette/models.py:184 sas/models.py:273 #: trombi/models.py:205 msgid "user" msgstr "utilisateur" -#: club/models.py:354 core/models.py:322 election/models.py:178 +#: club/models.py:365 core/models.py:245 election/models.py:178 #: election/models.py:212 trombi/models.py:210 msgid "role" msgstr "rôle" -#: club/models.py:359 core/models.py:84 counter/models.py:300 +#: club/models.py:370 core/models.py:69 counter/models.py:300 #: counter/models.py:333 election/models.py:13 election/models.py:115 #: election/models.py:188 forum/models.py:61 forum/models.py:245 msgid "description" msgstr "description" -#: club/models.py:415 club/models.py:510 +#: club/models.py:522 club/models.py:617 msgid "Email address" msgstr "Adresse email" -#: club/models.py:423 +#: club/models.py:530 msgid "Enter a valid address. Only the root of the address is needed." msgstr "" "Entrez une adresse valide. Seule la racine de l'adresse est nécessaire." -#: club/models.py:427 com/models.py:97 com/models.py:322 core/models.py:906 +#: club/models.py:534 com/models.py:97 com/models.py:322 core/models.py:816 msgid "is moderated" msgstr "est modéré" -#: club/models.py:431 com/models.py:101 com/models.py:326 +#: club/models.py:538 com/models.py:101 com/models.py:326 msgid "moderator" msgstr "modérateur" -#: club/models.py:457 +#: club/models.py:564 msgid "This mailing list already exists." msgstr "Cette liste de diffusion existe déjà." -#: club/models.py:496 club/templates/club/mailing.jinja:23 +#: club/models.py:603 club/templates/club/mailing.jinja:23 msgid "Mailing" msgstr "Liste de diffusion" -#: club/models.py:520 +#: club/models.py:627 msgid "At least user or email is required" msgstr "Au moins un utilisateur ou un email est nécessaire" -#: club/models.py:528 club/tests.py:770 +#: club/models.py:635 club/tests.py:801 msgid "This email is already suscribed in this mailing" msgstr "Cet email est déjà abonné à cette mailing" -#: club/models.py:556 +#: club/models.py:663 msgid "Unregistered user" msgstr "Utilisateur non enregistré" @@ -1146,7 +1142,7 @@ msgid "There are no members in this club." msgstr "Il n'y a pas de membres dans ce club." #: club/templates/club/club_members.jinja:80 -#: core/templates/core/file_detail.jinja:19 core/views/forms.py:309 +#: core/templates/core/file_detail.jinja:19 core/views/forms.py:308 #: launderette/views.py:208 trombi/templates/trombi/detail.jinja:19 msgid "Add" msgstr "Ajouter" @@ -1217,7 +1213,7 @@ msgid "Barman" msgstr "Barman" #: club/templates/club/club_sellings.jinja:51 -#: counter/templates/counter/counter_click.jinja:43 +#: counter/templates/counter/counter_click.jinja:36 #: counter/templates/counter/last_ops.jinja:22 #: counter/templates/counter/last_ops.jinja:47 #: counter/templates/counter/refilling_list.jinja:12 @@ -1359,41 +1355,41 @@ msgstr "Aucune page n'existe pour ce club" msgid "Club stats" msgstr "Statistiques du club" -#: club/views.py:89 +#: club/views.py:88 msgid "Members" msgstr "Membres" -#: club/views.py:98 +#: club/views.py:97 msgid "Old members" msgstr "Anciens membres" -#: club/views.py:108 core/templates/core/page.jinja:33 +#: club/views.py:107 core/templates/core/page.jinja:33 msgid "History" msgstr "Historique" -#: club/views.py:116 core/templates/core/base/header.jinja:61 +#: club/views.py:115 core/templates/core/base/header.jinja:61 #: core/views/user.py:224 sas/templates/sas/picture.jinja:110 #: trombi/views.py:62 msgid "Tools" msgstr "Outils" -#: club/views.py:136 +#: club/views.py:135 msgid "Edit club page" msgstr "Éditer la page de club" -#: club/views.py:145 club/views.py:452 +#: club/views.py:144 club/views.py:451 msgid "Sellings" msgstr "Vente" -#: club/views.py:152 +#: club/views.py:151 msgid "Mailing list" msgstr "Listes de diffusion" -#: club/views.py:161 com/views.py:134 +#: club/views.py:160 com/views.py:134 msgid "Posters list" msgstr "Liste d'affiches" -#: club/views.py:171 counter/templates/counter/counter_list.jinja:21 +#: club/views.py:170 counter/templates/counter/counter_list.jinja:21 #: counter/templates/counter/counter_list.jinja:37 #: counter/templates/counter/counter_list.jinja:53 msgid "Props" @@ -1454,7 +1450,7 @@ msgstr "contenu" msgid "A more detailed and exhaustive description of the event." msgstr "Une description plus détaillée et exhaustive de l'évènement." -#: com/models.py:82 core/models.py:1457 launderette/models.py:88 +#: com/models.py:82 core/models.py:1367 launderette/models.py:88 #: launderette/models.py:124 launderette/models.py:167 msgid "type" msgstr "type" @@ -1508,7 +1504,7 @@ msgstr "weekmail" msgid "rank" msgstr "rang" -#: com/models.py:308 core/models.py:871 core/models.py:921 +#: com/models.py:308 core/models.py:781 core/models.py:831 msgid "file" msgstr "fichier" @@ -1992,48 +1988,49 @@ msgstr "" "Vous devez êtres un membre du bureau du club sélectionné pour poster dans le " "Weekmail." -#: core/models.py:79 -msgid "meta group status" -msgstr "status du meta-groupe" +#: core/models.py:64 +msgid "Is manually manageable" +msgstr "Est gérable manuellement" -#: core/models.py:81 -msgid "Whether a group is a meta group or not" -msgstr "Si un groupe est un meta-groupe ou pas" +#: core/models.py:66 +msgid "If False, this shouldn't be shown on group management pages" +msgstr "" +"Si faux, ceci ne devrait pas être montré sur les pages de gestion des groupes" -#: core/models.py:167 +#: core/models.py:90 #, python-format msgid "%(value)s is not a valid promo (between 0 and %(end)s)" msgstr "%(value)s n'est pas une promo valide (doit être entre 0 et %(end)s)" -#: core/models.py:250 +#: core/models.py:173 msgid "first name" msgstr "Prénom" -#: core/models.py:251 +#: core/models.py:174 msgid "last name" msgstr "Nom" -#: core/models.py:252 +#: core/models.py:175 msgid "email address" msgstr "adresse email" -#: core/models.py:253 +#: core/models.py:176 msgid "date of birth" msgstr "date de naissance" -#: core/models.py:254 +#: core/models.py:177 msgid "nick name" msgstr "surnom" -#: core/models.py:255 +#: core/models.py:178 msgid "last update" msgstr "dernière mise à jour" -#: core/models.py:258 +#: core/models.py:181 msgid "groups" msgstr "groupes" -#: core/models.py:260 +#: core/models.py:183 msgid "" "The groups this user belongs to. A user will get all permissions granted to " "each of their groups." @@ -2041,147 +2038,147 @@ msgstr "" "Les groupes auxquels cet utilisateur appartient. Un utilisateur aura toutes " "les permissions de chacun de ses groupes." -#: core/models.py:277 +#: core/models.py:200 msgid "profile" msgstr "profil" -#: core/models.py:285 +#: core/models.py:208 msgid "avatar" msgstr "avatar" -#: core/models.py:293 +#: core/models.py:216 msgid "scrub" msgstr "blouse" -#: core/models.py:299 +#: core/models.py:222 msgid "sex" msgstr "Genre" -#: core/models.py:303 +#: core/models.py:226 msgid "Man" msgstr "Homme" -#: core/models.py:303 +#: core/models.py:226 msgid "Woman" msgstr "Femme" -#: core/models.py:305 +#: core/models.py:228 msgid "pronouns" msgstr "pronoms" -#: core/models.py:307 +#: core/models.py:230 msgid "tshirt size" msgstr "taille de t-shirt" -#: core/models.py:310 +#: core/models.py:233 msgid "-" msgstr "-" -#: core/models.py:311 +#: core/models.py:234 msgid "XS" msgstr "XS" -#: core/models.py:312 +#: core/models.py:235 msgid "S" msgstr "S" -#: core/models.py:313 +#: core/models.py:236 msgid "M" msgstr "M" -#: core/models.py:314 +#: core/models.py:237 msgid "L" msgstr "L" -#: core/models.py:315 +#: core/models.py:238 msgid "XL" msgstr "XL" -#: core/models.py:316 +#: core/models.py:239 msgid "XXL" msgstr "XXL" -#: core/models.py:317 +#: core/models.py:240 msgid "XXXL" msgstr "XXXL" -#: core/models.py:325 +#: core/models.py:248 msgid "Student" msgstr "Étudiant" -#: core/models.py:326 +#: core/models.py:249 msgid "Administrative agent" msgstr "Personnel administratif" -#: core/models.py:327 +#: core/models.py:250 msgid "Teacher" msgstr "Enseignant" -#: core/models.py:328 +#: core/models.py:251 msgid "Agent" msgstr "Personnel" -#: core/models.py:329 +#: core/models.py:252 msgid "Doctor" msgstr "Doctorant" -#: core/models.py:330 +#: core/models.py:253 msgid "Former student" msgstr "Ancien étudiant" -#: core/models.py:331 +#: core/models.py:254 msgid "Service" msgstr "Service" -#: core/models.py:337 +#: core/models.py:260 msgid "department" msgstr "département" -#: core/models.py:344 +#: core/models.py:267 msgid "dpt option" msgstr "Filière" -#: core/models.py:346 pedagogy/models.py:70 pedagogy/models.py:294 +#: core/models.py:269 pedagogy/models.py:70 pedagogy/models.py:294 msgid "semester" msgstr "semestre" -#: core/models.py:347 +#: core/models.py:270 msgid "quote" msgstr "citation" -#: core/models.py:348 +#: core/models.py:271 msgid "school" msgstr "école" -#: core/models.py:350 +#: core/models.py:273 msgid "promo" msgstr "promo" -#: core/models.py:353 +#: core/models.py:276 msgid "forum signature" msgstr "signature du forum" -#: core/models.py:355 +#: core/models.py:278 msgid "second email address" msgstr "adresse email secondaire" -#: core/models.py:357 +#: core/models.py:280 msgid "parent phone" msgstr "téléphone des parents" -#: core/models.py:360 +#: core/models.py:283 msgid "parent address" msgstr "adresse des parents" -#: core/models.py:363 +#: core/models.py:286 msgid "is subscriber viewable" msgstr "profil visible par les cotisants" -#: core/models.py:556 +#: core/models.py:466 msgid "A user with that username already exists" msgstr "Un utilisateur de ce nom d'utilisateur existe déjà" -#: core/models.py:710 core/templates/core/macros.jinja:80 +#: core/models.py:620 core/templates/core/macros.jinja:80 #: core/templates/core/macros.jinja:84 core/templates/core/macros.jinja:85 #: core/templates/core/user_detail.jinja:100 #: core/templates/core/user_detail.jinja:101 @@ -2201,101 +2198,101 @@ msgstr "Un utilisateur de ce nom d'utilisateur existe déjà" msgid "Profile" msgstr "Profil" -#: core/models.py:821 +#: core/models.py:731 msgid "Visitor" msgstr "Visiteur" -#: core/models.py:828 +#: core/models.py:738 msgid "receive the Weekmail" msgstr "recevoir le Weekmail" -#: core/models.py:829 +#: core/models.py:739 msgid "show your stats to others" msgstr "montrez vos statistiques aux autres" -#: core/models.py:831 +#: core/models.py:741 msgid "get a notification for every click" msgstr "avoir une notification pour chaque click" -#: core/models.py:834 +#: core/models.py:744 msgid "get a notification for every refilling" msgstr "avoir une notification pour chaque rechargement" -#: core/models.py:860 sas/forms.py:81 +#: core/models.py:770 sas/forms.py:81 msgid "file name" msgstr "nom du fichier" -#: core/models.py:864 core/models.py:1215 +#: core/models.py:774 core/models.py:1125 msgid "parent" msgstr "parent" -#: core/models.py:878 +#: core/models.py:788 msgid "compressed file" msgstr "version allégée" -#: core/models.py:885 +#: core/models.py:795 msgid "thumbnail" msgstr "miniature" -#: core/models.py:893 core/models.py:910 +#: core/models.py:803 core/models.py:820 msgid "owner" msgstr "propriétaire" -#: core/models.py:897 core/models.py:1232 +#: core/models.py:807 core/models.py:1142 msgid "edit group" msgstr "groupe d'édition" -#: core/models.py:900 core/models.py:1235 +#: core/models.py:810 core/models.py:1145 msgid "view group" msgstr "groupe de vue" -#: core/models.py:902 +#: core/models.py:812 msgid "is folder" msgstr "est un dossier" -#: core/models.py:903 +#: core/models.py:813 msgid "mime type" msgstr "type mime" -#: core/models.py:904 +#: core/models.py:814 msgid "size" msgstr "taille" -#: core/models.py:915 +#: core/models.py:825 msgid "asked for removal" msgstr "retrait demandé" -#: core/models.py:917 +#: core/models.py:827 msgid "is in the SAS" msgstr "est dans le SAS" -#: core/models.py:984 +#: core/models.py:894 msgid "Character '/' not authorized in name" msgstr "Le caractère '/' n'est pas autorisé dans les noms de fichier" -#: core/models.py:986 core/models.py:990 +#: core/models.py:896 core/models.py:900 msgid "Loop in folder tree" msgstr "Boucle dans l'arborescence des dossiers" -#: core/models.py:993 +#: core/models.py:903 msgid "You can not make a file be a children of a non folder file" msgstr "" "Vous ne pouvez pas mettre un fichier enfant de quelque chose qui n'est pas " "un dossier" -#: core/models.py:1004 +#: core/models.py:914 msgid "Duplicate file" msgstr "Un fichier de ce nom existe déjà" -#: core/models.py:1021 +#: core/models.py:931 msgid "You must provide a file" msgstr "Vous devez fournir un fichier" -#: core/models.py:1198 +#: core/models.py:1108 msgid "page unix name" msgstr "nom unix de la page" -#: core/models.py:1204 +#: core/models.py:1114 msgid "" "Enter a valid page name. This value may contain only unaccented letters, " "numbers and ./+/-/_ characters." @@ -2303,55 +2300,55 @@ msgstr "" "Entrez un nom de page correct. Uniquement des lettres non accentuées, " "numéros, et ./+/-/_" -#: core/models.py:1222 +#: core/models.py:1132 msgid "page name" msgstr "nom de la page" -#: core/models.py:1227 +#: core/models.py:1137 msgid "owner group" msgstr "groupe propriétaire" -#: core/models.py:1240 +#: core/models.py:1150 msgid "lock user" msgstr "utilisateur bloquant" -#: core/models.py:1247 +#: core/models.py:1157 msgid "lock_timeout" msgstr "décompte du déblocage" -#: core/models.py:1297 +#: core/models.py:1207 msgid "Duplicate page" msgstr "Une page de ce nom existe déjà" -#: core/models.py:1300 +#: core/models.py:1210 msgid "Loop in page tree" msgstr "Boucle dans l'arborescence des pages" -#: core/models.py:1411 +#: core/models.py:1321 msgid "revision" msgstr "révision" -#: core/models.py:1412 +#: core/models.py:1322 msgid "page title" msgstr "titre de la page" -#: core/models.py:1413 +#: core/models.py:1323 msgid "page content" msgstr "contenu de la page" -#: core/models.py:1454 +#: core/models.py:1364 msgid "url" msgstr "url" -#: core/models.py:1455 +#: core/models.py:1365 msgid "param" msgstr "param" -#: core/models.py:1460 +#: core/models.py:1370 msgid "viewed" msgstr "vue" -#: core/models.py:1518 +#: core/models.py:1428 msgid "operation type" msgstr "type d'opération" @@ -2562,7 +2559,7 @@ msgstr "Confirmation" #: core/templates/core/delete_confirm.jinja:20 #: core/templates/core/file_delete_confirm.jinja:46 -#: counter/templates/counter/counter_click.jinja:125 +#: counter/templates/counter/counter_click.jinja:155 #: counter/templates/counter/fragments/delete_student_card.jinja:12 #: sas/templates/sas/ask_picture_removal.jinja:20 msgid "Cancel" @@ -2676,7 +2673,7 @@ msgid "Edit group" msgstr "Éditer le groupe" #: core/templates/core/group_edit.jinja:9 -#: core/templates/core/user_edit.jinja:170 +#: core/templates/core/user_edit.jinja:167 #: core/templates/core/user_group.jinja:13 #: pedagogy/templates/pedagogy/uv_edit.jinja:36 msgid "Update" @@ -3162,27 +3159,27 @@ msgstr "Activer la caméra" msgid "Take a picture" msgstr "Prendre une photo" -#: core/templates/core/user_edit.jinja:69 +#: core/templates/core/user_edit.jinja:67 msgid "To edit your profile picture, ask a member of the AE" msgstr "Pour changer votre photo de profil, demandez à un membre de l'AE" -#: core/templates/core/user_edit.jinja:98 +#: core/templates/core/user_edit.jinja:96 msgid "Edit user profile" msgstr "Éditer le profil de l'utilisateur" -#: core/templates/core/user_edit.jinja:160 +#: core/templates/core/user_edit.jinja:157 msgid "Change my password" msgstr "Changer mon mot de passe" -#: core/templates/core/user_edit.jinja:165 +#: core/templates/core/user_edit.jinja:162 msgid "Change user password" msgstr "Changer le mot de passe" -#: core/templates/core/user_edit.jinja:175 +#: core/templates/core/user_edit.jinja:173 msgid "Username:" msgstr "Nom d'utilisateur : " -#: core/templates/core/user_edit.jinja:178 +#: core/templates/core/user_edit.jinja:176 msgid "Account number:" msgstr "Numéro de compte : " @@ -3276,7 +3273,7 @@ msgid "Go to my Trombi tools" msgstr "Allez à mes outils de Trombi" #: core/templates/core/user_preferences.jinja:39 -#: counter/templates/counter/counter_click.jinja:149 +#: counter/templates/counter/counter_click.jinja:180 msgid "Student card" msgstr "Carte étudiante" @@ -3326,7 +3323,7 @@ msgstr "Outils utilisateurs" msgid "Sith management" msgstr "Gestion de Sith" -#: core/templates/core/user_tools.jinja:21 core/views/forms.py:295 +#: core/templates/core/user_tools.jinja:21 core/views/forms.py:293 #: core/views/user.py:254 msgid "Groups" msgstr "Groupes" @@ -3355,7 +3352,7 @@ msgstr "Cotisations" msgid "Subscription stats" msgstr "Statistiques de cotisation" -#: core/templates/core/user_tools.jinja:48 counter/forms.py:180 +#: core/templates/core/user_tools.jinja:48 counter/forms.py:182 #: counter/views/mixins.py:89 msgid "Counters" msgstr "Comptoirs" @@ -3477,7 +3474,7 @@ msgstr "Ajouter un nouveau dossier" msgid "Error creating folder %(folder_name)s: %(msg)s" msgstr "Erreur de création du dossier %(folder_name)s : %(msg)s" -#: core/views/files.py:159 core/views/forms.py:270 core/views/forms.py:277 +#: core/views/files.py:159 core/views/forms.py:268 core/views/forms.py:275 #: sas/forms.py:60 #, python-format msgid "Error uploading file %(file_name)s: %(msg)s" @@ -3487,19 +3484,19 @@ msgstr "Erreur d'envoi du fichier %(file_name)s : %(msg)s" msgid "Apply rights recursively" msgstr "Appliquer les droits récursivement" -#: core/views/forms.py:95 core/views/forms.py:103 +#: core/views/forms.py:90 core/views/forms.py:98 msgid "Choose file" msgstr "Choisir un fichier" -#: core/views/forms.py:119 core/views/forms.py:127 +#: core/views/forms.py:114 core/views/forms.py:122 msgid "Choose user" msgstr "Choisir un utilisateur" -#: core/views/forms.py:159 +#: core/views/forms.py:154 msgid "Username, email, or account number" msgstr "Nom d'utilisateur, email, ou numéro de compte AE" -#: core/views/forms.py:220 +#: core/views/forms.py:218 msgid "" "Profile: you need to be visible on the picture, in order to be recognized (e." "g. by the barmen)" @@ -3507,44 +3504,44 @@ msgstr "" "Photo de profil: vous devez être visible sur la photo afin d'être reconnu " "(par exemple par les barmen)" -#: core/views/forms.py:225 +#: core/views/forms.py:223 msgid "Avatar: used on the forum" msgstr "Avatar : utilisé sur le forum" -#: core/views/forms.py:229 +#: core/views/forms.py:227 msgid "Scrub: let other know how your scrub looks like!" msgstr "Blouse : montrez aux autres à quoi ressemble votre blouse !" -#: core/views/forms.py:281 +#: core/views/forms.py:279 msgid "Bad image format, only jpeg, png, webp and gif are accepted" msgstr "Mauvais format d'image, seuls les jpeg, png, webp et gif sont acceptés" -#: core/views/forms.py:306 +#: core/views/forms.py:305 msgid "Godfather / Godmother" msgstr "Parrain / Marraine" -#: core/views/forms.py:307 +#: core/views/forms.py:306 msgid "Godchild" msgstr "Fillot / Fillote" -#: core/views/forms.py:312 counter/forms.py:78 trombi/views.py:151 +#: core/views/forms.py:311 counter/forms.py:80 trombi/views.py:151 msgid "Select user" msgstr "Choisir un utilisateur" -#: core/views/forms.py:326 +#: core/views/forms.py:325 msgid "This user does not exist" msgstr "Cet utilisateur n'existe pas" -#: core/views/forms.py:328 +#: core/views/forms.py:327 msgid "You cannot be related to yourself" msgstr "Vous ne pouvez pas être relié à vous-même" -#: core/views/forms.py:340 +#: core/views/forms.py:339 #, python-format msgid "%s is already your godfather" msgstr "%s est déjà votre parrain/marraine" -#: core/views/forms.py:346 +#: core/views/forms.py:345 #, python-format msgid "%s is already your godchild" msgstr "%s est déjà votre fillot/fillote" @@ -3583,21 +3580,21 @@ msgstr "Chèque" msgid "Cash" msgstr "Espèces" -#: counter/apps.py:30 counter/models.py:833 sith/settings.py:416 +#: counter/apps.py:30 counter/models.py:836 sith/settings.py:416 #: sith/settings.py:421 msgid "Credit card" msgstr "Carte bancaire" -#: counter/apps.py:36 counter/models.py:509 counter/models.py:994 -#: counter/models.py:1030 launderette/models.py:32 +#: counter/apps.py:36 counter/models.py:509 counter/models.py:997 +#: counter/models.py:1033 launderette/models.py:32 msgid "counter" msgstr "comptoir" -#: counter/forms.py:59 +#: counter/forms.py:61 msgid "This UID is invalid" msgstr "Cet UID est invalide" -#: counter/forms.py:108 +#: counter/forms.py:110 msgid "User not found" msgstr "Utilisateur non trouvé" @@ -3721,7 +3718,7 @@ msgstr "groupe d'achat" msgid "archived" msgstr "archivé" -#: counter/models.py:371 counter/models.py:1128 +#: counter/models.py:371 counter/models.py:1131 msgid "product" msgstr "produit" @@ -3749,44 +3746,44 @@ msgstr "vendeurs" msgid "token" msgstr "jeton" -#: counter/models.py:732 +#: counter/models.py:735 msgid "bank" msgstr "banque" -#: counter/models.py:734 counter/models.py:836 +#: counter/models.py:737 counter/models.py:839 msgid "is validated" msgstr "est validé" -#: counter/models.py:739 +#: counter/models.py:742 msgid "refilling" msgstr "rechargement" -#: counter/models.py:813 eboutic/models.py:249 +#: counter/models.py:816 eboutic/models.py:249 msgid "unit price" msgstr "prix unitaire" -#: counter/models.py:814 counter/models.py:1108 eboutic/models.py:250 +#: counter/models.py:817 counter/models.py:1111 eboutic/models.py:250 msgid "quantity" msgstr "quantité" -#: counter/models.py:833 +#: counter/models.py:836 msgid "Sith account" msgstr "Compte utilisateur" -#: counter/models.py:841 +#: counter/models.py:844 msgid "selling" msgstr "vente" -#: counter/models.py:945 +#: counter/models.py:948 msgid "Unknown event" msgstr "Événement inconnu" -#: counter/models.py:946 +#: counter/models.py:949 #, python-format msgid "Eticket bought for the event %(event)s" msgstr "Eticket acheté pour l'événement %(event)s" -#: counter/models.py:948 counter/models.py:961 +#: counter/models.py:951 counter/models.py:964 #, python-format msgid "" "You bought an eticket for the event %(event)s.\n" @@ -3798,67 +3795,67 @@ msgstr "" "Vous pouvez également retrouver tous vos e-tickets sur votre page de compte " "%(url)s." -#: counter/models.py:999 +#: counter/models.py:1002 msgid "last activity date" msgstr "dernière activité" -#: counter/models.py:1002 +#: counter/models.py:1005 msgid "permanency" msgstr "permanence" -#: counter/models.py:1035 +#: counter/models.py:1038 msgid "emptied" msgstr "coffre vidée" -#: counter/models.py:1038 +#: counter/models.py:1041 msgid "cash register summary" msgstr "relevé de caisse" -#: counter/models.py:1104 +#: counter/models.py:1107 msgid "cash summary" msgstr "relevé" -#: counter/models.py:1107 +#: counter/models.py:1110 msgid "value" msgstr "valeur" -#: counter/models.py:1110 +#: counter/models.py:1113 msgid "check" msgstr "chèque" -#: counter/models.py:1112 +#: counter/models.py:1115 msgid "True if this is a bank check, else False" msgstr "Vrai si c'est un chèque, sinon Faux." -#: counter/models.py:1116 +#: counter/models.py:1119 msgid "cash register summary item" msgstr "élément de relevé de caisse" -#: counter/models.py:1132 +#: counter/models.py:1135 msgid "banner" msgstr "bannière" -#: counter/models.py:1134 +#: counter/models.py:1137 msgid "event date" msgstr "date de l'événement" -#: counter/models.py:1136 +#: counter/models.py:1139 msgid "event title" msgstr "titre de l'événement" -#: counter/models.py:1138 +#: counter/models.py:1141 msgid "secret" msgstr "secret" -#: counter/models.py:1177 +#: counter/models.py:1180 msgid "uid" msgstr "uid" -#: counter/models.py:1182 counter/models.py:1187 +#: counter/models.py:1185 counter/models.py:1190 msgid "student card" msgstr "carte étudiante" -#: counter/models.py:1188 +#: counter/models.py:1191 msgid "student cards" msgstr "cartes étudiantes" @@ -3918,28 +3915,28 @@ msgstr "oui" msgid "There is no cash register summary in this website." msgstr "Il n'y a pas de relevé de caisse dans ce site web." -#: counter/templates/counter/counter_click.jinja:55 +#: counter/templates/counter/counter_click.jinja:48 #: launderette/templates/launderette/launderette_admin.jinja:8 msgid "Selling" msgstr "Vente" -#: counter/templates/counter/counter_click.jinja:66 +#: counter/templates/counter/counter_click.jinja:55 msgid "Select a product..." msgstr "Sélectionnez un produit…" -#: counter/templates/counter/counter_click.jinja:68 +#: counter/templates/counter/counter_click.jinja:57 msgid "Operations" msgstr "Opérations" -#: counter/templates/counter/counter_click.jinja:69 +#: counter/templates/counter/counter_click.jinja:58 msgid "Confirm (FIN)" msgstr "Confirmer (FIN)" -#: counter/templates/counter/counter_click.jinja:70 +#: counter/templates/counter/counter_click.jinja:59 msgid "Cancel (ANN)" msgstr "Annuler (ANN)" -#: counter/templates/counter/counter_click.jinja:81 +#: counter/templates/counter/counter_click.jinja:70 #: counter/templates/counter/fragments/create_refill.jinja:8 #: counter/templates/counter/fragments/create_student_card.jinja:10 #: counter/templates/counter/invoices_call.jinja:16 @@ -3950,25 +3947,25 @@ msgstr "Annuler (ANN)" msgid "Go" msgstr "Valider" -#: counter/templates/counter/counter_click.jinja:89 +#: counter/templates/counter/counter_click.jinja:78 #: eboutic/templates/eboutic/eboutic_makecommand.jinja:19 msgid "Basket: " msgstr "Panier : " -#: counter/templates/counter/counter_click.jinja:95 +#: counter/templates/counter/counter_click.jinja:97 msgid "This basket is empty" msgstr "Votre panier est vide" -#: counter/templates/counter/counter_click.jinja:124 +#: counter/templates/counter/counter_click.jinja:150 msgid "Finish" msgstr "Terminer" -#: counter/templates/counter/counter_click.jinja:130 +#: counter/templates/counter/counter_click.jinja:161 #: counter/templates/counter/refilling_list.jinja:9 msgid "Refilling" msgstr "Rechargement" -#: counter/templates/counter/counter_click.jinja:140 +#: counter/templates/counter/counter_click.jinja:171 msgid "" "As a barman, you are not able to refill any account on your own. An admin " "should be connected on this counter for that. The customer can refill by " @@ -3978,7 +3975,7 @@ msgstr "" "vous même. Un admin doit être connecté sur ce comptoir pour cela. Le client " "peut recharger son compte en utilisant l'eboutic" -#: counter/templates/counter/counter_click.jinja:161 +#: counter/templates/counter/counter_click.jinja:192 msgid "No products available on this counter for this user" msgstr "Pas de produits disponnibles dans ce comptoir pour cet utilisateur" @@ -4426,7 +4423,7 @@ msgid "Clear" msgstr "Vider" #: eboutic/templates/eboutic/eboutic_main.jinja:72 -#: eboutic/templates/eboutic/eboutic_makecommand.jinja:95 +#: eboutic/templates/eboutic/eboutic_makecommand.jinja:94 msgid "Validate" msgstr "Valider" @@ -4461,7 +4458,7 @@ msgstr "Solde restant : " msgid "Billing information" msgstr "Informations de facturation" -#: eboutic/templates/eboutic/eboutic_makecommand.jinja:103 +#: eboutic/templates/eboutic/eboutic_makecommand.jinja:102 msgid "" "You must fill your billing infos if you want to pay with your credit\n" " card" @@ -4469,7 +4466,7 @@ msgstr "" "Vous devez renseigner vos coordonnées de facturation si vous voulez payer " "par carte bancaire" -#: eboutic/templates/eboutic/eboutic_makecommand.jinja:108 +#: eboutic/templates/eboutic/eboutic_makecommand.jinja:107 msgid "" "\n" " The Crédit Agricole changed its policy related to the " @@ -4487,32 +4484,32 @@ msgstr "" "souhaitez payer par carte, vous devez rajouter un numéro de téléphone aux " "données que vous aviez déjà fourni." -#: eboutic/templates/eboutic/eboutic_makecommand.jinja:124 +#: eboutic/templates/eboutic/eboutic_makecommand.jinja:123 msgid "Pay with credit card" msgstr "Payer avec une carte bancaire" -#: eboutic/templates/eboutic/eboutic_makecommand.jinja:129 +#: eboutic/templates/eboutic/eboutic_makecommand.jinja:128 msgid "" "AE account payment disabled because your basket contains refilling items." msgstr "" "Paiement par compte AE désactivé parce que votre panier contient des bons de " "rechargement." -#: eboutic/templates/eboutic/eboutic_makecommand.jinja:131 +#: eboutic/templates/eboutic/eboutic_makecommand.jinja:130 msgid "" "AE account payment disabled because you do not have enough money remaining." msgstr "" "Paiement par compte AE désactivé parce que votre solde est insuffisant." -#: eboutic/templates/eboutic/eboutic_makecommand.jinja:136 +#: eboutic/templates/eboutic/eboutic_makecommand.jinja:135 msgid "Pay with Sith account" msgstr "Payer avec un compte AE" -#: eboutic/templates/eboutic/eboutic_makecommand.jinja:147 +#: eboutic/templates/eboutic/eboutic_makecommand.jinja:146 msgid "Billing info registration success" msgstr "Informations de facturation enregistrées" -#: eboutic/templates/eboutic/eboutic_makecommand.jinja:148 +#: eboutic/templates/eboutic/eboutic_makecommand.jinja:147 msgid "Billing info registration failure" msgstr "Echec de l'enregistrement des informations de facturation." @@ -6209,15 +6206,3 @@ msgstr "Vous ne pouvez plus écrire de commentaires, la date est passée." #, python-format msgid "Maximum characters: %(max_length)s" msgstr "Nombre de caractères max: %(max_length)s" - -#~ msgid "Too young for that product" -#~ msgstr "Trop jeune pour ce produit" - -#~ msgid "Not allowed for that product" -#~ msgstr "Non autorisé pour ce produit" - -#~ msgid "No date of birth provided" -#~ msgstr "Pas de date de naissance renseignée" - -#~ msgid "You have not enough money to buy all the basket" -#~ msgstr "Vous n'avez pas assez d'argent pour acheter le panier"