26 Commits

Author SHA1 Message Date
imperosol
3700fbb214 remove settings.SITH_MAXIMUM_FREE_ROLE 2026-03-21 22:38:35 +01:00
imperosol
b2b8d24003 adapt club members pages to new club roles framework 2026-03-21 22:38:35 +01:00
imperosol
d3c6402e28 adapt tests to new club roles framework 2026-03-21 22:38:35 +01:00
imperosol
74679c86d1 change on_delete constraint for club pages 2026-03-20 16:28:51 +01:00
imperosol
5275c30480 adapt populate and populate_more 2026-03-20 16:28:51 +01:00
imperosol
7e6d5fc435 add ClubRole model 2026-03-20 16:28:51 +01:00
thomas girod
ffa0b94408 Merge pull request #1319 from ae-utbm/show-my-stats
show user stats to subscribers if show_my_stats is enabled
2026-03-20 13:49:48 +01:00
thomas girod
22a1f4ba07 Merge pull request #1317 from ae-utbm/remove-settings
remove unused settings
2026-03-20 13:47:22 +01:00
imperosol
1c0b89bfc7 show user stats to subscribers if show_my_stats is enabled 2026-03-14 16:23:56 +01:00
thomas girod
d374ea9651 Merge pull request #1318 from ae-utbm/vite
upgrade to vite 8
2026-03-13 09:48:42 +01:00
imperosol
10a4e71b7a upgrade to vite 8
FASTER FASTER FASTER FASTER FASTER FASTER
2026-03-13 09:46:12 +01:00
imperosol
f1a60e589a remove unused settings 2026-03-12 10:26:40 +01:00
thomas girod
00acda7ba3 Merge pull request #1316 from ae-utbm/update-deps
Update deps
2026-03-12 08:32:13 +01:00
imperosol
1686a9da87 update JS deps 2026-03-11 22:41:51 +01:00
imperosol
83255945c4 update python deps 2026-03-11 22:30:36 +01:00
thomas girod
b4a6b6961b Merge pull request #1307 from ae-utbm/counter-sellers
Counter sellers
2026-03-11 18:09:49 +01:00
thomas girod
0f0702825e Merge pull request #1281 from ae-utbm/test_election
add test_election_form
2026-03-10 19:42:02 +01:00
imperosol
b74b1ac691 refactor TestElectionForm 2026-03-10 19:39:40 +01:00
TitouanDor
33d4a99a2c move form test into a class TestElectionForm 2026-03-10 19:39:40 +01:00
TitouanDor
c154b311c3 add test with wrong data form 2026-03-10 19:39:40 +01:00
TitouanDor
fb8da93c68 add test_election_form 2026-03-10 19:39:40 +01:00
imperosol
4f84ec09d7 add tests 2026-03-10 19:26:05 +01:00
imperosol
7e649b40c5 add translation 2026-03-10 19:26:05 +01:00
imperosol
78c373f84e differentiate regular and temporary barmen on the counter edit view 2026-03-09 16:04:46 +01:00
imperosol
a7c8b318bd add fields to CounterSellers 2026-03-09 16:04:46 +01:00
imperosol
1701ab5f33 feat: custom through model for Counter.sellers 2026-03-09 16:04:46 +01:00
53 changed files with 2415 additions and 2121 deletions

3
.gitignore vendored
View File

@@ -24,6 +24,9 @@ node_modules/
# compiled documentation # compiled documentation
site/ site/
# rollup-bundle-visualizer report
.bundle-size-report.html
### Redis ### ### Redis ###
# Ignore redis binary dump (dump.rdb) files # Ignore redis binary dump (dump.rdb) files

View File

@@ -1,7 +1,7 @@
repos: repos:
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version. # Ruff version.
rev: v0.15.0 rev: v0.15.5
hooks: hooks:
- id: ruff-check # just check the code, and print the errors - id: ruff-check # just check the code, and print the errors
- id: ruff-check # actually fix the fixable errors, but print nothing - id: ruff-check # actually fix the fixable errors, but print nothing
@@ -12,7 +12,7 @@ repos:
rev: v0.6.1 rev: v0.6.1
hooks: hooks:
- id: biome-check - id: biome-check
additional_dependencies: ["@biomejs/biome@2.3.14"] additional_dependencies: ["@biomejs/biome@2.4.6"]
- repo: https://github.com/rtts/djhtml - repo: https://github.com/rtts/djhtml
rev: 3.0.10 rev: 3.0.10
hooks: hooks:

View File

@@ -7,7 +7,7 @@
}, },
"files": { "files": {
"ignoreUnknown": false, "ignoreUnknown": false,
"includes": ["**/static/**"] "includes": ["**/static/**", "vite.config.mts"]
}, },
"formatter": { "formatter": {
"enabled": true, "enabled": true,

View File

@@ -14,7 +14,7 @@
# #
from django.contrib import admin from django.contrib import admin
from club.models import Club, Membership from club.models import Club, ClubRole, Membership
@admin.register(Club) @admin.register(Club)
@@ -30,6 +30,20 @@ class ClubAdmin(admin.ModelAdmin):
) )
@admin.register(ClubRole)
class ClubRoleAdmin(admin.ModelAdmin):
list_display = ("name", "club", "is_board", "is_presidency")
search_fields = ("name",)
autocomplete_fields = ("club",)
list_select_related = ("club",)
list_filter = (
"is_board",
"is_presidency",
("club", admin.RelatedOnlyFieldListFilter),
)
show_facets = admin.ModelAdmin.show_facets.ALWAYS
@admin.register(Membership) @admin.register(Membership)
class MembershipAdmin(admin.ModelAdmin): class MembershipAdmin(admin.ModelAdmin):
list_display = ("user", "club", "role", "start_date", "end_date") list_display = ("user", "club", "role", "start_date", "end_date")

View File

@@ -39,7 +39,8 @@ class ClubController(ControllerBase):
) )
def fetch_club(self, club_id: int): def fetch_club(self, club_id: int):
prefetch = Prefetch( prefetch = Prefetch(
"members", queryset=Membership.objects.ongoing().select_related("user") "members",
queryset=Membership.objects.ongoing().select_related("user", "role"),
) )
return self.get_object_or_exception( return self.get_object_or_exception(
Club.objects.prefetch_related(prefetch), id=club_id Club.objects.prefetch_related(prefetch), id=club_id
@@ -61,5 +62,5 @@ class UserClubController(ControllerBase):
return ( return (
Membership.objects.ongoing() Membership.objects.ongoing()
.filter(user=user) .filter(user=user)
.select_related("club", "user") .select_related("club", "user", "role")
) )

View File

@@ -23,13 +23,12 @@
# #
from django import forms from django import forms
from django.conf import settings from django.db.models import Exists, OuterRef, Q, QuerySet
from django.db.models import Exists, OuterRef, Q
from django.db.models.functions import Lower from django.db.models.functions import Lower
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from club.models import Club, Mailing, MailingSubscription, Membership from club.models import Club, ClubRole, Mailing, MailingSubscription, Membership
from core.models import User from core.models import User
from core.views.forms import SelectDateTime from core.views.forms import SelectDateTime
from core.views.widgets.ajax_select import ( from core.views.widgets.ajax_select import (
@@ -215,9 +214,7 @@ class ClubOldMemberForm(forms.Form):
def __init__(self, *args, user: User, club: Club, **kwargs): def __init__(self, *args, user: User, club: Club, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields["members_old"].queryset = ( self.fields["members_old"].queryset = club.members.ongoing().editable_by(user)
Membership.objects.ongoing().filter(club=club).editable_by(user)
)
class ClubMemberForm(forms.ModelForm): class ClubMemberForm(forms.ModelForm):
@@ -235,19 +232,14 @@ class ClubMemberForm(forms.ModelForm):
self.request_user = request_user self.request_user = request_user
self.request_user_membership = self.club.get_membership_for(self.request_user) self.request_user_membership = self.club.get_membership_for(self.request_user)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields["role"].required = True self.fields["role"].queryset = self.available_roles
self.fields["role"].choices = [
(value, name)
for value, name in settings.SITH_CLUB_ROLES.items()
if value <= self.max_available_role
]
self.instance.club = club self.instance.club = club
@property @property
def max_available_role(self): def available_roles(self) -> QuerySet[ClubRole]:
"""The greatest role that will be obtainable with this form.""" """The greatest role that will be obtainable with this form."""
# this is unreachable, because it will be overridden by subclasses # this is unreachable, because it will be overridden by subclasses
return -1 # pragma: no cover return ClubRole.objects.none() # pragma: no cover
class ClubAddMemberForm(ClubMemberForm): class ClubAddMemberForm(ClubMemberForm):
@@ -258,7 +250,7 @@ class ClubAddMemberForm(ClubMemberForm):
widgets = {"user": AutoCompleteSelectUser} widgets = {"user": AutoCompleteSelectUser}
@cached_property @cached_property
def max_available_role(self): def available_roles(self):
"""The greatest role that will be obtainable with this form. """The greatest role that will be obtainable with this form.
Admins and the club president can attribute any role. Admins and the club president can attribute any role.
@@ -266,13 +258,13 @@ class ClubAddMemberForm(ClubMemberForm):
Other users cannot attribute roles with this form Other users cannot attribute roles with this form
""" """
if self.request_user.has_perm("club.add_membership"): if self.request_user.has_perm("club.add_membership"):
return settings.SITH_CLUB_ROLES_ID["President"] return self.club.roles.all()
membership = self.request_user_membership membership = self.request_user_membership
if membership is None or membership.role <= settings.SITH_MAXIMUM_FREE_ROLE: if membership is None or not membership.role.is_board:
return -1 return ClubRole.objects.none()
if membership.role == settings.SITH_CLUB_ROLES_ID["President"]: if membership.role.is_presidency:
return membership.role return self.club.roles.all()
return membership.role - 1 return self.club.roles.above_instance(membership.role)
def clean_user(self): def clean_user(self):
"""Check that the user is not trying to add a user already in the club. """Check that the user is not trying to add a user already in the club.
@@ -296,13 +288,11 @@ class JoinClubForm(ClubMemberForm):
def __init__(self, *args, club: Club, request_user: User, **kwargs): def __init__(self, *args, club: Club, request_user: User, **kwargs):
super().__init__(*args, club=club, request_user=request_user, **kwargs) super().__init__(*args, club=club, request_user=request_user, **kwargs)
# this form doesn't manage the user who will join the club,
# so we must set this here to avoid errors
self.instance.user = self.request_user self.instance.user = self.request_user
@cached_property @cached_property
def max_available_role(self): def available_roles(self):
return settings.SITH_MAXIMUM_FREE_ROLE return self.club.roles.filter(is_board=False)
def clean(self): def clean(self):
"""Check that the user is subscribed and isn't already in the club.""" """Check that the user is subscribed and isn't already in the club."""

View File

@@ -2,12 +2,15 @@
import django.db.models.deletion import django.db.models.deletion
import django.db.models.functions.datetime import django.db.models.functions.datetime
from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
from django.db.migrations.state import StateApps from django.db.migrations.state import StateApps
from django.db.models import Q from django.db.models import Q
from django.utils.timezone import localdate from django.utils.timezone import localdate
# Before the club role rework, the maximum free role
# was the hardcoded highest non-board role
MAXIMUM_FREE_ROLE = 1
def migrate_meta_groups(apps: StateApps, schema_editor): def migrate_meta_groups(apps: StateApps, schema_editor):
"""Attach the existing meta groups to the clubs. """Attach the existing meta groups to the clubs.
@@ -46,10 +49,7 @@ def migrate_meta_groups(apps: StateApps, schema_editor):
).select_related("user") ).select_related("user")
club.members_group.users.set([m.user for m in memberships]) club.members_group.users.set([m.user for m in memberships])
club.board_group.users.set( club.board_group.users.set(
[ [m.user for m in memberships.filter(role__gt=MAXIMUM_FREE_ROLE)]
m.user
for m in memberships.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE)
]
) )

View File

@@ -0,0 +1,138 @@
# Generated by Django 5.2.3 on 2025-06-21 21:59
import django.db.models.deletion
from django.db import migrations, models
from django.db.migrations.state import StateApps
from django.db.models import Case, When
PRESIDENT_ROLE = 10
MAXIMUM_FREE_ROLE = 1
SITH_CLUB_ROLES = {
10: "Président⸱e",
9: "Vice-Président⸱e",
7: "Trésorier⸱e",
5: "Responsable communication",
4: "Secrétaire",
3: "Responsable info",
2: "Membre du bureau",
1: "Membre actif⸱ve",
0: "Curieux⸱euse",
}
def migrate_roles(apps: StateApps, schema_editor):
ClubRole = apps.get_model("club", "ClubRole")
Membership = apps.get_model("club", "Membership")
updates = []
for club_id, role in Membership.objects.values_list("club", "role").distinct():
new_role = ClubRole.objects.create(
name=SITH_CLUB_ROLES[role],
is_board=role > MAXIMUM_FREE_ROLE,
is_presidency=role == PRESIDENT_ROLE,
club_id=club_id,
order=PRESIDENT_ROLE - role,
)
updates.append(When(role=role, then=new_role.id))
# all updates must happen at the same time
# otherwise, the 10 first created ClubRole would be
# re-modified after their initial creation, and it would
# result in an incoherent state.
# To avoid that, all updates are wrapped in a single giant Case(When) statement
# cf. https://docs.djangoproject.com/fr/stable/ref/models/conditional-expressions/#conditional-update
Membership.objects.update(role=Case(*updates))
class Migration(migrations.Migration):
dependencies = [
("club", "0014_alter_club_options_rename_unix_name_club_slug_name_and_more"),
("core", "0047_alter_notification_date_alter_notification_type"),
]
operations = [
migrations.AlterField(
model_name="club",
name="page",
field=models.OneToOneField(
blank=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="club",
to="core.page",
),
),
migrations.CreateModel(
name="ClubRole",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"order",
models.PositiveIntegerField(
db_index=True, editable=False, verbose_name="order"
),
),
(
"club",
models.ForeignKey(
help_text="The club in which this role exists",
on_delete=django.db.models.deletion.CASCADE,
related_name="roles",
to="club.club",
verbose_name="club",
),
),
("name", models.CharField(max_length=50, verbose_name="name")),
(
"description",
models.TextField(
default="", blank=True, verbose_name="description"
),
),
(
"is_board",
models.BooleanField(default=False, verbose_name="Board role"),
),
(
"is_presidency",
models.BooleanField(default=False, verbose_name="Presidency role"),
),
(
"is_active",
models.BooleanField(
default=True,
help_text=(
"If the role is inactive, people joining the club "
"won't be able to get it."
),
verbose_name="is active",
),
),
],
options={
"ordering": ("order",),
"verbose_name": "club role",
"verbose_name_plural": "club roles",
},
),
migrations.AddConstraint(
model_name="clubrole",
constraint=models.CheckConstraint(
condition=models.Q(
("is_presidency", False), ("is_board", True), _connector="OR"
),
name="clubrole_presidency_implies_board",
),
),
migrations.RunPython(migrate_roles, migrations.RunPython.noop),
# because Postgres migrations run in a single transaction,
# we cannot change the actual values of Membership.role
# and apply the FOREIGN KEY constraint in the same migration.
# The constraint is created in the next migration
]

View File

@@ -0,0 +1,25 @@
# Generated by Django 5.2.3 on 2025-09-27 09:57
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("club", "0015_clubrole_alter_membership_role")]
operations = [
# because Postgres migrations run in a single transaction,
# we cannot change the actual values of Membership.role
# and apply the FOREIGN KEY constraint in the same migration.
# The data migration was made in the previous migration.
migrations.AlterField(
model_name="membership",
name="role",
field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="members",
to="club.clubrole",
verbose_name="role",
),
),
]

View File

@@ -29,14 +29,14 @@ from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.validators import RegexValidator, validate_email from django.core.validators import RegexValidator, validate_email
from django.db import models, transaction from django.db import models, transaction
from django.db.models import Exists, F, OuterRef, Q, Value from django.db.models import Exists, F, OuterRef, Q
from django.db.models.functions import Greatest
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.text import slugify from django.utils.text import slugify
from django.utils.timezone import localdate from django.utils.timezone import localdate
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from ordered_model.models import OrderedModel
from core.fields import ResizedImageField from core.fields import ResizedImageField
from core.models import Group, Notification, Page, SithFile, User from core.models import Group, Notification, Page, SithFile, User
@@ -89,7 +89,7 @@ class Club(models.Model):
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
) )
page = models.OneToOneField( page = models.OneToOneField(
Page, related_name="club", blank=True, on_delete=models.CASCADE Page, related_name="club", blank=True, on_delete=models.PROTECT
) )
members_group = models.OneToOneField( members_group = models.OneToOneField(
Group, related_name="club", on_delete=models.PROTECT Group, related_name="club", on_delete=models.PROTECT
@@ -138,9 +138,7 @@ class Club(models.Model):
@cached_property @cached_property
def president(self) -> Membership | None: def president(self) -> Membership | None:
"""Fetch the membership of the current president of this club.""" """Fetch the membership of the current president of this club."""
return self.members.filter( return self.members.filter(end_date=None).order_by("role__order").first()
role=settings.SITH_CLUB_ROLES_ID["President"], end_date=None
).first()
def check_loop(self): def check_loop(self):
"""Raise a validation error when a loop is found within the parent list.""" """Raise a validation error when a loop is found within the parent list."""
@@ -208,7 +206,9 @@ class Club(models.Model):
@cached_property @cached_property
def current_members(self) -> list[Membership]: def current_members(self) -> list[Membership]:
return list(self.members.ongoing().select_related("user").order_by("-role")) return list(
self.members.ongoing().select_related("user", "role").order_by("-role")
)
def get_membership_for(self, user: User) -> Membership | None: def get_membership_for(self, user: User) -> Membership | None:
"""Return the current membership of the given user.""" """Return the current membership of the given user."""
@@ -220,6 +220,77 @@ class Club(models.Model):
return user.is_in_group(pk=self.board_group_id) return user.is_in_group(pk=self.board_group_id)
class ClubRole(OrderedModel):
club = models.ForeignKey(
Club,
verbose_name=_("club"),
help_text=_("The club in which this role exists"),
related_name="roles",
on_delete=models.CASCADE,
)
name = models.CharField(_("name"), max_length=50)
description = models.TextField(_("description"), blank=True, default="")
is_board = models.BooleanField(_("Board role"), default=False)
is_presidency = models.BooleanField(_("Presidency role"), default=False)
is_active = models.BooleanField(
_("is active"),
default=True,
help_text=_(
"If the role is inactive, people joining the club won't be able to get it."
),
)
order_with_respect_to = "club"
class Meta(OrderedModel.Meta):
verbose_name = _("club role")
verbose_name_plural = _("club roles")
abstract = False
constraints = [
# presidency IMPLIES board <=> NOT presidency OR board
# cf. MT1 :)
models.CheckConstraint(
condition=Q(is_presidency=False) | Q(is_board=True),
name="clubrole_presidency_implies_board",
)
]
def __str__(self):
return self.name
def get_display_name(self):
return f"{self.name} - {self.club.name}"
def get_absolute_url(self):
return reverse("club:club_roles", kwargs={"club_id": self.club_id})
def clean(self):
errors = []
if self.is_presidency and not self.is_board:
errors.append(
ValidationError(
_(
"Role %(name)s was declared as a presidency role "
"without being a board role"
)
% {"name": self.name}
)
)
if (
self.is_board
and self.club.roles.filter(is_board=False, order__lt=self.order).exists()
):
errors.append(
ValidationError(
_("Board role %(role)s cannot be placed below a member role")
% {"role": self.name}
)
)
if errors:
raise ValidationError(errors)
return super().clean()
class MembershipQuerySet(models.QuerySet): class MembershipQuerySet(models.QuerySet):
def ongoing(self) -> Self: def ongoing(self) -> Self:
"""Filter all memberships which are not finished yet.""" """Filter all memberships which are not finished yet."""
@@ -232,9 +303,10 @@ class MembershipQuerySet(models.QuerySet):
are included, even if there are no more members. are included, even if there are no more members.
If you want to get the users who are currently in the board, If you want to get the users who are currently in the board,
mind combining this with the `ongoing` queryset method mind combining this with the [MembershipQuerySet.ongoing][]
queryset method
""" """
return self.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE) return self.filter(role__is_board=True)
def editable_by(self, user: User) -> Self: def editable_by(self, user: User) -> Self:
"""Filter Memberships that this user can edit. """Filter Memberships that this user can edit.
@@ -257,21 +329,16 @@ class MembershipQuerySet(models.QuerySet):
""" """
if user.has_perm("club.change_membership"): if user.has_perm("club.change_membership"):
return self.all() return self.all()
return self.filter( return self.ongoing().filter(
Q(user=user) Q(user=user)
| Exists( | Exists(
Membership.objects.filter( Membership.objects.ongoing().filter(
Q(
role__gt=Greatest(
OuterRef("role"), Value(settings.SITH_MAXIMUM_FREE_ROLE)
)
),
user=user, user=user,
end_date=None,
club=OuterRef("club"), club=OuterRef("club"),
role__is_board=True,
role__order__lt=OuterRef("role__order"),
) )
), )
end_date=None,
) )
def update(self, **kwargs) -> int: def update(self, **kwargs) -> int:
@@ -341,10 +408,11 @@ class Membership(models.Model):
) )
start_date = models.DateField(_("start date"), default=timezone.now) start_date = models.DateField(_("start date"), default=timezone.now)
end_date = models.DateField(_("end date"), null=True, blank=True) end_date = models.DateField(_("end date"), null=True, blank=True)
role = models.IntegerField( role = models.ForeignKey(
_("role"), ClubRole,
choices=sorted(settings.SITH_CLUB_ROLES.items()), verbose_name=_("role"),
default=sorted(settings.SITH_CLUB_ROLES.items())[0][0], related_name="members",
on_delete=models.PROTECT,
) )
description = models.CharField( description = models.CharField(
_("description"), max_length=128, null=False, blank=True _("description"), max_length=128, null=False, blank=True
@@ -362,7 +430,7 @@ class Membership(models.Model):
def __str__(self): def __str__(self):
return ( return (
f"{self.club.name} - {self.user.username} " f"{self.club.name} - {self.user.username} "
f"- {settings.SITH_CLUB_ROLES[self.role]} " f"- {self.role.name} "
f"- {str(_('past member')) if self.end_date is not None else ''}" f"- {str(_('past member')) if self.end_date is not None else ''}"
) )
@@ -391,7 +459,11 @@ class Membership(models.Model):
if user.is_root or user.is_board_member: if user.is_root or user.is_board_member:
return True return True
membership = self.club.get_membership_for(user) membership = self.club.get_membership_for(user)
return membership is not None and membership.role >= self.role if not membership:
return False
return membership.user_id == user.id or (
membership.is_board and membership.role.order < self.role.order
)
def delete(self, *args, **kwargs): def delete(self, *args, **kwargs):
self._remove_club_groups([self]) self._remove_club_groups([self])
@@ -467,7 +539,7 @@ class Membership(models.Model):
group_id=membership.club.members_group_id, group_id=membership.club.members_group_id,
) )
) )
if membership.role > settings.SITH_MAXIMUM_FREE_ROLE: if membership.role.is_board:
club_groups.append( club_groups.append(
User.groups.through( User.groups.through(
user_id=membership.user_id, user_id=membership.user_id,

View File

@@ -3,7 +3,7 @@ from typing import Annotated
from django.db.models import Q from django.db.models import Q
from ninja import FilterLookup, FilterSchema, ModelSchema from ninja import FilterLookup, FilterSchema, ModelSchema
from club.models import Club, Membership from club.models import Club, ClubRole, Membership
from core.schemas import NonEmptyStr, SimpleUserSchema from core.schemas import NonEmptyStr, SimpleUserSchema
@@ -39,14 +39,21 @@ class ClubProfileSchema(ModelSchema):
return obj.get_absolute_url() return obj.get_absolute_url()
class ClubRoleSchema(ModelSchema):
class Meta:
model = ClubRole
fields = ["id", "name", "is_presidency", "is_board"]
class ClubMemberSchema(ModelSchema): class ClubMemberSchema(ModelSchema):
"""A schema to represent all memberships in a club.""" """A schema to represent all memberships in a club."""
class Meta: class Meta:
model = Membership model = Membership
fields = ["start_date", "end_date", "role", "description"] fields = ["start_date", "end_date", "description"]
user: SimpleUserSchema user: SimpleUserSchema
role: ClubRoleSchema
class ClubSchema(ModelSchema): class ClubSchema(ModelSchema):
@@ -62,6 +69,7 @@ class UserMembershipSchema(ModelSchema):
class Meta: class Meta:
model = Membership model = Membership
fields = ["id", "start_date", "role", "description"] fields = ["id", "start_date", "description"]
club: SimpleClubSchema club: SimpleClubSchema
role: ClubRoleSchema

View File

@@ -45,7 +45,7 @@
{% for m in members %} {% for m in members %}
<tr> <tr>
<td>{{ user_profile_link(m.user) }}</td> <td>{{ user_profile_link(m.user) }}</td>
<td>{{ settings.SITH_CLUB_ROLES[m.role] }}</td> <td>{{ m.role.name }}</td>
<td>{{ m.description }}</td> <td>{{ m.description }}</td>
<td>{{ m.start_date }}</td> <td>{{ m.start_date }}</td>
{%- if can_end_membership -%} {%- if can_end_membership -%}

View File

@@ -17,7 +17,7 @@
{% for member in old_members %} {% for member in old_members %}
<tr> <tr>
<td>{{ user_profile_link(member.user) }}</td> <td>{{ user_profile_link(member.user) }}</td>
<td>{{ settings.SITH_CLUB_ROLES[member.role] }}</td> <td>{{ member.role.name }}</td>
<td>{{ member.description }}</td> <td>{{ member.description }}</td>
<td>{{ member.start_date }}</td> <td>{{ member.start_date }}</td>
<td>{{ member.end_date }}</td> <td>{{ member.end_date }}</td>

View File

@@ -8,7 +8,7 @@ from django.utils.timezone import now
from model_bakery import baker from model_bakery import baker
from model_bakery.recipe import Recipe from model_bakery.recipe import Recipe
from club.models import Club, Membership from club.models import Club, ClubRole, Membership
from core.baker_recipes import old_subscriber_user, subscriber_user from core.baker_recipes import old_subscriber_user, subscriber_user
from core.models import User from core.models import User
@@ -43,6 +43,11 @@ class TestClub(TestCase):
cls.ae = Club.objects.get(pk=settings.SITH_MAIN_CLUB_ID) cls.ae = Club.objects.get(pk=settings.SITH_MAIN_CLUB_ID)
cls.club = baker.make(Club) cls.club = baker.make(Club)
cls.president_role = baker.make(
ClubRole, club=cls.club, is_board=True, is_presidency=True, order=0
)
cls.board_role = baker.make(ClubRole, club=cls.club, is_board=True, order=1)
cls.member_role = baker.make(ClubRole, club=cls.club, order=2)
cls.new_members_url = reverse( cls.new_members_url = reverse(
"club:club_new_members", kwargs={"club_id": cls.club.id} "club:club_new_members", kwargs={"club_id": cls.club.id}
) )
@@ -51,12 +56,17 @@ class TestClub(TestCase):
yesterday = now() - timedelta(days=1) yesterday = now() - timedelta(days=1)
membership_recipe = Recipe(Membership, club=cls.club) membership_recipe = Recipe(Membership, club=cls.club)
membership_recipe.make( membership_recipe.make(
user=cls.simple_board_member, start_date=a_month_ago, role=3 user=cls.simple_board_member, start_date=a_month_ago, role=cls.board_role
)
membership_recipe.make(user=cls.richard, role=cls.member_role)
membership_recipe.make(
user=cls.president, start_date=a_month_ago, role=cls.president_role
) )
membership_recipe.make(user=cls.richard, role=1)
membership_recipe.make(user=cls.president, start_date=a_month_ago, role=10)
membership_recipe.make( # sli was a member but isn't anymore membership_recipe.make( # sli was a member but isn't anymore
user=cls.sli, start_date=a_month_ago, end_date=yesterday, role=2 user=cls.sli,
start_date=a_month_ago,
end_date=yesterday,
role=cls.board_role,
) )
def setUp(self): def setUp(self):

View File

@@ -5,7 +5,7 @@ from django.utils.timezone import localdate
from model_bakery import baker from model_bakery import baker
from model_bakery.recipe import Recipe from model_bakery.recipe import Recipe
from club.models import Club, Membership from club.models import Club, ClubRole, Membership
from core.baker_recipes import subscriber_user from core.baker_recipes import subscriber_user
@@ -16,11 +16,19 @@ def test_club_queryset_having_board_member():
membership_recipe = Recipe( membership_recipe = Recipe(
Membership, user=user, start_date=localdate() - timedelta(days=3) Membership, user=user, start_date=localdate() - timedelta(days=3)
) )
membership_recipe.make(club=clubs[0], role=1)
membership_recipe.make(club=clubs[1], role=3)
membership_recipe.make(club=clubs[2], role=7)
membership_recipe.make( membership_recipe.make(
club=clubs[3], role=3, end_date=localdate() - timedelta(days=1) club=clubs[0], role=baker.make(ClubRole, club=clubs[0], is_board=False)
)
membership_recipe.make(
club=clubs[1], role=baker.make(ClubRole, club=clubs[1], is_board=True)
)
membership_recipe.make(
club=clubs[2], role=baker.make(ClubRole, club=clubs[2], is_board=True)
)
membership_recipe.make(
club=clubs[3],
role=baker.make(ClubRole, club=clubs[3], is_board=True),
end_date=localdate() - timedelta(days=1),
) )
club_ids = Club.objects.having_board_member(user).values_list("id", flat=True) club_ids = Club.objects.having_board_member(user).values_list("id", flat=True)

View File

@@ -1,6 +1,7 @@
from datetime import date, timedelta from datetime import date, timedelta
import pytest import pytest
from django.conf import settings
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.test import Client, TestCase from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
@@ -8,7 +9,7 @@ from model_bakery import baker
from model_bakery.recipe import Recipe from model_bakery.recipe import Recipe
from pytest_django.asserts import assertNumQueries from pytest_django.asserts import assertNumQueries
from club.models import Club, Membership from club.models import Club, ClubRole, Membership
from core.baker_recipes import subscriber_user from core.baker_recipes import subscriber_user
from core.models import Group, Page, User from core.models import Group, Page, User
@@ -26,8 +27,10 @@ class TestClubSearch(TestCase):
"id", flat=True "id", flat=True
) )
) )
Page.objects.exclude(club=None).delete() Membership.objects.all().delete()
ClubRole.objects.all().delete()
Club.objects.all().delete() Club.objects.all().delete()
Page.objects.exclude(name=settings.SITH_CLUB_ROOT_PAGE).delete()
Group.objects.filter(id__in=groups).delete() Group.objects.filter(id__in=groups).delete()
cls.clubs = baker.make( cls.clubs = baker.make(

View File

@@ -4,7 +4,7 @@ from django.urls import reverse
from model_bakery import baker from model_bakery import baker
from pytest_django.asserts import assertRedirects from pytest_django.asserts import assertRedirects
from club.models import Club, Membership from club.models import Club, ClubRole, Membership
from core.baker_recipes import subscriber_user from core.baker_recipes import subscriber_user
@@ -12,7 +12,12 @@ from core.baker_recipes import subscriber_user
def test_club_board_member_cannot_edit_club_properties(client: Client): def test_club_board_member_cannot_edit_club_properties(client: Client):
user = subscriber_user.make() user = subscriber_user.make()
club = baker.make(Club, name="old name", is_active=True, address="old address") club = baker.make(Club, name="old name", is_active=True, address="old address")
baker.make(Membership, club=club, user=user, role=7) baker.make(
Membership,
club=club,
user=user,
role=baker.make(ClubRole, club=club, is_board=True),
)
client.force_login(user) client.force_login(user)
res = client.post( res = client.post(
reverse("club:club_edit", kwargs={"club_id": club.id}), reverse("club:club_edit", kwargs={"club_id": club.id}),
@@ -32,7 +37,12 @@ def test_edit_club_page_doesnt_crash(client: Client):
"""crash test for club:club_edit""" """crash test for club:club_edit"""
club = baker.make(Club) club = baker.make(Club)
user = subscriber_user.make() user = subscriber_user.make()
baker.make(Membership, club=club, user=user, role=3) baker.make(
Membership,
club=club,
user=user,
role=baker.make(ClubRole, club=club, is_board=True),
)
client.force_login(user) client.force_login(user)
res = client.get(reverse("club:club_edit", kwargs={"club_id": club.id})) res = client.get(reverse("club:club_edit", kwargs={"club_id": club.id}))
assert res.status_code == 200 assert res.status_code == 200

View File

@@ -3,9 +3,10 @@ from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from model_bakery import baker
from club.forms import MailingForm from club.forms import MailingForm
from club.models import Club, Mailing, Membership from club.models import Club, ClubRole, Mailing, Membership
from core.models import User from core.models import User
@@ -25,7 +26,7 @@ class TestMailingForm(TestCase):
user=cls.rbatsbak, user=cls.rbatsbak,
club=cls.club, club=cls.club,
start_date=timezone.now(), start_date=timezone.now(),
role=settings.SITH_CLUB_ROLES_ID["Board member"], role=baker.make(ClubRole, club=cls.club, is_board=True),
).save() ).save()
def test_mailing_list_add_no_moderation(self): def test_mailing_list_add_no_moderation(self):

View File

@@ -1,9 +1,9 @@
import itertools
from collections.abc import Callable from collections.abc import Callable
from datetime import timedelta from datetime import timedelta
import pytest import pytest
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from django.conf import settings
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.core.cache import cache from django.core.cache import cache
from django.db.models import Max from django.db.models import Max
@@ -14,7 +14,7 @@ from model_bakery import baker
from pytest_django.asserts import assertRedirects from pytest_django.asserts import assertRedirects
from club.forms import ClubAddMemberForm, JoinClubForm from club.forms import ClubAddMemberForm, JoinClubForm
from club.models import Club, Membership from club.models import Club, ClubRole, Membership
from club.tests.base import TestClub from club.tests.base import TestClub
from core.baker_recipes import subscriber_user from core.baker_recipes import subscriber_user
from core.models import AnonymousUser, User from core.models import AnonymousUser, User
@@ -75,17 +75,22 @@ class TestMembershipQuerySet(TestClub):
def test_update_change_club_groups(self): def test_update_change_club_groups(self):
"""Test that `update` set the user groups accordingly.""" """Test that `update` set the user groups accordingly."""
user = baker.make(User) user = baker.make(User)
membership = baker.make(Membership, end_date=None, user=user, role=5) board_role, member_role = baker.make(
ClubRole, is_board=iter([True, False]), _quantity=2, _bulk_create=True
)
membership = baker.make(
Membership, end_date=None, user=user, role=board_role, club=board_role.club
)
members_group = membership.club.members_group members_group = membership.club.members_group
board_group = membership.club.board_group board_group = membership.club.board_group
assert user.groups.contains(members_group) assert user.groups.contains(members_group)
assert user.groups.contains(board_group) assert user.groups.contains(board_group)
user.memberships.update(role=1) # from board to simple member user.memberships.update(role=member_role) # from board to simple member
assert user.groups.contains(members_group) assert user.groups.contains(members_group)
assert not user.groups.contains(board_group) assert not user.groups.contains(board_group)
user.memberships.update(role=5) # from member to board user.memberships.update(role=board_role) # from member to board
assert user.groups.contains(members_group) assert user.groups.contains(members_group)
assert user.groups.contains(board_group) assert user.groups.contains(board_group)
@@ -96,7 +101,17 @@ class TestMembershipQuerySet(TestClub):
def test_delete_remove_from_groups(self): def test_delete_remove_from_groups(self):
"""Test that `delete` removes from club groups""" """Test that `delete` removes from club groups"""
user = baker.make(User) user = baker.make(User)
memberships = baker.make(Membership, role=iter([1, 5]), user=user, _quantity=2) club = baker.make(Club)
roles = baker.make(
ClubRole,
is_board=iter([False, True]),
club=club,
_quantity=2,
_bulk_create=True,
)
memberships = baker.make(
Membership, club=club, role=iter(roles), user=user, _quantity=2
)
club_groups = { club_groups = {
memberships[0].club.members_group, memberships[0].club.members_group,
memberships[1].club.members_group, memberships[1].club.members_group,
@@ -112,13 +127,20 @@ class TestMembershipEditableBy(TestCase):
def setUpTestData(cls): def setUpTestData(cls):
Membership.objects.all().delete() Membership.objects.all().delete()
cls.club_a, cls.club_b = baker.make(Club, _quantity=2) cls.club_a, cls.club_b = baker.make(Club, _quantity=2)
roles = baker.make(
ClubRole,
is_presidency=itertools.cycle([True, False, False, False]),
is_board=itertools.cycle([True, True, True, False]),
order=itertools.cycle(range(4)),
club=iter(
[*itertools.repeat(cls.club_a, 4), *itertools.repeat(cls.club_b, 4)]
),
_quantity=8,
_bulk_create=True,
)
cls.memberships = [ cls.memberships = [
*baker.make( *baker.make(Membership, role=iter(roles[:4]), club=cls.club_a, _quantity=4),
Membership, role=iter([7, 3, 3, 1]), club=cls.club_a, _quantity=4 *baker.make(Membership, role=iter(roles[4:]), club=cls.club_b, _quantity=4),
),
*baker.make(
Membership, role=iter([7, 3, 3, 1]), club=cls.club_b, _quantity=4
),
] ]
def test_admin_user(self): def test_admin_user(self):
@@ -140,7 +162,7 @@ class TestMembershipEditableBy(TestCase):
class TestMembership(TestClub): class TestMembership(TestClub):
def assert_membership_started_today(self, user: User, role: int): def assert_membership_started_today(self, user: User, role: ClubRole):
"""Assert that the given membership is active and started today.""" """Assert that the given membership is active and started today."""
membership = user.memberships.ongoing().filter(club=self.club).first() membership = user.memberships.ongoing().filter(club=self.club).first()
assert membership is not None assert membership is not None
@@ -189,21 +211,27 @@ class TestMembership(TestClub):
"Marquer comme ancien", "Marquer comme ancien",
] ]
rows = table.find("tbody").find_all("tr") rows = table.find("tbody").find_all("tr")
memberships = self.club.members.ongoing().order_by("-role") memberships = (
for row, membership in zip( self.club.members.ongoing()
rows, memberships.select_related("user"), strict=False .order_by("role__order")
): .select_related("user", "role")
)
user_role = ClubRole.objects.get(members__user=self.simple_board_member)
for row, membership in zip(rows, memberships, strict=False):
user = membership.user user = membership.user
user_url = reverse("core:user_profile", args=[user.id]) user_url = reverse("core:user_profile", args=[user.id])
cols = row.find_all("td") cols = row.find_all("td")
user_link = cols[0].find("a") user_link = cols[0].find("a")
assert user_link.attrs["href"] == user_url assert user_link.attrs["href"] == user_url
assert user_link.text == user.get_display_name() assert user_link.text == user.get_display_name()
assert cols[1].text == settings.SITH_CLUB_ROLES[membership.role] assert cols[1].text == membership.role.name
assert cols[2].text == membership.description assert cols[2].text == membership.description
assert cols[3].text == str(membership.start_date) assert cols[3].text == str(membership.start_date)
if membership.role < 3 or membership.user_id == self.simple_board_member.id: if (
membership.role.order > user_role.order
or membership.user_id == self.simple_board_member.id
):
# 3 is the role of simple_board_member # 3 is the role of simple_board_member
form_input = cols[4].find("input") form_input = cols[4].find("input")
expected_attrs = { expected_attrs = {
@@ -219,14 +247,15 @@ class TestMembership(TestClub):
"""Test that root users can add members to clubs""" """Test that root users can add members to clubs"""
self.client.force_login(self.root) self.client.force_login(self.root)
response = self.client.post( response = self.client.post(
self.new_members_url, {"user": self.subscriber.id, "role": 3} self.new_members_url,
{"user": self.subscriber.id, "role": self.board_role.id},
) )
assert response.status_code == 200 assert response.status_code == 200
assert response.headers.get("HX-Redirect", "") == reverse( assert response.headers.get("HX-Redirect", "") == reverse(
"club:club_members", kwargs={"club_id": self.club.id} "club:club_members", kwargs={"club_id": self.club.id}
) )
self.subscriber.refresh_from_db() self.subscriber.refresh_from_db()
self.assert_membership_started_today(self.subscriber, role=3) self.assert_membership_started_today(self.subscriber, role=self.board_role)
def test_add_unauthorized_members(self): def test_add_unauthorized_members(self):
"""Test that users who are not currently subscribed """Test that users who are not currently subscribed
@@ -234,7 +263,7 @@ class TestMembership(TestClub):
""" """
for user in self.public, self.old_subscriber: for user in self.public, self.old_subscriber:
form = ClubAddMemberForm( form = ClubAddMemberForm(
data={"user": user.id, "role": 1}, data={"user": user.id, "role": self.member_role},
request_user=self.root, request_user=self.root,
club=self.club, club=self.club,
) )
@@ -255,7 +284,7 @@ class TestMembership(TestClub):
nb_memberships = self.simple_board_member.memberships.count() nb_memberships = self.simple_board_member.memberships.count()
self.client.post( self.client.post(
self.members_url, self.members_url,
{"users": self.simple_board_member.id, "role": current_membership.role + 1}, {"users": self.simple_board_member.id, "role": self.member_role},
) )
self.simple_board_member.refresh_from_db() self.simple_board_member.refresh_from_db()
assert nb_memberships == self.simple_board_member.memberships.count() assert nb_memberships == self.simple_board_member.memberships.count()
@@ -274,7 +303,7 @@ class TestMembership(TestClub):
max_id = User.objects.aggregate(id=Max("id"))["id"] max_id = User.objects.aggregate(id=Max("id"))["id"]
for members in [max_id + 1], [max_id + 1, self.subscriber.id]: for members in [max_id + 1], [max_id + 1, self.subscriber.id]:
form = ClubAddMemberForm( form = ClubAddMemberForm(
data={"user": members, "role": 1}, data={"user": members, "role": self.member_role},
request_user=self.root, request_user=self.root,
club=self.club, club=self.club,
) )
@@ -290,12 +319,13 @@ class TestMembership(TestClub):
def test_president_add_members(self): def test_president_add_members(self):
"""Test that the president of the club can add members.""" """Test that the president of the club can add members."""
president = self.club.members.get(role=10).user president = self.club.members.get(role=self.president_role).user
nb_club_membership = self.club.members.count() nb_club_membership = self.club.members.count()
nb_subscriber_memberships = self.subscriber.memberships.count() nb_subscriber_memberships = self.subscriber.memberships.count()
self.client.force_login(president) self.client.force_login(president)
response = self.client.post( response = self.client.post(
self.new_members_url, {"user": self.subscriber.id, "role": 9} self.new_members_url,
{"user": self.subscriber.id, "role": self.president_role.id},
) )
assert response.status_code == 200 assert response.status_code == 200
assert response.headers.get("HX-Redirect", "") == reverse( assert response.headers.get("HX-Redirect", "") == reverse(
@@ -305,14 +335,17 @@ class TestMembership(TestClub):
self.subscriber.refresh_from_db() self.subscriber.refresh_from_db()
assert self.club.members.count() == nb_club_membership + 1 assert self.club.members.count() == nb_club_membership + 1
assert self.subscriber.memberships.count() == nb_subscriber_memberships + 1 assert self.subscriber.memberships.count() == nb_subscriber_memberships + 1
self.assert_membership_started_today(self.subscriber, role=9) self.assert_membership_started_today(self.subscriber, role=self.president_role)
def test_add_member_greater_role(self): def test_add_member_greater_role(self):
"""Test that a member of the club member cannot create """Test that a member of the club member cannot create
a membership with a greater role than its own. a membership with a greater role than its own.
""" """
user_role = self.simple_board_member.memberships.first().role
other_role = baker.make(ClubRole, club=user_role.club, is_board=True)
other_role.above(user_role)
form = ClubAddMemberForm( form = ClubAddMemberForm(
data={"user": self.subscriber.id, "role": 10}, data={"user": self.subscriber.id, "role": other_role.id},
request_user=self.simple_board_member, request_user=self.simple_board_member,
club=self.club, club=self.club,
) )
@@ -320,7 +353,10 @@ class TestMembership(TestClub):
assert not form.is_valid() assert not form.is_valid()
assert form.errors == { assert form.errors == {
"role": ["Sélectionnez un choix valide. 10 n\u2019en fait pas partie."] "role": [
"Sélectionnez un choix valide. "
"Ce choix ne fait pas partie de ceux disponibles."
]
} }
self.club.refresh_from_db() self.club.refresh_from_db()
assert nb_memberships == self.club.members.count() assert nb_memberships == self.club.members.count()
@@ -336,8 +372,9 @@ class TestMembership(TestClub):
assert form.errors == {"role": ["Ce champ est obligatoire."]} assert form.errors == {"role": ["Ce champ est obligatoire."]}
def test_add_member_already_there(self): def test_add_member_already_there(self):
role = ClubRole.objects.get(members__user=self.simple_board_member)
form = ClubAddMemberForm( form = ClubAddMemberForm(
data={"user": self.simple_board_member, "role": 3}, data={"user": self.simple_board_member, "role": role.id},
request_user=self.root, request_user=self.root,
club=self.club, club=self.club,
) )
@@ -348,22 +385,27 @@ class TestMembership(TestClub):
def test_add_other_member_forbidden(self): def test_add_other_member_forbidden(self):
non_member = subscriber_user.make() non_member = subscriber_user.make()
simple_member = baker.make(Membership, club=self.club, role=1).user simple_member = baker.make(
Membership, club=self.club, role=self.member_role
).user
for user in non_member, simple_member: for user in non_member, simple_member:
form = ClubAddMemberForm( form = ClubAddMemberForm(
data={"user": subscriber_user.make(), "role": 1}, data={"user": subscriber_user.make(), "role": self.member_role.id},
request_user=user, request_user=user,
club=self.club, club=self.club,
) )
assert not form.is_valid() assert not form.is_valid()
assert form.errors == { assert form.errors == {
"role": ["Sélectionnez un choix valide. 1 n\u2019en fait pas partie."] "role": [
"Sélectionnez un choix valide. "
"Ce choix ne fait pas partie de ceux disponibles."
]
} }
def test_simple_members_dont_see_form_anymore(self): def test_simple_members_dont_see_form_anymore(self):
"""Test that simple club members don't see the form to add members""" """Test that simple club members don't see the form to add members"""
user = subscriber_user.make() user = subscriber_user.make()
baker.make(Membership, club=self.club, user=user, role=1) baker.make(Membership, club=self.club, user=user, role=self.member_role)
self.client.force_login(user) self.client.force_login(user)
res = self.client.get(self.members_url) res = self.client.get(self.members_url)
assert res.status_code == 200 assert res.status_code == 200
@@ -382,9 +424,10 @@ class TestMembership(TestClub):
"""Test that board members of the club can end memberships """Test that board members of the club can end memberships
of users with lower roles. of users with lower roles.
""" """
# reminder : simple_board_member has role 3
self.client.force_login(self.simple_board_member) self.client.force_login(self.simple_board_member)
membership = baker.make(Membership, club=self.club, role=2, end_date=None) role = baker.make(ClubRole, club=self.club, is_board=True)
role.below(self.board_role)
membership = baker.make(Membership, club=self.club, role=role)
response = self.client.post(self.members_url, {"members_old": [membership.id]}) response = self.client.post(self.members_url, {"members_old": [membership.id]})
self.assertRedirects(response, self.members_url) self.assertRedirects(response, self.members_url)
self.club.refresh_from_db() self.club.refresh_from_db()
@@ -394,7 +437,9 @@ class TestMembership(TestClub):
"""Test that board members of the club cannot end memberships """Test that board members of the club cannot end memberships
of users with higher roles. of users with higher roles.
""" """
membership = self.president.memberships.filter(club=self.club).first() membership = self.president.memberships.filter(
club=self.club, end_date=None
).first()
self.client.force_login(self.simple_board_member) self.client.force_login(self.simple_board_member)
self.client.post(self.members_url, {"members_old": [membership.id]}) self.client.post(self.members_url, {"members_old": [membership.id]})
self.club.refresh_from_db() self.club.refresh_from_db()
@@ -436,7 +481,9 @@ class TestMembership(TestClub):
def test_remove_from_club_group(self): def test_remove_from_club_group(self):
"""Test that when a membership ends, the user is removed from club groups.""" """Test that when a membership ends, the user is removed from club groups."""
user = baker.make(User) user = baker.make(User)
baker.make(Membership, user=user, club=self.club, end_date=None, role=3) baker.make(
Membership, user=user, club=self.club, end_date=None, role=self.board_role
)
assert user.groups.contains(self.club.members_group) assert user.groups.contains(self.club.members_group)
assert user.groups.contains(self.club.board_group) assert user.groups.contains(self.club.board_group)
user.memberships.update(end_date=localdate()) user.memberships.update(end_date=localdate())
@@ -447,18 +494,20 @@ class TestMembership(TestClub):
"""Test that when a membership begins, the user is added to the club group.""" """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.members_group)
assert not self.subscriber.groups.contains(self.club.board_group) assert not self.subscriber.groups.contains(self.club.board_group)
baker.make(Membership, club=self.club, user=self.subscriber, role=3) baker.make(
Membership, club=self.club, user=self.subscriber, role=self.board_role
)
assert self.subscriber.groups.contains(self.club.members_group) assert self.subscriber.groups.contains(self.club.members_group)
assert self.subscriber.groups.contains(self.club.board_group) assert self.subscriber.groups.contains(self.club.board_group)
def test_change_position_in_club(self): def test_change_position_in_club(self):
"""Test that when moving from board to members, club group change""" """Test that when moving from board to members, club group change"""
membership = baker.make( membership = baker.make(
Membership, club=self.club, user=self.subscriber, role=3 Membership, club=self.club, user=self.subscriber, role=self.board_role
) )
assert self.subscriber.groups.contains(self.club.members_group) assert self.subscriber.groups.contains(self.club.members_group)
assert self.subscriber.groups.contains(self.club.board_group) assert self.subscriber.groups.contains(self.club.board_group)
membership.role = 1 membership.role = self.member_role
membership.save() membership.save()
assert self.subscriber.groups.contains(self.club.members_group) assert self.subscriber.groups.contains(self.club.members_group)
assert not self.subscriber.groups.contains(self.club.board_group) assert not self.subscriber.groups.contains(self.club.board_group)
@@ -471,7 +520,11 @@ class TestMembership(TestClub):
# make sli a board member # make sli a board member
self.sli.memberships.all().delete() self.sli.memberships.all().delete()
Membership(club=self.ae, user=self.sli, role=3).save() Membership(
club=self.ae,
user=self.sli,
role=baker.make(ClubRole, club=self.ae, is_board=True),
).save()
assert self.club.is_owned_by(self.sli) assert self.club.is_owned_by(self.sli)
def test_change_club_name(self): def test_change_club_name(self):
@@ -497,7 +550,7 @@ class TestMembership(TestClub):
@pytest.mark.django_db @pytest.mark.django_db
def test_membership_set_old(client: Client): def test_membership_set_old(client: Client):
membership = baker.make(Membership, end_date=None, user=(subscriber_user.make())) membership = baker.make(Membership, end_date=None, user=subscriber_user.make())
client.force_login(membership.user) client.force_login(membership.user)
response = client.post( response = client.post(
reverse("club:membership_set_old", kwargs={"membership_id": membership.id}) reverse("club:membership_set_old", kwargs={"membership_id": membership.id})
@@ -531,55 +584,63 @@ class TestJoinClub:
cache.clear() cache.clear()
@pytest.mark.parametrize( @pytest.mark.parametrize(
("user_factory", "role", "errors"), ("user_factory", "board_role", "errors"),
[ [
( (
subscriber_user.make, subscriber_user.make,
2, True,
{ {
"role": [ "role": [
"Sélectionnez un choix valide. 2 n\u2019en fait pas partie." "Sélectionnez un choix valide. "
"Ce choix ne fait pas partie de ceux disponibles."
] ]
}, },
), ),
( (
lambda: baker.make(User), lambda: baker.make(User),
1, False,
{"__all__": ["Vous devez être cotisant pour faire partie d'un club"]}, {"__all__": ["Vous devez être cotisant pour faire partie d'un club"]},
), ),
], ],
) )
def test_join_club_errors( def test_join_club_errors(
self, user_factory: Callable[[], User], role: int, errors: dict self, user_factory: Callable[[], User], board_role, errors: dict
): ):
club = baker.make(Club) club = baker.make(Club)
user = user_factory() user = user_factory()
form = JoinClubForm(club=club, request_user=user, data={"role": role}) role = baker.make(ClubRole, club=club, is_board=board_role)
form = JoinClubForm(club=club, request_user=user, data={"role": role.id})
assert not form.is_valid() assert not form.is_valid()
assert form.errors == errors assert form.errors == errors
def test_user_already_in_club(self): def test_user_already_in_club(self):
club = baker.make(Club)
user = subscriber_user.make() user = subscriber_user.make()
baker.make(Membership, user=user, club=club) role = baker.make(ClubRole, is_board=False)
form = JoinClubForm(club=club, request_user=user, data={"role": 1}) baker.make(Membership, user=user, club=role.club)
form = JoinClubForm(club=role.club, request_user=user, data={"role": role.id})
assert not form.is_valid() assert not form.is_valid()
assert form.errors == {"__all__": ["Vous êtes déjà membre de ce club."]} assert form.errors == {"__all__": ["Vous êtes déjà membre de ce club."]}
def test_ok(self): def test_ok(self):
club = baker.make(Club)
user = subscriber_user.make() user = subscriber_user.make()
form = JoinClubForm(club=club, request_user=user, data={"role": 1}) role = baker.make(ClubRole, is_board=False)
form = JoinClubForm(club=role.club, request_user=user, data={"role": role.id})
assert form.is_valid() assert form.is_valid()
form.save() form.save()
assert Membership.objects.ongoing().filter(user=user, club=club).exists() assert Membership.objects.ongoing().filter(user=user, club=role.club).exists()
class TestOldMembersView(TestCase): class TestOldMembersView(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
club = baker.make(Club) club = baker.make(Club)
roles = [1, 1, 1, 2, 2, 4, 4, 5, 7, 9, 10] roles = baker.make(
ClubRole,
club=club,
is_board=itertools.cycle([True, True, False]),
_quantity=10,
_bulk_create=True,
)
cls.memberships = baker.make( cls.memberships = baker.make(
Membership, Membership,
role=iter(roles), role=iter(roles),

View File

@@ -5,7 +5,7 @@ from django.urls import reverse
from model_bakery import baker from model_bakery import baker
from pytest_django.asserts import assertHTMLEqual, assertRedirects from pytest_django.asserts import assertHTMLEqual, assertRedirects
from club.models import Club, Membership from club.models import Club, ClubRole, Membership
from core.baker_recipes import subscriber_user from core.baker_recipes import subscriber_user
from core.markdown import markdown from core.markdown import markdown
from core.models import PageRev, User from core.models import PageRev, User
@@ -59,7 +59,12 @@ def test_page_revision(client: Client):
def test_edit_page(client: Client): def test_edit_page(client: Client):
club = baker.make(Club) club = baker.make(Club)
user = subscriber_user.make() user = subscriber_user.make()
baker.make(Membership, user=user, club=club, role=3) baker.make(
Membership,
user=user,
club=club,
role=baker.make(ClubRole, club=club, is_board=True),
)
client.force_login(user) client.force_login(user)
url = reverse("club:club_edit_page", kwargs={"club_id": club.id}) url = reverse("club:club_edit_page", kwargs={"club_id": club.id})
content = "# foo\nLorem ipsum dolor sit amet" content = "# foo\nLorem ipsum dolor sit amet"

View File

@@ -6,7 +6,7 @@ from django.utils.timezone import localdate
from model_bakery import baker from model_bakery import baker
from model_bakery.recipe import Recipe from model_bakery.recipe import Recipe
from club.models import Club, Membership from club.models import Club, ClubRole, Membership
from club.schemas import UserMembershipSchema from club.schemas import UserMembershipSchema
from core.baker_recipes import subscriber_user from core.baker_recipes import subscriber_user
from core.models import Page from core.models import Page
@@ -19,7 +19,10 @@ class TestFetchClub(TestCase):
pages = baker.make(Page, _quantity=3, _bulk_create=True) pages = baker.make(Page, _quantity=3, _bulk_create=True)
clubs = baker.make(Club, page=iter(pages), _quantity=3, _bulk_create=True) clubs = baker.make(Club, page=iter(pages), _quantity=3, _bulk_create=True)
recipe = Recipe( recipe = Recipe(
Membership, user=cls.user, start_date=localdate() - timedelta(days=2) Membership,
user=cls.user,
start_date=localdate() - timedelta(days=2),
role=baker.make(ClubRole),
) )
cls.members = Membership.objects.bulk_create( cls.members = Membership.objects.bulk_create(
[ [

View File

@@ -28,7 +28,6 @@ import csv
import itertools import itertools
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.contrib.messages.views import SuccessMessageMixin from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import NON_FIELD_ERRORS, PermissionDenied, ValidationError from django.core.exceptions import NON_FIELD_ERRORS, PermissionDenied, ValidationError
@@ -318,7 +317,7 @@ class ClubMembersView(
membership = self.object.get_membership_for(self.request.user) membership = self.object.get_membership_for(self.request.user)
if ( if (
membership membership
and membership.role <= settings.SITH_MAXIMUM_FREE_ROLE and not membership.role.is_board
and not self.request.user.has_perm("club.add_membership") and not self.request.user.has_perm("club.add_membership")
): ):
# Simple club members won't see the form anymore. # Simple club members won't see the form anymore.
@@ -343,8 +342,8 @@ class ClubMembersView(
kwargs["members"] = list( kwargs["members"] = list(
self.object.members.ongoing() self.object.members.ongoing()
.annotate(is_editable=Q(id__in=editable)) .annotate(is_editable=Q(id__in=editable))
.order_by("-role") .order_by("role__order")
.select_related("user") .select_related("user", "role")
) )
kwargs["can_end_membership"] = len(editable) > 0 kwargs["can_end_membership"] = len(editable) > 0
return kwargs return kwargs
@@ -372,8 +371,8 @@ class ClubOldMembersView(ClubTabsMixin, PermissionRequiredMixin, DetailView):
return super().get_context_data(**kwargs) | { return super().get_context_data(**kwargs) | {
"old_members": ( "old_members": (
self.object.members.exclude(end_date=None) self.object.members.exclude(end_date=None)
.order_by("-role", "description", "-end_date") .order_by("role__order", "description", "-end_date")
.select_related("user") .select_related("user", "role")
) )
} }
@@ -724,9 +723,7 @@ class MailingAutoGenerationView(View):
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
club = self.mailing.club club = self.mailing.club
self.mailing.subscriptions.all().delete() self.mailing.subscriptions.all().delete()
members = club.members.filter( members = club.members.ongoing().filter(role__is_board=True)
role__gte=settings.SITH_CLUB_ROLES_ID["Board member"]
).exclude(end_date__lte=timezone.now())
for member in members.all(): for member in members.all():
MailingSubscription(user=member.user, mailing=self.mailing).save() MailingSubscription(user=member.user, mailing=self.mailing).save()
return redirect("club:mailing", club_id=club.id) return redirect("club:mailing", club_id=club.id)

View File

@@ -28,7 +28,7 @@ from django.utils.translation import gettext as _
from model_bakery import baker from model_bakery import baker
from pytest_django.asserts import assertNumQueries, assertRedirects from pytest_django.asserts import assertNumQueries, assertRedirects
from club.models import Club, Membership from club.models import Club, ClubRole, Membership
from com.models import News, NewsDate, Poster, Sith, Weekmail, WeekmailArticle from com.models import News, NewsDate, Poster, Sith, Weekmail, WeekmailArticle
from core.baker_recipes import subscriber_user from core.baker_recipes import subscriber_user
from core.models import AnonymousUser, Group, User from core.models import AnonymousUser, Group, User
@@ -214,7 +214,8 @@ class TestNewsCreation(TestCase):
def setUpTestData(cls): def setUpTestData(cls):
cls.club = baker.make(Club) cls.club = baker.make(Club)
cls.user = subscriber_user.make() cls.user = subscriber_user.make()
baker.make(Membership, user=cls.user, club=cls.club, role=5) role = baker.make(ClubRole, club=cls.club, is_board=True)
baker.make(Membership, user=cls.user, club=cls.club, role=role)
def setUp(self): def setUp(self):
self.client.force_login(self.user) self.client.force_login(self.user)

View File

@@ -504,7 +504,7 @@ class WeekmailArticleCreateView(CreateView):
self.object = form.instance self.object = form.instance
form.is_valid() # Valid a first time to populate club field form.is_valid() # Valid a first time to populate club field
m = form.instance.club.get_membership_for(request.user) m = form.instance.club.get_membership_for(request.user)
if m is None or m.role <= settings.SITH_MAXIMUM_FREE_ROLE: if m is None or not m.role.is_board:
form.add_error( form.add_error(
"club", "club",
ValidationError( ValidationError(

View File

@@ -4,9 +4,9 @@ from dateutil.relativedelta import relativedelta
from django.conf import settings from django.conf import settings
from django.utils.timezone import localdate, now from django.utils.timezone import localdate, now
from model_bakery import seq from model_bakery import seq
from model_bakery.recipe import Recipe, related from model_bakery.recipe import Recipe, foreign_key, related
from club.models import Membership from club.models import ClubRole, Membership
from core.models import Group, User from core.models import Group, User
from subscription.models import Subscription from subscription.models import Subscription
@@ -52,7 +52,9 @@ ae_board_membership = Recipe(
Membership, Membership,
start_date=now() - timedelta(days=30), start_date=now() - timedelta(days=30),
club_id=settings.SITH_MAIN_CLUB_ID, club_id=settings.SITH_MAIN_CLUB_ID,
role=settings.SITH_CLUB_ROLES_ID["Board member"], role=foreign_key(
Recipe(ClubRole, club_id=settings.SITH_MAIN_CLUB_ID, is_board=True)
),
) )
board_user = Recipe( board_user = Recipe(

View File

@@ -36,7 +36,7 @@ from django.utils import timezone
from django.utils.timezone import localdate from django.utils.timezone import localdate
from PIL import Image from PIL import Image
from club.models import Club, Membership from club.models import Club, ClubRole, Membership
from com.ics_calendar import IcsCalendar from com.ics_calendar import IcsCalendar
from com.models import News, NewsDate, Sith, Weekmail from com.models import News, NewsDate, Sith, Weekmail
from core.models import BanGroup, Group, Page, PageRev, SithFile, User from core.models import BanGroup, Group, Page, PageRev, SithFile, User
@@ -62,6 +62,13 @@ class PopulatedGroups(NamedTuple):
campus_admin: Group campus_admin: Group
class PopulatedClubs(NamedTuple):
ae: Club
troll: Club
pdf: Club
refound: Club
class Command(BaseCommand): class Command(BaseCommand):
ROOT_PATH: ClassVar[Path] = Path(__file__).parent.parent.parent.parent ROOT_PATH: ClassVar[Path] = Path(__file__).parent.parent.parent.parent
SAS_FIXTURE_PATH: ClassVar[Path] = ( SAS_FIXTURE_PATH: ClassVar[Path] = (
@@ -111,28 +118,16 @@ class Command(BaseCommand):
club_root = SithFile.objects.create(name="clubs", owner=root) club_root = SithFile.objects.create(name="clubs", owner=root)
sas = SithFile.objects.create(name="SAS", owner=root) sas = SithFile.objects.create(name="SAS", owner=root)
main_club = Club.objects.create( clubs = self._create_clubs()
id=1, name="AE", address="6 Boulevard Anatole France, 90000 Belfort"
)
main_club.board_group.permissions.add(
*Permission.objects.filter(
codename__in=["view_subscription", "add_subscription"]
)
)
bar_club = Club.objects.create(
id=settings.SITH_PDF_CLUB_ID,
name="PdF",
address="6 Boulevard Anatole France, 90000 Belfort",
)
self.reset_index("club") self.reset_index("club")
for bar_id, bar_name in settings.SITH_COUNTER_BARS: for bar_id, bar_name in settings.SITH_COUNTER_BARS:
Counter(id=bar_id, name=bar_name, club=bar_club, type="BAR").save() Counter(id=bar_id, name=bar_name, club=clubs.pdf, type="BAR").save()
self.reset_index("counter") self.reset_index("counter")
counters = [ counters = [
Counter(name="Eboutic", club=main_club, type="EBOUTIC"), Counter(name="Eboutic", club=clubs.ae, type="EBOUTIC"),
Counter(name="AE", club=main_club, type="OFFICE"), Counter(name="AE", club=clubs.ae, type="OFFICE"),
Counter(name="Vidage comptes AE", club=main_club, type="OFFICE"), Counter(name="Vidage comptes AE", club=clubs.ae, type="OFFICE"),
] ]
Counter.objects.bulk_create(counters) Counter.objects.bulk_create(counters)
bar_groups = [] bar_groups = []
@@ -315,54 +310,41 @@ class Command(BaseCommand):
self._create_subscription(tutu) self._create_subscription(tutu)
StudentCard(uid="9A89B82018B0A0", customer=sli.customer).save() StudentCard(uid="9A89B82018B0A0", customer=sli.customer).save()
# Clubs Membership.objects.create(
Club.objects.create( user=skia, club=clubs.ae, role=clubs.ae.roles.get(name="Respo Info")
name="Bibo'UT", address="46 de la Boustifaille", parent=main_club
) )
guyut = Club.objects.create(
name="Guy'UT", address="42 de la Boustifaille", parent=main_club
)
Club.objects.create(name="Woenzel'UT", address="Woenzel", parent=guyut)
troll = Club.objects.create(
name="Troll Penché", address="Terre Du Milieu", parent=main_club
)
refound = Club.objects.create(
name="Carte AE", address="Jamais imprimée", parent=main_club
)
Membership.objects.create(user=skia, club=main_club, role=3)
Membership.objects.create( Membership.objects.create(
user=comunity, user=comunity,
club=bar_club, club=clubs.pdf,
start_date=localdate(), start_date=localdate(),
role=settings.SITH_CLUB_ROLES_ID["Board member"], role=clubs.pdf.roles.get(name="Membre du bureau"),
) )
Membership.objects.create( Membership.objects.create(
user=sli, user=sli,
club=troll, club=clubs.troll,
role=9, role=clubs.troll.roles.get(name="Vice-Président⸱e"),
description="Padawan Troll", description="Padawan Troll",
start_date=localdate() - timedelta(days=17), start_date=localdate() - timedelta(days=17),
) )
Membership.objects.create( Membership.objects.create(
user=krophil, user=krophil,
club=troll, club=clubs.troll,
role=10, role=clubs.troll.roles.get(name="Président⸱e"),
description="Maitre Troll", description="Maitre Troll",
start_date=localdate() - timedelta(days=200), start_date=localdate() - timedelta(days=200),
) )
Membership.objects.create( Membership.objects.create(
user=skia, user=skia,
club=troll, club=clubs.troll,
role=2, role=clubs.troll.roles.get(name="Membre du bureau"),
description="Grand Ancien Troll", description="Grand Ancien Troll",
start_date=localdate() - timedelta(days=400), start_date=localdate() - timedelta(days=400),
end_date=localdate() - timedelta(days=86), end_date=localdate() - timedelta(days=86),
) )
Membership.objects.create( Membership.objects.create(
user=richard, user=richard,
club=troll, club=clubs.troll,
role=2, role=clubs.troll.roles.get(name="Membre du bureau"),
description="", description="",
start_date=localdate() - timedelta(days=200), start_date=localdate() - timedelta(days=200),
end_date=localdate() - timedelta(days=100), end_date=localdate() - timedelta(days=100),
@@ -379,7 +361,7 @@ class Command(BaseCommand):
purchase_price="15", purchase_price="15",
selling_price="15", selling_price="15",
special_selling_price="15", special_selling_price="15",
club=main_club, club=clubs.ae,
) )
cotis2 = Product.objects.create( cotis2 = Product.objects.create(
name="Cotis 2 semestres", name="Cotis 2 semestres",
@@ -388,7 +370,7 @@ class Command(BaseCommand):
purchase_price="28", purchase_price="28",
selling_price="28", selling_price="28",
special_selling_price="28", special_selling_price="28",
club=main_club, club=clubs.ae,
) )
refill = Product.objects.create( refill = Product.objects.create(
name="Rechargement 15 €", name="Rechargement 15 €",
@@ -397,7 +379,7 @@ class Command(BaseCommand):
purchase_price="15", purchase_price="15",
selling_price="15", selling_price="15",
special_selling_price="15", special_selling_price="15",
club=main_club, club=clubs.ae,
) )
barb = Product.objects.create( barb = Product.objects.create(
name="Barbar", name="Barbar",
@@ -406,7 +388,7 @@ class Command(BaseCommand):
purchase_price="1.50", purchase_price="1.50",
selling_price="1.7", selling_price="1.7",
special_selling_price="1.6", special_selling_price="1.6",
club=main_club, club=clubs.ae,
limit_age=18, limit_age=18,
) )
cble = Product.objects.create( cble = Product.objects.create(
@@ -416,7 +398,7 @@ class Command(BaseCommand):
purchase_price="1.50", purchase_price="1.50",
selling_price="1.7", selling_price="1.7",
special_selling_price="1.6", special_selling_price="1.6",
club=main_club, club=clubs.ae,
limit_age=18, limit_age=18,
) )
cons = Product.objects.create( cons = Product.objects.create(
@@ -426,7 +408,7 @@ class Command(BaseCommand):
purchase_price="1", purchase_price="1",
selling_price="1", selling_price="1",
special_selling_price="1", special_selling_price="1",
club=main_club, club=clubs.ae,
) )
dcons = Product.objects.create( dcons = Product.objects.create(
name="Déconsigne Eco-cup", name="Déconsigne Eco-cup",
@@ -435,7 +417,7 @@ class Command(BaseCommand):
purchase_price="-1", purchase_price="-1",
selling_price="-1", selling_price="-1",
special_selling_price="-1", special_selling_price="-1",
club=main_club, club=clubs.ae,
) )
cors = Product.objects.create( cors = Product.objects.create(
name="Corsendonk", name="Corsendonk",
@@ -444,7 +426,7 @@ class Command(BaseCommand):
purchase_price="1.50", purchase_price="1.50",
selling_price="1.7", selling_price="1.7",
special_selling_price="1.6", special_selling_price="1.6",
club=main_club, club=clubs.ae,
limit_age=18, limit_age=18,
) )
carolus = Product.objects.create( carolus = Product.objects.create(
@@ -454,7 +436,7 @@ class Command(BaseCommand):
purchase_price="1.50", purchase_price="1.50",
selling_price="1.7", selling_price="1.7",
special_selling_price="1.6", special_selling_price="1.6",
club=main_club, club=clubs.ae,
limit_age=18, limit_age=18,
) )
Product.objects.create( Product.objects.create(
@@ -463,7 +445,7 @@ class Command(BaseCommand):
purchase_price="0", purchase_price="0",
selling_price="0", selling_price="0",
special_selling_price="0", special_selling_price="0",
club=refound, club=clubs.refound,
) )
groups.subscribers.products.add( groups.subscribers.products.add(
cotis, cotis2, refill, barb, cble, cors, carolus cotis, cotis2, refill, barb, cble, cors, carolus
@@ -476,7 +458,7 @@ class Command(BaseCommand):
eboutic = Counter.objects.get(name="Eboutic") eboutic = Counter.objects.get(name="Eboutic")
eboutic.products.add(barb, cotis, cotis2, refill) eboutic.products.add(barb, cotis, cotis2, refill)
Counter.objects.create(name="Carte AE", club=refound, type="OFFICE") Counter.objects.create(name="Carte AE", club=clubs.refound, type="OFFICE")
ReturnableProduct.objects.create( ReturnableProduct.objects.create(
product=cons, returned_product=dcons, max_return=3 product=cons, returned_product=dcons, max_return=3
@@ -500,7 +482,7 @@ class Command(BaseCommand):
end_date="7942-06-12 10:28:45+01", end_date="7942-06-12 10:28:45+01",
) )
el.view_groups.add(groups.public) el.view_groups.add(groups.public)
el.edit_groups.add(main_club.board_group) el.edit_groups.add(clubs.ae.board_group)
el.candidature_groups.add(groups.subscribers) el.candidature_groups.add(groups.subscribers)
el.vote_groups.add(groups.subscribers) el.vote_groups.add(groups.subscribers)
liste = ElectionList.objects.create(title="Candidature Libre", election=el) liste = ElectionList.objects.create(title="Candidature Libre", election=el)
@@ -573,7 +555,7 @@ class Command(BaseCommand):
title="Apero barman", title="Apero barman",
summary="Viens boire un coup avec les barmans", summary="Viens boire un coup avec les barmans",
content="Glou glou glou glou glou glou glou", content="Glou glou glou glou glou glou glou",
club=bar_club, club=clubs.pdf,
author=subscriber, author=subscriber,
is_published=True, is_published=True,
moderator=skia, moderator=skia,
@@ -591,7 +573,7 @@ class Command(BaseCommand):
content=( content=(
"Viens donc t'enjailler avec les autres barmans aux frais du BdF! \\o/" "Viens donc t'enjailler avec les autres barmans aux frais du BdF! \\o/"
), ),
club=bar_club, club=clubs.pdf,
author=subscriber, author=subscriber,
is_published=True, is_published=True,
moderator=skia, moderator=skia,
@@ -607,7 +589,7 @@ class Command(BaseCommand):
title="Repas fromager", title="Repas fromager",
summary="Wien manger du l'bon fromeug'", summary="Wien manger du l'bon fromeug'",
content="Fô viendre mangey d'la bonne fondue!", content="Fô viendre mangey d'la bonne fondue!",
club=bar_club, club=clubs.pdf,
author=subscriber, author=subscriber,
is_published=True, is_published=True,
moderator=skia, moderator=skia,
@@ -623,7 +605,7 @@ class Command(BaseCommand):
title="SdF", title="SdF",
summary="Enjoy la fin des finaux!", summary="Enjoy la fin des finaux!",
content="Viens faire la fête avec tout plein de gens!", content="Viens faire la fête avec tout plein de gens!",
club=bar_club, club=clubs.pdf,
author=subscriber, author=subscriber,
is_published=True, is_published=True,
moderator=skia, moderator=skia,
@@ -641,7 +623,7 @@ class Command(BaseCommand):
summary="Viens jouer!", summary="Viens jouer!",
content="Rejoins la fine équipe du Troll Penché et viens " content="Rejoins la fine équipe du Troll Penché et viens "
"t'amuser le Vendredi soir!", "t'amuser le Vendredi soir!",
club=troll, club=clubs.troll,
author=subscriber, author=subscriber,
is_published=True, is_published=True,
moderator=skia, moderator=skia,
@@ -778,6 +760,52 @@ class Command(BaseCommand):
) )
s.save() s.save()
def _create_clubs(self) -> PopulatedClubs:
ae = Club.objects.create(
id=1, name="AE", address="6 Boulevard Anatole France, 90000 Belfort"
)
ae.board_group.permissions.add(
*Permission.objects.filter(
codename__in=["view_subscription", "add_subscription", "add_membership"]
)
)
pdf = Club.objects.create(
id=settings.SITH_PDF_CLUB_ID,
name="PdF",
address="6 Boulevard Anatole France, 90000 Belfort",
)
troll = Club.objects.create(
name="Troll Penché", address="Terre Du Milieu", parent=ae
)
refound = Club.objects.create(
name="Carte AE", address="Jamais imprimée", parent=ae
)
roles = []
presidency_roles = ["Président⸱e", "Vice-Président⸱e"]
board_roles = [
"Trésorier⸱e",
"Secrétaire",
"Respo Info",
"Respo Com",
"Membre du bureau",
]
simple_roles = ["Membre actif⸱ve", "Curieux⸱euse"]
for club in ae, pdf, troll, refound:
for i, role in enumerate(presidency_roles):
roles.append(
ClubRole(
club=club, order=i, name=role, is_presidency=True, is_board=True
)
)
for i, role in enumerate(board_roles, start=len(presidency_roles)):
roles.append(ClubRole(club=club, order=i, name=role, is_board=True))
for i, role in enumerate(
simple_roles, start=len(presidency_roles) + len(board_roles)
):
roles.append(ClubRole(club=club, order=i, name=role))
ClubRole.objects.bulk_create(roles)
return PopulatedClubs(ae=ae, troll=troll, pdf=pdf, refound=refound)
def _create_groups(self) -> PopulatedGroups: def _create_groups(self) -> PopulatedGroups:
perms = Permission.objects.all() perms = Permission.objects.all()

View File

@@ -11,7 +11,7 @@ from django.db.models import Count, Exists, Min, OuterRef, Subquery
from django.utils.timezone import localdate, make_aware, now from django.utils.timezone import localdate, make_aware, now
from faker import Faker from faker import Faker
from club.models import Club, Membership from club.models import Club, ClubRole, Membership
from core.models import Group, User, UserBan from core.models import Group, User, UserBan
from counter.models import ( from counter.models import (
Counter, Counter,
@@ -172,20 +172,25 @@ class Command(BaseCommand):
Customer.objects.bulk_create(customers, ignore_conflicts=True) Customer.objects.bulk_create(customers, ignore_conflicts=True)
def make_club(self, club: Club, members: list[User], old_members: list[User]): def make_club(self, club: Club, members: list[User], old_members: list[User]):
def zip_roles(users: list[User]) -> Iterator[tuple[User, int]]: roles: list[ClubRole] = list(club.roles.all())
roles = iter(sorted(settings.SITH_CLUB_ROLES.keys(), reverse=True))
def zip_roles(users: list[User]) -> Iterator[tuple[User, ClubRole]]:
important_roles = [r for r in roles if r.is_board]
important_roles.sort(key=lambda r: r.order)
simple_board_role = important_roles.pop()
member_roles = [r for r in roles if not r.is_board]
user_idx = 0 user_idx = 0
while (role := next(roles)) > 2: for _role in important_roles:
# one member for each major role # one member for each major role
yield users[user_idx], role yield users[user_idx], _role
user_idx += 1 user_idx += 1
for _ in range(int(0.3 * (len(users) - user_idx))): for _ in range(int(0.3 * (len(users) - user_idx))):
# 30% of the remaining in the board # 30% of the remaining in the board
yield users[user_idx], 2 yield users[user_idx], simple_board_role
user_idx += 1 user_idx += 1
for remaining in users[user_idx + 1 :]: for remaining in users[user_idx + 1 :]:
# everything else is a simple member # everything else is a simple member
yield remaining, 1 yield remaining, random.choices(member_roles, weights=(0.8, 0.2))[0]
memberships = [] memberships = []
old_members = old_members.copy() old_members = old_members.copy()
@@ -197,19 +202,14 @@ class Command(BaseCommand):
start_date=start, start_date=start,
end_date=self.faker.past_date(start), end_date=self.faker.past_date(start),
user=old, user=old,
role=random.choice(list(settings.SITH_CLUB_ROLES.keys())), role=random.choice(roles),
club=club, club=club,
) )
) )
for member, role in zip_roles(members): for member, role in zip_roles(members):
start = self.faker.past_date("-1y") start = self.faker.past_date("-1y")
memberships.append( memberships.append(
Membership( Membership(start_date=start, user=member, role=role, club=club)
start_date=start,
user=member,
role=role,
club=club,
)
) )
memberships = Membership.objects.bulk_create(memberships) memberships = Membership.objects.bulk_create(memberships)
Membership._add_club_groups(memberships) Membership._add_club_groups(memberships)

View File

@@ -26,7 +26,6 @@ export class NfcInput extends inheritHtmlElement("input") {
window.alert(gettext("Unsupported NFC card")); window.alert(gettext("Unsupported NFC card"));
}); });
// biome-ignore lint/correctness/noUndeclaredVariables: browser API
ndef.addEventListener("reading", (event: NDEFReadingEvent) => { ndef.addEventListener("reading", (event: NDEFReadingEvent) => {
this.removeAttribute("scan"); this.removeAttribute("scan");
this.node.value = event.serialNumber.replace(/:/g, "").toUpperCase(); this.node.value = event.serialNumber.replace(/:/g, "").toUpperCase();

View File

@@ -115,7 +115,6 @@ blockquote:before,
blockquote:after, blockquote:after,
q:before, q:before,
q:after { q:after {
content: "";
content: none; content: none;
} }
table { table {

View File

@@ -23,10 +23,10 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for m in profile.memberships.filter(end_date=None).all() %} {% for m in profile.memberships.ongoing().select_related("role") %}
<tr> <tr>
<td><a href="{{ url('club:club_members', club_id=m.club.id) }}">{{ m.club }}</a></td> <td><a href="{{ url('club:club_members', club_id=m.club.id) }}">{{ m.club }}</a></td>
<td>{{ settings.SITH_CLUB_ROLES[m.role] }}</td> <td>{{ m.role.name }}</td>
<td>{{ m.description }}</td> <td>{{ m.description }}</td>
<td>{{ m.start_date }}</td> <td>{{ m.start_date }}</td>
{% if m.can_be_edited_by(user) %} {% if m.can_be_edited_by(user) %}
@@ -65,10 +65,10 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for m in profile.memberships.exclude(end_date=None).all() %} {% for m in profile.memberships.ongoing().select_related("role") %}
<tr> <tr>
<td><a href="{{ url('club:club_members', club_id=m.club.id) }}">{{ m.club }}</a></td> <td><a href="{{ url('club:club_members', club_id=m.club.id) }}">{{ m.club }}</a></td>
<td>{{ settings.SITH_CLUB_ROLES[m.role] }}</td> <td>{{ m.role.name }}</td>
<td>{{ m.description }}</td> <td>{{ m.description }}</td>
<td>{{ m.start_date }}</td> <td>{{ m.start_date }}</td>
<td>{{ m.end_date }}</td> <td>{{ m.end_date }}</td>

View File

@@ -11,7 +11,7 @@ from django.utils.timezone import now
from model_bakery import baker from model_bakery import baker
from pytest_django.asserts import assertHTMLEqual, assertRedirects from pytest_django.asserts import assertHTMLEqual, assertRedirects
from club.models import Club from club.models import Club, Membership
from core.baker_recipes import board_user, subscriber_user from core.baker_recipes import board_user, subscriber_user
from core.markdown import markdown from core.markdown import markdown
from core.models import AnonymousUser, Page, PageRev, User from core.models import AnonymousUser, Page, PageRev, User
@@ -122,6 +122,9 @@ def test_page_revision_club_redirection(client: Client):
@pytest.mark.django_db @pytest.mark.django_db
def test_viewable_by(): def test_viewable_by():
# remove existing pages to prevent side effect # remove existing pages to prevent side effect
# club pages are protected, so we must delete clubs first
Membership.objects.all().delete()
Club.objects.all().delete()
Page.objects.all().delete() Page.objects.all().delete()
view_groups = [ view_groups = [
[settings.SITH_GROUP_PUBLIC_ID], [settings.SITH_GROUP_PUBLIC_ID],

View File

@@ -248,14 +248,15 @@ class UserTabsMixin(TabedViewMixin):
"name": _("Groups"), "name": _("Groups"),
} }
) )
if ( can_view_account = (
hasattr(user, "customer") hasattr(user, "customer")
and user.customer and user.customer
and ( and (
user == self.request.user user == self.request.user
or self.request.user.has_perm("counter.view_customer") or self.request.user.has_perm("counter.view_customer")
) )
): )
if can_view_account or user.preferences.show_my_stats:
tab_list.append( tab_list.append(
{ {
"url": reverse("core:user_stats", kwargs={"user_id": user.id}), "url": reverse("core:user_stats", kwargs={"user_id": user.id}),
@@ -263,6 +264,7 @@ class UserTabsMixin(TabedViewMixin):
"name": _("Stats"), "name": _("Stats"),
} }
) )
if can_view_account:
tab_list.append( tab_list.append(
{ {
"url": reverse("core:user_account", kwargs={"user_id": user.id}), "url": reverse("core:user_account", kwargs={"user_id": user.id}),
@@ -349,7 +351,7 @@ class UserGodfathersTreeView(UserTabsMixin, CanViewMixin, DetailView):
return kwargs return kwargs
class UserStatsView(UserTabsMixin, CanViewMixin, DetailView): class UserStatsView(UserTabsMixin, UserPassesTestMixin, DetailView):
"""Display a user's stats.""" """Display a user's stats."""
model = User model = User
@@ -357,15 +359,20 @@ class UserStatsView(UserTabsMixin, CanViewMixin, DetailView):
context_object_name = "profile" context_object_name = "profile"
template_name = "core/user_stats.jinja" template_name = "core/user_stats.jinja"
current_tab = "stats" current_tab = "stats"
queryset = User.objects.exclude(customer=None).select_related("customer") queryset = User.objects.exclude(customer=None).select_related(
"customer", "_preferences"
)
def dispatch(self, request, *arg, **kwargs): def test_func(self):
profile = self.get_object() profile: User = self.get_object()
if not ( return (
profile == request.user or request.user.has_perm("counter.view_customer") profile == self.request.user
): or self.request.user.has_perm("counter.view_customer")
raise PermissionDenied or (
return super().dispatch(request, *arg, **kwargs) self.request.user.can_view(profile)
and profile.preferences.show_my_stats
)
)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)

View File

@@ -5,6 +5,7 @@ from datetime import date, datetime, timezone
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django import forms from django import forms
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator from django.core.validators import MaxValueValidator
from django.db.models import Exists, OuterRef, Q from django.db.models import Exists, OuterRef, Q
from django.forms import BaseModelFormSet from django.forms import BaseModelFormSet
@@ -15,7 +16,7 @@ from phonenumber_field.widgets import RegionalPhoneNumberWidget
from club.models import Club from club.models import Club
from club.widgets.ajax_select import AutoCompleteSelectClub from club.widgets.ajax_select import AutoCompleteSelectClub
from core.models import User from core.models import User, UserQuerySet
from core.views.forms import ( from core.views.forms import (
FutureDateTimeField, FutureDateTimeField,
NFCTextInput, NFCTextInput,
@@ -32,6 +33,7 @@ from core.views.widgets.ajax_select import (
from counter.models import ( from counter.models import (
BillingInfo, BillingInfo,
Counter, Counter,
CounterSellers,
Customer, Customer,
Eticket, Eticket,
InvoiceCall, InvoiceCall,
@@ -170,14 +172,39 @@ class RefillForm(forms.ModelForm):
class CounterEditForm(forms.ModelForm): class CounterEditForm(forms.ModelForm):
class Meta: class Meta:
model = Counter model = Counter
fields = ["sellers", "products"] fields = ["products"]
widgets = {"sellers": AutoCompleteSelectMultipleUser}
sellers_regular = forms.ModelMultipleChoiceField(
label=_("Regular barmen"),
help_text=_(
"Barmen having regular permanences "
"or frequently giving a hand throughout the semester."
),
queryset=User.objects.all(),
widget=AutoCompleteSelectMultipleUser,
required=False,
)
sellers_temporary = forms.ModelMultipleChoiceField(
label=_("Temporary barmen"),
help_text=_(
"Barmen who will be there only for a limited period (e.g. for one evening)"
),
queryset=User.objects.all(),
widget=AutoCompleteSelectMultipleUser,
required=False,
)
field_order = ["sellers_regular", "sellers_temporary", "products"]
def __init__(self, *args, user: User, instance: Counter, **kwargs): def __init__(self, *args, user: User, instance: Counter, **kwargs):
super().__init__(*args, instance=instance, **kwargs) super().__init__(*args, instance=instance, **kwargs)
# if the user is an admin, he will have access to all products,
# else only to active products owned by the counter's club
# or already on the counter
if user.has_perm("counter.change_counter"): if user.has_perm("counter.change_counter"):
self.fields["products"].widget = AutoCompleteSelectMultipleProduct() self.fields["products"].widget = AutoCompleteSelectMultipleProduct()
else: else:
# updating the queryset of the field also updates the choices of
# the widget, so it's important to set the queryset after the widget
self.fields["products"].widget = AutoCompleteSelectMultiple() self.fields["products"].widget = AutoCompleteSelectMultiple()
self.fields["products"].queryset = Product.objects.filter( self.fields["products"].queryset = Product.objects.filter(
Q(club_id=instance.club_id) | Q(counters=instance), archived=False Q(club_id=instance.club_id) | Q(counters=instance), archived=False
@@ -186,6 +213,61 @@ class CounterEditForm(forms.ModelForm):
"If you want to add a product that is not owned by " "If you want to add a product that is not owned by "
"your club to this counter, you should ask an admin." "your club to this counter, you should ask an admin."
) )
self.fields["sellers_regular"].initial = self.instance.sellers.filter(
countersellers__is_regular=True
).all()
self.fields["sellers_temporary"].initial = self.instance.sellers.filter(
countersellers__is_regular=False
).all()
def clean(self):
regular: UserQuerySet = self.cleaned_data["sellers_regular"]
temporary: UserQuerySet = self.cleaned_data["sellers_temporary"]
duplicates = list(regular.intersection(temporary))
if duplicates:
raise ValidationError(
_(
"A user cannot be a regular and a temporary barman "
"at the same time, "
"but the following users have been defined as both : %(users)s"
)
% {"users": ", ".join([u.get_display_name() for u in duplicates])}
)
return self.cleaned_data
def save_sellers(self):
sellers = []
for users, is_regular in (
(self.cleaned_data["sellers_regular"], True),
(self.cleaned_data["sellers_temporary"], False),
):
sellers.extend(
[
CounterSellers(counter=self.instance, user=u, is_regular=is_regular)
for u in users
]
)
# start by deleting removed CounterSellers objects
user_ids = [seller.user.id for seller in sellers]
CounterSellers.objects.filter(
~Q(user_id__in=user_ids), counter=self.instance
).delete()
# then create or update the new barmen
CounterSellers.objects.bulk_create(
sellers,
update_conflicts=True,
update_fields=["is_regular"],
unique_fields=["user", "counter"],
)
def save(self, commit=True): # noqa: FBT002
self.instance = super().save(commit=commit)
if commit and any(
key in self.changed_data for key in ("sellers_regular", "sellers_temporary")
):
self.save_sellers()
return self.instance
class ScheduledProductActionForm(forms.ModelForm): class ScheduledProductActionForm(forms.ModelForm):

View File

@@ -0,0 +1,88 @@
# Generated by Django 5.2.11 on 2026-03-04 15:26
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("counter", "0037_productformula"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
# cf. https://docs.djangoproject.com/fr/stable/howto/writing-migrations/#changing-a-manytomanyfield-to-use-a-through-model
migrations.SeparateDatabaseAndState(
database_operations=[
migrations.RunSQL(
sql="ALTER TABLE counter_counter_sellers RENAME TO counter_countersellers",
reverse_sql="ALTER TABLE counter_countersellers RENAME TO counter_counter_sellers",
),
],
state_operations=[
migrations.CreateModel(
name="CounterSellers",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"counter",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="counter.counter",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"constraints": [
models.UniqueConstraint(
fields=("counter", "user"),
name="counter_counter_sellers_counter_id_subscriber_id_key",
)
],
},
),
migrations.AlterField(
model_name="counter",
name="sellers",
field=models.ManyToManyField(
blank=True,
related_name="counters",
through="counter.CounterSellers",
to=settings.AUTH_USER_MODEL,
verbose_name="sellers",
),
),
],
),
migrations.AddField(
model_name="countersellers",
name="created_at",
field=models.DateTimeField(
auto_now_add=True,
default=django.utils.timezone.now,
verbose_name="created at",
),
preserve_default=False,
),
migrations.AddField(
model_name="countersellers",
name="is_regular",
field=models.BooleanField(default=False, verbose_name="regular barman"),
),
]

View File

@@ -551,7 +551,11 @@ class Counter(models.Model):
choices=[("BAR", _("Bar")), ("OFFICE", _("Office")), ("EBOUTIC", _("Eboutic"))], choices=[("BAR", _("Bar")), ("OFFICE", _("Office")), ("EBOUTIC", _("Eboutic"))],
) )
sellers = models.ManyToManyField( sellers = models.ManyToManyField(
User, verbose_name=_("sellers"), related_name="counters", blank=True User,
verbose_name=_("sellers"),
related_name="counters",
blank=True,
through="CounterSellers",
) )
edit_groups = models.ManyToManyField( edit_groups = models.ManyToManyField(
Group, related_name="editable_counters", blank=True Group, related_name="editable_counters", blank=True
@@ -579,7 +583,7 @@ class Counter(models.Model):
if user.is_anonymous: if user.is_anonymous:
return False return False
mem = self.club.get_membership_for(user) mem = self.club.get_membership_for(user)
if mem and mem.role >= settings.SITH_CLUB_ROLES_ID["Treasurer"]: if mem and mem.role.is_presidency:
return True return True
return user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID) return user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)
@@ -743,6 +747,26 @@ class Counter(models.Model):
] ]
class CounterSellers(models.Model):
"""Custom through model for the counter-sellers M2M relationship."""
counter = models.ForeignKey(Counter, on_delete=models.CASCADE)
user = models.ForeignKey(User, on_delete=models.CASCADE)
is_regular = models.BooleanField(_("regular barman"), default=False)
created_at = models.DateTimeField(_("created at"), auto_now_add=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["counter", "user"],
name="counter_counter_sellers_counter_id_subscriber_id_key",
)
]
def __str__(self):
return f"counter {self.counter_id} - user {self.user_id}"
class RefillingQuerySet(models.QuerySet): class RefillingQuerySet(models.QuerySet):
def annotate_total(self) -> Self: def annotate_total(self) -> Self:
"""Annotate the Queryset with the total amount. """Annotate the Queryset with the total amount.

View File

@@ -64,7 +64,7 @@ document.addEventListener("alpine:init", () => {
checkFormulas() { checkFormulas() {
const products = new Set( const products = new Set(
Object.keys(this.basket).map((i: string) => Number.parseInt(i)), Object.keys(this.basket).map((i: string) => Number.parseInt(i, 10)),
); );
const formula: ProductFormula = config.formulas.find((f: ProductFormula) => { const formula: ProductFormula = config.formulas.find((f: ProductFormula) => {
return f.products.every((p: number) => products.has(p)); return f.products.every((p: number) => products.has(p));

View File

@@ -32,7 +32,7 @@ from model_bakery import baker
from model_bakery.recipe import Recipe from model_bakery.recipe import Recipe
from pytest_django.asserts import assertRedirects from pytest_django.asserts import assertRedirects
from club.models import Membership from club.models import ClubRole, Membership
from core.baker_recipes import board_user, subscriber_user, very_old_subscriber_user from core.baker_recipes import board_user, subscriber_user, very_old_subscriber_user
from core.models import BanGroup, User from core.models import BanGroup, User
from counter.baker_recipes import product_recipe, sale_recipe from counter.baker_recipes import product_recipe, sale_recipe
@@ -88,7 +88,7 @@ class TestFullClickBase(TestCase):
Membership, Membership,
start_date=now() - timedelta(days=30), start_date=now() - timedelta(days=30),
club=cls.club_counter.club, club=cls.club_counter.club,
role=settings.SITH_CLUB_ROLES_ID["Board member"], role=baker.make(ClubRole, club=cls.club_counter.club, is_board=True),
user=cls.club_admin, user=cls.club_admin,
) )
@@ -782,7 +782,13 @@ class TestClubCounterClickAccess(TestCase):
"counter:click", "counter:click",
kwargs={"counter_id": cls.counter.id, "user_id": cls.customer.id}, kwargs={"counter_id": cls.counter.id, "user_id": cls.customer.id},
) )
cls.board_role, cls.member_role = baker.make(
ClubRole,
club=cls.counter.club,
is_board=iter([True, False]),
_quantity=2,
_bulk_create=True,
)
cls.user = subscriber_user.make() cls.user = subscriber_user.make()
def setUp(self): def setUp(self):
@@ -797,13 +803,17 @@ class TestClubCounterClickAccess(TestCase):
res = self.client.get(self.click_url) res = self.client.get(self.click_url)
assert res.status_code == 403 assert res.status_code == 403
# being a member of the club, without being in the board, isn't enough # being a member of the club, without being in the board, isn't enough
baker.make(Membership, club=self.counter.club, user=self.user, role=1) baker.make(
Membership, club=self.counter.club, user=self.user, role=self.member_role
)
res = self.client.get(self.click_url) res = self.client.get(self.click_url)
assert res.status_code == 403 assert res.status_code == 403
def test_board_member(self): def test_board_member(self):
"""By default, board members should be able to click on office counters""" """By default, board members should be able to click on office counters"""
baker.make(Membership, club=self.counter.club, user=self.user, role=3) baker.make(
Membership, club=self.counter.club, user=self.user, role=self.board_role
)
self.client.force_login(self.user) self.client.force_login(self.user)
res = self.client.get(self.click_url) res = self.client.get(self.click_url)
assert res.status_code == 200 assert res.status_code == 200
@@ -818,7 +828,9 @@ class TestClubCounterClickAccess(TestCase):
def test_both_barman_and_board_member(self): def test_both_barman_and_board_member(self):
"""If the user is barman and board member, he should be authorized as well.""" """If the user is barman and board member, he should be authorized as well."""
self.counter.sellers.add(self.user) self.counter.sellers.add(self.user)
baker.make(Membership, club=self.counter.club, user=self.user, role=3) baker.make(
Membership, club=self.counter.club, user=self.user, role=self.board_role
)
self.client.force_login(self.user) self.client.force_login(self.user)
res = self.client.get(self.click_url) res = self.client.get(self.click_url)
assert res.status_code == 200 assert res.status_code == 200

View File

@@ -1,13 +1,132 @@
from django.conf import settings
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.test import TestCase from django.test import TestCase
from django.urls import reverse
from model_bakery import baker from model_bakery import baker
from club.models import Membership from club.models import Membership
from core.baker_recipes import subscriber_user from core.baker_recipes import subscriber_user
from core.models import User from core.models import Group, User
from counter.baker_recipes import product_recipe from counter.baker_recipes import product_recipe
from counter.forms import CounterEditForm from counter.forms import CounterEditForm
from counter.models import Counter from counter.models import Counter, CounterSellers
class TestEditCounterSellers(TestCase):
@classmethod
def setUpTestData(cls):
cls.counter = baker.make(Counter, type="BAR")
cls.products = product_recipe.make(_quantity=2, _bulk_create=True)
cls.counter.products.add(*cls.products)
users = subscriber_user.make(_quantity=6, _bulk_create=True)
cls.regular_barmen = users[:2]
cls.tmp_barmen = users[2:4]
cls.not_barmen = users[4:]
CounterSellers.objects.bulk_create(
[
*baker.prepare(
CounterSellers,
counter=cls.counter,
user=iter(cls.regular_barmen),
is_regular=True,
_quantity=len(cls.regular_barmen),
),
*baker.prepare(
CounterSellers,
counter=cls.counter,
user=iter(cls.tmp_barmen),
is_regular=False,
_quantity=len(cls.tmp_barmen),
),
]
)
cls.operator = baker.make(
User, groups=[Group.objects.get(id=settings.SITH_GROUP_COUNTER_ADMIN_ID)]
)
def test_view_ok(self):
url = reverse("counter:admin", kwargs={"counter_id": self.counter.id})
self.client.force_login(self.operator)
res = self.client.get(url)
assert res.status_code == 200
res = self.client.post(
url,
data={
"sellers_regular": [u.id for u in self.regular_barmen],
"sellers_temporary": [u.id for u in self.tmp_barmen],
"products": [p.id for p in self.products],
},
)
self.assertRedirects(res, url)
def test_add_barmen(self):
form = CounterEditForm(
data={
"sellers_regular": [*self.regular_barmen, self.not_barmen[0]],
"sellers_temporary": [*self.tmp_barmen, self.not_barmen[1]],
"products": self.products,
},
instance=self.counter,
user=self.operator,
)
assert form.is_valid()
form.save()
assert set(self.counter.sellers.filter(countersellers__is_regular=True)) == {
*self.regular_barmen,
self.not_barmen[0],
}
assert set(self.counter.sellers.filter(countersellers__is_regular=False)) == {
*self.tmp_barmen,
self.not_barmen[1],
}
def test_barman_change_status(self):
"""Test when a barman goes from temporary to regular"""
form = CounterEditForm(
data={
"sellers_regular": [*self.regular_barmen, self.tmp_barmen[0]],
"sellers_temporary": [*self.tmp_barmen[1:]],
"products": self.products,
},
instance=self.counter,
user=self.operator,
)
assert form.is_valid()
form.save()
assert set(self.counter.sellers.filter(countersellers__is_regular=True)) == {
*self.regular_barmen,
self.tmp_barmen[0],
}
assert set(
self.counter.sellers.filter(countersellers__is_regular=False)
) == set(self.tmp_barmen[1:])
def test_barman_duplicate(self):
"""Test that a barman cannot be regular and temporary at the same time."""
form = CounterEditForm(
data={
"sellers_regular": [*self.regular_barmen, self.not_barmen[0]],
"sellers_temporary": [*self.tmp_barmen, self.not_barmen[0]],
"products": self.products,
},
instance=self.counter,
user=self.operator,
)
assert not form.is_valid()
assert form.errors == {
"__all__": [
"Un utilisateur ne peut pas être un barman "
"régulier et temporaire en même temps, "
"mais les utilisateurs suivants ont été définis "
f"comme les deux : {self.not_barmen[0].get_display_name()}"
],
}
assert set(self.counter.sellers.filter(countersellers__is_regular=True)) == set(
self.regular_barmen
)
assert set(
self.counter.sellers.filter(countersellers__is_regular=False)
) == set(self.tmp_barmen)
class TestEditCounterProducts(TestCase): class TestEditCounterProducts(TestCase):

View File

@@ -3,14 +3,13 @@ import string
from datetime import timedelta from datetime import timedelta
import pytest import pytest
from django.conf import settings
from django.contrib.auth.base_user import make_password from django.contrib.auth.base_user import make_password
from django.test import Client, TestCase from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import now from django.utils.timezone import now
from model_bakery import baker from model_bakery import baker
from club.models import Membership from club.models import ClubRole, Membership
from core.baker_recipes import board_user, subscriber_user from core.baker_recipes import board_user, subscriber_user
from core.models import User from core.models import User
from counter.baker_recipes import product_recipe, refill_recipe, sale_recipe from counter.baker_recipes import product_recipe, refill_recipe, sale_recipe
@@ -42,11 +41,12 @@ class TestStudentCard(TestCase):
cls.counter.sellers.add(cls.barmen) cls.counter.sellers.add(cls.barmen)
cls.club_counter = baker.make(Counter) cls.club_counter = baker.make(Counter)
role = baker.make(ClubRole, club=cls.club_counter.club, is_board=True)
baker.make( baker.make(
Membership, Membership,
start_date=now() - timedelta(days=30), start_date=now() - timedelta(days=30),
club=cls.club_counter.club, club=cls.club_counter.club,
role=settings.SITH_CLUB_ROLES_ID["Board member"], role=role,
user=cls.club_admin, user=cls.club_admin,
) )

View File

@@ -16,6 +16,7 @@ from datetime import datetime, timedelta
from django.conf import settings from django.conf import settings
from django.contrib.auth.mixins import PermissionRequiredMixin, UserPassesTestMixin from django.contrib.auth.mixins import PermissionRequiredMixin, UserPassesTestMixin
from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.db import transaction from django.db import transaction
from django.forms import CheckboxSelectMultiple from django.forms import CheckboxSelectMultiple
@@ -58,7 +59,9 @@ class CounterListView(CounterAdminTabsMixin, CanViewMixin, ListView):
current_tab = "counters" current_tab = "counters"
class CounterEditView(CounterAdminTabsMixin, UserPassesTestMixin, UpdateView): class CounterEditView(
CounterAdminTabsMixin, UserPassesTestMixin, SuccessMessageMixin, UpdateView
):
"""Edit a counter's main informations (for the counter's manager).""" """Edit a counter's main informations (for the counter's manager)."""
model = Counter model = Counter
@@ -66,6 +69,7 @@ class CounterEditView(CounterAdminTabsMixin, UserPassesTestMixin, UpdateView):
pk_url_kwarg = "counter_id" pk_url_kwarg = "counter_id"
template_name = "core/edit.jinja" template_name = "core/edit.jinja"
current_tab = "counters" current_tab = "counters"
success_message = _("Counter update done")
def test_func(self): def test_func(self):
if self.request.user.has_perm("counter.change_counter"): if self.request.user.has_perm("counter.change_counter"):

View File

@@ -6,6 +6,8 @@ from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import now from django.utils.timezone import now
from model_bakery import baker from model_bakery import baker
from model_bakery.recipe import Recipe
from pytest_django.asserts import assertRedirects
from core.baker_recipes import subscriber_user from core.baker_recipes import subscriber_user
from core.models import Group, User from core.models import Group, User
@@ -52,6 +54,102 @@ class TestElectionUpdateView(TestElection):
assert response.status_code == 403 assert response.status_code == 403
class TestElectionForm(TestCase):
@classmethod
def setUpTestData(cls):
cls.election = baker.make(Election, end_date=now() + timedelta(days=1))
cls.group = baker.make(Group)
cls.election.vote_groups.add(cls.group)
cls.election.edit_groups.add(cls.group)
lists = baker.make(
ElectionList, election=cls.election, _quantity=2, _bulk_create=True
)
cls.roles = baker.make(
Role, election=cls.election, _quantity=2, _bulk_create=True
)
users = baker.make(User, _quantity=4, _bulk_create=True)
recipe = Recipe(Candidature)
cls.cand = [
recipe.prepare(role=cls.roles[0], user=users[0], election_list=lists[0]),
recipe.prepare(role=cls.roles[0], user=users[1], election_list=lists[1]),
recipe.prepare(role=cls.roles[1], user=users[2], election_list=lists[0]),
recipe.prepare(role=cls.roles[1], user=users[3], election_list=lists[1]),
]
Candidature.objects.bulk_create(cls.cand)
cls.vote_url = reverse("election:vote", kwargs={"election_id": cls.election.id})
cls.detail_url = reverse(
"election:detail", kwargs={"election_id": cls.election.id}
)
def test_election_good_form(self):
postes = (self.roles[0].title, self.roles[1].title)
votes = [
{postes[0]: "", postes[1]: str(self.cand[2].id)},
{postes[0]: "", postes[1]: ""},
{postes[0]: str(self.cand[0].id), postes[1]: str(self.cand[2].id)},
{postes[0]: str(self.cand[0].id), postes[1]: str(self.cand[3].id)},
]
voters = subscriber_user.make(_quantity=len(votes), _bulk_create=True)
self.group.users.set(voters)
for voter, vote in zip(voters, votes, strict=True):
assert self.election.can_vote(voter)
self.client.force_login(voter)
response = self.client.post(self.vote_url, data=vote)
assertRedirects(response, self.detail_url)
assert set(self.election.voters.all()) == set(voters)
assert self.election.results == {
postes[0]: {
self.cand[0].user.username: {"percent": 50.0, "vote": 2},
self.cand[1].user.username: {"percent": 0.0, "vote": 0},
"blank vote": {"percent": 50.0, "vote": 2},
"total vote": 4,
},
postes[1]: {
self.cand[2].user.username: {"percent": 50.0, "vote": 2},
self.cand[3].user.username: {"percent": 25.0, "vote": 1},
"blank vote": {"percent": 25.0, "vote": 1},
"total vote": 4,
},
}
def test_election_bad_form(self):
postes = (self.roles[0].title, self.roles[1].title)
votes = [
{postes[0]: "", postes[1]: str(self.cand[0].id)}, # wrong candidate
{postes[0]: ""},
{
postes[0]: "0123456789", # unknow users
postes[1]: str(subscriber_user.make().id), # not a candidate
},
{},
]
voters = subscriber_user.make(_quantity=len(votes), _bulk_create=True)
self.group.users.set(voters)
for voter, vote in zip(voters, votes, strict=True):
self.client.force_login(voter)
response = self.client.post(self.vote_url, data=vote)
assertRedirects(response, self.detail_url)
assert self.election.results == {
postes[0]: {
self.cand[0].user.username: {"percent": 0.0, "vote": 0},
self.cand[1].user.username: {"percent": 0.0, "vote": 0},
"blank vote": {"percent": 100.0, "vote": 2},
"total vote": 2,
},
postes[1]: {
self.cand[2].user.username: {"percent": 0.0, "vote": 0},
self.cand[3].user.username: {"percent": 0.0, "vote": 0},
"blank vote": {"percent": 100.0, "vote": 2},
"total vote": 2,
},
}
@pytest.mark.django_db @pytest.mark.django_db
def test_election_create_list_permission(client: Client): def test_election_create_list_permission(client: Client):
election = baker.make(Election, end_candidature=now() + timedelta(hours=1)) election = baker.make(Election, end_candidature=now() + timedelta(hours=1))

View File

@@ -183,7 +183,7 @@ class Forum(models.Model):
Forum._club_memberships[self.id] = {} Forum._club_memberships[self.id] = {}
Forum._club_memberships[self.id][user.id] = m Forum._club_memberships[self.id][user.id] = m
if m: if m:
return m.role > settings.SITH_MAXIMUM_FREE_ROLE return m.role.is_board
return False return False
def check_loop(self): def check_loop(self):

View File

@@ -29,8 +29,9 @@ from django.conf import settings
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.utils import timezone from django.utils import timezone
from model_bakery import baker
from club.models import Club, Membership from club.models import Club, ClubRole, Membership
from core.models import Group, Page, SithFile, User from core.models import Group, Page, SithFile, User
from core.utils import RED_PIXEL_PNG from core.utils import RED_PIXEL_PNG
from sas.models import Album, PeoplePictureRelation, Picture from sas.models import Album, PeoplePictureRelation, Picture
@@ -217,11 +218,19 @@ class Command(BaseCommand):
"The `make_clubs()` method must be called before `make_club_memberships()`" "The `make_clubs()` method must be called before `make_club_memberships()`"
) )
memberships = [] memberships = []
roles = {
r.club_id: r.id
for r in baker.make(
ClubRole,
club=iter(self.clubs),
_quantity=len(self.clubs),
_bulk_create=True,
)
}
for i in range(1, 11): # users can be in up to 20 clubs for i in range(1, 11): # users can be in up to 20 clubs
self.logger.info(f"Club membership, pass {i}") self.logger.info(f"Club membership, pass {i}")
for uid in range( for uid in range(i, self.NB_USERS, i):
i, self.NB_USERS, i # Pass #1 will make sure every user is at least in one club
): # Pass #1 will make sure every user is at least in one club
user = self.users[uid] user = self.users[uid]
club = self.clubs[(uid + i**2) % self.NB_CLUBS] club = self.clubs[(uid + i**2) % self.NB_CLUBS]
@@ -236,7 +245,7 @@ class Command(BaseCommand):
Membership( Membership(
user=user, user=user,
club=club, club=club,
role=(uid + i) % 10 + 1, # spread the different roles role_id=roles[club.id],
start_date=start, start_date=start,
end_date=end, end_date=end,
) )
@@ -259,7 +268,7 @@ class Command(BaseCommand):
Membership( Membership(
user=user, user=user,
club=club, club=club,
role=((uid // 10) + i) % 10 + 1, # spread the different roles role_id=roles[club.id],
start_date=start, start_date=start,
end_date=end, end_date=end,
) )

View File

@@ -6,7 +6,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-07 15:47+0100\n" "POT-Creation-Date: 2026-03-10 10:28+0100\n"
"PO-Revision-Date: 2016-07-18\n" "PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n" "Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -2937,6 +2937,29 @@ msgstr "Cet UID est invalide"
msgid "User not found" msgid "User not found"
msgstr "Utilisateur non trouvé" msgstr "Utilisateur non trouvé"
#: counter/forms.py
msgid "Regular barmen"
msgstr "Barmen réguliers"
#: counter/forms.py
msgid ""
"Barmen having regular permanences or frequently giving a hand throughout the "
"semester."
msgstr ""
"Les barmen assurant des permanences régulières ou donnant régulièrement un "
"coup de main au cours du semestre."
#: counter/forms.py
msgid "Temporary barmen"
msgstr "Barmen temporaires"
#: counter/forms.py
msgid ""
"Barmen who will be there only for a limited period (e.g. for one evening)"
msgstr ""
"Les barmen qui seront là uniquement pour une durée limitée (par exemple, le "
"temps d'une soirée)"
#: counter/forms.py #: counter/forms.py
msgid "" msgid ""
"If you want to add a product that is not owned by your club to this counter, " "If you want to add a product that is not owned by your club to this counter, "
@@ -2945,6 +2968,16 @@ msgstr ""
"Si vous souhaitez ajouter sur ce comptoir un produit qui n'appartient pas à " "Si vous souhaitez ajouter sur ce comptoir un produit qui n'appartient pas à "
"votre club, vous devriez demander à un admin." "votre club, vous devriez demander à un admin."
#: counter/forms.py
#, python-format
msgid ""
"A user cannot be a regular and a temporary barman at the same time, but the "
"following users have been defined as both : %(users)s"
msgstr ""
"Un utilisateur ne peut pas être un barman régulier et temporaire en même "
"temps, mais les utilisateurs suivants ont été définis comme les deux : "
"%(users)s"
#: counter/forms.py #: counter/forms.py
msgid "Date and time of action" msgid "Date and time of action"
msgstr "Date et heure de l'action" msgstr "Date et heure de l'action"
@@ -3193,6 +3226,10 @@ msgstr "vendeurs"
msgid "token" msgid "token"
msgstr "jeton" msgstr "jeton"
#: counter/models.py
msgid "regular barman"
msgstr "barman régulier"
#: counter/models.py sith/settings.py #: counter/models.py sith/settings.py
msgid "Credit card" msgid "Credit card"
msgstr "Carte bancaire" msgstr "Carte bancaire"
@@ -3905,6 +3942,10 @@ msgstr "Temps"
msgid "Top 100 barman %(counter_name)s (all semesters)" msgid "Top 100 barman %(counter_name)s (all semesters)"
msgstr "Top 100 barman %(counter_name)s (tous les semestres)" msgstr "Top 100 barman %(counter_name)s (tous les semestres)"
#: counter/views/admin.py
msgid "Counter update done"
msgstr "Mise à jour du comptoir effectuée"
#: counter/views/admin.py #: counter/views/admin.py
#, python-format #, python-format
msgid "%(formula)s (formula)" msgid "%(formula)s (formula)"
@@ -5253,8 +5294,6 @@ msgid "One day"
msgstr "Un jour" msgstr "Un jour"
#: sith/settings.py #: sith/settings.py
#, fuzzy
#| msgid "GA staff member"
msgid "GA staff member" msgid "GA staff member"
msgstr "Membre staff GA" msgstr "Membre staff GA"

2367
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,8 +8,6 @@
"compile-dev": "vite build --mode development", "compile-dev": "vite build --mode development",
"serve": "vite build --mode development --watch --minify false", "serve": "vite build --mode development --watch --minify false",
"openapi": "openapi-ts", "openapi": "openapi-ts",
"analyse-dev": "vite-bundle-visualizer --mode development",
"analyse-prod": "vite-bundle-visualizer --mode production",
"check": "tsc && biome check --write" "check": "tsc && biome check --write"
}, },
"keywords": [], "keywords": [],
@@ -28,29 +26,28 @@
"devDependencies": { "devDependencies": {
"@babel/core": "^7.29.0", "@babel/core": "^7.29.0",
"@babel/preset-env": "^7.29.0", "@babel/preset-env": "^7.29.0",
"@biomejs/biome": "^2.3.14", "@biomejs/biome": "^2.4.6",
"@hey-api/openapi-ts": "^0.92.4", "@hey-api/openapi-ts": "^0.94.0",
"@rollup/plugin-inject": "^5.0.5", "@rollup/plugin-inject": "^5.0.5",
"@types/alpinejs": "^3.13.11", "@types/alpinejs": "^3.13.11",
"@types/cytoscape-cxtmenu": "^3.4.5", "@types/cytoscape-cxtmenu": "^3.4.5",
"@types/cytoscape-klay": "^3.1.5", "@types/cytoscape-klay": "^3.1.5",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"rollup-plugin-visualizer": "^7.0.1",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.3.1", "vite": "^8.0.0"
"vite-bundle-visualizer": "^1.2.1",
"vite-plugin-static-copy": "^3.2.0"
}, },
"dependencies": { "dependencies": {
"@alpinejs/sort": "^3.15.8", "@alpinejs/sort": "^3.15.8",
"@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0", "@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0",
"@floating-ui/dom": "^1.7.5", "@floating-ui/dom": "^1.7.6",
"@fortawesome/fontawesome-free": "^7.2.0", "@fortawesome/fontawesome-free": "^7.2.0",
"@fullcalendar/core": "^6.1.20", "@fullcalendar/core": "^6.1.20",
"@fullcalendar/daygrid": "^6.1.20", "@fullcalendar/daygrid": "^6.1.20",
"@fullcalendar/icalendar": "^6.1.20", "@fullcalendar/icalendar": "^6.1.20",
"@fullcalendar/list": "^6.1.20", "@fullcalendar/list": "^6.1.20",
"@sentry/browser": "^10.38.0", "@sentry/browser": "^10.43.0",
"@zip.js/zip.js": "^2.8.20", "@zip.js/zip.js": "^2.8.23",
"3d-force-graph": "^1.79.1", "3d-force-graph": "^1.79.1",
"alpinejs": "^3.15.8", "alpinejs": "^3.15.8",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
@@ -60,14 +57,14 @@
"cytoscape-klay": "^3.1.4", "cytoscape-klay": "^3.1.4",
"d3-force-3d": "^3.0.6", "d3-force-3d": "^3.0.6",
"easymde": "^2.20.0", "easymde": "^2.20.0",
"glob": "^13.0.2", "glob": "^13.0.6",
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"htmx.org": "^2.0.8", "htmx.org": "^2.0.8",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"lit-html": "^3.3.2", "lit-html": "^3.3.2",
"native-file-system-adapter": "^3.0.1", "native-file-system-adapter": "^3.0.1",
"three": "^0.182.0", "three": "^0.183.2",
"three-spritetext": "^1.10.0", "three-spritetext": "^1.10.0",
"tom-select": "^2.5.1" "tom-select": "^2.5.2"
} }
} }

View File

@@ -19,7 +19,7 @@ authors = [
license = { text = "GPL-3.0-only" } license = { text = "GPL-3.0-only" }
requires-python = "<4.0,>=3.12" requires-python = "<4.0,>=3.12"
dependencies = [ dependencies = [
"django>=5.2.11,<6.0.0", "django>=5.2.12,<6.0.0",
"django-ninja>=1.5.3,<6.0.0", "django-ninja>=1.5.3,<6.0.0",
"django-ninja-extra>=0.31.0", "django-ninja-extra>=0.31.0",
"Pillow>=12.1.1,<13.0.0", "Pillow>=12.1.1,<13.0.0",
@@ -27,15 +27,15 @@ dependencies = [
"django-jinja<3.0.0,>=2.11.0", "django-jinja<3.0.0,>=2.11.0",
"cryptography>=46.0.5,<47.0.0", "cryptography>=46.0.5,<47.0.0",
"django-phonenumber-field>=8.4.0,<9.0.0", "django-phonenumber-field>=8.4.0,<9.0.0",
"phonenumbers>=9.0.23,<10.0.0", "phonenumbers>=9.0.25,<10.0.0",
"reportlab>=4.4.9,<5.0.0", "reportlab>=4.4.10,<5.0.0",
"django-haystack<4.0.0,>=3.3.0", "django-haystack<4.0.0,>=3.3.0",
"xapian-haystack<4.0.0,>=3.1.0", "xapian-haystack<4.0.0,>=3.1.0",
"libsass<1.0.0,>=0.23.0", "libsass<1.0.0,>=0.23.0",
"django-ordered-model<4.0.0,>=3.7.4", "django-ordered-model<4.0.0,>=3.7.4",
"django-simple-captcha<1.0.0,>=0.6.3", "django-simple-captcha<1.0.0,>=0.6.3",
"python-dateutil<3.0.0.0,>=2.9.0.post0", "python-dateutil<3.0.0.0,>=2.9.0.post0",
"sentry-sdk>=2.52.0,<3.0.0", "sentry-sdk>=2.54.0,<3.0.0",
"jinja2<4.0.0,>=3.1.6", "jinja2<4.0.0,>=3.1.6",
"django-countries>=8.2.0,<9.0.0", "django-countries>=8.2.0,<9.0.0",
"dict2xml>=1.7.8,<2.0.0", "dict2xml>=1.7.8,<2.0.0",
@@ -51,7 +51,7 @@ dependencies = [
"psutil>=7.2.2,<8.0.0", "psutil>=7.2.2,<8.0.0",
"celery[redis]>=5.6.2,<7", "celery[redis]>=5.6.2,<7",
"django-celery-results>=2.5.1", "django-celery-results>=2.5.1",
"django-celery-beat>=2.7.0", "django-celery-beat>=2.9.0",
] ]
[project.urls] [project.urls]
@@ -60,31 +60,31 @@ documentation = "https://sith-ae.readthedocs.io/"
[dependency-groups] [dependency-groups]
prod = [ prod = [
"psycopg[c]>=3.3.2,<4.0.0", "psycopg[c]>=3.3.3,<4.0.0",
] ]
dev = [ dev = [
"django-debug-toolbar>=6.2.0,<7", "django-debug-toolbar>=6.2.0,<7",
"ipython>=9.10.0,<10.0.0", "ipython>=9.11.0,<10.0.0",
"pre-commit>=4.5.1,<5.0.0", "pre-commit>=4.5.1,<5.0.0",
"ruff>=0.15.0,<1.0.0", "ruff>=0.15.5,<1.0.0",
"djhtml>=3.0.10,<4.0.0", "djhtml>=3.0.10,<4.0.0",
"faker>=40.4.0,<41.0.0", "faker>=40.8.0,<41.0.0",
"rjsmin>=1.2.5,<2.0.0", "rjsmin>=1.2.5,<2.0.0",
] ]
tests = [ tests = [
"freezegun>=1.5.5,<2.0.0", "freezegun>=1.5.5,<2.0.0",
"pytest>=9.0.2,<10.0.0", "pytest>=9.0.2,<10.0.0",
"pytest-cov>=7.0.0,<8.0.0", "pytest-cov>=7.0.0,<8.0.0",
"pytest-django<5.0.0,>=4.10.0", "pytest-django<5.0.0,>=4.12.0",
"model-bakery<2.0.0,>=1.23.2", "model-bakery<2.0.0,>=1.23.3",
"beautifulsoup4>=4.14.3,<5", "beautifulsoup4>=4.14.3,<5",
"lxml>=6.0.2,<7", "lxml>=6.0.2,<7",
] ]
docs = [ docs = [
"mkdocs<2.0.0,>=1.6.1", "mkdocs<2.0.0,>=1.6.1",
"mkdocs-material>=9.7.1,<10.0.0", "mkdocs-material>=9.7.5,<10.0.0",
"mkdocstrings>=1.0.3,<2.0.0", "mkdocstrings>=1.0.3,<2.0.0",
"mkdocstrings-python>=2.0.2,<3.0.0", "mkdocstrings-python>=2.0.3,<3.0.0",
"mkdocs-include-markdown-plugin>=7.2.1,<8.0.0", "mkdocs-include-markdown-plugin>=7.2.1,<8.0.0",
] ]

View File

@@ -1,7 +1,6 @@
import type TomSelect from "tom-select"; import type TomSelect from "tom-select";
import type { UserAjaxSelect } from "#core:core/components/ajax-select-index.ts"; import type { UserAjaxSelect } from "#core:core/components/ajax-select-index.ts";
import { paginated } from "#core:utils/api.ts"; import { paginated } from "#core:utils/api.ts";
import { exportToHtml } from "#core:utils/globals.ts";
import { History } from "#core:utils/history.ts"; import { History } from "#core:utils/history.ts";
import { import {
type IdentifiedUserSchema, type IdentifiedUserSchema,

View File

@@ -355,7 +355,6 @@ SITH_TWITTER = "@ae_utbm"
# AE configuration # AE configuration
SITH_MAIN_CLUB_ID = env.int("SITH_MAIN_CLUB_ID", default=1) SITH_MAIN_CLUB_ID = env.int("SITH_MAIN_CLUB_ID", default=1)
SITH_PDF_CLUB_ID = env.int("SITH_PDF_CLUB_ID", default=2) SITH_PDF_CLUB_ID = env.int("SITH_PDF_CLUB_ID", default=2)
SITH_LAUNDERETTE_CLUB_ID = env.int("SITH_LAUNDERETTE_CLUB_ID", default=84)
# Main root for club pages # Main root for club pages
SITH_CLUB_ROOT_PAGE = "clubs" SITH_CLUB_ROOT_PAGE = "clubs"
@@ -483,13 +482,6 @@ SITH_LOG_OPERATION_TYPE = [
SITH_PEDAGOGY_UTBM_API = "https://extranet1.utbm.fr/gpedago/api/guide" SITH_PEDAGOGY_UTBM_API = "https://extranet1.utbm.fr/gpedago/api/guide"
SITH_ECOCUP_CONS = env.int("SITH_ECOCUP_CONS", default=1151)
SITH_ECOCUP_DECO = env.int("SITH_ECOCUP_DECO", default=1152)
# The limit is the maximum difference between cons and deco possible for a customer
SITH_ECOCUP_LIMIT = 3
# Defines pagination for cash summary # Defines pagination for cash summary
SITH_COUNTER_CASH_SUMMARY_LENGTH = 50 SITH_COUNTER_CASH_SUMMARY_LENGTH = 50
@@ -512,7 +504,6 @@ SITH_PRODUCT_SUBSCRIPTION_ONE_SEMESTER = env.int(
SITH_PRODUCT_SUBSCRIPTION_TWO_SEMESTERS = env.int( SITH_PRODUCT_SUBSCRIPTION_TWO_SEMESTERS = env.int(
"SITH_PRODUCT_SUBSCRIPTION_TWO_SEMESTERS", default=2 "SITH_PRODUCT_SUBSCRIPTION_TWO_SEMESTERS", default=2
) )
SITH_PRODUCTTYPE_SUBSCRIPTION = env.int("SITH_PRODUCTTYPE_SUBSCRIPTION", default=2)
# Number of weeks before the end of a subscription when the subscriber can resubscribe # Number of weeks before the end of a subscription when the subscriber can resubscribe
SITH_SUBSCRIPTION_END = 10 SITH_SUBSCRIPTION_END = 10
@@ -583,35 +574,6 @@ SITH_SUBSCRIPTIONS = {
# To be completed.... # To be completed....
} }
SITH_CLUB_ROLES_ID = {
"President": 10,
"Vice-President": 9,
"Treasurer": 7,
"Communication supervisor": 5,
"Secretary": 4,
"IT supervisor": 3,
"Board member": 2,
"Active member": 1,
"Curious": 0,
}
SITH_CLUB_ROLES = {
10: _("President"),
9: _("Vice-President"),
7: _("Treasurer"),
5: _("Communication supervisor"),
4: _("Secretary"),
3: _("IT supervisor"),
2: _("Board member"),
1: _("Active member"),
0: _("Curious"),
}
# This corresponds to the maximum role a user can freely subscribe to
# In this case, SITH_MAXIMUM_FREE_ROLE=1 means that a user can
# set himself as "Membre actif" or "Curieux", but not higher
SITH_MAXIMUM_FREE_ROLE = 1
# Minutes to timeout the logged barmen # Minutes to timeout the logged barmen
SITH_BARMAN_TIMEOUT = 30 SITH_BARMAN_TIMEOUT = 30

View File

@@ -4,7 +4,7 @@ from django.urls import reverse
from model_bakery import baker from model_bakery import baker
from pytest_django.asserts import assertRedirects from pytest_django.asserts import assertRedirects
from club.models import Club, Membership from club.models import Club, ClubRole, Membership
from core.baker_recipes import subscriber_user from core.baker_recipes import subscriber_user
from core.models import User from core.models import User
@@ -15,7 +15,8 @@ class TestSubscriptionPermission(TestCase):
cls.user: User = subscriber_user.make() cls.user: User = subscriber_user.make()
cls.admin = baker.make(User, is_superuser=True) cls.admin = baker.make(User, is_superuser=True)
cls.club = baker.make(Club) cls.club = baker.make(Club)
baker.make(Membership, user=cls.user, club=cls.club, role=7) role = baker.make(ClubRole, club=cls.club, is_board=True)
baker.make(Membership, user=cls.user, club=cls.club, role=role)
def test_give_permission(self): def test_give_permission(self):
self.client.force_login(self.admin) self.client.force_login(self.admin)

View File

@@ -23,7 +23,6 @@
from datetime import date from datetime import date
from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
@@ -152,10 +151,12 @@ class TrombiUser(models.Model):
def make_memberships(self): def make_memberships(self):
self.memberships.all().delete() self.memberships.all().delete()
for m in self.user.memberships.filter( for m in (
role__gt=settings.SITH_MAXIMUM_FREE_ROLE self.user.memberships.filter(role__is_board=True)
).order_by("end_date"): .select_related("role")
role = str(settings.SITH_CLUB_ROLES[m.role]) .order_by("end_date")
):
role = m.role.name
if m.description: if m.description:
role += " (%s)" % m.description role += " (%s)" % m.description
end_date = get_semester_code(m.end_date) if m.end_date else "" end_date = get_semester_code(m.end_date) if m.end_date else ""

629
uv.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,17 @@
// biome-ignore lint/correctness/noNodejsModules: this is backend side
import { parse, resolve } from "node:path"; import { parse, resolve } from "node:path";
import inject from "@rollup/plugin-inject"; import inject from "@rollup/plugin-inject";
import { glob } from "glob"; import { glob } from "glob";
import type { Rollup } from "vite"; import { visualizer } from "rollup-plugin-visualizer";
import { type AliasOptions, defineConfig, type UserConfig } from "vite"; import {
type AliasOptions,
defineConfig,
type PluginOption,
type Rollup,
type UserConfig,
} from "vite";
import tsconfig from "./tsconfig.json"; import tsconfig from "./tsconfig.json";
const outDir = resolve(__dirname, "./staticfiles/generated/bundled"); const outDir = resolve(__dirname, "./staticfiles/generated/bundled");
const vendored = resolve(outDir, "vendored");
const nodeModules = resolve(__dirname, "node_modules");
const collectedFiles = glob.sync( const collectedFiles = glob.sync(
"./!(static)/static/bundled/**/*?(-)index.?(m)[j|t]s?(x)", "./!(static)/static/bundled/**/*?(-)index.?(m)[j|t]s?(x)",
); );
@@ -42,7 +45,6 @@ function getRelativeAssetPath(path: string): string {
return relativePath.join("/"); return relativePath.join("/");
} }
// biome-ignore lint/style/noDefaultExport: this is recommended by documentation
export default defineConfig((config: UserConfig) => { export default defineConfig((config: UserConfig) => {
return { return {
base: "/static/bundled/", base: "/static/bundled/",
@@ -86,6 +88,7 @@ export default defineConfig((config: UserConfig) => {
Alpine: "alpinejs", Alpine: "alpinejs",
htmx: "htmx.org", htmx: "htmx.org",
}), }),
visualizer({ filename: ".bundle-size-report.html" }) as PluginOption,
], ],
} satisfies UserConfig; } satisfies UserConfig;
}); });