Compare commits

..

13 Commits

Author SHA1 Message Date
imperosol
daac42eabf unify eboutic and regular counter price selection 2026-03-15 09:58:25 +01:00
imperosol
fdc91ab7f1 directly work on group ids
add tests
2026-03-15 09:58:25 +01:00
imperosol
277b39f033 fix existing tests 2026-03-15 09:58:25 +01:00
imperosol
4591b07a35 add translations 2026-03-15 09:58:25 +01:00
imperosol
65a7447bdd adapt formulas to new price system 2026-03-15 09:58:25 +01:00
imperosol
5301efe006 adapt products export to new price system 2026-03-15 09:58:25 +01:00
imperosol
d5bc5cfeaa Price formset in the Product edit page 2026-03-15 09:58:25 +01:00
imperosol
9c6b6a132f use new price system in populate 2026-03-15 09:58:25 +01:00
imperosol
7dcd3f8288 remove Product.selling_price and Product.special_selling_price 2026-03-15 09:58:25 +01:00
imperosol
f740fe57ca use new price system in counters 2026-03-15 09:58:25 +01:00
imperosol
e62d0a2e1d use new price system in the eboutic 2026-03-15 09:58:25 +01:00
imperosol
caebc98bc8 refactor InvoiceItem and BasketItem models 2026-03-15 09:58:25 +01:00
imperosol
8e64909772 Price model 2026-03-15 09:58:10 +01:00
115 changed files with 1893 additions and 3797 deletions

View File

@@ -13,10 +13,8 @@
# #
# #
from django.contrib import admin from django.contrib import admin
from django.forms.models import ModelForm
from django.http import HttpRequest
from club.models import Club, ClubRole, Membership from club.models import Club, Membership
@admin.register(Club) @admin.register(Club)
@@ -31,31 +29,6 @@ class ClubAdmin(admin.ModelAdmin):
"page", "page",
) )
def save_model(
self,
request: HttpRequest,
obj: Club,
form: ModelForm,
change: bool, # noqa: FBT001
):
super().save_model(request, obj, form, change)
if not change:
obj.create_default_roles()
@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):

View File

@@ -6,7 +6,7 @@ from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.schemas import PaginatedResponseSchema from ninja_extra.schemas import PaginatedResponseSchema
from api.auth import ApiKeyAuth from api.auth import ApiKeyAuth
from api.permissions import CanView, HasPerm from api.permissions import CanAccessLookup, CanView, HasPerm
from club.models import Club, Membership from club.models import Club, Membership
from club.schemas import ( from club.schemas import (
ClubSchema, ClubSchema,
@@ -22,11 +22,13 @@ class ClubController(ControllerBase):
@route.get( @route.get(
"/search", "/search",
response=PaginatedResponseSchema[SimpleClubSchema], response=PaginatedResponseSchema[SimpleClubSchema],
auth=[ApiKeyAuth(), SessionAuth()],
permissions=[CanAccessLookup],
url_name="search_club", url_name="search_club",
) )
@paginate(PageNumberPaginationExtra, page_size=50) @paginate(PageNumberPaginationExtra, page_size=50)
def search_club(self, filters: Query[ClubSearchFilterSchema]): def search_club(self, filters: Query[ClubSearchFilterSchema]):
return filters.filter(Club.objects.order_by("name")).values() return filters.filter(Club.objects.all())
@route.get( @route.get(
"/{int:club_id}", "/{int:club_id}",
@@ -37,8 +39,7 @@ class ClubController(ControllerBase):
) )
def fetch_club(self, club_id: int): def fetch_club(self, club_id: int):
prefetch = Prefetch( prefetch = Prefetch(
"members", "members", queryset=Membership.objects.ongoing().select_related("user")
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
@@ -60,5 +61,5 @@ class UserClubController(ControllerBase):
return ( return (
Membership.objects.ongoing() Membership.objects.ongoing()
.filter(user=user) .filter(user=user)
.select_related("club", "user", "role") .select_related("club", "user")
) )

View File

@@ -23,12 +23,13 @@
# #
from django import forms from django import forms
from django.db.models import Exists, OuterRef, Q, QuerySet from django.conf import settings
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, ClubRole, Mailing, MailingSubscription, Membership from club.models import Club, 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 (
@@ -214,7 +215,9 @@ 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 = club.members.ongoing().editable_by(user) self.fields["members_old"].queryset = (
Membership.objects.ongoing().filter(club=club).editable_by(user)
)
class ClubMemberForm(forms.ModelForm): class ClubMemberForm(forms.ModelForm):
@@ -232,14 +235,19 @@ 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"].queryset = self.available_roles self.fields["role"].required = True
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 available_roles(self) -> QuerySet[ClubRole]: def max_available_role(self):
"""The roles 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 ClubRole.objects.none() # pragma: no cover return -1 # pragma: no cover
class ClubAddMemberForm(ClubMemberForm): class ClubAddMemberForm(ClubMemberForm):
@@ -250,22 +258,21 @@ class ClubAddMemberForm(ClubMemberForm):
widgets = {"user": AutoCompleteSelectUser} widgets = {"user": AutoCompleteSelectUser}
@cached_property @cached_property
def available_roles(self): def max_available_role(self):
"""The roles 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.
Board members can attribute roles lower than their own. Board members can attribute roles lower than their own.
Other users cannot attribute roles with this form Other users cannot attribute roles with this form
""" """
qs = self.club.roles.filter(is_active=True)
if self.request_user.has_perm("club.add_membership"): if self.request_user.has_perm("club.add_membership"):
return qs.all() return settings.SITH_CLUB_ROLES_ID["President"]
membership = self.request_user_membership membership = self.request_user_membership
if membership is None or not membership.role.is_board: if membership is None or membership.role <= settings.SITH_MAXIMUM_FREE_ROLE:
return ClubRole.objects.none() return -1
if membership.role.is_presidency: if membership.role == settings.SITH_CLUB_ROLES_ID["President"]:
return qs.all() return membership.role
return qs.above_instance(membership.role) return membership.role - 1
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.
@@ -289,11 +296,13 @@ 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 available_roles(self): def max_available_role(self):
return self.club.roles.filter(is_board=False, is_active=True) return settings.SITH_MAXIMUM_FREE_ROLE
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."""
@@ -306,88 +315,3 @@ class JoinClubForm(ClubMemberForm):
_("You are already a member of this club"), code="invalid" _("You are already a member of this club"), code="invalid"
) )
return super().clean() return super().clean()
class ClubSearchForm(forms.ModelForm):
class Meta:
model = Club
fields = ["name"]
widgets = {"name": forms.SearchInput(attrs={"autocomplete": "off"})}
club_status = forms.NullBooleanField(
label=_("Club status"),
widget=forms.RadioSelect(
choices=[(True, _("Active")), (False, _("Inactive")), ("", _("All clubs"))],
),
initial=True,
)
def __init__(self, *args, data: dict | None = None, **kwargs):
super().__init__(*args, data=data, **kwargs)
if data is not None and "club_status" not in data:
# if the key is missing, it is considered as None,
# even though we want the default True value to be applied in such a case
# so we enforce it.
self.fields["club_status"].value = True
self.fields["name"].required = False
class ClubRoleForm(forms.ModelForm):
error_css_class = "error"
required_css_class = "required"
class Meta:
model = ClubRole
fields = ["name", "description", "is_presidency", "is_board", "is_active"]
widgets = {
"is_presidency": forms.HiddenInput(),
"is_board": forms.HiddenInput(),
"is_active": forms.CheckboxInput(attrs={"class": "switch"}),
}
def clean(self):
cleaned_data = super().clean()
if "ORDER" in cleaned_data:
self.instance.order = cleaned_data["ORDER"]
return cleaned_data
class ClubRoleCreateForm(forms.ModelForm):
"""Form to create a club role.
Notes:
For UX purposes, users are not meant to fill `is_presidency`
and `is_board`, so those values are required by the form constructor
in order to initialize the instance properly.
"""
error_css_class = "error"
required_css_class = "required"
class Meta:
model = ClubRole
fields = ["name", "description"]
def __init__(
self, *args, club: Club, is_presidency: bool, is_board: bool, **kwargs
):
super().__init__(*args, **kwargs)
self.instance.club = club
self.instance.is_presidency = is_presidency
self.instance.is_board = is_board
class ClubRoleBaseFormSet(forms.BaseInlineFormSet):
ordering_widget = forms.HiddenInput()
ClubRoleFormSet = forms.inlineformset_factory(
Club,
ClubRole,
ClubRoleForm,
ClubRoleBaseFormSet,
can_delete=False,
can_order=True,
edit_only=True,
extra=0,
)

View File

@@ -2,15 +2,12 @@
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.
@@ -49,7 +46,10 @@ 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

@@ -1,161 +0,0 @@
# 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
PRESIDENCY_ROLES = [10, 9]
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 in PRESIDENCY_ROLES,
club_id=club_id,
order=max(SITH_CLUB_ROLES) - 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 with which this role is associated",
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.AlterField(
model_name="club",
name="board_group",
field=models.OneToOneField(
editable=False,
on_delete=django.db.models.deletion.PROTECT,
related_name="club_board",
to="core.group",
),
),
migrations.AlterField(
model_name="club",
name="members_group",
field=models.OneToOneField(
editable=False,
on_delete=django.db.models.deletion.PROTECT,
related_name="club",
to="core.group",
),
),
migrations.AddConstraint(
model_name="clubrole",
constraint=models.CheckConstraint(
condition=models.Q(
("is_presidency", False), ("is_board", True), _connector="OR"
),
name="clubrole_presidency_implies_board",
violation_error_message=(
"A role cannot be in the presidency while not being in the 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

@@ -1,25 +0,0 @@
# 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

@@ -28,15 +28,15 @@ from typing import Iterable, Self
from django.conf import settings 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 ProgrammingError, models, transaction from django.db import models, transaction
from django.db.models import Exists, F, OuterRef, Q from django.db.models import Exists, F, OuterRef, Q, Value
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,13 +89,13 @@ 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.PROTECT Page, related_name="club", blank=True, on_delete=models.CASCADE
) )
members_group = models.OneToOneField( members_group = models.OneToOneField(
Group, related_name="club", on_delete=models.PROTECT, editable=False Group, related_name="club", on_delete=models.PROTECT
) )
board_group = models.OneToOneField( board_group = models.OneToOneField(
Group, related_name="club_board", on_delete=models.PROTECT, editable=False Group, related_name="club_board", on_delete=models.PROTECT
) )
objects = ClubQuerySet.as_manager() objects = ClubQuerySet.as_manager()
@@ -138,7 +138,9 @@ 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(end_date=None).order_by("role__order").first() return self.members.filter(
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."""
@@ -183,40 +185,6 @@ class Club(models.Model):
self.page.parent = self.parent.page self.page.parent = self.parent.page
self.page.save(force_lock=True) self.page.save(force_lock=True)
def create_default_roles(self):
"""Create some roles that should exist by default for this club.
The created roles are : president, treasurer, active member and curious.
Warnings:
When calling this method, no club must exist yet for this club.
"""
if self.roles.exists():
raise ProgrammingError(
"Default roles can be created only for clubs "
"that don't have associated roles yet"
)
# The names are written in French, because there is no gettext involved
# for strings stored in database, and the majority of users are french.
roles = [
ClubRole(name="Président⸱e", is_board=True, is_presidency=True),
ClubRole(name="Trésorier⸱e", is_board=True, is_presidency=False),
ClubRole(name="Membre actif⸱ve", is_board=False, is_presidency=False),
ClubRole(
name="Curieux⸱euse",
description=(
"Les gens qui suivent l'activité "
"du club sans forcément y participer"
),
is_board=False,
is_presidency=False,
),
]
for i, role in enumerate(roles):
role.club = self
role.order = i
ClubRole.objects.bulk_create(roles)
def delete(self, *args, **kwargs) -> tuple[int, dict[str, int]]: def delete(self, *args, **kwargs) -> tuple[int, dict[str, int]]:
self.board_group.delete() self.board_group.delete()
self.members_group.delete() self.members_group.delete()
@@ -238,20 +206,9 @@ class Club(models.Model):
"""Method to see if that object can be edited by the given user.""" """Method to see if that object can be edited by the given user."""
return self.has_rights_in_club(user) return self.has_rights_in_club(user)
def can_roles_be_edited_by(self, user: User) -> bool:
"""Return True if the given user can edit the roles of this club"""
return user.is_authenticated and (
user.has_perm("club.change_clubrole")
or self.members.ongoing()
.filter(user=user, role__is_presidency=True)
.exists()
)
@cached_property @cached_property
def current_members(self) -> list[Membership]: def current_members(self) -> list[Membership]:
return list( return list(self.members.ongoing().select_related("user").order_by("-role"))
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."""
@@ -263,95 +220,6 @@ 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 with which this role is associated"),
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")
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",
violation_error_message=_(
"A role cannot be in the presidency while not being in the board"
),
)
]
def __str__(self):
return self.name
def get_display_name(self):
return f"{self.name} - {self.club.name}"
def clean(self):
errors = []
roles = list(self.club.roles.all())
if (
self.is_board
and self.order
and any(r.order < self.order and not r.is_board for r in roles)
):
errors.append(
ValidationError(
_("Role %(role)s cannot be placed below a member role")
% {"role": self.name}
)
)
if (
self.is_presidency
and self.order
and any(r.order < self.order and not r.is_presidency for r in roles)
):
errors.append(
ValidationError(
_("Role %(role)s cannot be placed below a non-presidency role")
% {"role": self.name}
)
)
if errors:
raise ValidationError(errors)
return super().clean()
def save(self, *args, **kwargs):
auto_order = self.order is None and self.is_board
if not auto_order:
super().save(*args, **kwargs)
return
# get the role that should be placed after the role we are dealing with.
# So, if this is role is presidency, get the first board role ;
# if it is a board role, get the first member role ;
# and if it is a member role, get nothing (OrderedModel.save will
# automatically put it in the last position anyway)
filters = {"is_board": self.is_presidency, "is_presidency": False}
next_role = self.club.roles.filter(**filters).order_by("order").first()
super().save(*args, **kwargs)
if next_role:
self.above(next_role)
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."""
@@ -364,10 +232,9 @@ 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 [MembershipQuerySet.ongoing][] mind combining this with the `ongoing` queryset method
queryset method
""" """
return self.filter(role__is_board=True) return self.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE)
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.
@@ -390,16 +257,21 @@ 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.ongoing().filter( return self.filter(
Q(user=user) Q(user=user)
| Exists( | Exists(
Membership.objects.ongoing().filter( Membership.objects.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:
@@ -469,11 +341,10 @@ 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.ForeignKey( role = models.IntegerField(
ClubRole, _("role"),
verbose_name=_("role"), choices=sorted(settings.SITH_CLUB_ROLES.items()),
related_name="members", default=sorted(settings.SITH_CLUB_ROLES.items())[0][0],
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
@@ -491,7 +362,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"- {self.role.name} " f"- {settings.SITH_CLUB_ROLES[self.role]} "
f"- {str(_('past member')) if self.end_date is not None else ''}" f"- {str(_('past member')) if self.end_date is not None else ''}"
) )
@@ -520,11 +391,7 @@ 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)
if not membership: return membership is not None and membership.role >= self.role
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])
@@ -600,7 +467,7 @@ class Membership(models.Model):
group_id=membership.club.members_group_id, group_id=membership.club.members_group_id,
) )
) )
if membership.role.is_board: if membership.role > settings.SITH_MAXIMUM_FREE_ROLE:
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, ClubRole, Membership from club.models import Club, Membership
from core.schemas import NonEmptyStr, SimpleUserSchema from core.schemas import NonEmptyStr, SimpleUserSchema
@@ -30,7 +30,7 @@ class ClubProfileSchema(ModelSchema):
class Meta: class Meta:
model = Club model = Club
fields = ["id", "name", "logo", "is_active", "short_description"] fields = ["id", "name", "logo"]
url: str url: str
@@ -39,21 +39,14 @@ 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", "description"] fields = ["start_date", "end_date", "role", "description"]
user: SimpleUserSchema user: SimpleUserSchema
role: ClubRoleSchema
class ClubSchema(ModelSchema): class ClubSchema(ModelSchema):
@@ -69,7 +62,6 @@ class UserMembershipSchema(ModelSchema):
class Meta: class Meta:
model = Membership model = Membership
fields = ["id", "start_date", "description"] fields = ["id", "start_date", "role", "description"]
club: SimpleClubSchema club: SimpleClubSchema
role: ClubRoleSchema

View File

@@ -1,61 +0,0 @@
import type { AlpineComponent } from "alpinejs";
interface RoleGroupData {
isBoard: boolean;
isPresidency: boolean;
roleId: number;
}
document.addEventListener("alpine:init", () => {
Alpine.data("clubRoleList", (config: { userRoleId: number | null }) => ({
confirmOnSubmit: false,
/**
* Edit relevant item data after it has been moved by x-sort
*/
reorder(item: AlpineComponent<RoleGroupData>, conf: RoleGroupData) {
item.isBoard = conf.isBoard;
item.isPresidency = conf.isPresidency;
// if the user has moved its own role outside the presidency,
// submitting the form will require a confirmation
this.confirmOnSubmit = config.userRoleId === item.roleId && !item.isPresidency;
this.resetOrder();
},
/**
* Reset the value of the ORDER input of all items in the list.
* This is to be called after any reordering operation, in order to make sure
* that the order that will be saved is coherent with what is displayed.
*/
resetOrder() {
// When moving items with x-sort, the only information we truly have is
// the end position in the target group, not the previous position nor
// the position in the global list.
// To overcome this, we loop through an enumeration of all inputs
// that are in the form `roles-X-ORDER` and sequentially set the value of the field.
const inputs = document.querySelectorAll<HTMLInputElement>(
"input[name^='roles'][name$='ORDER']",
);
for (const [i, elem] of inputs.entries()) {
elem.value = (i + 1).toString();
}
},
/**
* If the user moved its role out of the presidency, ask a confirmation
* before submitting the form
*/
confirmSubmission(event: SubmitEvent) {
if (
this.confirmOnSubmit &&
!confirm(
gettext(
"You're going to remove your own role from the presidency. " +
"You may lock yourself out of this page. Do you want to continue ? ",
),
)
) {
event.preventDefault();
}
},
}));
});

View File

@@ -1,47 +0,0 @@
#club-list {
display: flex;
flex-direction: column;
gap: 2em;
padding: 2em;
.card {
display: block;
background-color: unset;
.club-image {
float: left;
margin-right: 2rem;
margin-bottom: .5rem;
width: 150px;
height: 150px;
border-radius: 10%;
background-color: rgba(173, 173, 173, 0.2);
@media screen and (max-width: 500px) {
width: 100px;
height: 100px;
}
}
i.club-image {
display: flex;
flex-direction: column;
justify-content: center;
color: black;
}
.content {
display: block;
text-align: justify;
h4 {
margin-top: 0;
margin-right: .5rem;
}
p {
font-size: 100%;
}
}
}
}

View File

@@ -1,7 +0,0 @@
.fa-grip-vertical {
display: flex;
flex-direction: column;
justify-content: center;
cursor: pointer;
margin-right: .5em;
}

View File

@@ -1,76 +1,52 @@
{% if is_fragment %}
{% extends "core/base_fragment.jinja" %}
{# Don't display tabs and errors #}
{% block tabs %}
{% endblock %}
{% block errors %}
{% endblock %}
{% else %}
{% extends "core/base.jinja" %} {% extends "core/base.jinja" %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static("club/list.scss") }}">
{% endblock %}
{% block description -%}
{% trans %}The list of all clubs existing at UTBM.{% endtrans %}
{%- endblock %}
{% block title -%} {% block title -%}
{% trans %}Club list{% endtrans %} {% trans %}Club list{% endtrans %}
{%- endblock %} {%- endblock %}
{% block description -%}
{% trans %}The list of all clubs existing at UTBM.{% endtrans %}
{%- endblock %}
{% macro display_club(club) -%}
{% if club.is_active or user.is_root %}
<li><a href="{{ url('club:club_view', club_id=club.id) }}">{{ club.name }}</a>
{% if not club.is_active %}
({% trans %}inactive{% endtrans %})
{% endif %} {% endif %}
{% from "core/macros.jinja" import paginate_htmx %} {% if club.president %} - <a href="{{ url('core:user_profile', user_id=club.president.user.id) }}">{{ club.president.user }}</a>{% endif %}
{% if club.short_description %}<p>{{ club.short_description|markdown }}</p>{% endif %}
{% endif %}
{%- if club.children.all()|length != 0 %}
<ul>
{%- for c in club.children.order_by('name').prefetch_related("children") %}
{{ display_club(c) }}
{%- endfor %}
</ul>
{%- endif -%}
</li>
{%- endmacro %}
{% block content %} {% block content %}
<main> {% if user.is_root %}
<h3>{% trans %}Filters{% endtrans %}</h3> <p><a href="{{ url('club:club_new') }}">{% trans %}New club{% endtrans %}</a></p>
<form {% endif %}
id="club-list-filters" {% if club_list %}
hx-get="{{ url("club:club_list") }}"
hx-target="#content"
hx-swap="outerHtml"
hx-push-url="true"
>
<div class="row gap-4x">
{{ form }}
</div>
<button type="submit" class="btn btn-blue margin-bottom">
<i class="fa fa-magnifying-glass"></i>{% trans %}Search{% endtrans %}
</button>
</form>
<h3>{% trans %}Club list{% endtrans %}</h3> <h3>{% trans %}Club list{% endtrans %}</h3>
{% if user.has_perm("club.add_club") %} <ul>
<br> {%- for club in club_list %}
<a href="{{ url('club:club_new') }}" class="btn btn-blue"> {{ display_club(club) }}
<i class="fa fa-plus"></i> {% trans %}New club{% endtrans %} {%- endfor %}
</a> </ul>
{% endif %}
<section class="aria-busy-grow" id="club-list">
{% for club in object_list %}
<div class="card">
{% set club_url = club.get_absolute_url() %}
<a href="{{ club_url }}">
{% if club.logo %}
<img class="club-image" src="{{ club.logo.url }}" alt="logo {{ club.name }}">
{% else %} {% else %}
<i class="fa-regular fa-image fa-4x club-image"></i> {% trans %}There is no club in this website.{% endtrans %}
{% endif %} {% endif %}
</a>
<div class="content">
<a href="{{ club_url }}">
<h4>
{{ club.name }} {% if not club.is_active %}({% trans %}inactive{% endtrans %}){% endif %}
</h4>
</a>
{{ club.short_description|markdown }}
</div>
</div>
{% endfor %}
</section>
{% if is_paginated %}
{{ paginate_htmx(request, page_obj, paginator) }}
{% endif %}
</main>
{% endblock %} {% endblock %}

View File

@@ -1,7 +1,11 @@
{% extends "core/base.jinja" %} {% extends "core/base.jinja" %}
{% from 'core/macros.jinja' import user_profile_link, select_all_checkbox %} {% from 'core/macros.jinja' import user_profile_link, select_all_checkbox %}
{% block additional_js %}
<script type="module" src="{{ static("bundled/core/components/ajax-select-index.ts") }}"></script>
{% endblock %}
{% block additional_css %} {% block additional_css %}
<link rel="stylesheet" href="{{ static("bundled/core/components/ajax-select-index.css") }}">
<link rel="stylesheet" href="{{ static("club/members.scss") }}"> <link rel="stylesheet" href="{{ static("club/members.scss") }}">
{% endblock %} {% endblock %}
@@ -12,15 +16,6 @@
<h2>{% trans %}Club members{% endtrans %}</h2> <h2>{% trans %}Club members{% endtrans %}</h2>
{% if club.can_roles_be_edited_by(user) %}
<a
href="{{ url("club:club_roles", club_id=object.id) }}"
class="btn btn-blue margin-bottom"
>
{% trans %}Manage roles{% endtrans %}
</a>
{% endif %}
{% if add_member_fragment %} {% if add_member_fragment %}
<br /> <br />
{{ add_member_fragment }} {{ add_member_fragment }}
@@ -50,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>{{ m.role.name }}</td> <td>{{ settings.SITH_CLUB_ROLES[m.role] }}</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>{{ member.role.name }}</td> <td>{{ settings.SITH_CLUB_ROLES[member.role] }}</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

@@ -1,165 +0,0 @@
{% extends "core/base.jinja" %}
{% block additional_js %}
<script type="module" src="{{ static("bundled/club/role-list-index.ts") }}"></script>
{% endblock %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static("club/roles.scss") }}">
{% endblock %}
{% macro display_subform(subform) %}
<div
class="row"
x-data="{
isPresidency: {{ subform.is_presidency.value()|lower }},
isBoard: {{ subform.is_board.value()|lower }},
roleId: {{ subform.id.value() }},
}"
x-sort:item="$data"
>
{# hidden fields #}
{{ subform.ORDER }}
{{ subform.id }}
{{ subform.club }}
{{ subform.is_presidency|add_attr("x-model=isPresidency") }}
{{ subform.is_board|add_attr("x-model=isBoard") }}
<i class="fa fa-grip-vertical" x-sort:handle></i>
<details class="accordion grow" {% if subform.errors %}open{% endif %}>
<summary>
{{ subform.name.value() }}
{% if not subform.instance.is_active -%}
({% trans %}inactive{% endtrans %})
{%- endif %}
</summary>
<div class="accordion-content">
{{ subform.non_field_errors() }}
<div class="form-group">
{{ subform.name.as_field_group() }}
</div>
<div class="form-group">
{{ subform.description.as_field_group() }}
</div>
<div class="form-group">
<div>
{{ subform.is_active }}
{{ subform.is_active.label_tag() }}
</div>
<span class="helptext">
{{ subform.is_active.help_text }}
</span>
</div>
</div>
</details>
</div>
{% endmacro %}
{% block content %}
<p>
{% trans trimmed %}
Roles give rights on the club.
Higher roles grant more rights, and the members having them are displayed higher
in the club members list.
{% endtrans %}
</p>
<p>
{% trans trimmed %}
On this page, you can edit their name and description, as well as their order.
You can also drag roles from a category to another
(e.g. a board role can be made into a presidency role).
{% endtrans %}
</p>
<form
method="post"
x-data="clubRoleList({ userRoleId: {{ user_role or "null" }} })"
@submit="confirmSubmission"
>
{% csrf_token %}
{{ form.management_form }}
{{ form.non_form_errors() }}
<h3>{% trans %}Presidency{% endtrans %}</h3>
<a class="btn btn-blue margin-bottom" href="{{ url("club:new_role_president", club_id=club.id) }}">
<i class="fa fa-plus"></i> {% trans %}add role{% endtrans %}
</a>
<details class="clickable margin-bottom">
<summary>{% trans %}Help{% endtrans %}</summary>
{# The style we use for markdown rendering is quite nice for what we want to display,
so we are just gonna reuse it. #}
<div class="markdown">
<p>{% trans %}Users with a presidency role can :{% endtrans %}</p>
<ul>
<li>{% trans %}create new club roles and edit existing ones{% endtrans %}</li>
<li>{% trans %}manage the club counters{% endtrans %}</li>
<li>{% trans %}add new members with any active role and end any membership{% endtrans %}</li>
</ul>
<p>{% trans %}They also have all the rights of the club board.{% endtrans %}</p>
</div>
</details>
<div
x-sort="reorder($item, { isBoard: true, isPresidency: true })"
x-sort:group="roles"
>
{% for subform in form %}
{% if subform.is_presidency.value() %}
{{ display_subform(subform) }}
{% endif %}
{% endfor %}
</div>
<h3>{% trans %}Board{% endtrans %}</h3>
<a class="btn btn-blue margin-bottom" href="{{ url("club:new_role_board", club_id=club.id) }}">
<i class="fa fa-plus"></i> {% trans %}add role{% endtrans %}
</a>
<details class="clickable margin-bottom">
<summary>{% trans %}Help{% endtrans %}</summary>
<div class="markdown">
<p>
{% trans trimmed %}
Board members can do most administrative actions in the club, including :
{% endtrans %}
</p>
<ul>
<li>{% trans %}manage the club posters{% endtrans %}</li>
<li>{% trans %}create news for the club{% endtrans %}</li>
<li>{% trans %}click users on the club's counters{% endtrans %}</li>
<li>
{% trans trimmed %}
add new members and end active memberships
for roles that are lower than their own.
{% endtrans %}
</li>
</ul>
</div>
</details>
<div
x-sort="reorder($item, { isBoard: true, isPresidency: false })"
x-sort:group="roles"
>
{% for subform in form %}
{% if subform.is_board.value() and not subform.is_presidency.value() %}
{{ display_subform(subform) }}
{% endif %}
{% endfor %}
</div>
<h3>{% trans %}Members{% endtrans %}</h3>
<a class="btn btn-blue margin-bottom" href="{{ url("club:new_role_member", club_id=club.id) }}">
<i class="fa fa-plus"></i> {% trans %}add role{% endtrans %}
</a>
<details class="clickable margin-bottom">
<summary>{% trans %}Help{% endtrans %}</summary>
<div class="markdown">
<p>{% trans %}Simple members cannot perform administrative actions.{% endtrans %}</p>
</div>
</details>
<div
x-sort="reorder($item, { isBoard: false, isPresidency: false })"
x-sort:group="roles"
>
{% for subform in form %}
{% if not subform.is_board.value() %}
{{ display_subform(subform) }}
{% endif %}
{% endfor %}
</div>
<p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p>
</form>
{% endblock content %}

View File

@@ -5,19 +5,8 @@
<div> <div>
<h4>{% trans %}Communication:{% endtrans %}</h4> <h4>{% trans %}Communication:{% endtrans %}</h4>
<ul> <ul>
<li> <li> <a href="{{ url('com:news_new') }}?club={{ object.id }}">{% trans %}Create a news{% endtrans %}</a></li>
<a href="{{ url('com:news_new') }}?club={{ object.id }}"> <li> <a href="{{ url('com:weekmail_article') }}?club={{ object.id }}">{% trans %}Post in the Weekmail{% endtrans %}</a></li>
{% trans %}Create a news{% endtrans %}
</a>
</li>
<li>
<a href="{{ url('com:weekmail_article') }}?club={{ object.id }}">
{% trans %}Post in the Weekmail{% endtrans %}
</a>
</li>
{% if object.can_roles_be_edited_by(user) %}
<li><a href="{{ url("club:club_roles", club_id=object.id) }}"></a></li>
{% endif %}
{% if object.trombi %} {% if object.trombi %}
<li> <a href="{{ url('trombi:detail', trombi_id=object.trombi.id) }}">{% trans %}Edit Trombi{% endtrans %}</a></li> <li> <a href="{{ url('trombi:detail', trombi_id=object.trombi.id) }}">{% trans %}Edit Trombi{% endtrans %}</a></li>
{% else %} {% else %}

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, ClubRole, Membership from club.models import Club, 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,11 +43,6 @@ 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}
) )
@@ -56,17 +51,12 @@ 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=cls.board_role user=cls.simple_board_member, start_date=a_month_ago, role=3
)
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, user=cls.sli, start_date=a_month_ago, end_date=yesterday, role=2
start_date=a_month_ago,
end_date=yesterday,
role=cls.board_role,
) )
def setUp(self): def setUp(self):

View File

@@ -1,18 +1,12 @@
from datetime import timedelta from datetime import timedelta
import pytest import pytest
from django.conf import settings
from django.db import ProgrammingError
from django.test import Client
from django.urls import reverse
from django.utils.timezone import localdate 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 pytest_django.asserts import assertRedirects
from club.models import Club, ClubRole, Membership from club.models import Club, Membership
from core.baker_recipes import subscriber_user from core.baker_recipes import subscriber_user
from core.models import User
@pytest.mark.django_db @pytest.mark.django_db
@@ -22,106 +16,12 @@ 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[0], role=baker.make(ClubRole, club=clubs[0], is_board=False) club=clubs[3], role=3, end_date=localdate() - timedelta(days=1)
)
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)
assert set(club_ids) == {clubs[1].id, clubs[2].id} assert set(club_ids) == {clubs[1].id, clubs[2].id}
@pytest.mark.parametrize("nb_additional_clubs", [10, 30])
@pytest.mark.parametrize("is_fragment", [True, False])
@pytest.mark.django_db
def test_club_list(client: Client, nb_additional_clubs: int, is_fragment):
client.force_login(baker.make(User))
baker.make(Club, _quantity=nb_additional_clubs)
headers = {"HX-Request": True} if is_fragment else {}
res = client.get(reverse("club:club_list"), headers=headers)
assert res.status_code == 200
def assert_club_created(club_name: str):
club = Club.objects.last()
assert club.name == club_name
assert club.board_group.name == f"{club_name} - Bureau"
assert club.members_group.name == f"{club_name} - Membres"
# default roles should be added on club creation,
# whether the creation happens on the admin site or on the user site
assert list(club.roles.values("name", "is_presidency", "is_board")) == [
{"name": "Président⸱e", "is_presidency": True, "is_board": True},
{"name": "Trésorier⸱e", "is_presidency": False, "is_board": True},
{"name": "Membre actif⸱ve", "is_presidency": False, "is_board": False},
{"name": "Curieux⸱euse", "is_presidency": False, "is_board": False},
]
@pytest.mark.django_db
def test_create_view(admin_client: Client):
"""Test that the club creation view works well"""
res = admin_client.get(reverse("club:club_new"))
assert res.status_code == 200
res = admin_client.post(
reverse("club:club_new"),
data={"name": "foo", "parent": settings.SITH_MAIN_CLUB_ID},
)
club = Club.objects.last()
assertRedirects(res, club.get_absolute_url())
assert_club_created("foo")
@pytest.mark.django_db
def test_default_roles_for_club_with_roles_fails():
"""Test that an Error is raised if trying to create
default roles for a club that already has roles.
"""
club = baker.make(Club)
baker.make(ClubRole, club=club)
with pytest.raises(ProgrammingError):
club.create_default_roles()
@pytest.mark.django_db
class TestAdminInterface:
def test_create(self, admin_client: Client):
"""Test the creation of a club via the admin interface."""
res = admin_client.post(
reverse("admin:club_club_add"),
data={
"name": "foo",
"parent": settings.SITH_MAIN_CLUB_ID,
"address": "Rome",
},
)
assertRedirects(res, reverse("admin:club_club_changelist"))
assert_club_created("foo")
def test_change(self, admin_client: Client):
"""Test the edition of a club via the admin interface."""
club = baker.make(Club)
res = admin_client.post(
reverse("admin:club_club_change", kwargs={"object_id": club.id}),
data={
"name": "foo",
"page": club.page_id,
"home": club.home_id,
"address": club.address,
},
)
assertRedirects(res, reverse("admin:club_club_changelist"))
club.refresh_from_db()
assert club.name == "foo"
# Club roles shouldn't be modified when editing the club on the admin interface
# This club had no roles beforehand, therefore it shouldn't have roles now.
assert not club.roles.exists()

View File

@@ -1,7 +1,6 @@
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
@@ -9,7 +8,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, ClubRole, Membership from club.models import Club, 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
@@ -27,10 +26,8 @@ class TestClubSearch(TestCase):
"id", flat=True "id", flat=True
) )
) )
Membership.objects.all().delete() Page.objects.exclude(club=None).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

@@ -1,254 +0,0 @@
from collections.abc import Callable
import pytest
from django.contrib.auth.models import Permission
from django.test import Client, TestCase
from django.urls import reverse
from model_bakery import baker, seq
from model_bakery.recipe import Recipe
from pytest_django.asserts import assertRedirects
from club.forms import ClubRoleFormSet
from club.models import Club, ClubRole, Membership
from core.baker_recipes import subscriber_user
from core.models import AnonymousUser, User
def make_club():
# unittest-style tests cannot use fixture, so we create a function
# that will be callable either by a pytest fixture or inside
# a TestCase.setUpTestData method.
club = baker.make(Club)
recipe = Recipe(ClubRole, club=club, name=seq("role "))
recipe.make(
is_board=iter([True, True, False]),
is_presidency=iter([True, False, False]),
order=iter([1, 2, 3]),
_quantity=3,
_bulk_create=True,
)
return club
@pytest.fixture
def club(db):
"""A club with a presidency role, a board role and a member role"""
return make_club()
@pytest.mark.django_db
def test_order_auto(club):
"""Test that newly created roles are put in the right place."""
roles = list(club.roles.all())
# create new roles one by one (like they will be in prod)
# each new role should be placed at the end of its category
recipe = Recipe(ClubRole, club=club, name=seq("new role "))
role_a = recipe.make(is_board=True, is_presidency=True, order=None)
role_b = recipe.make(is_board=True, is_presidency=False, order=None)
role_c = recipe.make(is_board=False, is_presidency=False, order=None)
assert list(club.roles.order_by("order")) == [
roles[0],
role_a,
roles[1],
role_b,
roles[2],
role_c,
]
@pytest.mark.django_db
@pytest.mark.parametrize(
("user_factory", "is_allowed"),
[
(
lambda club: baker.make(
User,
user_permissions=[Permission.objects.get(codename="change_clubrole")],
),
True,
),
( # user with presidency roles can edit the club roles
lambda club: subscriber_user.make(
memberships=[
baker.make(
Membership,
club=club,
role=club.roles.filter(is_presidency=True).first(),
)
]
),
True,
),
( # user in the board but not in the presidency cannot edit roles
lambda club: subscriber_user.make(
memberships=[
baker.make(
Membership,
club=club,
role=club.roles.filter(
is_presidency=False, is_board=True
).first(),
)
]
),
False,
),
(lambda _: AnonymousUser(), False),
],
)
def test_can_roles_be_edited_by(
club: Club, user_factory: Callable[[Club], User], is_allowed
):
"""Test that `Club.can_roles_be_edited_by` return the right value"""
user = user_factory(club)
assert club.can_roles_be_edited_by(user) == is_allowed
@pytest.mark.django_db
@pytest.mark.parametrize(
["route", "is_presidency", "is_board"],
[
("club:new_role_president", True, True),
("club:new_role_board", False, True),
("club:new_role_member", False, False),
],
)
def test_create_role_view(client: Client, route: str, is_presidency, is_board):
"""Test that the role creation views work."""
club = baker.make(Club)
role = baker.make(ClubRole, club=club, is_presidency=True, is_board=True)
user = subscriber_user.make()
baker.make(Membership, club=club, role=role, user=user, end_date=None)
url = reverse(route, kwargs={"club_id": club.id})
client.force_login(user)
res = client.get(url)
assert res.status_code == 200
res = client.post(url, data={"name": "foo"})
assertRedirects(res, reverse("club:club_roles", kwargs={"club_id": club.id}))
new_role = club.roles.last()
assert new_role.name == "foo"
assert new_role.is_presidency == is_presidency
assert new_role.is_board == is_board
class TestClubRoleUpdate(TestCase):
@classmethod
def setUpTestData(cls):
cls.club = make_club()
cls.roles = list(cls.club.roles.all())
cls.user = subscriber_user.make()
baker.make(
Membership, club=cls.club, role=cls.roles[0], user=cls.user, end_date=None
)
cls.url = reverse("club:club_roles", kwargs={"club_id": cls.club.id})
def setUp(self):
self.payload = {
"roles-TOTAL_FORMS": 3,
"roles-INITIAL_FORMS": 3,
"roles-MIN_NUM_FORMS": 0,
"roles-MAX_NUM_FORMS": 1000,
"roles-0-ORDER": self.roles[0].order,
"roles-0-id": self.roles[0].id,
"roles-0-club": self.club.id,
"roles-0-is_presidency": True,
"roles-0-is_board": True,
"roles-0-name": self.roles[0].name,
"roles-0-description": self.roles[0].description,
"roles-0-is_active": True,
"roles-1-ORDER": self.roles[1].order,
"roles-1-id": self.roles[1].id,
"roles-1-club": self.club.id,
"roles-1-is_presidency": False,
"roles-1-is_board": True,
"roles-1-name": self.roles[1].name,
"roles-1-description": self.roles[1].description,
"roles-1-is_active": True,
"roles-2-ORDER": self.roles[2].order,
"roles-2-id": self.roles[2].id,
"roles-2-club": self.club.id,
"roles-2-is_presidency": False,
"roles-2-is_board": False,
"roles-2-name": self.roles[2].name,
"roles-2-description": self.roles[2].description,
"roles-2-is_active": True,
}
def test_view_ok(self):
"""Basic test to check that the view works."""
self.client.force_login(self.user)
res = self.client.get(self.url)
assert res.status_code == 200
self.payload["roles-2-name"] = "foo"
res = self.client.post(self.url, data=self.payload)
assertRedirects(res, self.url)
self.roles[2].refresh_from_db()
assert self.roles[2].name == "foo"
def test_incoherent_order(self):
"""Test that placing a member role over a board role fails."""
self.payload["roles-0-ORDER"] = 4
formset = ClubRoleFormSet(data=self.payload, instance=self.club)
assert not formset.is_valid()
assert formset.errors == [
{
"__all__": [
f"Le rôle {self.roles[0].name} ne peut pas "
"être placé en-dessous d'un rôle de membre.",
f"Le rôle {self.roles[0].name} ne peut pas être placé "
"en-dessous d'un rôle qui n'est pas de la présidence.",
]
},
{},
{},
]
def test_change_order_ok(self):
"""Test that changing order the intended way works"""
self.payload["roles-1-ORDER"] = 3
self.payload["roles-1-is_board"] = False
self.payload["roles-2-ORDER"] = 2
formset = ClubRoleFormSet(data=self.payload, instance=self.club)
assert formset.is_valid()
formset.save()
assert list(self.club.roles.order_by("order")) == [
self.roles[0],
self.roles[2],
self.roles[1],
]
self.roles[1].refresh_from_db()
assert not self.roles[1].is_board
def test_non_board_presidency_is_forbidden(self):
"""Test that a role cannot be in the presidency without being in the board."""
self.payload["roles-0-is_board"] = False
formset = ClubRoleFormSet(data=self.payload, instance=self.club)
assert not formset.is_valid()
assert formset.errors == [
{
"__all__": [
"Un rôle ne peut pas appartenir à la présidence sans être dans le bureau",
]
},
{},
{},
]
def test_president_moves_itself_out_of_the_presidency(self):
"""Test that if the user moves its own role out of the presidency,
then it's redirected to another page and loses access to the update page."""
self.payload["roles-0-is_presidency"] = False
self.client.force_login(self.user)
res = self.client.post(self.url, data=self.payload)
assertRedirects(
res, reverse("club:club_members", kwargs={"club_id": self.club.id})
)
# When the user clicked that button, it still had the right to update roles,
# so the modification should be applied
self.roles[0].refresh_from_db()
assert self.roles[0].is_presidency is False
res = self.client.get(self.url)
assert res.status_code == 403

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, ClubRole, Membership from club.models import Club, Membership
from core.baker_recipes import subscriber_user from core.baker_recipes import subscriber_user
@@ -12,12 +12,7 @@ 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( baker.make(Membership, club=club, user=user, role=7)
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}),
@@ -37,12 +32,7 @@ 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( baker.make(Membership, club=club, user=user, role=3)
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,10 +3,9 @@ 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, ClubRole, Mailing, Membership from club.models import Club, Mailing, Membership
from core.models import User from core.models import User
@@ -26,7 +25,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=baker.make(ClubRole, club=cls.club, is_board=True), role=settings.SITH_CLUB_ROLES_ID["Board member"],
).save() ).save()
def test_mailing_list_add_no_moderation(self): def test_mailing_list_add_no_moderation(self):

View File

@@ -1,20 +1,20 @@
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
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 localdate, localtime, now from django.utils.timezone import localdate, localtime, now
from model_bakery import baker, seq 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, ClubRole, Membership from club.models import Club, 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,22 +75,17 @@ 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)
board_role, member_role = baker.make( membership = baker.make(Membership, end_date=None, user=user, role=5)
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=member_role) # from board to simple member user.memberships.update(role=1) # 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=board_role) # from member to board user.memberships.update(role=5) # 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)
@@ -101,17 +96,7 @@ 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)
club = baker.make(Club) memberships = baker.make(Membership, role=iter([1, 5]), user=user, _quantity=2)
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,
@@ -127,20 +112,13 @@ 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(Membership, role=iter(roles[:4]), club=cls.club_a, _quantity=4), *baker.make(
*baker.make(Membership, role=iter(roles[4:]), club=cls.club_b, _quantity=4), Membership, role=iter([7, 3, 3, 1]), club=cls.club_a, _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):
@@ -162,7 +140,7 @@ class TestMembershipEditableBy(TestCase):
class TestMembership(TestClub): class TestMembership(TestClub):
def assert_membership_started_today(self, user: User, role: ClubRole): def assert_membership_started_today(self, user: User, role: int):
"""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
@@ -211,27 +189,21 @@ class TestMembership(TestClub):
"Marquer comme ancien", "Marquer comme ancien",
] ]
rows = table.find("tbody").find_all("tr") rows = table.find("tbody").find_all("tr")
memberships = ( memberships = self.club.members.ongoing().order_by("-role")
self.club.members.ongoing() for row, membership in zip(
.order_by("role__order") rows, memberships.select_related("user"), strict=False
.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 == membership.role.name assert cols[1].text == settings.SITH_CLUB_ROLES[membership.role]
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 ( if membership.role < 3 or membership.user_id == self.simple_board_member.id:
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 = {
@@ -247,15 +219,14 @@ 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, self.new_members_url, {"user": self.subscriber.id, "role": 3}
{"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=self.board_role) self.assert_membership_started_today(self.subscriber, role=3)
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
@@ -263,7 +234,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": self.member_role}, data={"user": user.id, "role": 1},
request_user=self.root, request_user=self.root,
club=self.club, club=self.club,
) )
@@ -284,7 +255,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": self.member_role}, {"users": self.simple_board_member.id, "role": current_membership.role + 1},
) )
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()
@@ -303,7 +274,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": self.member_role}, data={"user": members, "role": 1},
request_user=self.root, request_user=self.root,
club=self.club, club=self.club,
) )
@@ -317,6 +288,44 @@ class TestMembership(TestClub):
self.club.refresh_from_db() self.club.refresh_from_db()
assert self.club.members.count() == nb_memberships assert self.club.members.count() == nb_memberships
def test_president_add_members(self):
"""Test that the president of the club can add members."""
president = self.club.members.get(role=10).user
nb_club_membership = self.club.members.count()
nb_subscriber_memberships = self.subscriber.memberships.count()
self.client.force_login(president)
response = self.client.post(
self.new_members_url, {"user": self.subscriber.id, "role": 9}
)
assert response.status_code == 200
assert response.headers.get("HX-Redirect", "") == reverse(
"club:club_members", kwargs={"club_id": self.club.id}
)
self.club.refresh_from_db()
self.subscriber.refresh_from_db()
assert self.club.members.count() == nb_club_membership + 1
assert self.subscriber.memberships.count() == nb_subscriber_memberships + 1
self.assert_membership_started_today(self.subscriber, role=9)
def test_add_member_greater_role(self):
"""Test that a member of the club member cannot create
a membership with a greater role than its own.
"""
form = ClubAddMemberForm(
data={"user": self.subscriber.id, "role": 10},
request_user=self.simple_board_member,
club=self.club,
)
nb_memberships = self.club.members.count()
assert not form.is_valid()
assert form.errors == {
"role": ["Sélectionnez un choix valide. 10 n\u2019en fait pas partie."]
}
self.club.refresh_from_db()
assert nb_memberships == self.club.members.count()
assert not self.subscriber.memberships.filter(club=self.club).exists()
def test_add_member_without_role(self): def test_add_member_without_role(self):
"""Test that trying to add members without specifying their role fails.""" """Test that trying to add members without specifying their role fails."""
form = ClubAddMemberForm( form = ClubAddMemberForm(
@@ -327,9 +336,8 @@ 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": role.id}, data={"user": self.simple_board_member, "role": 3},
request_user=self.root, request_user=self.root,
club=self.club, club=self.club,
) )
@@ -340,27 +348,22 @@ 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( simple_member = baker.make(Membership, club=self.club, role=1).user
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": self.member_role.id}, data={"user": subscriber_user.make(), "role": 1},
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": [ "role": ["Sélectionnez un choix valide. 1 n\u2019en fait pas partie."]
"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=self.member_role) baker.make(Membership, club=self.club, user=user, role=1)
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
@@ -379,10 +382,9 @@ 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)
role = baker.make(ClubRole, club=self.club, is_board=True) membership = baker.make(Membership, club=self.club, role=2, end_date=None)
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()
@@ -392,9 +394,7 @@ 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( membership = self.president.memberships.filter(club=self.club).first()
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,9 +436,7 @@ 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( baker.make(Membership, user=user, club=self.club, end_date=None, role=3)
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())
@@ -449,20 +447,18 @@ 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( 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)
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=self.board_role Membership, club=self.club, user=self.subscriber, role=3
) )
assert self.subscriber.groups.contains(self.club.members_group) assert self.subscriber.groups.contains(self.club.members_group)
assert self.subscriber.groups.contains(self.club.board_group) assert self.subscriber.groups.contains(self.club.board_group)
membership.role = self.member_role membership.role = 1
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)
@@ -475,11 +471,7 @@ class TestMembership(TestClub):
# make sli a board member # make sli a board member
self.sli.memberships.all().delete() self.sli.memberships.all().delete()
Membership( Membership(club=self.ae, user=self.sli, role=3).save()
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):
@@ -505,7 +497,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})
@@ -532,50 +524,6 @@ def test_membership_delete(client: Client):
assert not Membership.objects.filter(id=membership.id).exists() assert not Membership.objects.filter(id=membership.id).exists()
@pytest.mark.django_db
class TestAddMemberForm(TestCase):
@classmethod
def setUpTestData(cls):
cls.club = baker.make(Club)
cls.roles = baker.make(
ClubRole,
club=cls.club,
is_board=iter([True, True, True, True, False, False]),
is_presidency=iter([True, True, False, False, False, False]),
order=seq(0),
_quantity=6,
_bulk_create=True,
)
cls.roles[-1].is_active = False
cls.roles[-1].save()
def test_admin(self):
"""Test that admin users can give any active role."""
user = baker.make(
User, user_permissions=[Permission.objects.get(codename="add_membership")]
)
form = ClubAddMemberForm(request_user=user, club=self.club)
assert list(form.fields["role"].queryset) == self.roles[:-1]
def test_president(self):
"""Test that someone with a presidency role can give any active role."""
user = baker.make(Membership, club=self.club, role=self.roles[0]).user
form = ClubAddMemberForm(request_user=user, club=self.club)
assert list(form.fields["role"].queryset) == self.roles[:-1]
def test_board_member(self):
"""Test that someone with a board role can give lower active role."""
user = baker.make(Membership, club=self.club, role=self.roles[2]).user
form = ClubAddMemberForm(request_user=user, club=self.club)
assert list(form.fields["role"].queryset) == self.roles[3:-1]
def test_simple_member(self):
"""Test that someone with a non-board role cannot give roles."""
user = baker.make(Membership, club=self.club, role=self.roles[4]).user
form = ClubAddMemberForm(request_user=user, club=self.club)
assert list(form.fields["role"].queryset) == []
@pytest.mark.django_db @pytest.mark.django_db
class TestJoinClub: class TestJoinClub:
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@@ -583,64 +531,55 @@ class TestJoinClub:
cache.clear() cache.clear()
@pytest.mark.parametrize( @pytest.mark.parametrize(
("user_factory", "board_role", "errors"), ("user_factory", "role", "errors"),
[ [
( (
subscriber_user.make, subscriber_user.make,
True, 2,
{ {
"role": [ "role": [
"Sélectionnez un choix valide. " "Sélectionnez un choix valide. 2 n\u2019en fait pas partie."
"Ce choix ne fait pas partie de ceux disponibles."
] ]
}, },
), ),
( (
lambda: baker.make(User), lambda: baker.make(User),
False, 1,
{"__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], board_role, errors: dict self, user_factory: Callable[[], User], role: int, errors: dict
): ):
club = baker.make(Club) club = baker.make(Club)
user = user_factory() user = user_factory()
role = baker.make(ClubRole, club=club, is_board=board_role) form = JoinClubForm(club=club, request_user=user, data={"role": 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()
role = baker.make(ClubRole, is_board=False) baker.make(Membership, user=user, club=club)
baker.make(Membership, user=user, club=role.club) form = JoinClubForm(club=club, request_user=user, data={"role": 1})
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()
role = baker.make(ClubRole, is_board=False) form = JoinClubForm(club=club, request_user=user, data={"role": 1})
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=role.club).exists() assert Membership.objects.ongoing().filter(user=user, club=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 = baker.make( roles = [1, 1, 1, 2, 2, 4, 4, 5, 7, 9, 10]
ClubRole,
club=club,
is_board=itertools.cycle([True, True, False]),
order=seq(0),
_quantity=10,
_bulk_create=True,
)
cls.memberships = baker.make( cls.memberships = baker.make(
Membership, Membership,
role=iter(roles), role=iter(roles),
@@ -665,11 +604,3 @@ class TestOldMembersView(TestCase):
self.client.force_login(baker.make(User)) self.client.force_login(baker.make(User))
res = self.client.get(self.url) res = self.client.get(self.url)
assert res.status_code == 403 assert res.status_code == 403
def test_context_data(self):
# mark a membership as not ended, to make sure it is excluded from the result
self.memberships[0].end_date = None
self.memberships[0].save()
self.client.force_login(subscriber_user.make())
res = self.client.get(self.url)
assert list(res.context_data.get("old_members")) == self.memberships[1:]

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, ClubRole, Membership from club.models import Club, 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,12 +59,7 @@ 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( baker.make(Membership, user=user, club=club, role=3)
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, ClubRole, Membership from club.models import Club, 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,10 +19,7 @@ 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, Membership, user=cls.user, start_date=localdate() - timedelta(days=2)
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

@@ -35,10 +35,6 @@ from club.views import (
ClubPageEditView, ClubPageEditView,
ClubPageHistView, ClubPageHistView,
ClubRevView, ClubRevView,
ClubRoleBoardCreateView,
ClubRoleMemberCreateView,
ClubRolePresidencyCreateView,
ClubRoleUpdateView,
ClubSellingCSVView, ClubSellingCSVView,
ClubSellingView, ClubSellingView,
ClubToolsView, ClubToolsView,
@@ -75,22 +71,6 @@ urlpatterns = [
ClubOldMembersView.as_view(), ClubOldMembersView.as_view(),
name="club_old_members", name="club_old_members",
), ),
path("<int:club_id>/role/", ClubRoleUpdateView.as_view(), name="club_roles"),
path(
"<int:club_id>/role/new/president/",
ClubRolePresidencyCreateView.as_view(),
name="new_role_president",
),
path(
"<int:club_id>/role/new/board/",
ClubRoleBoardCreateView.as_view(),
name="new_role_board",
),
path(
"<int:club_id>/role/new/member/",
ClubRoleMemberCreateView.as_view(),
name="new_role_member",
),
path("<int:club_id>/sellings/", ClubSellingView.as_view(), name="club_sellings"), path("<int:club_id>/sellings/", ClubSellingView.as_view(), name="club_sellings"),
path( path(
"<int:club_id>/sellings/csv/", ClubSellingCSVView.as_view(), name="sellings_csv" "<int:club_id>/sellings/csv/", ClubSellingCSVView.as_view(), name="sellings_csv"

View File

@@ -28,11 +28,8 @@ import csv
import itertools import itertools
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from django.contrib.auth.mixins import ( from django.conf import settings
LoginRequiredMixin, from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
PermissionRequiredMixin,
UserPassesTestMixin,
)
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
from django.core.paginator import InvalidPage, Paginator from django.core.paginator import InvalidPage, Paginator
@@ -47,26 +44,18 @@ from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView, View from django.views.generic import DetailView, ListView, View
from django.views.generic.detail import SingleObjectMixin from django.views.generic.detail import SingleObjectMixin
from django.views.generic.edit import ( from django.views.generic.edit import CreateView, DeleteView, UpdateView
CreateView,
DeleteView,
FormMixin,
UpdateView,
)
from club.forms import ( from club.forms import (
ClubAddMemberForm, ClubAddMemberForm,
ClubAdminEditForm, ClubAdminEditForm,
ClubEditForm, ClubEditForm,
ClubOldMemberForm, ClubOldMemberForm,
ClubRoleCreateForm,
ClubRoleFormSet,
ClubSearchForm,
JoinClubForm, JoinClubForm,
MailingForm, MailingForm,
SellingsForm, SellingsForm,
) )
from club.models import Club, ClubRole, Mailing, MailingSubscription, Membership from club.models import Club, Mailing, MailingSubscription, Membership
from com.models import Poster from com.models import Poster
from com.views import ( from com.views import (
PosterCreateBaseView, PosterCreateBaseView,
@@ -77,12 +66,7 @@ from com.views import (
from core.auth.mixins import CanEditMixin, PermissionOrClubBoardRequiredMixin from core.auth.mixins import CanEditMixin, PermissionOrClubBoardRequiredMixin
from core.models import Page, PageRev from core.models import Page, PageRev
from core.views import BasePageEditView, DetailFormView, UseFragmentsMixin from core.views import BasePageEditView, DetailFormView, UseFragmentsMixin
from core.views.mixins import ( from core.views.mixins import FragmentMixin, FragmentRenderer, TabedViewMixin
AllowFragment,
FragmentMixin,
FragmentRenderer,
TabedViewMixin,
)
from counter.models import Selling from counter.models import Selling
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -196,41 +180,15 @@ class ClubTabsMixin(TabedViewMixin):
return tab_list return tab_list
class ClubListView(AllowFragment, FormMixin, ListView): class ClubListView(ListView):
"""List the clubs of the AE, with a form to perform basic search. """List the Clubs."""
Notes:
This view is fully public, because we want to advertise as much as possible
the cultural life of the AE.
In accordance with that matter, searching and listing the clubs is done
entirely server-side (no AlpineJS involved) ;
this is done this way in order to be sure the page is the most accessible
and SEO-friendly possible, even if it makes the UX slightly less smooth.
"""
model = Club
template_name = "club/club_list.jinja" template_name = "club/club_list.jinja"
form_class = ClubSearchForm queryset = (
queryset = Club.objects.order_by("name") Club.objects.filter(parent=None).order_by("name").prefetch_related("children")
paginate_by = 20 )
context_object_name = "club_list"
def get_form_kwargs(self):
res = super().get_form_kwargs()
if self.request.method == "GET":
res |= {"data": self.request.GET, "initial": self.request.GET}
return res
def get_queryset(self):
form: ClubSearchForm = self.get_form()
qs = self.queryset
if not form.is_bound:
return qs.filter(is_active=True)
if not form.is_valid():
return qs.none()
if name := form.cleaned_data.get("name"):
qs = qs.filter(name__icontains=name)
if (is_active := form.cleaned_data.get("club_status")) is not None:
qs = qs.filter(is_active=is_active)
return qs
class ClubView(ClubTabsMixin, DetailView): class ClubView(ClubTabsMixin, DetailView):
@@ -360,7 +318,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 not membership.role.is_board and membership.role <= settings.SITH_MAXIMUM_FREE_ROLE
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.
@@ -385,8 +343,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") .order_by("-role")
.select_related("user", "role") .select_related("user")
) )
kwargs["can_end_membership"] = len(editable) > 0 kwargs["can_end_membership"] = len(editable) > 0
return kwargs return kwargs
@@ -414,130 +372,12 @@ 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__order", "description", "-end_date") .order_by("-role", "description", "-end_date")
.select_related("user", "role") .select_related("user")
) )
} }
class ClubRoleUpdateView(
ClubTabsMixin, UserPassesTestMixin, SuccessMessageMixin, UpdateView
):
form_class = ClubRoleFormSet
model = Club
template_name = "club/club_roles.jinja"
pk_url_kwarg = "club_id"
current_tab = "members"
success_message = _("Club roles updated")
@cached_property
def club(self) -> Club:
return self.get_object()
def test_func(self):
return self.club.can_roles_be_edited_by(self.request.user)
def get_form_kwargs(self):
return super().get_form_kwargs() | {"form_kwargs": {"label_suffix": ""}}
def get_success_url(self):
# if the user lost the right to view the role update page
# (because it moved its own role out of the presidency),
# redirect to the club member page, else stay on the same page.
if self.club.can_roles_be_edited_by(self.request.user):
return self.request.path
return reverse("club:club_members", kwargs={"club_id": self.club.id})
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {
"user_role": ClubRole.objects.filter(
club=self.object,
members__user=self.request.user,
members__end_date=None,
)
.values_list("id", flat=True)
.first()
}
class ClubRoleBaseCreateView(UserPassesTestMixin, SuccessMessageMixin, CreateView):
"""View to create a new Club Role, using [][club.forms.ClubRoleCreateForm].
This view isn't meant to be called directly, but rather subclassed for each
type of role that can exist :
- `[ClubRolePresidencyCreateView][club.views.ClubRolePresidencyCreateView]`
to create a presidency role
- `[ClubRoleBoardCreateView][club.views.ClubRoleBoardCreateView]`
to create a board role
- `[ClubRoleMemberCreateView][club.views.ClubRoleMemberCreateView]`
to create a member role
Each subclass have to override the following variables :
- `is_presidency` and `is_board`, indicating what type of role
the view creates.
- `role_description`, which is the title of the page, indication
the user what kind of role is being created.
This way, we are making sure the correct type of role will
be created, without bothering the user with the implementation details.
"""
form_class = ClubRoleCreateForm
model = ClubRole
template_name = "core/create.jinja"
success_message = _("Role %(name)s created")
role_description = ""
is_presidency: bool
is_board: bool
@cached_property
def club(self):
return get_object_or_404(Club, id=self.kwargs["club_id"])
def test_func(self):
return self.request.user.is_authenticated and (
self.request.user.has_perm("club.add_clubrole")
or self.club.members.filter(
user=self.request.user, role__is_presidency=True
).exists()
)
def get_form_kwargs(self):
return super().get_form_kwargs() | {
"club": self.club,
"is_presidency": self.is_presidency,
"is_board": self.is_board,
}
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {
"object_name": self.role_description
}
def get_success_url(self):
return reverse("club:club_roles", kwargs={"club_id": self.club.id})
class ClubRolePresidencyCreateView(ClubRoleBaseCreateView):
is_presidency = True
is_board = True
role_description = _("club role \u2013 presidency")
class ClubRoleBoardCreateView(ClubRoleBaseCreateView):
is_presidency = False
is_board = True
role_description = _("club role \u2013 board")
class ClubRoleMemberCreateView(ClubRoleBaseCreateView):
is_presidency = False
is_board = False
role_description = _("club role \u2013 member")
class ClubSellingView(ClubTabsMixin, CanEditMixin, DetailFormView): class ClubSellingView(ClubTabsMixin, CanEditMixin, DetailFormView):
"""Sales of a club.""" """Sales of a club."""
@@ -704,11 +544,6 @@ class ClubCreateView(PermissionRequiredMixin, CreateView):
template_name = "core/create.jinja" template_name = "core/create.jinja"
permission_required = "club.add_club" permission_required = "club.add_club"
def form_valid(self, form):
res = super().form_valid(form)
self.object.create_default_roles()
return res
class MembershipSetOldView(CanEditMixin, SingleObjectMixin, View): class MembershipSetOldView(CanEditMixin, SingleObjectMixin, View):
"""Set a membership as being old.""" """Set a membership as being old."""
@@ -889,7 +724,9 @@ 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.ongoing().filter(role__is_board=True) members = club.members.filter(
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, ClubRole, Membership from club.models import Club, 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,8 +214,7 @@ 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()
role = baker.make(ClubRole, club=cls.club, is_board=True) baker.make(Membership, user=cls.user, club=cls.club, role=5)
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

@@ -244,8 +244,9 @@ class NewsListView(TemplateView):
.filter( .filter(
date_of_birth__month=localdate().month, date_of_birth__month=localdate().month,
date_of_birth__day=localdate().day, date_of_birth__day=localdate().day,
role__in=["STUDENT", "FORMER STUDENT"], is_viewable=True,
) )
.filter(role__in=["STUDENT", "FORMER STUDENT"])
.order_by("-date_of_birth"), .order_by("-date_of_birth"),
key=lambda u: u.date_of_birth.year, key=lambda u: u.date_of_birth.year,
) )
@@ -503,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 not m.role.is_board: if m is None or m.role <= settings.SITH_MAXIMUM_FREE_ROLE:
form.add_error( form.add_error(
"club", "club",
ValidationError( ValidationError(

View File

@@ -63,7 +63,6 @@ class UserAdmin(admin.ModelAdmin):
"scrub_pict", "scrub_pict",
"user_permissions", "user_permissions",
"groups", "groups",
"whitelisted_users",
) )
inlines = (UserBanInline,) inlines = (UserBanInline,)
search_fields = ["first_name", "last_name", "username"] search_fields = ["first_name", "last_name", "username"]

View File

@@ -123,7 +123,7 @@ class GroupController(ControllerBase):
) )
@paginate(PageNumberPaginationExtra, page_size=50) @paginate(PageNumberPaginationExtra, page_size=50)
def search_group(self, search: Annotated[str, MinLen(1)]): def search_group(self, search: Annotated[str, MinLen(1)]):
return Group.objects.filter(name__icontains=search).values() return Group.objects.filter(name__icontains=search).order_by("name").values()
DepthValue = Annotated[int, Ge(0), Le(10)] DepthValue = Annotated[int, Ge(0), Le(10)]

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, foreign_key, related from model_bakery.recipe import Recipe, related
from club.models import ClubRole, Membership from club.models import Membership
from core.models import Group, User from core.models import Group, User
from subscription.models import Subscription from subscription.models import Subscription
@@ -52,9 +52,7 @@ 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=foreign_key( role=settings.SITH_CLUB_ROLES_ID["Board member"],
Recipe(ClubRole, club_id=settings.SITH_MAIN_CLUB_ID, is_board=True)
),
) )
board_user = Recipe( board_user = Recipe(

View File

@@ -39,16 +39,12 @@ class Command(BaseCommand):
return None return None
return xapian.version_string() return xapian.version_string()
def _desired_version(self) -> tuple[str, str, str]: def _desired_version(self) -> str:
with open( with open(
Path(__file__).parent.parent.parent.parent / "pyproject.toml", "rb" Path(__file__).parent.parent.parent.parent / "pyproject.toml", "rb"
) as f: ) as f:
pyproject = tomli.load(f) pyproject = tomli.load(f)
return ( return pyproject["tool"]["xapian"]["version"]
pyproject["tool"]["xapian"]["version"],
pyproject["tool"]["xapian"]["core-sha256"],
pyproject["tool"]["xapian"]["bindings-sha256"],
)
def handle(self, *args, force: bool, **options): def handle(self, *args, force: bool, **options):
if not os.environ.get("VIRTUAL_ENV", None): if not os.environ.get("VIRTUAL_ENV", None):
@@ -57,7 +53,7 @@ class Command(BaseCommand):
) )
return return
desired, core_checksum, bindings_checksum = self._desired_version() desired = self._desired_version()
if desired == self._current_version(): if desired == self._current_version():
if not force: if not force:
self.stdout.write( self.stdout.write(
@@ -69,12 +65,7 @@ class Command(BaseCommand):
f"Installing xapian version {desired} at {os.environ['VIRTUAL_ENV']}" f"Installing xapian version {desired} at {os.environ['VIRTUAL_ENV']}"
) )
subprocess.run( subprocess.run(
[ [str(Path(__file__).parent / "install_xapian.sh"), desired],
str(Path(__file__).parent / "install_xapian.sh"),
desired,
core_checksum,
bindings_checksum,
],
env=dict(os.environ), env=dict(os.environ),
check=True, check=True,
) )

View File

@@ -1,11 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Originates from https://gist.github.com/jorgecarleitao/ab6246c86c936b9c55fd # Originates from https://gist.github.com/jorgecarleitao/ab6246c86c936b9c55fd
# first argument of the script is Xapian version (e.g. 1.2.19) # first argument of the script is Xapian version (e.g. 1.2.19)
# second argument of the script is core sha256
# second argument of the script is binding sha256
VERSION="$1" VERSION="$1"
CORE_SHA256="$2"
BINDINGS_SHA256="$3"
# Cleanup env vars for auto discovery mechanism # Cleanup env vars for auto discovery mechanism
unset CPATH unset CPATH
@@ -25,15 +21,9 @@ BINDINGS=xapian-bindings-$VERSION
# download # download
echo "Downloading source..." echo "Downloading source..."
curl -O "https://oligarchy.co.uk/xapian/$VERSION/${CORE}.tar.xz" || exit 1 curl -O "https://oligarchy.co.uk/xapian/$VERSION/${CORE}.tar.xz"
echo "${CORE_SHA256} ${CORE}.tar.xz" | sha256sum -c - || exit 1
curl -O "https://oligarchy.co.uk/xapian/$VERSION/${BINDINGS}.tar.xz" curl -O "https://oligarchy.co.uk/xapian/$VERSION/${BINDINGS}.tar.xz"
echo "${BINDINGS_SHA256} ${BINDINGS}.tar.xz" | sha256sum -c - || exit 1
# extract # extract
echo "Extracting source..." echo "Extracting source..."
tar xf "${CORE}.tar.xz" tar xf "${CORE}.tar.xz"

View File

@@ -16,7 +16,7 @@
# details. # details.
# #
# You should have received a copy of the GNU General Public License along with # You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Software Foundation, Inc., 59 Temple # this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA. # Place - Suite 330, Boston, MA 02111-1307, USA.
# #
# #
@@ -36,12 +36,19 @@ 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, ClubRole, Membership from club.models import Club, 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
from core.utils import resize_image from core.utils import resize_image
from counter.models import Counter, Product, ProductType, ReturnableProduct, StudentCard from counter.models import (
Counter,
Price,
Product,
ProductType,
ReturnableProduct,
StudentCard,
)
from election.models import Candidature, Election, ElectionList, Role from election.models import Candidature, Election, ElectionList, Role
from forum.models import Forum from forum.models import Forum
from pedagogy.models import UE from pedagogy.models import UE
@@ -62,13 +69,6 @@ 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] = (
@@ -117,17 +117,29 @@ class Command(BaseCommand):
p.save(force_lock=True) p.save(force_lock=True)
club_root = SithFile.objects.create(name="clubs", owner=root) club_root = SithFile.objects.create(name="clubs", owner=root)
sas = SithFile.objects.create(name="SAS", owner=root, is_in_sas=True) sas = SithFile.objects.create(name="SAS", owner=root)
clubs = self._create_clubs() main_club = Club.objects.create(
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=clubs.pdf, type="BAR").save() Counter(id=bar_id, name=bar_name, club=bar_club, type="BAR").save()
self.reset_index("counter") self.reset_index("counter")
counters = [ counters = [
Counter(name="Eboutic", club=clubs.ae, type="EBOUTIC"), Counter(name="Eboutic", club=main_club, type="EBOUTIC"),
Counter(name="AE", club=clubs.ae, type="OFFICE"), Counter(name="AE", club=main_club, type="OFFICE"),
Counter(name="Vidage comptes AE", club=clubs.ae, type="OFFICE"), Counter(name="Vidage comptes AE", club=main_club, type="OFFICE"),
] ]
Counter.objects.bulk_create(counters) Counter.objects.bulk_create(counters)
bar_groups = [] bar_groups = []
@@ -310,165 +322,68 @@ 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()
Membership.objects.create( # Clubs
user=skia, club=clubs.ae, role=clubs.ae.roles.get(name="Respo Info") Club.objects.create(
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=clubs.pdf, club=bar_club,
start_date=localdate(), start_date=localdate(),
role=clubs.pdf.roles.get(name="Membre du bureau"), role=settings.SITH_CLUB_ROLES_ID["Board member"],
) )
Membership.objects.create( Membership.objects.create(
user=sli, user=sli,
club=clubs.troll, club=troll,
role=clubs.troll.roles.get(name="Vice-Président⸱e"), role=9,
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=clubs.troll, club=troll,
role=clubs.troll.roles.get(name="Président⸱e"), role=10,
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=clubs.troll, club=troll,
role=clubs.troll.roles.get(name="Membre du bureau"), role=2,
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=clubs.troll, club=troll,
role=clubs.troll.roles.get(name="Membre du bureau"), role=2,
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),
) )
p = ProductType.objects.create(name="Bières bouteilles") self._create_products(groups, main_club, refound)
c = ProductType.objects.create(name="Cotisations")
r = ProductType.objects.create(name="Rechargements")
verre = ProductType.objects.create(name="Verre")
cotis = Product.objects.create(
name="Cotis 1 semestre",
code="1SCOTIZ",
product_type=c,
purchase_price="15",
selling_price="15",
special_selling_price="15",
club=clubs.ae,
)
cotis2 = Product.objects.create(
name="Cotis 2 semestres",
code="2SCOTIZ",
product_type=c,
purchase_price="28",
selling_price="28",
special_selling_price="28",
club=clubs.ae,
)
refill = Product.objects.create(
name="Rechargement 15 €",
code="15REFILL",
product_type=r,
purchase_price="15",
selling_price="15",
special_selling_price="15",
club=clubs.ae,
)
barb = Product.objects.create(
name="Barbar",
code="BARB",
product_type=p,
purchase_price="1.50",
selling_price="1.7",
special_selling_price="1.6",
club=clubs.ae,
limit_age=18,
)
cble = Product.objects.create(
name="Chimay Bleue",
code="CBLE",
product_type=p,
purchase_price="1.50",
selling_price="1.7",
special_selling_price="1.6",
club=clubs.ae,
limit_age=18,
)
cons = Product.objects.create(
name="Consigne Eco-cup",
code="CONS",
product_type=verre,
purchase_price="1",
selling_price="1",
special_selling_price="1",
club=clubs.ae,
)
dcons = Product.objects.create(
name="Déconsigne Eco-cup",
code="DECO",
product_type=verre,
purchase_price="-1",
selling_price="-1",
special_selling_price="-1",
club=clubs.ae,
)
cors = Product.objects.create(
name="Corsendonk",
code="CORS",
product_type=p,
purchase_price="1.50",
selling_price="1.7",
special_selling_price="1.6",
club=clubs.ae,
limit_age=18,
)
carolus = Product.objects.create(
name="Carolus",
code="CARO",
product_type=p,
purchase_price="1.50",
selling_price="1.7",
special_selling_price="1.6",
club=clubs.ae,
limit_age=18,
)
Product.objects.create(
name="remboursement",
code="REMBOURS",
purchase_price="0",
selling_price="0",
special_selling_price="0",
club=clubs.refound,
)
groups.subscribers.products.add(
cotis, cotis2, refill, barb, cble, cors, carolus
)
groups.old_subscribers.products.add(cotis, cotis2)
mde = Counter.objects.get(name="MDE") Counter.objects.create(name="Carte AE", club=refound, type="OFFICE")
mde.products.add(barb, cble, cons, dcons)
eboutic = Counter.objects.get(name="Eboutic")
eboutic.products.add(barb, cotis, cotis2, refill)
Counter.objects.create(name="Carte AE", club=clubs.refound, type="OFFICE")
ReturnableProduct.objects.create(
product=cons, returned_product=dcons, max_return=3
)
# Add barman to counter # Add barman to counter
Counter.sellers.through.objects.bulk_create( Counter.sellers.through.objects.bulk_create(
[ [
Counter.sellers.through(counter_id=2, user=krophil), Counter.sellers.through(counter_id=1, user=skia), # MDE
Counter.sellers.through(counter=mde, user=skia), Counter.sellers.through(counter_id=2, user=krophil), # Foyer
] ]
) )
@@ -482,7 +397,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(clubs.ae.board_group) el.edit_groups.add(main_club.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)
@@ -555,7 +470,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=clubs.pdf, club=bar_club,
author=subscriber, author=subscriber,
is_published=True, is_published=True,
moderator=skia, moderator=skia,
@@ -573,7 +488,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=clubs.pdf, club=bar_club,
author=subscriber, author=subscriber,
is_published=True, is_published=True,
moderator=skia, moderator=skia,
@@ -589,7 +504,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=clubs.pdf, club=bar_club,
author=subscriber, author=subscriber,
is_published=True, is_published=True,
moderator=skia, moderator=skia,
@@ -605,7 +520,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=clubs.pdf, club=bar_club,
author=subscriber, author=subscriber,
is_published=True, is_published=True,
moderator=skia, moderator=skia,
@@ -623,7 +538,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=clubs.troll, club=troll,
author=subscriber, author=subscriber,
is_published=True, is_published=True,
moderator=skia, moderator=skia,
@@ -724,6 +639,131 @@ class Command(BaseCommand):
] ]
) )
def _create_products(
self, groups: PopulatedGroups, main_club: Club, refound_club: Club
):
beers_type, cotis_type, refill_type, verre_type = (
ProductType.objects.bulk_create(
[
ProductType(name="Bières bouteilles"),
ProductType(name="Cotisations"),
ProductType(name="Rechargements"),
ProductType(name="Verre"),
]
)
)
cotis = Product.objects.create(
name="Cotis 1 semestre",
code="1SCOTIZ",
product_type=cotis_type,
purchase_price=15,
club=main_club,
)
cotis2 = Product.objects.create(
name="Cotis 2 semestres",
code="2SCOTIZ",
product_type=cotis_type,
purchase_price="28",
club=main_club,
)
refill = Product.objects.create(
name="Rechargement 15 €",
code="15REFILL",
product_type=refill_type,
purchase_price=15,
club=main_club,
)
barb = Product.objects.create(
name="Barbar",
code="BARB",
product_type=beers_type,
purchase_price="1.50",
club=main_club,
limit_age=18,
)
cble = Product.objects.create(
name="Chimay Bleue",
code="CBLE",
product_type=beers_type,
purchase_price="1.50",
club=main_club,
limit_age=18,
)
cons = Product.objects.create(
name="Consigne Eco-cup",
code="CONS",
product_type=verre_type,
purchase_price="1",
club=main_club,
)
dcons = Product.objects.create(
name="Déconsigne Eco-cup",
code="DECO",
product_type=verre_type,
purchase_price="-1",
club=main_club,
)
cors = Product.objects.create(
name="Corsendonk",
code="CORS",
product_type=beers_type,
purchase_price="1.50",
club=main_club,
limit_age=18,
)
carolus = Product.objects.create(
name="Carolus",
code="CARO",
product_type=beers_type,
purchase_price="1.50",
club=main_club,
limit_age=18,
)
Product.objects.create(
name="remboursement",
code="REMBOURS",
purchase_price=0,
club=refound_club,
)
ReturnableProduct.objects.create(
product=cons, returned_product=dcons, max_return=3
)
mde = Counter.objects.get(name="MDE")
mde.products.add(barb, cble, cons, dcons)
eboutic = Counter.objects.get(name="Eboutic")
eboutic.products.add(barb, cotis, cotis2, refill)
cotis, cotis2, refill, barb, cble, cors, carolus, cons, dcons = (
Price.objects.bulk_create(
[
Price(product=cotis, amount=15),
Price(product=cotis2, amount=28),
Price(product=refill, amount=15),
Price(product=barb, amount=1.7),
Price(product=cble, amount=1.7),
Price(product=cors, amount=1.7),
Price(product=carolus, amount=1.7),
Price(product=cons, amount=1),
Price(product=dcons, amount=-1),
]
)
)
Price.groups.through.objects.bulk_create(
[
Price.groups.through(price=cotis, group=groups.subscribers),
Price.groups.through(price=cotis2, group=groups.subscribers),
Price.groups.through(price=refill, group=groups.subscribers),
Price.groups.through(price=barb, group=groups.subscribers),
Price.groups.through(price=cble, group=groups.subscribers),
Price.groups.through(price=cors, group=groups.subscribers),
Price.groups.through(price=carolus, group=groups.subscribers),
Price.groups.through(price=cotis, group=groups.old_subscribers),
Price.groups.through(price=cotis2, group=groups.old_subscribers),
Price.groups.through(price=cons, group=groups.old_subscribers),
Price.groups.through(price=dcons, group=groups.old_subscribers),
]
)
def _create_profile_pict(self, user: User): def _create_profile_pict(self, user: User):
path = self.SAS_FIXTURE_PATH / "Family" / f"{user.username}.jpg" path = self.SAS_FIXTURE_PATH / "Family" / f"{user.username}.jpg"
file = resize_image(Image.open(path), 400, "WEBP") file = resize_image(Image.open(path), 400, "WEBP")
@@ -760,57 +800,6 @@ 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",
"view_hidden_user",
]
)
)
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,12 +11,13 @@ 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, ClubRole, Membership from club.models import Club, 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,
Customer, Customer,
Permanency, Permanency,
Price,
Product, Product,
ProductType, ProductType,
Refilling, Refilling,
@@ -172,25 +173,20 @@ 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]):
roles: list[ClubRole] = list(club.roles.all()) def zip_roles(users: list[User]) -> Iterator[tuple[User, int]]:
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
for _role in important_roles: while (role := next(roles)) > 2:
# 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], simple_board_role yield users[user_idx], 2
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, random.choices(member_roles, weights=(0.8, 0.2))[0] yield remaining, 1
memberships = [] memberships = []
old_members = old_members.copy() old_members = old_members.copy()
@@ -202,14 +198,19 @@ 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(roles), role=random.choice(list(settings.SITH_CLUB_ROLES.keys())),
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(start_date=start, user=member, role=role, club=club) Membership(
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)
@@ -278,6 +279,7 @@ class Command(BaseCommand):
# 2/3 of the products are owned by AE # 2/3 of the products are owned by AE
clubs = [ae, ae, ae, ae, ae, ae, *other_clubs] clubs = [ae, ae, ae, ae, ae, ae, *other_clubs]
products = [] products = []
prices = []
buying_groups = [] buying_groups = []
selling_places = [] selling_places = []
for _ in range(200): for _ in range(200):
@@ -288,25 +290,28 @@ class Command(BaseCommand):
product_type=random.choice(categories), product_type=random.choice(categories),
code="".join(self.faker.random_letters(length=random.randint(4, 8))), code="".join(self.faker.random_letters(length=random.randint(4, 8))),
purchase_price=price, purchase_price=price,
selling_price=price,
special_selling_price=price - min(0.5, price),
club=random.choice(clubs), club=random.choice(clubs),
limit_age=0 if random.random() > 0.2 else 18, limit_age=0 if random.random() > 0.2 else 18,
archived=bool(random.random() > 0.7), archived=self.faker.boolean(60),
) )
products.append(product) products.append(product)
# there will be products without buying groups for i in range(random.randint(0, 3)):
# but there are also such products in the real database product_price = Price(
buying_groups.extend( amount=price, product=product, is_always_shown=self.faker.boolean()
Product.buying_groups.through(product=product, group=group) )
for group in random.sample(groups, k=random.randint(0, 3)) # prices for non-subscribers will be higher than for subscribers
price *= 1.2
prices.append(product_price)
buying_groups.append(
Price.groups.through(price=product_price, group=groups[i])
) )
selling_places.extend( selling_places.extend(
Counter.products.through(counter=counter, product=product) Counter.products.through(counter=counter, product=product)
for counter in random.sample(counters, random.randint(0, 4)) for counter in random.sample(counters, random.randint(0, 4))
) )
Product.objects.bulk_create(products) Product.objects.bulk_create(products)
Product.buying_groups.through.objects.bulk_create(buying_groups) Price.objects.bulk_create(prices)
Price.groups.through.objects.bulk_create(buying_groups)
Counter.products.through.objects.bulk_create(selling_places) Counter.products.through.objects.bulk_create(selling_places)
def create_sales(self, sellers: list[User]): def create_sales(self, sellers: list[User]):
@@ -320,7 +325,7 @@ class Command(BaseCommand):
) )
) )
) )
products = list(Product.objects.all()) prices = list(Price.objects.select_related("product").all())
counters = list( counters = list(
Counter.objects.filter(name__in=["Foyer", "MDE", "La Gommette"]) Counter.objects.filter(name__in=["Foyer", "MDE", "La Gommette"])
) )
@@ -330,14 +335,14 @@ class Command(BaseCommand):
# the longer the customer has existed, the higher the mean of nb_products # the longer the customer has existed, the higher the mean of nb_products
mu = 5 + (now().year - customer.since.year) * 2 mu = 5 + (now().year - customer.since.year) * 2
nb_sales = max(0, int(random.normalvariate(mu=mu, sigma=mu * 5))) nb_sales = max(0, int(random.normalvariate(mu=mu, sigma=mu * 5)))
favoured_products = random.sample(products, k=(random.randint(1, 5))) favoured_prices = random.sample(prices, k=(random.randint(1, 5)))
favoured_counter = random.choice(counters) favoured_counter = random.choice(counters)
this_customer_sales = [] this_customer_sales = []
for _ in range(nb_sales): for _ in range(nb_sales):
product = ( price = (
random.choice(favoured_products) random.choice(favoured_prices)
if random.random() > 0.7 if random.random() > 0.7
else random.choice(products) else random.choice(prices)
) )
counter = ( counter = (
favoured_counter favoured_counter
@@ -346,11 +351,11 @@ class Command(BaseCommand):
) )
this_customer_sales.append( this_customer_sales.append(
Selling( Selling(
product=product, product=price.product,
counter=counter, counter=counter,
club_id=product.club_id, club_id=price.product.club_id,
quantity=random.randint(1, 5), quantity=random.randint(1, 5),
unit_price=product.selling_price, unit_price=price.amount,
seller=random.choice(sellers), seller=random.choice(sellers),
customer=customer, customer=customer,
date=make_aware( date=make_aware(

View File

@@ -1,37 +0,0 @@
# Generated by Django 5.2.12 on 2026-03-14 08:39
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("core", "0048_alter_user_options")]
operations = [
migrations.AddField(
model_name="user",
name="whitelisted_users",
field=models.ManyToManyField(
blank=True,
help_text=(
"Even if this profile is hidden, "
"the users in this list will still be able to see it."
),
related_name="visible_by_whitelist",
to=settings.AUTH_USER_MODEL,
verbose_name="whitelisted users",
),
),
migrations.AlterField(
model_name="preferences",
name="show_my_stats",
field=models.BooleanField(
default=False,
help_text=(
"Allow subscribers (or whitelisted users "
"if your profile is hidden) to access your AE account stats."
),
verbose_name="show your stats to others",
),
),
]

View File

@@ -131,7 +131,7 @@ class UserQuerySet(models.QuerySet):
if user.has_perm("core.view_hidden_user"): if user.has_perm("core.view_hidden_user"):
return self return self
if user.has_perm("core.view_user"): if user.has_perm("core.view_user"):
return self.filter(Q(is_viewable=True) | Q(whitelisted_users=user)) return self.filter(is_viewable=True)
if user.is_anonymous: if user.is_anonymous:
return self.none() return self.none()
return self.filter(id=user.id) return self.filter(id=user.id)
@@ -279,16 +279,6 @@ class User(AbstractUser):
), ),
default=True, default=True,
) )
whitelisted_users = models.ManyToManyField(
"User",
related_name="visible_by_whitelist",
verbose_name=_("whitelisted users"),
help_text=_(
"Even if this profile is hidden, "
"the users in this list will still be able to see it."
),
blank=True,
)
godfathers = models.ManyToManyField("User", related_name="godchildren", blank=True) godfathers = models.ManyToManyField("User", related_name="godchildren", blank=True)
objects = CustomUserManager() objects = CustomUserManager()
@@ -528,7 +518,7 @@ class User(AbstractUser):
self.username = user_name self.username = user_name
return user_name return user_name
def is_owner(self, obj: models.Model): def is_owner(self, obj):
"""Determine if the object is owned by the user.""" """Determine if the object is owned by the user."""
if hasattr(obj, "is_owned_by") and obj.is_owned_by(self): if hasattr(obj, "is_owned_by") and obj.is_owned_by(self):
return True return True
@@ -536,7 +526,7 @@ class User(AbstractUser):
return True return True
return self.is_root return self.is_root
def can_edit(self, obj: models.Model): def can_edit(self, obj):
"""Determine if the object can be edited by the user.""" """Determine if the object can be edited by the user."""
if hasattr(obj, "can_be_edited_by") and obj.can_be_edited_by(self): if hasattr(obj, "can_be_edited_by") and obj.can_be_edited_by(self):
return True return True
@@ -550,9 +540,11 @@ class User(AbstractUser):
pks = list(obj.edit_groups.values_list("id", flat=True)) pks = list(obj.edit_groups.values_list("id", flat=True))
if any(self.is_in_group(pk=pk) for pk in pks): if any(self.is_in_group(pk=pk) for pk in pks):
return True return True
if isinstance(obj, User) and obj == self:
return True
return self.is_owner(obj) return self.is_owner(obj)
def can_view(self, obj: models.Model): def can_view(self, obj):
"""Determine if the object can be viewed by the user.""" """Determine if the object can be viewed by the user."""
if hasattr(obj, "can_be_viewed_by") and obj.can_be_viewed_by(self): if hasattr(obj, "can_be_viewed_by") and obj.can_be_viewed_by(self):
return True return True
@@ -571,35 +563,14 @@ class User(AbstractUser):
return True return True
return self.can_edit(obj) return self.can_edit(obj)
def can_be_edited_by(self, user: User): def can_be_edited_by(self, user):
return user == self or user.is_root or user.is_board_member return user.is_root or user.is_board_member
def can_be_viewed_by(self, user: User) -> bool: def can_be_viewed_by(self, user: User) -> bool:
"""Check if the given user can be viewed by this user.
Given users A and B. A can be viewed by B if :
- A and B are the same user
- or B has the permission to view hidden users
- or B can view users in general and A didn't hide its profile
- or B is in A's whitelist.
"""
def is_in_whitelist(u: User):
if (
hasattr(self, "_prefetched_objects_cache")
and "whitelisted_users" in self._prefetched_objects_cache
):
return u in self.whitelisted_users.all()
return self.whitelisted_users.contains(u)
return ( return (
user.id == self.id user.id == self.id
or user.has_perm("core.view_hidden_user") or user.has_perm("core.view_hidden_user")
or ( or (user.has_perm("core.view_user") and self.is_viewable)
user.has_perm("core.view_user")
and (self.is_viewable or is_in_whitelist(user))
)
) )
def get_mini_item(self): def get_mini_item(self):
@@ -779,14 +750,7 @@ class Preferences(models.Model):
User, related_name="_preferences", on_delete=models.CASCADE User, related_name="_preferences", on_delete=models.CASCADE
) )
receive_weekmail = models.BooleanField(_("receive the Weekmail"), default=False) receive_weekmail = models.BooleanField(_("receive the Weekmail"), default=False)
show_my_stats = models.BooleanField( show_my_stats = models.BooleanField(_("show your stats to others"), default=False)
_("show your stats to others"),
help_text=_(
"Allow subscribers (or whitelisted users "
"if your profile is hidden) to access your AE account stats."
),
default=False,
)
notify_on_click = models.BooleanField( notify_on_click = models.BooleanField(
_("get a notification for every click"), default=False _("get a notification for every click"), default=False
) )

View File

@@ -1,136 +1,18 @@
import { import { inheritHtmlElement, registerComponent } from "#core:utils/web-components.ts";
type InheritedHtmlElement,
inheritHtmlElement,
registerComponent,
} from "#core:utils/web-components.ts";
/**
* ElementOnce web components
*
* Those elements ensures that their content is always included only once on a document
* They are compatible with elements that are not managed with our Web Components
**/
export interface ElementOnce<K extends keyof HTMLElementTagNameMap>
extends InheritedHtmlElement<K> {
getElementQuerySelector(): string;
refresh(): void;
}
/**
* Create an abstract class for ElementOnce types Web Components
**/
export function elementOnce<K extends keyof HTMLElementTagNameMap>(tagName: K) {
abstract class ElementOnceImpl
extends inheritHtmlElement(tagName)
implements ElementOnce<K>
{
abstract getElementQuerySelector(): string;
clearNode() {
while (this.firstChild) {
this.removeChild(this.lastChild);
}
}
refresh() {
this.clearNode();
if (document.querySelectorAll(this.getElementQuerySelector()).length === 0) {
this.appendChild(this.node);
}
}
connectedCallback() {
super.connectedCallback(false);
this.refresh();
}
disconnectedCallback() {
// The MutationObserver can't see web components being removed
// It also can't see if something is removed inside after the component gets deleted
// We need to manually clear the containing node to trigger the observer
this.clearNode();
}
}
return ElementOnceImpl;
}
// Set of ElementOnce type components to refresh with the observer
const registeredComponents: Set<string> = new Set();
/**
* Helper to register ElementOnce types Web Components
* It's a wrapper around registerComponent that registers that component on
* a MutationObserver that activates a refresh on them when elements are removed
*
* You are not supposed to unregister an element
**/
export function registerElementOnce(name: string, options?: ElementDefinitionOptions) {
registeredComponents.add(name);
return registerComponent(name, options);
}
// Refresh all ElementOnce components on the document based on the tag name of the removed element
const refreshElement = <
T extends keyof HTMLElementTagNameMap,
K extends keyof HTMLElementTagNameMap,
>(
components: HTMLCollectionOf<ElementOnce<T>>,
removedTagName: K,
) => {
for (const element of components) {
// We can't guess if an element is compatible before we get one
// We exit the function completely if it's not compatible
if (element.inheritedTagName.toUpperCase() !== removedTagName.toUpperCase()) {
return;
}
element.refresh();
}
};
// Since we need to pause the observer, we make an helper to start it with consistent arguments
const startObserver = (observer: MutationObserver) => {
observer.observe(document, {
// We want to also listen for elements contained in the header (eg: link)
subtree: true,
childList: true,
});
};
// Refresh ElementOnce components when changes happens
const observer = new MutationObserver((mutations: MutationRecord[]) => {
// To avoid infinite recursion, we need to pause the observer while manipulation nodes
observer.disconnect();
for (const mutation of mutations) {
for (const node of mutation.removedNodes) {
if (node.nodeType !== node.ELEMENT_NODE) {
continue;
}
for (const registered of registeredComponents) {
refreshElement(
document.getElementsByTagName(registered) as HTMLCollectionOf<
ElementOnce<"html"> // The specific tag doesn't really matter
>,
(node as HTMLElement).tagName as keyof HTMLElementTagNameMap,
);
}
}
}
// We then resume the observer
startObserver(observer);
});
startObserver(observer);
/** /**
* Web component used to import css files only once * Web component used to import css files only once
* If called multiple times or the file was already imported, it does nothing * If called multiple times or the file was already imported, it does nothing
**/ **/
@registerElementOnce("link-once") @registerComponent("link-once")
export class LinkOnce extends elementOnce("link") { export class LinkOnce extends inheritHtmlElement("link") {
getElementQuerySelector(): string { connectedCallback() {
super.connectedCallback(false);
// We get href from node.attributes instead of node.href to avoid getting the domain part // We get href from node.attributes instead of node.href to avoid getting the domain part
return `link[href='${this.node.attributes.getNamedItem("href").nodeValue}']`; const href = this.node.attributes.getNamedItem("href").nodeValue;
if (document.querySelectorAll(`link[href='${href}']`).length === 0) {
this.appendChild(this.node);
}
} }
} }
@@ -138,10 +20,14 @@ export class LinkOnce extends elementOnce("link") {
* Web component used to import javascript files only once * Web component used to import javascript files only once
* If called multiple times or the file was already imported, it does nothing * If called multiple times or the file was already imported, it does nothing
**/ **/
@registerElementOnce("script-once") @registerComponent("script-once")
export class ScriptOnce extends inheritHtmlElement("script") { export class ScriptOnce extends inheritHtmlElement("script") {
getElementQuerySelector(): string { connectedCallback() {
// We get href from node.attributes instead of node.src to avoid getting the domain part super.connectedCallback(false);
return `script[src='${this.node.attributes.getNamedItem("src").nodeValue}']`; // We get src from node.attributes instead of node.src to avoid getting the domain part
const src = this.node.attributes.getNamedItem("src").nodeValue;
if (document.querySelectorAll(`script[src='${src}']`).length === 0) {
this.appendChild(this.node);
}
} }
} }

View File

@@ -23,17 +23,10 @@ export function registerComponent(name: string, options?: ElementDefinitionOptio
* The technique is to: * The technique is to:
* create a new web component * create a new web component
* create the desired type inside * create the desired type inside
* move all attributes to the child component * pass all attributes to the child component
* store is at as `node` inside the parent * store is at as `node` inside the parent
**/ *
export interface InheritedHtmlElement<K extends keyof HTMLElementTagNameMap> * Since we can't use the generic type to instantiate the node, we create a generator function
extends HTMLElement {
readonly inheritedTagName: K;
node: HTMLElementTagNameMap[K];
}
/**
* Generator function that creates an InheritedHtmlElement compatible class
* *
* ```js * ```js
* class MyClass extends inheritHtmlElement("select") { * class MyClass extends inheritHtmlElement("select") {
@@ -42,15 +35,11 @@ export interface InheritedHtmlElement<K extends keyof HTMLElementTagNameMap>
* ``` * ```
**/ **/
export function inheritHtmlElement<K extends keyof HTMLElementTagNameMap>(tagName: K) { export function inheritHtmlElement<K extends keyof HTMLElementTagNameMap>(tagName: K) {
return class InheritedHtmlElementImpl return class Inherited extends HTMLElement {
extends HTMLElement protected node: HTMLElementTagNameMap[K];
implements InheritedHtmlElement<K>
{
readonly inheritedTagName = tagName;
node: HTMLElementTagNameMap[K];
connectedCallback(autoAddNode?: boolean) { connectedCallback(autoAddNode?: boolean) {
this.node = document.createElement(this.inheritedTagName); this.node = document.createElement(tagName);
const attributes: Attr[] = []; // We need to make a copy to delete while iterating const attributes: Attr[] = []; // We need to make a copy to delete while iterating
for (const attr of this.attributes) { for (const attr of this.attributes) {
if (attr.name in this.node) { if (attr.name in this.node) {
@@ -58,10 +47,6 @@ export function inheritHtmlElement<K extends keyof HTMLElementTagNameMap>(tagNam
} }
} }
// We move compatible attributes to the child element
// This avoids weird inconsistencies between attributes
// when we manipulate the dom in the future
// This is especially important when using attribute based reactivity
for (const attr of attributes) { for (const attr of attributes) {
this.removeAttributeNode(attr); this.removeAttributeNode(attr);
this.node.setAttributeNode(attr); this.node.setAttributeNode(attr);

View File

@@ -53,7 +53,7 @@ details.accordion>.accordion-content {
opacity: 0; opacity: 0;
@supports (max-height: calc-size(max-content, size)) { @supports (max-height: calc-size(max-content, size)) {
max-height: 0; max-height: 0px;
} }
} }
@@ -71,12 +71,11 @@ details.accordion>.accordion-content {
} }
} }
// ::details-content is available on firefox only since september 2025 // ::details-content isn't available on firefox yet
// (and wasn't available when this code was initially written)
// we use .accordion-content as a workaround // we use .accordion-content as a workaround
// But we need to use ::details-content for chrome because it's // But we need to use ::details-content for chrome because it's
// not working correctly otherwise // not working correctly otherwise
// it only happens in chrome, not safari or firefox // it only happen in chrome, not safari or firefox
// Note: `selector` is not supported by scss so we comment it out to // Note: `selector` is not supported by scss so we comment it out to
// avoid compiling it and sending it straight to the css // avoid compiling it and sending it straight to the css
// This is a trick that comes from here : // This is a trick that comes from here :

View File

@@ -157,7 +157,6 @@ form {
margin-bottom: .25rem; margin-bottom: .25rem;
font-size: 80%; font-size: 80%;
display: block; display: block;
max-width: calc(100% - calc(var(--nf-input-size) * 2))
} }
fieldset { fieldset {

View File

@@ -5,6 +5,17 @@
} }
.profile { .profile {
&-visible {
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
padding-top: 10px;
input[type="checkbox"]+label {
max-width: unset;
}
}
&-pictures { &-pictures {
box-sizing: border-box; box-sizing: border-box;
display: flex; display: flex;

View File

@@ -19,6 +19,28 @@
} }
} }
} }
&-cards,
&-trombi {
>p {
display: flex;
flex-direction: column;
align-items: flex-start;
text-align: justify;
gap: 5px;
margin: 0;
>input,
>select {
min-width: 300px;
}
}
}
&-submit-btn {
margin-top: 10px !important;
max-width: 100px;
}
} }
.justify { .justify {

View File

@@ -5,8 +5,9 @@
<details name="navbar" class="menu"> <details name="navbar" class="menu">
<summary class="head">{% trans %}Associations & Clubs{% endtrans %}</summary> <summary class="head">{% trans %}Associations & Clubs{% endtrans %}</summary>
<ul class="content"> <ul class="content">
<li><a href="{{ url("core:page", page_name="ae") }}">{% trans %}AE{% endtrans %}</a></li> <li><a href="{{ url('core:page', page_name='ae') }}">{% trans %}AE{% endtrans %}</a></li>
<li><a href="{{ url("club:club_list") }}">{% trans %}AE's clubs{% endtrans %}</a></li> <li><a href="{{ url('core:page', page_name='clubs') }}">{% trans %}AE's clubs{% endtrans %}</a></li>
<li><a href="{{ url('core:page', page_name='utbm-associations') }}">{% trans %}Others UTBM's Associations{% endtrans %}</a></li>
</ul> </ul>
</details> </details>
<details name="navbar" class="menu"> <details name="navbar" class="menu">

View File

@@ -1,11 +1,14 @@
<div id="quick-notifications" <div id="quick-notifications"
x-data="{ x-data="{
messages: [ messages: [
{%- for message in messages -%} {% if messages %}
{%- if not message.extra_tags -%} {% for message in messages %}
{ tag: '{{ message.tags }}', text: '{{ message }}' }, {
{%- endif -%} tag: '{{ message.tags }}',
{%- endfor -%} text: '{{ message }}',
},
{% endfor %}
{% endif %}
] ]
}" }"
@quick-notification-add="(e) => messages.push(e?.detail)" @quick-notification-add="(e) => messages.push(e?.detail)"

View File

@@ -1,18 +1,11 @@
{% extends "core/base.jinja" %} {% extends "core/base.jinja" %}
{# if the template context has the `object_name` variable,
then this one will be used in the page title,
instead of the result of `str(object)` #}
{% if not object_name %}
{% set object_name=form.instance.__class__._meta.verbose_name %}
{% endif %}
{% block title %} {% block title %}
{% trans name=object_name %}Create {{ name }}{% endtrans %} {% trans name=form.instance.__class__._meta.verbose_name %}Create {{ name }}{% endtrans %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<h2>{% trans name=object_name %}Create {{ name }}{% endtrans %}</h2> <h2>{% trans name=form.instance.__class__._meta.verbose_name %}Create {{ name }}{% endtrans %}</h2>
<form action="" method="post" enctype="multipart/form-data"> <form action="" method="post" enctype="multipart/form-data">
{% csrf_token %} {% csrf_token %}
{{ form.as_p() }} {{ form.as_p() }}

View File

@@ -48,6 +48,6 @@
>{% trans %}Delete{% endtrans %}</button></p> >{% trans %}Delete{% endtrans %}</button></p>
</div> </div>
{% endfor %} {% endfor %}
{{ paginate_htmx(request, page_obj, paginator) }} {{ paginate_htmx(page_obj, paginator) }}
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -1,33 +0,0 @@
<form
hx-post="{{ url("core:user_visibility_fragment", user_id=form.instance.id) }}"
hx-disabled-elt="find input[type='submit']"
hx-swap="outerHTML" x-data="{ isViewable: {{ form.is_viewable.value()|tojson }} }"
>
{% for message in messages %}
{% if message.extra_tags=="visibility" %}
<div class="alert alert-success">
{{ message }}
</div>
{% endif %}
{% endfor %}
{% csrf_token %}
{{ form.non_field_errors() }}
<fieldset class="form-group">
{{ form.is_viewable|add_attr("x-model=isViewable") }}
{{ form.is_viewable.label_tag() }}
<span class="helptext">{{ form.is_viewable.help_text }}</span>
{{ form.is_viewable.errors }}
</fieldset>
<fieldset class="form-group" x-show="!isViewable">
{{ form.whitelisted_users.as_field_group() }}
</fieldset>
<fieldset class="form-group">
{{ form.show_my_stats }}
{{ form.show_my_stats.label_tag() }}
<span class="helptext">
{{ form.show_my_stats.help_text }}
</span>
{{ form.show_my_stats.errors }}
</fieldset>
<input type="submit" class="btn btn-blue" value="{% trans %}Save{% endtrans %}">
</form>

View File

@@ -118,21 +118,20 @@
</nav> </nav>
{% endmacro %} {% endmacro %}
{% macro paginate_jinja(request, current_page, paginator) %} {% macro paginate_jinja(current_page, paginator) %}
{# Add pagination buttons for pages without Alpine. {# Add pagination buttons for pages without Alpine.
This must be coupled with a view that handles pagination This must be coupled with a view that handles pagination
with the Django Paginator object. with the Django Paginator object.
Parameters: Parameters:
request (django.http.request.HttpRequest): the current django request
current_page (django.core.paginator.Page): the current page object current_page (django.core.paginator.Page): the current page object
paginator (django.core.paginator.Paginator): the paginator object paginator (django.core.paginator.Paginator): the paginator object
#} #}
{{ paginate_server_side(request, current_page, paginator, "") }} {{ paginate_server_side(current_page, paginator, False) }}
{% endmacro %} {% endmacro %}
{% macro paginate_htmx(request, current_page, paginator, htmx_target="#content") %} {% macro paginate_htmx(current_page, paginator) %}
{# Add pagination buttons for pages without Alpine but supporting fragments. {# Add pagination buttons for pages without Alpine but supporting fragments.
This must be coupled with a view that handles pagination This must be coupled with a view that handles pagination
@@ -141,26 +140,24 @@
The replaced fragment will be #content so make sure you are calling this macro inside your content block. The replaced fragment will be #content so make sure you are calling this macro inside your content block.
Parameters: Parameters:
request (django.http.request.HttpRequest): the current django request
current_page (django.core.paginator.Page): the current page object current_page (django.core.paginator.Page): the current page object
paginator (django.core.paginator.Paginator): the paginator object paginator (django.core.paginator.Paginator): the paginator object
htmx_target (string): htmx target selector (default '#content')
#} #}
{{ paginate_server_side(request, current_page, paginator, htmx_target) }} {{ paginate_server_side(current_page, paginator, True) }}
{% endmacro %} {% endmacro %}
{% macro paginate_server_side(request, current_page, paginator, htmx_target) %} {% macro paginate_server_side(current_page, paginator, use_htmx) %}
<nav class="pagination"> <nav class="pagination">
{% if current_page.has_previous() %} {% if current_page.has_previous() %}
<a <a
{% if htmx_target -%} {% if use_htmx -%}
hx-get="?{{ querystring(request, page=current_page.previous_page_number()) }}" hx-get="?{{ querystring(page=current_page.previous_page_number()) }}"
hx-swap="innerHTML" hx-swap="innerHTML"
hx-target="{{ htmx_target }}" hx-target="#content"
hx-push-url="true" hx-push-url="true"
hx-trigger="click, keyup[key=='ArrowLeft'] from:body" hx-trigger="click, keyup[key=='ArrowLeft'] from:body"
{%- else -%} {%- else -%}
href="?{{ querystring(request, page=current_page.previous_page_number()) }}" href="?{{ querystring(page=current_page.previous_page_number()) }}"
{%- endif -%} {%- endif -%}
> >
<button> <button>
@@ -177,13 +174,13 @@
<strong>{{ paginator.ELLIPSIS }}</strong> <strong>{{ paginator.ELLIPSIS }}</strong>
{% else %} {% else %}
<a <a
{% if htmx_target -%} {% if use_htmx -%}
hx-get="?{{ querystring(request, page=i) }}" hx-get="?{{ querystring(page=i) }}"
hx-swap="innerHTML" hx-swap="innerHTML"
hx-target="{{ htmx_target }}" hx-target="#content"
hx-push-url="true" hx-push-url="true"
{%- else -%} {%- else -%}
href="?{{ querystring(request, page=i) }}" href="?{{ querystring(page=i) }}"
{%- endif -%} {%- endif -%}
> >
<button>{{ i }}</button> <button>{{ i }}</button>
@@ -192,14 +189,14 @@
{% endfor %} {% endfor %}
{% if current_page.has_next() %} {% if current_page.has_next() %}
<a <a
{% if htmx_target -%} {% if use_htmx -%}
hx-get="?{{querystring(request, page=current_page.next_page_number())}}" hx-get="?{{querystring(page=current_page.next_page_number())}}"
hx-swap="innerHTML" hx-swap="innerHTML"
hx-target="{{ htmx_target }}" hx-target="#content"
hx-push-url="true" hx-push-url="true"
hx-trigger="click, keyup[key=='ArrowRight'] from:body" hx-trigger="click, keyup[key=='ArrowRight'] from:body"
{%- else -%} {%- else -%}
href="?{{querystring(request, page=current_page.next_page_number())}}" href="?{{querystring(page=current_page.next_page_number())}}"
{%- endif -%} {%- endif -%}
><button> ><button>
<i class="fa fa-caret-right"></i> <i class="fa fa-caret-right"></i>
@@ -250,8 +247,15 @@
{% endmacro %} {% endmacro %}
{% macro querystring(request) %} {% macro querystring() %}
{%- set qs = request.GET.copy() -%} {%- for key, values in request.GET.lists() -%}
{%- do qs.update(kwargs) -%} {%- if key not in kwargs -%}
{{- qs | urlencode -}} {%- for value in values -%}
{{ key }}={{ value }}&amp;
{%- endfor -%}
{%- endif -%}
{%- endfor -%}
{%- for key, value in kwargs.items() -%}
{{ key }}={{ value }}&amp;
{%- endfor -%}
{% endmacro %} {% endmacro %}

View File

@@ -23,10 +23,10 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for m in profile.memberships.ongoing().select_related("role") %} {% for m in profile.memberships.filter(end_date=None).all() %}
<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>{{ m.role.name }}</td> <td>{{ settings.SITH_CLUB_ROLES[m.role] }}</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).select_related("role") %} {% for m in profile.memberships.exclude(end_date=None).all() %}
<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>{{ m.role.name }}</td> <td>{{ settings.SITH_CLUB_ROLES[m.role] }}</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

@@ -147,7 +147,18 @@
{%- endfor -%} {%- endfor -%}
</div> </div>
{# Checkboxes #}
<div class="profile-visible">
<div class="row">
{{ form.is_viewable }}
{{ form.is_viewable.label_tag() }}
</div>
<span class="helptext">
{{ form.is_viewable.help_text }}
</span>
</div>
<div class="final-actions"> <div class="final-actions">
{%- if form.instance == user -%} {%- if form.instance == user -%}
<p> <p>
<a href="{{ url('core:password_change') }}">{%- trans -%}Change my password{%- endtrans -%}</a> <a href="{{ url('core:password_change') }}">{%- trans -%}Change my password{%- endtrans -%}</a>
@@ -159,6 +170,7 @@
</a> </a>
</p> </p>
{%- endif -%} {%- endif -%}
<p> <p>
<input type="submit" value="{%- trans -%}Update{%- endtrans -%}" /> <input type="submit" value="{%- trans -%}Update{%- endtrans -%}" />
</p> </p>

View File

@@ -11,22 +11,30 @@
{% block content %} {% block content %}
<div class="main"> <div class="main">
<h2>{% trans %}Preferences{% endtrans %}</h2> <h2>{% trans %}Preferences{% endtrans %}</h2>
<br /> <h3>{% trans %}General{% endtrans %}</h3>
<h3>{% trans %}Notifications{% endtrans %}</h3> <form class="form form-general" action="" method="post" enctype="multipart/form-data">
<form action="" method="post" enctype="multipart/form-data">
{% csrf_token %} {% csrf_token %}
<div class="form form-general">
{{ form.as_p() }} {{ form.as_p() }}
</div> <input class="form-submit-btn" type="submit" value="{% trans %}Save{% endtrans %}" />
<input class="btn btn-blue" type="submit" value="{% trans %}Save{% endtrans %}" />
</form> </form>
<br /> <h3>{% trans %}Trombi{% endtrans %}</h3>
<h3>{% trans %}Visibility{% endtrans %}</h3>
{{ user_visibility_fragment }} {% if trombi_form %}
<form class="form form-trombi" action="{{ url('trombi:user_tools') }}" method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ trombi_form.as_p() }}
<input class="form-submit-btn" type="submit" value="{% trans %}Save{% endtrans %}" />
</form>
{% else %}
<p>{% trans trombi=profile.trombi_user.trombi %}You already choose to be in that Trombi: {{ trombi }}.{% endtrans %}
<br /> <br />
<a href="{{ url('trombi:user_tools') }}">{% trans %}Go to my Trombi tools{% endtrans %}</a>
</p>
{% endif %}
{% if student_card_fragment %} {% if student_card_fragment %}
<h3>{% trans %}Student card{% endtrans %}</h3> <h3>{% trans %}Student card{% endtrans %}</h3>
{{ student_card_fragment }} {{ student_card_fragment }}
@@ -35,21 +43,5 @@
add a student card yourself, you'll need a NFC reader. We store the UID of the card which is 14 characters long.{% endtrans %} add a student card yourself, you'll need a NFC reader. We store the UID of the card which is 14 characters long.{% endtrans %}
</p> </p>
{% endif %} {% endif %}
<br />
<h3>{% trans %}Trombi{% endtrans %}</h3>
{% if trombi_form %}
<form action="{{ url('trombi:user_tools') }}" method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ trombi_form.as_p() }}
<input class="btn btn-blue" type="submit" value="{% trans %}Save{% endtrans %}" />
</form>
{% else %}
<p>{% trans trombi=profile.trombi_user.trombi %}You already choose to be in that Trombi: {{ trombi }}.{% endtrans %}
<br />
<a href="{{ url('trombi:user_tools') }}">{% trans %}Go to my Trombi tools{% endtrans %}</a>
</p>
{% endif %}
</div> </div>
{% endblock %} {% endblock %}

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, Membership from club.models import Club
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,9 +122,6 @@ 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

@@ -213,9 +213,9 @@ def test_user_invoice_with_multiple_items():
"""Test that annotate_total() works when invoices contain multiple items.""" """Test that annotate_total() works when invoices contain multiple items."""
user: User = subscriber_user.make() user: User = subscriber_user.make()
item_recipe = Recipe(InvoiceItem, invoice=foreign_key(Recipe(Invoice, user=user))) item_recipe = Recipe(InvoiceItem, invoice=foreign_key(Recipe(Invoice, user=user)))
item_recipe.make(_quantity=3, quantity=1, product_unit_price=5) item_recipe.make(_quantity=3, quantity=1, unit_price=5)
item_recipe.make(_quantity=1, quantity=1, product_unit_price=5) item_recipe.make(_quantity=1, quantity=1, unit_price=5)
item_recipe.make(_quantity=2, quantity=1, product_unit_price=iter([5, 8])) item_recipe.make(_quantity=2, quantity=1, unit_price=iter([5, 8]))
res = list( res = list(
Invoice.objects.filter(user=user) Invoice.objects.filter(user=user)
.annotate_total() .annotate_total()
@@ -399,12 +399,13 @@ class TestUserQuerySetViewableBy:
return [ return [
baker.make(User), baker.make(User),
subscriber_user.make(), subscriber_user.make(),
*subscriber_user.make(is_viewable=False, _quantity=2), subscriber_user.make(is_viewable=False),
] ]
def test_admin_user(self, users: list[User]): def test_admin_user(self, users: list[User]):
user = baker.make( user = baker.make(
User, user_permissions=[Permission.objects.get(codename="view_hidden_user")] User,
user_permissions=[Permission.objects.get(codename="view_hidden_user")],
) )
viewable = User.objects.filter(id__in=[u.id for u in users]).viewable_by(user) viewable = User.objects.filter(id__in=[u.id for u in users]).viewable_by(user)
assert set(viewable) == set(users) assert set(viewable) == set(users)
@@ -417,12 +418,6 @@ class TestUserQuerySetViewableBy:
viewable = User.objects.filter(id__in=[u.id for u in users]).viewable_by(user) viewable = User.objects.filter(id__in=[u.id for u in users]).viewable_by(user)
assert set(viewable) == {users[0], users[1]} assert set(viewable) == {users[0], users[1]}
def test_whitelist(self, users: list[User]):
user = subscriber_user.make()
users[3].whitelisted_users.add(user)
viewable = User.objects.filter(id__in=[u.id for u in users]).viewable_by(user)
assert set(viewable) == {users[0], users[1], users[3]}
@pytest.mark.parametrize("user_factory", [lambda: baker.make(User), AnonymousUser]) @pytest.mark.parametrize("user_factory", [lambda: baker.make(User), AnonymousUser])
def test_not_subscriber(self, users: list[User], user_factory): def test_not_subscriber(self, users: list[User], user_factory):
user = user_factory() user = user_factory()

View File

@@ -69,6 +69,7 @@ from core.views import (
UserCreationView, UserCreationView,
UserGodfathersTreeView, UserGodfathersTreeView,
UserGodfathersView, UserGodfathersView,
UserListView,
UserMeRedirect, UserMeRedirect,
UserMiniView, UserMiniView,
UserPreferencesView, UserPreferencesView,
@@ -77,7 +78,6 @@ from core.views import (
UserUpdateGroupView, UserUpdateGroupView,
UserUpdateProfileView, UserUpdateProfileView,
UserView, UserView,
UserVisibilityFormFragment,
delete_user_godfather, delete_user_godfather,
logout, logout,
notification, notification,
@@ -136,11 +136,7 @@ urlpatterns = [
"group/<int:group_id>/detail/", GroupTemplateView.as_view(), name="group_detail" "group/<int:group_id>/detail/", GroupTemplateView.as_view(), name="group_detail"
), ),
# User views # User views
path( path("user/", UserListView.as_view(), name="user_list"),
"fragment/user/<int:user_id>/",
UserVisibilityFormFragment.as_view(),
name="user_visibility_fragment",
),
path( path(
"user/me/<path:remaining_path>/", "user/me/<path:remaining_path>/",
UserMeRedirect.as_view(), UserMeRedirect.as_view(),

View File

@@ -48,13 +48,12 @@ from phonenumber_field.widgets import RegionalPhoneNumberWidget
from PIL import Image from PIL import Image
from antispam.forms import AntiSpamEmailField from antispam.forms import AntiSpamEmailField
from core.models import Gift, Group, Page, PageRev, Preferences, SithFile, User from core.models import Gift, Group, Page, PageRev, SithFile, User
from core.utils import resize_image from core.utils import resize_image
from core.views.widgets.ajax_select import ( from core.views.widgets.ajax_select import (
AutoCompleteSelect, AutoCompleteSelect,
AutoCompleteSelectGroup, AutoCompleteSelectGroup,
AutoCompleteSelectMultipleGroup, AutoCompleteSelectMultipleGroup,
AutoCompleteSelectMultipleUser,
AutoCompleteSelectUser, AutoCompleteSelectUser,
) )
from core.views.widgets.markdown import MarkdownInput from core.views.widgets.markdown import MarkdownInput
@@ -180,6 +179,7 @@ class UserProfileForm(forms.ModelForm):
"school", "school",
"promo", "promo",
"forum_signature", "forum_signature",
"is_viewable",
] ]
widgets = { widgets = {
"date_of_birth": SelectDate, "date_of_birth": SelectDate,
@@ -264,38 +264,6 @@ class UserProfileForm(forms.ModelForm):
self._post_clean() self._post_clean()
class UserVisibilityForm(forms.ModelForm):
class Meta:
model = User
fields = ["is_viewable", "whitelisted_users"]
widgets = {
"is_viewable": forms.CheckboxInput(attrs={"class": "switch"}),
"whitelisted_users": AutoCompleteSelectMultipleUser,
}
__preferences_fields = forms.fields_for_model(
Preferences,
["show_my_stats"],
widgets={"show_my_stats": forms.CheckboxInput(attrs={"class": "switch"})},
)
show_my_stats = __preferences_fields["show_my_stats"]
def __init__(
self, *args, initial: dict | None = None, instance: User | None = None, **kwargs
):
if instance:
initial = initial or {}
initial["show_my_stats"] = instance.preferences.show_my_stats
super().__init__(*args, initial=initial, instance=instance, **kwargs)
def save(self, commit=True) -> User: # noqa: FBT002
instance = super().save(commit=commit)
if commit:
instance.preferences.show_my_stats = self.cleaned_data["show_my_stats"]
instance.preferences.save()
return instance
class UserGroupsForm(forms.ModelForm): class UserGroupsForm(forms.ModelForm):
error_css_class = "error" error_css_class = "error"
required_css_class = "required" required_css_class = "required"

View File

@@ -28,12 +28,10 @@ from datetime import timedelta
from operator import itemgetter from operator import itemgetter
from smtplib import SMTPException from smtplib import SMTPException
from django.contrib import messages
from django.contrib.auth import login, views from django.contrib.auth import login, views
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.contrib.auth.forms import PasswordChangeForm, SetPasswordForm from django.contrib.auth.forms import PasswordChangeForm, SetPasswordForm
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.db.models import DateField, F, QuerySet, Sum from django.db.models import DateField, F, QuerySet, Sum
from django.db.models.functions import Trunc from django.db.models.functions import Trunc
@@ -50,6 +48,7 @@ from django.views.generic import (
CreateView, CreateView,
DeleteView, DeleteView,
DetailView, DetailView,
ListView,
RedirectView, RedirectView,
TemplateView, TemplateView,
) )
@@ -66,9 +65,8 @@ from core.views.forms import (
UserGodfathersForm, UserGodfathersForm,
UserGroupsForm, UserGroupsForm,
UserProfileForm, UserProfileForm,
UserVisibilityForm,
) )
from core.views.mixins import FragmentMixin, TabedViewMixin, UseFragmentsMixin from core.views.mixins import TabedViewMixin, UseFragmentsMixin
from counter.models import Refilling, Selling from counter.models import Refilling, Selling
from eboutic.models import Invoice from eboutic.models import Invoice
from trombi.views import UserTrombiForm from trombi.views import UserTrombiForm
@@ -250,15 +248,14 @@ class UserTabsMixin(TabedViewMixin):
"name": _("Groups"), "name": _("Groups"),
} }
) )
can_view_account = ( if (
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}),
@@ -266,7 +263,6 @@ 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}),
@@ -353,7 +349,7 @@ class UserGodfathersTreeView(UserTabsMixin, CanViewMixin, DetailView):
return kwargs return kwargs
class UserStatsView(UserTabsMixin, UserPassesTestMixin, DetailView): class UserStatsView(UserTabsMixin, CanViewMixin, DetailView):
"""Display a user's stats.""" """Display a user's stats."""
model = User model = User
@@ -361,20 +357,15 @@ class UserStatsView(UserTabsMixin, UserPassesTestMixin, 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( queryset = User.objects.exclude(customer=None).select_related("customer")
"customer", "_preferences"
)
def test_func(self): def dispatch(self, request, *arg, **kwargs):
profile: User = self.get_object() profile = self.get_object()
return ( if not (
profile == self.request.user profile == request.user or request.user.has_perm("counter.view_customer")
or self.request.user.has_perm("counter.view_customer") ):
or ( raise PermissionDenied
self.request.user.can_view(profile) return super().dispatch(request, *arg, **kwargs)
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)
@@ -413,6 +404,13 @@ class UserMiniView(CanViewMixin, DetailView):
template_name = "core/user_mini.jinja" template_name = "core/user_mini.jinja"
class UserListView(ListView, CanEditPropMixin):
"""Displays the user list."""
model = User
template_name = "core/user_list.jinja"
# FIXME: the edit_once fields aren't displayed to the user (as expected). # FIXME: the edit_once fields aren't displayed to the user (as expected).
# However, if the user re-add them manually in the form, they are saved. # However, if the user re-add them manually in the form, they are saved.
class UserUpdateProfileView(UserTabsMixin, CanEditMixin, UpdateView): class UserUpdateProfileView(UserTabsMixin, CanEditMixin, UpdateView):
@@ -470,30 +468,6 @@ class UserClubView(UserTabsMixin, CanViewMixin, DetailView):
current_tab = "clubs" current_tab = "clubs"
class UserVisibilityFormFragment(FragmentMixin, SuccessMessageMixin, UpdateView):
model = User
form_class = UserVisibilityForm
template_name = "core/fragment/user_visibility.jinja"
pk_url_kwarg = "user_id"
def get_form_kwargs(self):
return super().get_form_kwargs() | {"label_suffix": ""}
def form_valid(self, form):
response = super().form_valid(form)
messages.success(
self.request, _("Visibility parameters updated."), extra_tags="visibility"
)
return response
def render_fragment(self, request, **kwargs) -> SafeString:
self.object = kwargs.get("user")
return super().render_fragment(request, **kwargs)
def get_success_url(self, **kwargs):
return self.request.path
class UserPreferencesView(UserTabsMixin, UseFragmentsMixin, CanEditMixin, UpdateView): class UserPreferencesView(UserTabsMixin, UseFragmentsMixin, CanEditMixin, UpdateView):
"""Edit a user's preferences.""" """Edit a user's preferences."""
@@ -507,10 +481,7 @@ class UserPreferencesView(UserTabsMixin, UseFragmentsMixin, CanEditMixin, Update
current_tab = "prefs" current_tab = "prefs"
def get_form_kwargs(self): def get_form_kwargs(self):
return super().get_form_kwargs() | { return super().get_form_kwargs() | {"instance": self.object.preferences}
"instance": self.object.preferences,
"label_suffix": "",
}
def get_success_url(self): def get_success_url(self):
return self.request.path return self.request.path
@@ -520,9 +491,6 @@ class UserPreferencesView(UserTabsMixin, UseFragmentsMixin, CanEditMixin, Update
from counter.views.student_card import StudentCardFormFragment from counter.views.student_card import StudentCardFormFragment
res = super().get_fragment_context_data() res = super().get_fragment_context_data()
res["user_visibility_fragment"] = UserVisibilityFormFragment.as_fragment()(
self.request, user=self.object
)
if hasattr(self.object, "customer"): if hasattr(self.object, "customer"):
res["student_card_fragment"] = StudentCardFormFragment.as_fragment()( res["student_card_fragment"] = StudentCardFormFragment.as_fragment()(
self.request, customer=self.object.customer self.request, customer=self.object.customer

View File

@@ -24,6 +24,7 @@ from counter.models import (
Eticket, Eticket,
InvoiceCall, InvoiceCall,
Permanency, Permanency,
Price,
Product, Product,
ProductType, ProductType,
Refilling, Refilling,
@@ -32,19 +33,24 @@ from counter.models import (
) )
class PriceInline(admin.TabularInline):
model = Price
autocomplete_fields = ("groups",)
@admin.register(Product) @admin.register(Product)
class ProductAdmin(SearchModelAdmin): class ProductAdmin(SearchModelAdmin):
list_display = ( list_display = (
"name", "name",
"code", "code",
"product_type", "product_type",
"selling_price",
"archived", "archived",
"created_at", "created_at",
"updated_at", "updated_at",
) )
list_select_related = ("product_type",) list_select_related = ("product_type",)
search_fields = ("name", "code") search_fields = ("name", "code")
inlines = [PriceInline]
@admin.register(ReturnableProduct) @admin.register(ReturnableProduct)

View File

@@ -101,13 +101,9 @@ class ProductController(ControllerBase):
"""Get the detailed information about the products.""" """Get the detailed information about the products."""
return filters.filter( return filters.filter(
Product.objects.select_related("club") Product.objects.select_related("club")
.prefetch_related("buying_groups") .prefetch_related("prices", "prices__groups")
.select_related("product_type") .select_related("product_type")
.order_by( .order_by(F("product_type__order").asc(nulls_last=True), "name")
F("product_type__order").asc(nulls_last=True),
"product_type",
"name",
)
) )

View File

@@ -2,10 +2,11 @@ from model_bakery.recipe import Recipe, foreign_key
from club.models import Club from club.models import Club
from core.models import User from core.models import User
from counter.models import Counter, Product, Refilling, Selling from counter.models import Counter, Price, Product, Refilling, Selling
counter_recipe = Recipe(Counter) counter_recipe = Recipe(Counter)
product_recipe = Recipe(Product, club=foreign_key(Recipe(Club))) product_recipe = Recipe(Product, club=foreign_key(Recipe(Club)))
price_recipe = Recipe(Price, product=foreign_key(product_recipe))
sale_recipe = Recipe( sale_recipe = Recipe(
Selling, Selling,
product=foreign_key(product_recipe), product=foreign_key(product_recipe),

View File

@@ -1,12 +1,12 @@
import json import json
import math import math
import uuid import uuid
from collections import defaultdict
from datetime import date, datetime, timezone 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.exceptions import ValidationError
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
from django.utils.timezone import now from django.utils.timezone import now
@@ -37,6 +37,7 @@ from counter.models import (
Customer, Customer,
Eticket, Eticket,
InvoiceCall, InvoiceCall,
Price,
Product, Product,
ProductFormula, ProductFormula,
Refilling, Refilling,
@@ -374,7 +375,21 @@ ScheduledProductActionFormSet = forms.modelformset_factory(
can_delete=True, can_delete=True,
can_delete_extra=False, can_delete_extra=False,
extra=0, extra=0,
)
ProductPriceFormSet = forms.inlineformset_factory(
parent_model=Product,
model=Price,
fields=["amount", "label", "groups", "is_always_shown"],
widgets={
"groups": AutoCompleteSelectMultipleGroup,
"is_always_shown": forms.CheckboxInput(attrs={"class": "switch"}),
},
absolute_max=None,
can_delete_extra=False,
min_num=1, min_num=1,
extra=0,
) )
@@ -389,10 +404,7 @@ class ProductForm(forms.ModelForm):
"description", "description",
"product_type", "product_type",
"code", "code",
"buying_groups",
"purchase_price", "purchase_price",
"selling_price",
"special_selling_price",
"icon", "icon",
"club", "club",
"limit_age", "limit_age",
@@ -407,8 +419,8 @@ class ProductForm(forms.ModelForm):
} }
widgets = { widgets = {
"product_type": AutoCompleteSelect, "product_type": AutoCompleteSelect,
"buying_groups": AutoCompleteSelectMultipleGroup,
"club": AutoCompleteSelectClub, "club": AutoCompleteSelectClub,
"tray": forms.CheckboxInput(attrs={"class": "switch"}),
} }
counters = forms.ModelMultipleChoiceField( counters = forms.ModelMultipleChoiceField(
@@ -418,50 +430,40 @@ class ProductForm(forms.ModelForm):
queryset=Counter.objects.all(), queryset=Counter.objects.all(),
) )
def __init__(self, *args, instance=None, **kwargs): def __init__(self, *args, prefix: str | None = None, instance=None, **kwargs):
super().__init__(*args, instance=instance, **kwargs) super().__init__(*args, prefix=prefix, instance=instance, **kwargs)
self.fields["name"].widget.attrs["autofocus"] = "autofocus"
if self.instance.id: if self.instance.id:
self.fields["counters"].initial = self.instance.counters.all() self.fields["counters"].initial = self.instance.counters.all()
if hasattr(self.instance, "formula"): if hasattr(self.instance, "formula"):
self.formula_init(self.instance.formula) self.formula_init(self.instance.formula)
self.price_formset = ProductPriceFormSet(
*args, instance=self.instance, prefix="price", **kwargs
)
self.action_formset = ScheduledProductActionFormSet( self.action_formset = ScheduledProductActionFormSet(
*args, product=self.instance, **kwargs *args, product=self.instance, prefix="action", **kwargs
) )
def formula_init(self, formula: ProductFormula):
"""Part of the form initialisation specific to formula products."""
self.fields["selling_price"].help_text = _(
"This product is a formula. "
"Its price cannot be greater than the price "
"of the products constituting it, which is %(price)s"
) % {"price": formula.max_selling_price}
self.fields["special_selling_price"].help_text = _(
"This product is a formula. "
"Its special price cannot be greater than the price "
"of the products constituting it, which is %(price)s"
) % {"price": formula.max_special_selling_price}
for key, price in (
("selling_price", formula.max_selling_price),
("special_selling_price", formula.max_special_selling_price),
):
self.fields[key].widget.attrs["max"] = price
self.fields[key].validators.append(MaxValueValidator(price))
def is_valid(self): def is_valid(self):
return super().is_valid() and self.action_formset.is_valid() return (
super().is_valid()
and self.price_formset.is_valid()
and self.action_formset.is_valid()
)
def save(self, *args, **kwargs) -> Product: def save(self, *args, **kwargs) -> Product:
product = super().save(*args, **kwargs) product = super().save(*args, **kwargs)
product.counters.set(self.cleaned_data["counters"]) product.counters.set(self.cleaned_data["counters"])
for form in self.action_formset:
# if it's a creation, the product given in the formset # if it's a creation, the product given in the formset
# wasn't a persisted instance. # wasn't a persisted instance.
# So if we tried to persist the scheduled actions in the current state, # So if we tried to persist the related objects in the current state,
# they would be linked to no product, thus be completely useless # they would be linked to no product, thus be completely useless
# To make it work, we have to replace # To make it work, we have to replace
# the initial product with a persisted one # the initial product with a persisted one
for form in self.action_formset:
form.set_product(product) form.set_product(product)
self.action_formset.save() self.action_formset.save()
self.price_formset.save()
return product return product
@@ -484,18 +486,6 @@ class ProductFormulaForm(forms.ModelForm):
"the result and a part of the formula." "the result and a part of the formula."
), ),
) )
prices = [p.selling_price for p in cleaned_data["products"]]
special_prices = [p.special_selling_price for p in cleaned_data["products"]]
selling_price = cleaned_data["result"].selling_price
special_selling_price = cleaned_data["result"].special_selling_price
if selling_price > sum(prices) or special_selling_price > sum(special_prices):
self.add_error(
"result",
_(
"The result cannot be more expensive "
"than the total of the other products."
),
)
return cleaned_data return cleaned_data
@@ -546,48 +536,47 @@ class CloseCustomerAccountForm(forms.Form):
) )
class BasketProductForm(forms.Form): class BasketItemForm(forms.Form):
quantity = forms.IntegerField(min_value=1, required=True) quantity = forms.IntegerField(min_value=1, required=True)
id = forms.IntegerField(min_value=0, required=True) price_id = forms.IntegerField(min_value=0, required=True)
def __init__( def __init__(
self, self,
customer: Customer, customer: Customer,
counter: Counter, counter: Counter,
allowed_products: dict[int, Product], allowed_prices: dict[int, Price],
*args, *args,
**kwargs, **kwargs,
): ):
self.customer = customer # Used by formset self.customer = customer # Used by formset
self.counter = counter # Used by formset self.counter = counter # Used by formset
self.allowed_products = allowed_products self.allowed_prices = allowed_prices
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
def clean_id(self): def clean_price_id(self):
data = self.cleaned_data["id"] data = self.cleaned_data["price_id"]
# We store self.product so we can use it later on the formset validation # We store self.price so we can use it later on the formset validation
# And also in the global clean # And also in the global clean
self.product = self.allowed_products.get(data, None) self.price = self.allowed_prices.get(data, None)
if self.product is None: if self.price is None:
raise forms.ValidationError( raise forms.ValidationError(
_("The selected product isn't available for this user") _("The selected product isn't available for this user")
) )
return data return data
def clean(self): def clean(self):
cleaned_data = super().clean() cleaned_data = super().clean()
if len(self.errors) > 0: if len(self.errors) > 0:
return return cleaned_data
# Compute prices # Compute prices
cleaned_data["bonus_quantity"] = 0 cleaned_data["bonus_quantity"] = 0
if self.product.tray: if self.price.product.tray:
cleaned_data["bonus_quantity"] = math.floor( cleaned_data["bonus_quantity"] = math.floor(
cleaned_data["quantity"] / Product.QUANTITY_FOR_TRAY_PRICE cleaned_data["quantity"] / Product.QUANTITY_FOR_TRAY_PRICE
) )
cleaned_data["total_price"] = self.product.price * ( cleaned_data["total_price"] = self.price.amount * (
cleaned_data["quantity"] - cleaned_data["bonus_quantity"] cleaned_data["quantity"] - cleaned_data["bonus_quantity"]
) )
@@ -611,8 +600,8 @@ class BaseBasketForm(forms.BaseFormSet):
raise forms.ValidationError(_("Submitted basket is invalid")) raise forms.ValidationError(_("Submitted basket is invalid"))
def _check_product_are_unique(self): def _check_product_are_unique(self):
product_ids = {form.cleaned_data["id"] for form in self.forms} price_ids = {form.cleaned_data["price_id"] for form in self.forms}
if len(product_ids) != len(self.forms): if len(price_ids) != len(self.forms):
raise forms.ValidationError(_("Duplicated product entries.")) raise forms.ValidationError(_("Duplicated product entries."))
def _check_enough_money(self, counter: Counter, customer: Customer): def _check_enough_money(self, counter: Counter, customer: Customer):
@@ -622,10 +611,9 @@ class BaseBasketForm(forms.BaseFormSet):
def _check_recorded_products(self, customer: Customer): def _check_recorded_products(self, customer: Customer):
"""Check for, among other things, ecocups and pitchers""" """Check for, among other things, ecocups and pitchers"""
items = { items = defaultdict(int)
form.cleaned_data["id"]: form.cleaned_data["quantity"] for form in self.forms:
for form in self.forms items[form.price.product_id] += form.cleaned_data["quantity"]
}
ids = list(items.keys()) ids = list(items.keys())
returnables = list( returnables = list(
ReturnableProduct.objects.filter( ReturnableProduct.objects.filter(
@@ -651,7 +639,7 @@ class BaseBasketForm(forms.BaseFormSet):
BasketForm = forms.formset_factory( BasketForm = forms.formset_factory(
BasketProductForm, formset=BaseBasketForm, absolute_max=None, min_num=1 BasketItemForm, formset=BaseBasketForm, absolute_max=None, min_num=1
) )

View File

@@ -0,0 +1,149 @@
# Generated by Django 5.2.11 on 2026-02-18 13:30
import django.db.models.deletion
from django.db import migrations, models
from django.db.migrations.state import StateApps
import counter.fields
def migrate_prices(apps: StateApps, schema_editor):
Product = apps.get_model("counter", "Product")
Price = apps.get_model("counter", "Price")
prices = [
Price(
amount=p.selling_price,
product=p,
created_at=p.created_at,
updated_at=p.updated_at,
)
for p in Product.objects.all()
]
Price.objects.bulk_create(prices)
groups = [
Price.groups.through(price=price, group=group)
for price in Price.objects.select_related("product").prefetch_related(
"product__buying_groups"
)
for group in price.product.buying_groups.all()
]
Price.groups.through.objects.bulk_create(groups)
class Migration(migrations.Migration):
dependencies = [
("core", "0048_alter_user_options"),
("counter", "0038_countersellers"),
]
operations = [
migrations.CreateModel(
name="Price",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"amount",
counter.fields.CurrencyField(
decimal_places=2, max_digits=12, verbose_name="amount"
),
),
(
"is_always_shown",
models.BooleanField(
default=False,
help_text=(
"If this option is enabled, "
"people will see this price and be able to pay it, "
"even if another cheaper price exists. "
"Else it will visible only if it is the cheapest available price."
),
verbose_name="always show",
),
),
(
"label",
models.CharField(
default="",
help_text=(
"A short label for easier differentiation "
"if a user can see multiple prices."
),
max_length=32,
verbose_name="label",
blank=True,
),
),
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="created at"),
),
(
"updated_at",
models.DateTimeField(auto_now=True, verbose_name="updated at"),
),
(
"groups",
models.ManyToManyField(
related_name="prices", to="core.group", verbose_name="groups"
),
),
(
"product",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="prices",
to="counter.product",
verbose_name="product",
),
),
],
options={"verbose_name": "price"},
),
migrations.AlterField(
model_name="product",
name="tray",
field=models.BooleanField(
default=False,
help_text="Buy five, get the sixth free",
verbose_name="tray price",
),
),
migrations.RunPython(migrate_prices, reverse_code=migrations.RunPython.noop),
migrations.RemoveField(model_name="product", name="selling_price"),
migrations.RemoveField(model_name="product", name="special_selling_price"),
migrations.AlterField(
model_name="product",
name="description",
field=models.TextField(blank=True, default="", verbose_name="description"),
),
migrations.AlterField(
model_name="product",
name="product_type",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="products",
to="counter.producttype",
verbose_name="product type",
),
),
migrations.AlterField(
model_name="productformula",
name="result",
field=models.OneToOneField(
help_text="The product got with the formula.",
on_delete=django.db.models.deletion.CASCADE,
related_name="formula",
to="counter.product",
verbose_name="result product",
),
),
]

View File

@@ -22,7 +22,7 @@ import string
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from datetime import timezone as tz from datetime import timezone as tz
from decimal import Decimal from decimal import Decimal
from typing import Literal, Self from typing import TYPE_CHECKING, Literal, Self
from dict2xml import dict2xml from dict2xml import dict2xml
from django.conf import settings from django.conf import settings
@@ -47,6 +47,9 @@ from core.utils import get_start_of_semester
from counter.fields import CurrencyField from counter.fields import CurrencyField
from subscription.models import Subscription from subscription.models import Subscription
if TYPE_CHECKING:
from collections.abc import Sequence
def get_eboutic() -> Counter: def get_eboutic() -> Counter:
return Counter.objects.filter(type="EBOUTIC").order_by("id").first() return Counter.objects.filter(type="EBOUTIC").order_by("id").first()
@@ -157,14 +160,7 @@ class Customer(models.Model):
@property @property
def can_buy(self) -> bool: def can_buy(self) -> bool:
"""Check if whether this customer has the right to purchase any item. """Check if whether this customer has the right to purchase any item."""
This must be not confused with the Product.can_be_sold_to(user)
method as the present method returns an information
about a customer whereas the other tells something
about the relation between a User (not a Customer,
don't mix them) and a Product.
"""
subscription = self.user.subscriptions.order_by("subscription_end").last() subscription = self.user.subscriptions.order_by("subscription_end").last()
if subscription is None: if subscription is None:
return False return False
@@ -363,13 +359,13 @@ class Product(models.Model):
QUANTITY_FOR_TRAY_PRICE = 6 QUANTITY_FOR_TRAY_PRICE = 6
name = models.CharField(_("name"), max_length=64) name = models.CharField(_("name"), max_length=64)
description = models.TextField(_("description"), default="") description = models.TextField(_("description"), blank=True, default="")
product_type = models.ForeignKey( product_type = models.ForeignKey(
ProductType, ProductType,
related_name="products", related_name="products",
verbose_name=_("product type"), verbose_name=_("product type"),
null=True, null=True,
blank=True, blank=False,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
) )
code = models.CharField(_("code"), max_length=16, blank=True) code = models.CharField(_("code"), max_length=16, blank=True)
@@ -377,11 +373,6 @@ class Product(models.Model):
_("purchase price"), _("purchase price"),
help_text=_("Initial cost of purchasing the product"), help_text=_("Initial cost of purchasing the product"),
) )
selling_price = CurrencyField(_("selling price"))
special_selling_price = CurrencyField(
_("special selling price"),
help_text=_("Price for barmen during their permanence"),
)
icon = ResizedImageField( icon = ResizedImageField(
height=70, height=70,
force_format="WEBP", force_format="WEBP",
@@ -394,7 +385,9 @@ class Product(models.Model):
Club, related_name="products", verbose_name=_("club"), on_delete=models.CASCADE Club, related_name="products", verbose_name=_("club"), on_delete=models.CASCADE
) )
limit_age = models.IntegerField(_("limit age"), default=0) limit_age = models.IntegerField(_("limit age"), default=0)
tray = models.BooleanField(_("tray price"), default=False) tray = models.BooleanField(
_("tray price"), help_text=_("Buy five, get the sixth free"), default=False
)
buying_groups = models.ManyToManyField( buying_groups = models.ManyToManyField(
Group, related_name="products", verbose_name=_("buying groups"), blank=True Group, related_name="products", verbose_name=_("buying groups"), blank=True
) )
@@ -419,41 +412,77 @@ class Product(models.Model):
pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID
) or user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID) ) or user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)
def can_be_sold_to(self, user: User) -> bool:
"""Check if whether the user given in parameter has the right to buy
this product or not.
This must be not confused with the Customer.can_buy() class PriceQuerySet(models.QuerySet):
method as the present method returns an information def for_user(self, user: User) -> Self:
about the relation between a User and a Product, age = user.age
whereas the other tells something about a Customer if user.is_banned_alcohol:
(and not a user, they are not the same model). age = min(age, 17)
return self.filter(
Q(is_always_shown=True, groups__in=user.all_groups)
| Q(
id=Subquery(
Price.objects.filter(
product_id=OuterRef("product_id"), groups__in=user.all_groups
)
.order_by("amount")
.values("id")[:1]
)
),
product__archived=False,
product__limit_age__lte=age,
)
Returns:
True if the user can buy this product else False
Warning: class Price(models.Model):
This performs a db query, thus you can quickly have amount = CurrencyField(_("amount"))
a N+1 queries problem if you call it in a loop. product = models.ForeignKey(
Hopefully, you can avoid that if you prefetch the buying_groups : Product,
verbose_name=_("product"),
related_name="prices",
on_delete=models.CASCADE,
)
groups = models.ManyToManyField(
Group, verbose_name=_("groups"), related_name="prices"
)
is_always_shown = models.BooleanField(
_("always show"),
help_text=_(
"If this option is enabled, "
"people will see this price and be able to pay it, "
"even if another cheaper price exists. "
"Else it will visible only if it is the cheapest available price."
),
default=False,
)
label = models.CharField(
_("label"),
help_text=_(
"A short label for easier differentiation "
"if a user can see multiple prices."
),
max_length=32,
default="",
blank=True,
)
created_at = models.DateTimeField(_("created at"), auto_now_add=True)
updated_at = models.DateTimeField(_("updated at"), auto_now=True)
```python objects = PriceQuerySet.as_manager()
user = User.objects.get(username="foobar")
products = [ class Meta:
p verbose_name = _("price")
for p in Product.objects.prefetch_related("buying_groups")
if p.can_be_sold_to(user) def __str__(self):
] if not self.label:
``` return f"{self.product.name} ({self.amount}€)"
""" return f"{self.product.name} {self.label} ({self.amount}€)"
buying_groups = list(self.buying_groups.all())
if not buying_groups:
return True
return any(user.is_in_group(pk=group.id) for group in buying_groups)
@property @property
def profit(self): def full_label(self):
return self.selling_price - self.purchase_price if not self.label:
return self.product.name
return f"{self.product.name} \u2013 {self.label}"
class ProductFormula(models.Model): class ProductFormula(models.Model):
@@ -474,18 +503,6 @@ class ProductFormula(models.Model):
def __str__(self): def __str__(self):
return self.result.name return self.result.name
@cached_property
def max_selling_price(self) -> float:
# iterating over all products is less efficient than doing
# a simple aggregation, but this method is likely to be used in
# coordination with `max_special_selling_price`,
# and Django caches the result of the `all` queryset.
return sum(p.selling_price for p in self.products.all())
@cached_property
def max_special_selling_price(self) -> float:
return sum(p.special_selling_price for p in self.products.all())
class CounterQuerySet(models.QuerySet): class CounterQuerySet(models.QuerySet):
def annotate_has_barman(self, user: User) -> Self: def annotate_has_barman(self, user: User) -> Self:
@@ -583,7 +600,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.is_presidency: if mem and mem.role >= settings.SITH_CLUB_ROLES_ID["Treasurer"]:
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)
@@ -716,35 +733,20 @@ class Counter(models.Model):
# but they share the same primary key # but they share the same primary key
return self.type == "BAR" and any(b.pk == customer.pk for b in self.barmen_list) return self.type == "BAR" and any(b.pk == customer.pk for b in self.barmen_list)
def get_products_for(self, customer: Customer) -> list[Product]: def get_prices_for(
""" self, customer: Customer, *, order_by: Sequence[str] | None = None
Get all allowed products for the provided customer on this counter ) -> list[Price]:
Prices will be annotated qs = (
""" Price.objects.filter(
product__counters=self, product__product_type__isnull=False
products = (
self.products.filter(archived=False)
.select_related("product_type")
.prefetch_related("buying_groups")
) )
.for_user(customer.user)
# Only include age appropriate products .select_related("product", "product__product_type")
age = customer.user.age .prefetch_related("groups")
if customer.user.is_banned_alcohol: )
age = min(age, 17) if order_by:
products = products.filter(limit_age__lte=age) qs = qs.order_by(*order_by)
return list(qs)
# Compute special price for customer if he is a barmen on that bar
if self.customer_is_barman(customer):
products = products.annotate(price=F("special_selling_price"))
else:
products = products.annotate(price=F("selling_price"))
return [
product
for product in products.all()
if product.can_be_sold_to(customer.user)
]
class CounterSellers(models.Model): class CounterSellers(models.Model):
@@ -1025,7 +1027,9 @@ class Selling(models.Model):
event = self.product.eticket.event_title or _("Unknown event") event = self.product.eticket.event_title or _("Unknown event")
subject = _("Eticket bought for the event %(event)s") % {"event": event} subject = _("Eticket bought for the event %(event)s") % {"event": event}
message_html = _( message_html = _(
"You bought an eticket for the event %(event)s.\nYou can download it directly from this link %(eticket)s.\nYou can also retrieve all your e-tickets on your account page %(url)s." "You bought an eticket for the event %(event)s.\n"
"You can download it directly from this link %(eticket)s.\n"
"You can also retrieve all your e-tickets on your account page %(url)s."
) % { ) % {
"event": event, "event": event,
"url": ( "url": (

View File

@@ -6,8 +6,8 @@ from ninja import FilterLookup, FilterSchema, ModelSchema, Schema
from pydantic import model_validator from pydantic import model_validator
from club.schemas import SimpleClubSchema from club.schemas import SimpleClubSchema
from core.schemas import GroupSchema, NonEmptyStr, SimpleUserSchema from core.schemas import NonEmptyStr, SimpleUserSchema
from counter.models import Counter, Product, ProductType from counter.models import Counter, Price, Product, ProductType
class CounterSchema(ModelSchema): class CounterSchema(ModelSchema):
@@ -66,6 +66,12 @@ class SimpleProductSchema(ModelSchema):
fields = ["id", "name", "code"] fields = ["id", "name", "code"]
class ProductPriceSchema(ModelSchema):
class Meta:
model = Price
fields = ["amount", "groups"]
class ProductSchema(ModelSchema): class ProductSchema(ModelSchema):
class Meta: class Meta:
model = Product model = Product
@@ -75,13 +81,12 @@ class ProductSchema(ModelSchema):
"code", "code",
"description", "description",
"purchase_price", "purchase_price",
"selling_price",
"icon", "icon",
"limit_age", "limit_age",
"archived", "archived",
] ]
buying_groups: list[GroupSchema] prices: list[ProductPriceSchema]
club: SimpleClubSchema club: SimpleClubSchema
product_type: SimpleProductTypeSchema | None product_type: SimpleProductTypeSchema | None
url: str url: str

View File

@@ -1,12 +1,11 @@
import type { Product } from "#counter:counter/types.ts"; import type { CounterItem } from "#counter:counter/types";
export class BasketItem { export class BasketItem {
quantity: number; quantity: number;
product: Product; product: CounterItem;
quantityForTrayPrice: number;
errors: string[]; errors: string[];
constructor(product: Product, quantity: number) { constructor(product: CounterItem, quantity: number) {
this.quantity = quantity; this.quantity = quantity;
this.product = product; this.product = product;
this.errors = []; this.errors = [];
@@ -20,6 +19,6 @@ export class BasketItem {
} }
sum(): number { sum(): number {
return (this.quantity - this.getBonusQuantity()) * this.product.price; return (this.quantity - this.getBonusQuantity()) * this.product.price.amount;
} }
} }

View File

@@ -1,11 +1,12 @@
import { AlertMessage } from "#core:utils/alert-message.ts"; import { AlertMessage } from "#core:utils/alert-message";
import { BasketItem } from "#counter:counter/basket.ts"; import { BasketItem } from "#counter:counter/basket";
import type { import type {
CounterConfig, CounterConfig,
CounterItem,
ErrorMessage, ErrorMessage,
ProductFormula, ProductFormula,
} from "#counter:counter/types.ts"; } from "#counter:counter/types";
import type { CounterProductSelect } from "./components/counter-product-select-index.ts"; import type { CounterProductSelect } from "./components/counter-product-select-index";
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
Alpine.data("counter", (config: CounterConfig) => ({ Alpine.data("counter", (config: CounterConfig) => ({
@@ -63,8 +64,10 @@ document.addEventListener("alpine:init", () => {
}, },
checkFormulas() { checkFormulas() {
// Try to find a formula.
// A formula is found if all its elements are already in the basket
const products = new Set( const products = new Set(
Object.keys(this.basket).map((i: string) => Number.parseInt(i, 10)), Object.values(this.basket).map((item: BasketItem) => item.product.productId),
); );
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));
@@ -72,22 +75,29 @@ document.addEventListener("alpine:init", () => {
if (formula === undefined) { if (formula === undefined) {
return; return;
} }
// Now that the formula is found, remove the items composing it from the basket
for (const product of formula.products) { for (const product of formula.products) {
const key = product.toString(); const key = Object.entries(this.basket).find(
([_, i]: [string, BasketItem]) => i.product.productId === product,
)[0];
this.basket[key].quantity -= 1; this.basket[key].quantity -= 1;
if (this.basket[key].quantity <= 0) { if (this.basket[key].quantity <= 0) {
this.removeFromBasket(key); this.removeFromBasket(key);
} }
} }
// Then add the result product of the formula to the basket
const result = Object.values(config.products)
.filter((item: CounterItem) => item.productId === formula.result)
.reduce((acc, curr) => (acc.price.amount < curr.price.amount ? acc : curr));
this.addToBasket(result.price.id, 1);
this.alertMessage.display( this.alertMessage.display(
interpolate( interpolate(
gettext("Formula %(formula)s applied"), gettext("Formula %(formula)s applied"),
{ formula: config.products[formula.result.toString()].name }, { formula: result.name },
true, true,
), ),
{ success: true }, { success: true },
); );
this.addToBasket(formula.result.toString(), 1);
}, },
getBasketSize() { getBasketSize() {

View File

@@ -1,13 +1,9 @@
import { showSaveFilePicker } from "native-file-system-adapter"; import { showSaveFilePicker } from "native-file-system-adapter";
import type TomSelect from "tom-select"; import type TomSelect from "tom-select";
import { paginated } from "#core:utils/api.ts"; import { paginated } from "#core:utils/api";
import { csv } from "#core:utils/csv.ts"; import { csv } from "#core:utils/csv";
import { import { getCurrentUrlParams, History, updateQueryString } from "#core:utils/history";
getCurrentUrlParams, import type { NestedKeyOf } from "#core:utils/types";
History,
updateQueryString,
} from "#core:utils/history.ts";
import type { NestedKeyOf } from "#core:utils/types.ts";
import { import {
type ProductSchema, type ProductSchema,
type ProductSearchProductsDetailedData, type ProductSearchProductsDetailedData,
@@ -20,6 +16,9 @@ type GroupedProducts = Record<ProductType, ProductSchema[]>;
const defaultPageSize = 100; const defaultPageSize = 100;
const defaultPage = 1; const defaultPage = 1;
// biome-ignore lint/style/useNamingConvention: api is snake case
type ProductWithPriceSchema = ProductSchema & { selling_price: string };
/** /**
* Keys of the properties to include in the CSV. * Keys of the properties to include in the CSV.
*/ */
@@ -34,7 +33,7 @@ const csvColumns = [
"purchase_price", "purchase_price",
"selling_price", "selling_price",
"archived", "archived",
] as NestedKeyOf<ProductSchema>[]; ] as NestedKeyOf<ProductWithPriceSchema>[];
/** /**
* Title of the csv columns. * Title of the csv columns.
@@ -175,7 +174,16 @@ document.addEventListener("alpine:init", () => {
this.nbPages > 1 this.nbPages > 1
? await paginated(productSearchProductsDetailed, this.getQueryParams()) ? await paginated(productSearchProductsDetailed, this.getQueryParams())
: Object.values<ProductSchema[]>(this.products).flat(); : Object.values<ProductSchema[]>(this.products).flat();
const content = csv.stringify(products, { // CSV cannot represent nested data
// so we create a row for each price of each product.
const productsWithPrice: ProductWithPriceSchema[] = products.flatMap(
(product: ProductSchema) =>
product.prices.map((price) =>
// biome-ignore lint/style/useNamingConvention: API is snake_case
Object.assign(product, { selling_price: price.amount }),
),
);
const content = csv.stringify(productsWithPrice, {
columns: csvColumns, columns: csvColumns,
titleRow: csvColumnTitles, titleRow: csvColumnTitles,
}); });

View File

@@ -2,7 +2,7 @@ export type ErrorMessage = string;
export interface InitialFormData { export interface InitialFormData {
/* Used to refill the form when the backend raises an error */ /* Used to refill the form when the backend raises an error */
id?: keyof Record<string, Product>; id?: keyof Record<string, CounterItem>;
quantity?: number; quantity?: number;
errors?: string[]; errors?: string[];
} }
@@ -15,17 +15,22 @@ export interface ProductFormula {
export interface CounterConfig { export interface CounterConfig {
customerBalance: number; customerBalance: number;
customerId: number; customerId: number;
products: Record<string, Product>; products: Record<string, CounterItem>;
formulas: ProductFormula[]; formulas: ProductFormula[];
formInitial: InitialFormData[]; formInitial: InitialFormData[];
cancelUrl: string; cancelUrl: string;
} }
export interface Product { interface Price {
id: string; id: number;
amount: number;
}
export interface CounterItem {
productId: number;
price: Price;
code: string; code: string;
name: string; name: string;
price: number;
hasTrayPrice: boolean; hasTrayPrice: boolean;
quantityForTrayPrice: number; quantityForTrayPrice: number;
} }

View File

@@ -57,7 +57,7 @@
</table> </table>
<br> <br>
{% if is_paginated %} {% if is_paginated %}
{{ paginate_jinja(request, page_obj, paginator) }} {{ paginate_jinja(page_obj, paginator) }}
{% endif %} {% endif %}
{% else %} {% else %}
{% trans %}There is no cash register summary in this website.{% endtrans %} {% trans %}There is no cash register summary in this website.{% endtrans %}

View File

@@ -6,10 +6,10 @@
{% endblock %} {% endblock %}
{% block additional_css %} {% block additional_css %}
<link rel="stylesheet" type="text/css" href="{{ static('counter/css/counter-click.scss') }}" defer></link> <link rel="stylesheet" href="{{ static('counter/css/counter-click.scss') }}">
<link rel="stylesheet" type="text/css" href="{{ static('bundled/core/components/ajax-select-index.css') }}" defer></link> <link rel="stylesheet" href="{{ static('bundled/core/components/ajax-select-index.css') }}">
<link rel="stylesheet" type="text/css" href="{{ static('core/components/ajax-select.scss') }}" defer></link> <link rel="stylesheet" href="{{ static('core/components/ajax-select.scss') }}">
<link rel="stylesheet" type="text/css" href="{{ static('core/components/tabs.scss') }}" defer></link> <link rel="stylesheet" href="{{ static('core/components/tabs.scss') }}">
<link rel="stylesheet" href="{{ static("core/components/card.scss") }}"> <link rel="stylesheet" href="{{ static("core/components/card.scss") }}">
{% endblock %} {% endblock %}
@@ -65,10 +65,10 @@
<option value="FIN">{% trans %}Confirm (FIN){% endtrans %}</option> <option value="FIN">{% trans %}Confirm (FIN){% endtrans %}</option>
<option value="ANN">{% trans %}Cancel (ANN){% endtrans %}</option> <option value="ANN">{% trans %}Cancel (ANN){% endtrans %}</option>
</optgroup> </optgroup>
{%- for category in categories.keys() -%} {%- for category, prices in categories.items() -%}
<optgroup label="{{ category }}"> <optgroup label="{{ category }}">
{%- for product in categories[category] -%} {%- for price in prices -%}
<option value="{{ product.id }}">{{ product }}</option> <option value="{{ price.id }}">{{ price.full_label }}</option>
{%- endfor -%} {%- endfor -%}
</optgroup> </optgroup>
{%- endfor -%} {%- endfor -%}
@@ -103,24 +103,25 @@
</div> </div>
<ul> <ul>
<li x-show="getBasketSize() === 0">{% trans %}This basket is empty{% endtrans %}</li> <li x-show="getBasketSize() === 0">{% trans %}This basket is empty{% endtrans %}</li>
<template x-for="(item, index) in Object.values(basket)" :key="item.product.id"> <template x-for="(item, index) in Object.values(basket)" :key="item.product.price.id">
<li> <li>
<template x-for="error in item.errors"> <template x-for="error in item.errors">
<div class="alert alert-red" x-text="error"> <div class="alert alert-red" x-text="error">
</div> </div>
</template> </template>
<button @click.prevent="addToBasket(item.product.id, -1)">-</button> <button @click.prevent="addToBasket(item.product.price.id, -1)">-</button>
<span class="quantity" x-text="item.quantity"></span> <span class="quantity" x-text="item.quantity"></span>
<button @click.prevent="addToBasket(item.product.id, 1)">+</button> <button @click.prevent="addToBasket(item.product.price.id, 1)">+</button>
<span x-text="item.product.name"></span> : <span x-text="item.product.name"></span> :
<span x-text="item.sum().toLocaleString(undefined, { minimumFractionDigits: 2 })">€</span> <span x-text="item.sum().toLocaleString(undefined, { minimumFractionDigits: 2 })">€</span>
<span x-show="item.getBonusQuantity() > 0" x-text="`${item.getBonusQuantity()} x P`"></span> <span x-show="item.getBonusQuantity() > 0"
x-text="`${item.getBonusQuantity()} x P`"></span>
<button <button
class="remove-item" class="remove-item"
@click.prevent="removeFromBasket(item.product.id)" @click.prevent="removeFromBasket(item.product.price.id)"
><i class="fa fa-trash-can delete-action"></i></button> ><i class="fa fa-trash-can delete-action"></i></button>
<input <input
@@ -133,9 +134,9 @@
> >
<input <input
type="hidden" type="hidden"
:value="item.product.id" :value="item.product.price.id"
:id="`id_form-${index}-id`" :id="`id_form-${index}-price_id`"
:name="`form-${index}-id`" :name="`form-${index}-price_id`"
required required
readonly readonly
> >
@@ -201,30 +202,30 @@
</div> </div>
<div id="products"> <div id="products">
{% if not products %} {% if not prices %}
<div class="alert alert-red"> <div class="alert alert-red">
{% trans %}No products available on this counter for this user{% endtrans %} {% trans %}No products available on this counter for this user{% endtrans %}
</div> </div>
{% else %} {% else %}
<ui-tab-group> <ui-tab-group>
{% for category in categories.keys() -%} {% for category, prices in categories.items() -%}
<ui-tab title="{{ category }}" {% if loop.index == 1 -%}active{%- endif -%}> <ui-tab title="{{ category }}" {% if loop.index == 1 -%}active{%- endif -%}>
<h5 class="margin-bottom">{{ category }}</h5> <h5 class="margin-bottom">{{ category }}</h5>
<div class="row gap-2x"> <div class="row gap-2x">
{% for product in categories[category] -%} {% for price in prices -%}
<button class="card shadow" @click="addToBasket('{{ product.id }}', 1)"> <button class="card shadow" @click="addToBasket('{{ price.id }}', 1)">
<img <img
class="card-image" class="card-image"
alt="image de {{ product.name }}" alt="image de {{ price.full_label }}"
{% if product.icon %} {% if price.product.icon %}
src="{{ product.icon.url }}" src="{{ price.product.icon.url }}"
{% else %} {% else %}
src="{{ static('core/img/na.gif') }}" src="{{ static('core/img/na.gif') }}"
{% endif %} {% endif %}
/> />
<span class="card-content"> <span class="card-content">
<strong class="card-title">{{ product.name }}</strong> <strong class="card-title">{{ price.full_label }}</strong>
<p>{{ product.price }} €<br>{{ product.code }}</p> <p>{{ price.amount }} €<br>{{ price.product.code }}</p>
</span> </span>
</button> </button>
{%- endfor %} {%- endfor %}
@@ -241,13 +242,14 @@
{{ super() }} {{ super() }}
<script> <script>
const products = { const products = {
{%- for product in products -%} {%- for price in prices -%}
{{ product.id }}: { {{ price.id }}: {
id: "{{ product.id }}", productId: {{ price.product_id }},
name: "{{ product.name }}", price: { id: "{{ price.id }}", amount: {{ price.amount }} },
price: {{ product.price }}, code: "{{ price.product.code }}",
hasTrayPrice: {{ product.tray | tojson }}, name: "{{ price.full_label }}",
quantityForTrayPrice: {{ product.QUANTITY_FOR_TRAY_PRICE }}, hasTrayPrice: {{ price.product.tray | tojson }},
quantityForTrayPrice: {{ price.product.QUANTITY_FOR_TRAY_PRICE }},
}, },
{%- endfor -%} {%- endfor -%}
}; };

View File

@@ -49,14 +49,10 @@
<strong class="card-title">{{ formula.result.name }}</strong> <strong class="card-title">{{ formula.result.name }}</strong>
<p> <p>
{% for p in formula.products.all() %} {% for p in formula.products.all() %}
<i>{{ p.code }} ({{ p.selling_price }})</i> <i>{{ p.name }} ({{ p.code }})</i>
{% if not loop.last %}+{% endif %} {% if not loop.last %}+{% endif %}
{% endfor %} {% endfor %}
</p> </p>
<p>
{{ formula.result.selling_price }}
({% trans %}instead of{% endtrans %} {{ formula.max_selling_price}} €)
</p>
</div> </div>
{% if user.has_perm("counter.delete_productformula") %} {% if user.has_perm("counter.delete_productformula") %}
<button <button

View File

@@ -39,6 +39,49 @@
{% endmacro %} {% endmacro %}
{% macro price_form(form) %}
<fieldset>
{{ form.non_field_errors() }}
<div class="form-group row gap-2x">
<div>{{ form.amount.as_field_group() }}</div>
<div>
{{ form.label.errors }}
<label for="{{ form.label.id_for_label }}">{{ form.label.label }}</label>
{{ form.label }}
<span class="helptext">{{ form.label.help_text }}</span>
</div>
<div class="grow">{{ form.groups.as_field_group() }}</div>
</div>
<div class="form-group">
<div>
{{ form.is_always_shown.errors }}
<div class="row gap">
{{ form.is_always_shown }}
<label for="{{ form.is_always_shown.id_for_label }}">{{ form.is_always_shown.label }}</label>
</div>
<span class="helptext">{{ form.is_always_shown.help_text }}</span>
</div>
</div>
{%- if form.DELETE -%}
<div class="form-group row gap">
{{ form.DELETE.as_field_group() }}
</div>
{%- else -%}
<br>
<button
class="btn btn-grey"
@click.prevent="removeForm($event.target.closest('fieldset').parentElement)"
>
<i class="fa fa-minus"></i> {% trans %}Remove price{% endtrans %}
</button>
{%- endif -%}
{%- for field in form.hidden_fields() -%}
{{ field }}
{%- endfor -%}
</fieldset>
<hr class="margin-bottom">
{% endmacro %}
{% block content %} {% block content %}
{% if object %} {% if object %}
<h2>{% trans name=object %}Edit product {{ name }}{% endtrans %}</h2> <h2>{% trans name=object %}Edit product {{ name }}{% endtrans %}</h2>
@@ -49,7 +92,54 @@
{% endif %} {% endif %}
<form method="post" enctype="multipart/form-data"> <form method="post" enctype="multipart/form-data">
{% csrf_token %} {% csrf_token %}
{{ form.as_p() }} {{ form.non_field_errors() }}
<fieldset class="row gap">
<div>{{ form.name.as_field_group() }}</div>
<div>{{ form.code.as_field_group() }}</div>
</fieldset>
<fieldset>
<div class="form-group">{{ form.description.as_field_group() }}</div>
</fieldset>
<fieldset class="row gap">
<div>{{ form.club.as_field_group() }}</div>
<div>{{ form.product_type.as_field_group() }}</div>
</fieldset>
<fieldset><div>{{ form.icon.as_field_group() }}</div></fieldset>
<fieldset><div>{{ form.purchase_price.as_field_group() }}</div></fieldset>
<fieldset>
<div>{{ form.limit_age.as_field_group() }}</div>
</fieldset>
<fieldset>
<div class="row gap">
{{ form.tray }}
<div>
{{ form.tray.label_tag() }}
<span class="helptext">{{ form.tray.help_text }}</span>
</div>
</div>
</fieldset>
<fieldset><div>{{ form.counters.as_field_group() }}</div></fieldset>
<h3 class="margin-bottom">{% trans %}Prices{% endtrans %}</h3>
<div x-data="dynamicFormSet({ prefix: '{{ form.price_formset.prefix }}' })">
{{ form.price_formset.management_form }}
<div x-ref="formContainer">
{%- for form in form.price_formset.forms -%}
<div>
{{ price_form(form) }}
</div>
{%- endfor -%}
</div>
<template x-ref="formTemplate">
<div>
{{ price_form(form.price_formset.empty_form) }}
</div>
</template>
<button class="btn btn-grey" @click.prevent="addForm()">
<i class="fa fa-plus"></i> {% trans %}Add a price{% endtrans %}
</button>
</div>
<br /> <br />
@@ -64,7 +154,7 @@
</em> </em>
</p> </p>
<div x-data="dynamicFormSet" class="margin-bottom"> <div x-data="dynamicFormSet({ prefix: '{{ form.action_formset.prefix }}' })" class="margin-bottom">
{{ form.action_formset.management_form }} {{ form.action_formset.management_form }}
<div x-ref="formContainer"> <div x-ref="formContainer">
{%- for f in form.action_formset.forms -%} {%- for f in form.action_formset.forms -%}
@@ -78,6 +168,7 @@
<i class="fa fa-plus"></i>{% trans %}Add action{% endtrans %} <i class="fa fa-plus"></i>{% trans %}Add action{% endtrans %}
</button> </button>
</div> </div>
<div class="row gap margin-bottom">{{ form.archived.as_field_group() }}</div>
<p><input class="btn btn-blue" type="submit" value="{% trans %}Save{% endtrans %}" /></p> <p><input class="btn btn-blue" type="submit" value="{% trans %}Save{% endtrans %}" /></p>
</form> </form>
{% endblock %} {% endblock %}

View File

@@ -108,7 +108,7 @@
</template> </template>
<span class="card-content"> <span class="card-content">
<strong class="card-title" x-text="`${p.name} (${p.code})`"></strong> <strong class="card-title" x-text="`${p.name} (${p.code})`"></strong>
<p x-text="`${p.selling_price} €`"></p> <p x-text="`${p.prices.map((p) => p.amount).join(' ')} €`"></p>
</span> </span>
</a> </a>
</template> </template>

View File

@@ -28,7 +28,7 @@
{%- endfor %} {%- endfor %}
</table> </table>
{% if is_paginated %} {% if is_paginated %}
{{ paginate_jinja(request, page_obj, paginator) }} {{ paginate_jinja(page_obj, paginator) }}
{% endif %} {% endif %}
{%- endblock %} {%- endblock %}

View File

@@ -16,7 +16,7 @@ from counter.forms import (
ScheduledProductActionForm, ScheduledProductActionForm,
ScheduledProductActionFormSet, ScheduledProductActionFormSet,
) )
from counter.models import Product, ScheduledProductAction from counter.models import Product, ProductType, ScheduledProductAction
@pytest.mark.django_db @pytest.mark.django_db
@@ -47,20 +47,22 @@ def test_create_actions_alongside_product():
form = ProductForm( form = ProductForm(
data={ data={
"name": "foo", "name": "foo",
"description": "bar", "product_type": ProductType.objects.first(),
"product_type": product.product_type_id,
"club": product.club_id, "club": product.club_id,
"code": "FOO", "code": "FOO",
"purchase_price": 1.0, "purchase_price": 1.0,
"selling_price": 1.0, "selling_price": 1.0,
"special_selling_price": 1.0, "special_selling_price": 1.0,
"limit_age": 0, "limit_age": 0,
"form-TOTAL_FORMS": "2", "price-TOTAL_FORMS": "0",
"form-INITIAL_FORMS": "0", "price-INITIAL_FORMS": "0",
"form-0-task": "counter.tasks.archive_product", "action-TOTAL_FORMS": "1",
"form-0-trigger_at": trigger_at, "action-INITIAL_FORMS": "0",
"action-0-task": "counter.tasks.archive_product",
"action-0-trigger_at": trigger_at,
}, },
) )
form.is_valid()
assert form.is_valid() assert form.is_valid()
product = form.save() product = form.save()
action = ScheduledProductAction.objects.last() action = ScheduledProductAction.objects.last()

View File

@@ -20,7 +20,6 @@ import pytest
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import Permission, make_password from django.contrib.auth.models import Permission, make_password
from django.core.cache import cache
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import resolve_url from django.shortcuts import resolve_url
from django.test import Client, TestCase from django.test import Client, TestCase
@@ -32,15 +31,15 @@ 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 ClubRole, Membership from club.models import 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, Group, User
from counter.baker_recipes import product_recipe, sale_recipe from counter.baker_recipes import price_recipe, product_recipe, sale_recipe
from counter.models import ( from counter.models import (
Counter, Counter,
Customer, Customer,
Permanency, Permanency,
Product, ProductType,
Refilling, Refilling,
ReturnableProduct, ReturnableProduct,
Selling, Selling,
@@ -88,7 +87,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=baker.make(ClubRole, club=cls.club_counter.club, is_board=True), role=settings.SITH_CLUB_ROLES_ID["Board member"],
user=cls.club_admin, user=cls.club_admin,
) )
@@ -204,7 +203,7 @@ class TestRefilling(TestFullClickBase):
@dataclass @dataclass
class BasketItem: class BasketItem:
id: int | None = None price_id: int | None = None
quantity: int | None = None quantity: int | None = None
def to_form(self, index: int) -> dict[str, str]: def to_form(self, index: int) -> dict[str, str]:
@@ -236,38 +235,59 @@ class TestCounterClick(TestFullClickBase):
cls.banned_counter_customer.ban_groups.add( cls.banned_counter_customer.ban_groups.add(
BanGroup.objects.get(pk=settings.SITH_GROUP_BANNED_COUNTER_ID) BanGroup.objects.get(pk=settings.SITH_GROUP_BANNED_COUNTER_ID)
) )
subscriber_group = Group.objects.get(id=settings.SITH_GROUP_SUBSCRIBERS_ID)
old_subscriber_group = Group.objects.get(
id=settings.SITH_GROUP_OLD_SUBSCRIBERS_ID
)
_product_recipe = product_recipe.extend(product_type=baker.make(ProductType))
cls.gift = product_recipe.make( cls.gift = price_recipe.make(
selling_price="-1.5", amount=-1.5, groups=[subscriber_group], product=_product_recipe.make()
special_selling_price="-1.5",
) )
cls.beer = product_recipe.make( cls.beer = price_recipe.make(
limit_age=18, selling_price=1.5, special_selling_price=1 groups=[subscriber_group],
amount=1.5,
product=_product_recipe.make(limit_age=18),
) )
cls.beer_tap = product_recipe.make( cls.beer_tap = price_recipe.make(
limit_age=18, tray=True, selling_price=1.5, special_selling_price=1 groups=[subscriber_group],
amount=1.5,
product=_product_recipe.make(limit_age=18, tray=True),
) )
cls.snack = product_recipe.make( cls.snack = price_recipe.make(
limit_age=0, selling_price=1.5, special_selling_price=1 groups=[subscriber_group, old_subscriber_group],
amount=1.5,
product=_product_recipe.make(limit_age=0),
) )
cls.stamps = product_recipe.make( cls.stamps = price_recipe.make(
limit_age=0, selling_price=1.5, special_selling_price=1 groups=[subscriber_group],
amount=1.5,
product=_product_recipe.make(limit_age=0),
) )
ReturnableProduct.objects.all().delete() ReturnableProduct.objects.all().delete()
cls.cons = baker.make(Product, selling_price=1) cls.cons = price_recipe.make(
cls.dcons = baker.make(Product, selling_price=-1) amount=1, groups=[subscriber_group], product=_product_recipe.make()
)
cls.dcons = price_recipe.make(
amount=-1, groups=[subscriber_group], product=_product_recipe.make()
)
baker.make( baker.make(
ReturnableProduct, ReturnableProduct,
product=cls.cons, product=cls.cons.product,
returned_product=cls.dcons, returned_product=cls.dcons.product,
max_return=3, max_return=3,
) )
cls.counter.products.add( cls.counter.products.add(
cls.gift, cls.beer, cls.beer_tap, cls.snack, cls.cons, cls.dcons cls.gift.product,
cls.beer.product,
cls.beer_tap.product,
cls.snack.product,
cls.cons.product,
cls.dcons.product,
) )
cls.other_counter.products.add(cls.snack) cls.other_counter.products.add(cls.snack.product)
cls.club_counter.products.add(cls.stamps) cls.club_counter.products.add(cls.stamps.product)
def login_in_bar(self, barmen: User | None = None): def login_in_bar(self, barmen: User | None = None):
used_barman = barmen if barmen is not None else self.barmen used_barman = barmen if barmen is not None else self.barmen
@@ -285,10 +305,7 @@ class TestCounterClick(TestFullClickBase):
) -> HttpResponse: ) -> HttpResponse:
used_counter = counter if counter is not None else self.counter used_counter = counter if counter is not None else self.counter
used_client = client if client is not None else self.client used_client = client if client is not None else self.client
data = { data = {"form-TOTAL_FORMS": str(len(basket)), "form-INITIAL_FORMS": "0"}
"form-TOTAL_FORMS": str(len(basket)),
"form-INITIAL_FORMS": "0",
}
for index, item in enumerate(basket): for index, item in enumerate(basket):
data.update(item.to_form(index)) data.update(item.to_form(index))
return used_client.post( return used_client.post(
@@ -331,32 +348,22 @@ class TestCounterClick(TestFullClickBase):
res = self.submit_basket( res = self.submit_basket(
self.customer, [BasketItem(self.beer.id, 2), BasketItem(self.snack.id, 1)] self.customer, [BasketItem(self.beer.id, 2), BasketItem(self.snack.id, 1)]
) )
assert res.status_code == 302 self.assertRedirects(res, self.counter.get_absolute_url())
assert self.updated_amount(self.customer) == Decimal("5.5") assert self.updated_amount(self.customer) == Decimal("5.5")
# Test barmen special price
force_refill_user(self.barmen, 10)
assert (
self.submit_basket(self.barmen, [BasketItem(self.beer.id, 1)])
).status_code == 302
assert self.updated_amount(self.barmen) == Decimal(9)
def test_click_tray_price(self): def test_click_tray_price(self):
force_refill_user(self.customer, 20) force_refill_user(self.customer, 20)
self.login_in_bar(self.barmen) self.login_in_bar(self.barmen)
# Not applying tray price # Not applying tray price
res = self.submit_basket(self.customer, [BasketItem(self.beer_tap.id, 2)]) res = self.submit_basket(self.customer, [BasketItem(self.beer_tap.id, 2)])
assert res.status_code == 302 self.assertRedirects(res, self.counter.get_absolute_url())
assert self.updated_amount(self.customer) == Decimal(17) assert self.updated_amount(self.customer) == Decimal(17)
# Applying tray price # Applying tray price
res = self.submit_basket(self.customer, [BasketItem(self.beer_tap.id, 7)]) res = self.submit_basket(self.customer, [BasketItem(self.beer_tap.id, 7)])
assert res.status_code == 302 self.assertRedirects(res, self.counter.get_absolute_url())
assert self.updated_amount(self.customer) == Decimal(8) assert self.updated_amount(self.customer) == Decimal(8)
def test_click_alcool_unauthorized(self): def test_click_alcool_unauthorized(self):
@@ -477,7 +484,8 @@ class TestCounterClick(TestFullClickBase):
BasketItem(None, 1), BasketItem(None, 1),
BasketItem(self.beer.id, None), BasketItem(self.beer.id, None),
]: ]:
assert self.submit_basket(self.customer, [item]).status_code == 200 res = self.submit_basket(self.customer, [item])
assert res.status_code == 200
assert self.updated_amount(self.customer) == Decimal(10) assert self.updated_amount(self.customer) == Decimal(10)
def test_click_not_enough_money(self): def test_click_not_enough_money(self):
@@ -506,29 +514,30 @@ class TestCounterClick(TestFullClickBase):
res = self.submit_basket( res = self.submit_basket(
self.customer, [BasketItem(self.beer.id, 1), BasketItem(self.gift.id, 1)] self.customer, [BasketItem(self.beer.id, 1), BasketItem(self.gift.id, 1)]
) )
assert res.status_code == 302 self.assertRedirects(res, self.counter.get_absolute_url())
assert self.updated_amount(self.customer) == 0 assert self.updated_amount(self.customer) == 0
def test_recordings(self): def test_recordings(self):
force_refill_user(self.customer, self.cons.selling_price * 3) force_refill_user(self.customer, self.cons.amount * 3)
self.login_in_bar(self.barmen) self.login_in_bar(self.barmen)
res = self.submit_basket(self.customer, [BasketItem(self.cons.id, 3)]) res = self.submit_basket(self.customer, [BasketItem(self.cons.id, 3)])
assert res.status_code == 302 assert res.status_code == 302
assert self.updated_amount(self.customer) == 0 assert self.updated_amount(self.customer) == 0
assert list( assert list(
self.customer.customer.return_balances.values("returnable", "balance") self.customer.customer.return_balances.values("returnable", "balance")
) == [{"returnable": self.cons.cons.id, "balance": 3}] ) == [{"returnable": self.cons.product.cons.id, "balance": 3}]
res = self.submit_basket(self.customer, [BasketItem(self.dcons.id, 3)]) res = self.submit_basket(self.customer, [BasketItem(self.dcons.id, 3)])
assert res.status_code == 302 assert res.status_code == 302
assert self.updated_amount(self.customer) == self.dcons.selling_price * -3 assert self.updated_amount(self.customer) == self.dcons.amount * -3
res = self.submit_basket( res = self.submit_basket(
self.customer, [BasketItem(self.dcons.id, self.dcons.dcons.max_return)] self.customer,
[BasketItem(self.dcons.id, self.dcons.product.dcons.max_return)],
) )
# from now on, the user amount should not change # from now on, the user amount should not change
expected_amount = self.dcons.selling_price * (-3 - self.dcons.dcons.max_return) expected_amount = self.dcons.amount * (-3 - self.dcons.product.dcons.max_return)
assert res.status_code == 302 assert res.status_code == 302
assert self.updated_amount(self.customer) == expected_amount assert self.updated_amount(self.customer) == expected_amount
@@ -545,48 +554,57 @@ class TestCounterClick(TestFullClickBase):
def test_recordings_when_negative(self): def test_recordings_when_negative(self):
sale_recipe.make( sale_recipe.make(
customer=self.customer.customer, customer=self.customer.customer,
product=self.dcons, product=self.dcons.product,
unit_price=self.dcons.selling_price, unit_price=self.dcons.amount,
quantity=10, quantity=10,
) )
self.customer.customer.update_returnable_balance() self.customer.customer.update_returnable_balance()
self.login_in_bar(self.barmen) self.login_in_bar(self.barmen)
res = self.submit_basket(self.customer, [BasketItem(self.dcons.id, 1)]) res = self.submit_basket(self.customer, [BasketItem(self.dcons.id, 1)])
assert res.status_code == 200 assert res.status_code == 200
assert self.updated_amount(self.customer) == self.dcons.selling_price * -10 assert self.updated_amount(self.customer) == self.dcons.amount * -10
res = self.submit_basket(self.customer, [BasketItem(self.cons.id, 3)]) res = self.submit_basket(self.customer, [BasketItem(self.cons.id, 3)])
assert res.status_code == 302 assert res.status_code == 302
assert ( assert (
self.updated_amount(self.customer) self.updated_amount(self.customer)
== self.dcons.selling_price * -10 - self.cons.selling_price * 3 == self.dcons.amount * -10 - self.cons.amount * 3
) )
res = self.submit_basket(self.customer, [BasketItem(self.beer.id, 1)]) res = self.submit_basket(self.customer, [BasketItem(self.beer.id, 1)])
assert res.status_code == 302 assert res.status_code == 302
assert ( assert (
self.updated_amount(self.customer) self.updated_amount(self.customer)
== self.dcons.selling_price * -10 == self.dcons.amount * -10 - self.cons.amount * 3 - self.beer.amount
- self.cons.selling_price * 3
- self.beer.selling_price
) )
def test_no_fetch_archived_product(self): def test_no_fetch_archived_product(self):
counter = baker.make(Counter) counter = baker.make(Counter)
group = baker.make(Group)
customer = baker.make(Customer) customer = baker.make(Customer)
product_recipe.make(archived=True, counters=[counter]) group.users.add(customer.user)
unarchived_products = product_recipe.make( _product_recipe = product_recipe.extend(
archived=False, counters=[counter], _quantity=3 counters=[counter], product_type=baker.make(ProductType)
) )
customer_products = counter.get_products_for(customer) price_recipe.make(
assert unarchived_products == customer_products _quantity=2,
product=iter(_product_recipe.make(archived=True, _quantity=2)),
groups=[group],
)
unarchived_prices = price_recipe.make(
_quantity=2,
product=iter(_product_recipe.make(archived=False, _quantity=2)),
groups=[group],
)
customer_prices = counter.get_prices_for(customer)
assert unarchived_prices == customer_prices
class TestCounterStats(TestCase): class TestCounterStats(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
cls.users = subscriber_user.make(_quantity=4) cls.users = subscriber_user.make(_quantity=4)
product = product_recipe.make(selling_price=1) product = price_recipe.make(amount=1).product
cls.counter = baker.make( cls.counter = baker.make(
Counter, type=["BAR"], sellers=cls.users[:4], products=[product] Counter, type=["BAR"], sellers=cls.users[:4], products=[product]
) )
@@ -782,17 +800,8 @@ 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()
def setUp(self): cls.user = subscriber_user.make()
cache.clear()
def test_anonymous(self): def test_anonymous(self):
res = self.client.get(self.click_url) res = self.client.get(self.click_url)
@@ -803,17 +812,13 @@ 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( baker.make(Membership, club=self.counter.club, user=self.user, role=1)
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( baker.make(Membership, club=self.counter.club, user=self.user, role=3)
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
@@ -828,9 +833,7 @@ 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( baker.make(Membership, club=self.counter.club, user=self.user, role=3)
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

@@ -3,13 +3,14 @@ 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 ClubRole, Membership from club.models import 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
@@ -41,12 +42,11 @@ 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=role, role=settings.SITH_CLUB_ROLES_ID["Board member"],
user=cls.club_admin, user=cls.club_admin,
) )
@@ -341,7 +341,7 @@ def test_update_balance():
def test_update_returnable_balance(): def test_update_returnable_balance():
ReturnableProduct.objects.all().delete() ReturnableProduct.objects.all().delete()
customer = baker.make(Customer) customer = baker.make(Customer)
products = product_recipe.make(selling_price=0, _quantity=4, _bulk_create=True) products = product_recipe.make(_quantity=4, _bulk_create=True)
returnables = [ returnables = [
baker.make( baker.make(
ReturnableProduct, product=products[0], returned_product=products[1] ReturnableProduct, product=products[0], returned_product=products[1]

View File

@@ -7,12 +7,7 @@ from counter.forms import ProductFormulaForm
class TestFormulaForm(TestCase): class TestFormulaForm(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
cls.products = product_recipe.make( cls.products = product_recipe.make(_quantity=3, _bulk_create=True)
selling_price=iter([1.5, 1, 1]),
special_selling_price=iter([1.4, 0.9, 0.9]),
_quantity=3,
_bulk_create=True,
)
def test_ok(self): def test_ok(self):
form = ProductFormulaForm( form = ProductFormulaForm(
@@ -26,23 +21,6 @@ class TestFormulaForm(TestCase):
assert formula.result == self.products[0] assert formula.result == self.products[0]
assert set(formula.products.all()) == set(self.products[1:]) assert set(formula.products.all()) == set(self.products[1:])
def test_price_invalid(self):
self.products[0].selling_price = 2.1
self.products[0].save()
form = ProductFormulaForm(
data={
"result": self.products[0].id,
"products": [self.products[1].id, self.products[2].id],
}
)
assert not form.is_valid()
assert form.errors == {
"result": [
"Le résultat ne peut pas être plus cher "
"que le total des autres produits."
]
}
def test_product_both_in_result_and_products(self): def test_product_both_in_result_and_products(self):
form = ProductFormulaForm( form = ProductFormulaForm(
data={ data={

View File

@@ -9,6 +9,7 @@ from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import Client, TestCase from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
from model_bakery import baker from model_bakery import baker
from model_bakery.recipe import Recipe
from PIL import Image from PIL import Image
from pytest_django.asserts import assertNumQueries, assertRedirects from pytest_django.asserts import assertNumQueries, assertRedirects
@@ -16,8 +17,8 @@ from club.models import Club
from core.baker_recipes import board_user, subscriber_user from core.baker_recipes import board_user, subscriber_user
from core.models import Group, 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 ProductForm from counter.forms import ProductForm, ProductPriceFormSet
from counter.models import Product, ProductFormula, ProductType from counter.models import Price, Product, ProductType
@pytest.mark.django_db @pytest.mark.django_db
@@ -81,11 +82,11 @@ def test_fetch_product_access(
def test_fetch_product_nb_queries(client: Client): def test_fetch_product_nb_queries(client: Client):
client.force_login(baker.make(User, is_superuser=True)) client.force_login(baker.make(User, is_superuser=True))
cache.clear() cache.clear()
with assertNumQueries(5): with assertNumQueries(6):
# - 2 for authentication # - 2 for authentication
# - 1 for pagination # - 1 for pagination
# - 1 for the actual request # - 1 for the actual request
# - 1 to prefetch the related buying_groups # - 2 to prefetch the related prices and groups
client.get(reverse("api:search_products_detailed")) client.get(reverse("api:search_products_detailed"))
@@ -107,48 +108,21 @@ class TestCreateProduct(TestCase):
"selling_price": 1.0, "selling_price": 1.0,
"special_selling_price": 1.0, "special_selling_price": 1.0,
"limit_age": 0, "limit_age": 0,
"form-TOTAL_FORMS": 0, "price-TOTAL_FORMS": 0,
"form-INITIAL_FORMS": 0, "price-INITIAL_FORMS": 0,
"action-TOTAL_FORMS": 0,
"action-INITIAL_FORMS": 0,
} }
def test_form(self): def test_form_simple(self):
form = ProductForm(data=self.data) form = ProductForm(data=self.data)
assert form.is_valid() assert form.is_valid()
instance = form.save() instance = form.save()
assert instance.club == self.club assert instance.club == self.club
assert instance.product_type == self.product_type assert instance.product_type == self.product_type
assert instance.name == "foo" assert instance.name == "foo"
assert instance.selling_price == 1.0
def test_form_with_product_from_formula(self): def test_view_simple(self):
"""Test when the edited product is a result of a formula."""
self.client.force_login(self.counter_admin)
products = product_recipe.make(
selling_price=iter([1.5, 1, 1]),
special_selling_price=iter([1.4, 0.9, 0.9]),
_quantity=3,
_bulk_create=True,
)
baker.make(ProductFormula, result=products[0], products=products[1:])
data = self.data | {"selling_price": 1.7, "special_selling_price": 1.5}
form = ProductForm(data=data, instance=products[0])
assert form.is_valid()
# it shouldn't be possible to give a price higher than the formula's products
data = self.data | {"selling_price": 2.1, "special_selling_price": 1.9}
form = ProductForm(data=data, instance=products[0])
assert not form.is_valid()
assert form.errors == {
"selling_price": [
"Assurez-vous que cette valeur est inférieure ou égale à 2.00."
],
"special_selling_price": [
"Assurez-vous que cette valeur est inférieure ou égale à 1.80."
],
}
def test_view(self):
self.client.force_login(self.counter_admin) self.client.force_login(self.counter_admin)
url = reverse("counter:new_product") url = reverse("counter:new_product")
response = self.client.get(url) response = self.client.get(url)
@@ -159,3 +133,92 @@ class TestCreateProduct(TestCase):
assert product.name == "foo" assert product.name == "foo"
assert product.club == self.club assert product.club == self.club
assert product.product_type == self.product_type assert product.product_type == self.product_type
class TestPriceFormSet(TestCase):
@classmethod
def setUpTestData(cls):
cls.product = product_recipe.make()
cls.counter_admin = baker.make(
User, groups=[Group.objects.get(id=settings.SITH_GROUP_COUNTER_ADMIN_ID)]
)
cls.groups = baker.make(Group, _quantity=3)
def test_add_price(self):
data = {
"prices-0-amount": 2,
"prices-0-label": "foo",
"prices-0-groups": [self.groups[0].id, self.groups[1].id],
"prices-0-is_always_shown": True,
"prices-1-amount": 1.5,
"prices-1-label": "",
"prices-1-groups": [self.groups[1].id, self.groups[2].id],
"prices-1-is_always_shown": False,
"prices-TOTAL_FORMS": 2,
"prices-INITIAL_FORMS": 0,
}
form = ProductPriceFormSet(instance=self.product, data=data)
assert form.is_valid()
form.save()
prices = list(self.product.prices.order_by("amount"))
assert len(prices) == 2
assert prices[0].amount == 1.5
assert prices[0].label == ""
assert prices[0].is_always_shown is False
assert set(prices[0].groups.all()) == {self.groups[1], self.groups[2]}
assert prices[1].amount == 2
assert prices[1].label == "foo"
assert prices[1].is_always_shown is True
assert set(prices[1].groups.all()) == {self.groups[0], self.groups[1]}
def test_change_prices(self):
price_a = baker.make(
Price, product=self.product, amount=1.5, groups=self.groups[:1]
)
price_b = baker.make(
Price, product=self.product, amount=2, groups=self.groups[1:]
)
data = {
"prices-0-id": price_a.id,
"prices-0-DELETE": True,
"prices-1-id": price_b.id,
"prices-1-DELETE": False,
"prices-1-amount": 3,
"prices-1-label": "foo",
"prices-1-groups": [self.groups[1].id],
"prices-1-is_always_shown": True,
"prices-TOTAL_FORMS": 2,
"prices-INITIAL_FORMS": 2,
}
form = ProductPriceFormSet(instance=self.product, data=data)
assert form.is_valid()
form.save()
prices = list(self.product.prices.order_by("amount"))
assert len(prices) == 1
assert prices[0].amount == 3
assert prices[0].label == "foo"
assert prices[0].is_always_shown is True
assert set(prices[0].groups.all()) == {self.groups[1]}
assert not Price.objects.filter(id=price_a.id).exists()
@pytest.mark.django_db
def test_price_for_user():
groups = baker.make(Group, _quantity=4)
users = [
baker.make(User, groups=groups[:2]),
baker.make(User, groups=groups[1:3]),
baker.make(User, groups=[groups[3]]),
]
recipe = Recipe(Price, product=product_recipe.make())
prices = [
recipe.make(amount=5, groups=groups, is_always_shown=True),
recipe.make(amount=4, groups=[groups[0]], is_always_shown=True),
recipe.make(amount=3, groups=[groups[1]], is_always_shown=False),
recipe.make(amount=2, groups=[groups[3]], is_always_shown=False),
recipe.make(amount=1, groups=[groups[1]], is_always_shown=False),
]
qs = Price.objects.order_by("-amount")
assert set(qs.for_user(users[0])) == {prices[0], prices[1], prices[4]}
assert set(qs.for_user(users[1])) == {prices[0], prices[4]}
assert set(qs.for_user(users[2])) == {prices[0], prices[3]}

View File

@@ -73,7 +73,7 @@ class CounterClick(
kwargs["form_kwargs"] = { kwargs["form_kwargs"] = {
"customer": self.customer, "customer": self.customer,
"counter": self.object, "counter": self.object,
"allowed_products": {product.id: product for product in self.products}, "allowed_prices": {price.id: price for price in self.prices},
} }
return kwargs return kwargs
@@ -103,7 +103,7 @@ class CounterClick(
): ):
return redirect(obj) # Redirect to counter return redirect(obj) # Redirect to counter
self.products = obj.get_products_for(self.customer) self.prices = obj.get_prices_for(self.customer)
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
@@ -121,32 +121,31 @@ class CounterClick(
# This is important because some items have a negative price # This is important because some items have a negative price
# Negative priced items gives money to the customer and should # Negative priced items gives money to the customer and should
# be processed first so that we don't throw a not enough money error # be processed first so that we don't throw a not enough money error
for form in sorted(formset, key=lambda form: form.product.price): for form in sorted(formset, key=lambda form: form.price.amount):
self.request.session["last_basket"].append( self.request.session["last_basket"].append(
f"{form.cleaned_data['quantity']} x {form.product.name}" f"{form.cleaned_data['quantity']} x {form.price.full_label}"
) )
common_kwargs = {
"product": form.price.product,
"club_id": form.price.product.club_id,
"counter": self.object,
"seller": operator,
"customer": self.customer,
}
Selling( Selling(
label=form.product.name, **common_kwargs,
product=form.product, label=form.price.full_label,
club=form.product.club, unit_price=form.price.amount,
counter=self.object,
unit_price=form.product.price,
quantity=form.cleaned_data["quantity"] quantity=form.cleaned_data["quantity"]
- form.cleaned_data["bonus_quantity"], - form.cleaned_data["bonus_quantity"],
seller=operator,
customer=self.customer,
).save() ).save()
if form.cleaned_data["bonus_quantity"] > 0: if form.cleaned_data["bonus_quantity"] > 0:
Selling( Selling(
label=f"{form.product.name} (Plateau)", **common_kwargs,
product=form.product, label=f"{form.price.full_label} (Plateau)",
club=form.product.club,
counter=self.object,
unit_price=0, unit_price=0,
quantity=form.cleaned_data["bonus_quantity"], quantity=form.cleaned_data["bonus_quantity"],
seller=operator,
customer=self.customer,
).save() ).save()
self.customer.update_returnable_balance() self.customer.update_returnable_balance()
@@ -207,14 +206,13 @@ class CounterClick(
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
"""Add customer to the context.""" """Add customer to the context."""
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)
kwargs["products"] = self.products kwargs["prices"] = self.prices
kwargs["formulas"] = ProductFormula.objects.filter( kwargs["formulas"] = ProductFormula.objects.filter(
result__in=self.products result__in=[p.product_id for p in self.prices]
).prefetch_related("products") ).prefetch_related("products")
kwargs["categories"] = defaultdict(list) kwargs["categories"] = defaultdict(list)
for product in kwargs["products"]: for price in self.prices:
if product.product_type: kwargs["categories"][price.product.product_type].append(price)
kwargs["categories"][product.product_type].append(product)
kwargs["customer"] = self.customer kwargs["customer"] = self.customer
kwargs["cancel_url"] = self.get_success_url() kwargs["cancel_url"] = self.get_success_url()

View File

@@ -1,73 +0,0 @@
## Pourquoi Xapian
Xapian permet de faire de la recherche fulltext.
C'est une librairie écrite en C++ avec des bindings Python
qu'on utilise avec la dépendance `django-haystack` via `xapian-haystack`.
Elle a les avantages suivants:
* C'est très rapide et ça correspond très bien à notre échelle
* C'est performant
* Pas besoin de service supplémentaire, c'est une librairie qui utilise des fichiers, comme sqlite
Mais elle a un défaut majeur: on ne peut pas « juste » la `pip install`,
il faut installer une librairie système et des bindings et ça a toujours été
l'étape la plus frustrante et buggée de notre process d'installation. C'est
aussi la seule raison qui fait que le projet n'es pas compatible windows.
## Mettre à jour Xapian
Pour installer xapian le plus simplement possible, on le compile depuis les
sources via la commande `./manage.py install_xapian` comme indiqué dans la
documentation d'installation.
La version de xapian est contrôlée par le `pyproject.toml` dans la section
`[tool.xapian]`.
Cette section ressemble à ceci:
```toml
[tool.xapian]
version = "x.y.z"
core-sha256 = "abcdefghijklmnopqrstuvwyz0123456789"
bindings-sha256 = "abcdefghijklmnopqrstuvwyz0123456789"
```
Comme on peut le voir, il y a 3 variables différentes, une variable de version,
qui sert à choisir la version à télécharger, et deux variables sha256.
Ces variables sha256 permettent de protéger des attaques par supply chain, un
peu comme uv et npm font avec leurs respectifs `uv.lock` et `package-lock.json`
. Elles permettent de vérifier que les fichiers téléchargés n'ont pas été
altérés entre la configuration du fichier et l'installation par l'utilisateur
et/ou le déploiement.
L'installation de xapian passe par deux fichiers, `xapian-core` et
`xapian-bindings` disponibles sur [https://xapian.org/download](https://xapian.org/download).
Lorsque le script d'installation télécharge les fichiers, il vérifie leur
signature sha256 contre celles contenues dans ces deux variables. Si la
signature n'est pas la même, une erreur est levée, protégant l'utilisateur
d'une potentielle attaque.
Pour mettre à jour, il faut donc changer la version ET modifier la signature !
Pour récupérer ces signatures, il suffit de télécharger soi-même les archives
du logiciel sur ce site, utiliser la commande `sha256sum` dessus et, enfin,
reporter la valeur sortie par cette commande.
Pour ce qui est de la correspondance, `core-sha256` correspond à la signature
de `xapian-core` et `bindings-sha256` de `xapian-bindings`.
Voici un bout de script qui peut faciliter une mise à jour:
```bash
VERSION="x.y.z" # À modifier avec la bonne version
curl -O "https://oligarchy.co.uk/xapian/${VERSION}/xapian-core-${VERSION}.tar.xz"
sha256sum xapian-core-${VERSION}.tar.xz # Affiche la signature pour `core-sha256`
rm -f xapian-core-${VERSION}
curl -O "https://oligarchy.co.uk/xapian/${VERSION}/xapian-bindings-${VERSION}.tar.xz"
sha256sum xapian-bindings-${VERSION}.tar.xz # Affiche la signature pour `bindingse-sha256`
rm -f xapian-bindings-${VERSION}.tar.xz
```

View File

@@ -56,12 +56,6 @@ Commencez par installer les dépendances système :
sudo pacman -S postgresql nginx sudo pacman -S postgresql nginx
``` ```
=== "Fedora/RHEL/AlmaLinux/Rocky"
```bash
sudo dnf install postgresql libpq-devel nginx
```
=== "macOS" === "macOS"
```bash ```bash
@@ -106,11 +100,9 @@ PROCFILE_SERVICE=
vous devez ouvrir une autre fenêtre de votre terminal vous devez ouvrir une autre fenêtre de votre terminal
et lancer la commande `npm run serve` et lancer la commande `npm run serve`
## Configurer Redis/Valkey en service externe ## Configurer Redis en service externe
Redis est installé comme dépendance mais n'es pas lancé par défaut. Redis est installé comme dépendance mais pas lancé par défaut.
Si vous avez installé Valkey parce que Redis n'es pas disponible, remplacez juste `redis` par `valkey`.
En mode développement, le sith se charge de le démarrer mais En mode développement, le sith se charge de le démarrer mais
pas en production ! pas en production !

View File

@@ -79,29 +79,6 @@ cd /mnt/<la_lettre_du_disque>/vos/fichiers/comme/dhab
sudo pacman -S uv gcc git gettext pkgconf npm valkey sudo pacman -S uv gcc git gettext pkgconf npm valkey
``` ```
=== "Fedora"
```bash
sudo dnf update
sudo dnf install epel-release
sudo dnf install python-devel uv git gettext pkgconf npm redis @c-development @development-tools
```
=== "RHEL/AlmaLinux/Rocky"
```bash
dnf update
dnf install epel-release
dnf install python-devel uv git gettext pkgconf npm valkey
dnf group install "Development Tools"
```
La couche de compatibilitée valkey/redis est un package Fedora.
Il est nécessaire de faire un alias nous même:
```bash
ln -s /usr/bin/valkey-server /usr/bin/redis-server
```
=== "macOS" === "macOS"
Pour installer les dépendances, il est fortement recommandé d'installer le gestionnaire de paquets `homebrew <https://brew.sh/index_fr>`_. Pour installer les dépendances, il est fortement recommandé d'installer le gestionnaire de paquets `homebrew <https://brew.sh/index_fr>`_.
@@ -121,7 +98,7 @@ cd /mnt/<la_lettre_du_disque>/vos/fichiers/comme/dhab
!!!note !!!note
Python ne fait pas parti des dépendances puisqu'il est automatiquement Python ne fait pas parti des dépendances puisqu'il est automatiquement
installé par uv. Il est cependant parfois nécessaire d'installer les headers Python nécessaire à la compilation de certains paquets. installé par uv.
## Finaliser l'installation ## Finaliser l'installation

View File

@@ -22,23 +22,22 @@ from eboutic.models import Basket, BasketItem, Invoice, InvoiceItem
class BasketAdmin(admin.ModelAdmin): class BasketAdmin(admin.ModelAdmin):
list_display = ("user", "date", "total") list_display = ("user", "date", "total")
autocomplete_fields = ("user",) autocomplete_fields = ("user",)
date_hierarchy = "date"
def get_queryset(self, request): def get_queryset(self, request):
return ( return (
super() super()
.get_queryset(request) .get_queryset(request)
.annotate( .annotate(
total=Sum( total=Sum(F("items__quantity") * F("items__unit_price"), default=0)
F("items__quantity") * F("items__product_unit_price"), default=0
)
) )
) )
@admin.register(BasketItem) @admin.register(BasketItem)
class BasketItemAdmin(admin.ModelAdmin): class BasketItemAdmin(admin.ModelAdmin):
list_display = ("basket", "product_name", "product_unit_price", "quantity") list_display = ("label", "unit_price", "quantity")
search_fields = ("product_name",) search_fields = ("label",)
@admin.register(Invoice) @admin.register(Invoice)
@@ -50,5 +49,6 @@ class InvoiceAdmin(admin.ModelAdmin):
@admin.register(InvoiceItem) @admin.register(InvoiceItem)
class InvoiceItemAdmin(admin.ModelAdmin): class InvoiceItemAdmin(admin.ModelAdmin):
list_display = ("invoice", "product_name", "product_unit_price", "quantity") list_display = ("label", "unit_price", "quantity")
search_fields = ("product_name",) search_fields = ("label",)
list_select_related = ("price",)

View File

@@ -0,0 +1,53 @@
# Generated by Django 5.2.11 on 2026-02-22 18:13
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("counter", "0039_price"), ("eboutic", "0002_auto_20221005_2243")]
operations = [
migrations.RenameField(
model_name="basketitem", old_name="product_name", new_name="label"
),
migrations.RenameField(
model_name="basketitem",
old_name="product_unit_price",
new_name="unit_price",
),
migrations.RenameField(
model_name="basketitem", old_name="product_id", new_name="product"
),
migrations.RenameField(
model_name="invoiceitem", old_name="product_name", new_name="label"
),
migrations.RenameField(
model_name="invoiceitem",
old_name="product_unit_price",
new_name="unit_price",
),
migrations.RenameField(
model_name="invoiceitem", old_name="product_id", new_name="product"
),
migrations.RemoveField(model_name="basketitem", name="type_id"),
migrations.RemoveField(model_name="invoiceitem", name="type_id"),
migrations.AlterField(
model_name="basketitem",
name="product",
field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
to="counter.product",
verbose_name="product",
),
),
migrations.AlterField(
model_name="invoiceitem",
name="product",
field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
to="counter.product",
verbose_name="product",
),
),
]

View File

@@ -17,7 +17,7 @@ from __future__ import annotations
import hmac import hmac
from datetime import datetime from datetime import datetime
from enum import Enum from enum import Enum
from typing import Any, Self from typing import Self
from dict2xml import dict2xml from dict2xml import dict2xml
from django.conf import settings from django.conf import settings
@@ -30,8 +30,8 @@ from core.models import User
from counter.fields import CurrencyField from counter.fields import CurrencyField
from counter.models import ( from counter.models import (
BillingInfo, BillingInfo,
Counter,
Customer, Customer,
Price,
Product, Product,
Refilling, Refilling,
Selling, Selling,
@@ -39,22 +39,6 @@ from counter.models import (
) )
def get_eboutic_products(user: User) -> list[Product]:
products = (
get_eboutic()
.products.filter(product_type__isnull=False)
.filter(archived=False, limit_age__lte=user.age)
.annotate(
order=F("product_type__order"),
category=F("product_type__name"),
category_comment=F("product_type__comment"),
price=F("selling_price"), # <-- selected price for basket validation
)
.prefetch_related("buying_groups") # <-- used in `Product.can_be_sold_to`
)
return [p for p in products if p.can_be_sold_to(user)]
class BillingInfoState(Enum): class BillingInfoState(Enum):
VALID = 1 VALID = 1
EMPTY = 2 EMPTY = 2
@@ -94,21 +78,21 @@ class Basket(models.Model):
def __str__(self): def __str__(self):
return f"{self.user}'s basket ({self.items.all().count()} items)" return f"{self.user}'s basket ({self.items.all().count()} items)"
def can_be_viewed_by(self, user): def can_be_viewed_by(self, user: User):
return self.user == user return self.user == user
@cached_property @cached_property
def contains_refilling_item(self) -> bool: def contains_refilling_item(self) -> bool:
return self.items.filter( return self.items.filter(
type_id=settings.SITH_COUNTER_PRODUCTTYPE_REFILLING product__product_type_id=settings.SITH_COUNTER_PRODUCTTYPE_REFILLING
).exists() ).exists()
@cached_property @cached_property
def total(self) -> float: def total(self) -> float:
return float( return float(
self.items.aggregate( self.items.aggregate(total=Sum(F("quantity") * F("unit_price"), default=0))[
total=Sum(F("quantity") * F("product_unit_price"), default=0) "total"
)["total"] ]
) )
def generate_sales( def generate_sales(
@@ -120,7 +104,8 @@ class Basket(models.Model):
Example: Example:
```python ```python
counter = Counter.objects.get(name="Eboutic") counter = Counter.objects.get(name="Eboutic")
sales = basket.generate_sales(counter, "SITH_ACCOUNT") user = User.objects.get(username="bibou")
sales = basket.generate_sales(counter, user, Selling.PaymentMethod.SITH_ACCOUNT)
# here the basket is in the same state as before the method call # here the basket is in the same state as before the method call
with transaction.atomic(): with transaction.atomic():
@@ -131,31 +116,23 @@ class Basket(models.Model):
# thus only the sales remain # thus only the sales remain
``` ```
""" """
# I must proceed with two distinct requests instead of customer = Customer.get_or_create(self.user)[0]
# only one with a join because the AbstractBaseItem model has been return [
# poorly designed. If you refactor the model, please refactor this too.
items = self.items.order_by("product_id")
ids = [item.product_id for item in items]
products = Product.objects.filter(id__in=ids).order_by("id")
# items and products are sorted in the same order
sales = []
for item, product in zip(items, products, strict=False):
sales.append(
Selling( Selling(
label=product.name, label=item.label,
counter=counter, counter=counter,
club=product.club, club_id=item.product.club_id,
product=product, product=item.product,
seller=seller, seller=seller,
customer=Customer.get_or_create(self.user)[0], customer=customer,
unit_price=item.product_unit_price, unit_price=item.unit_price,
quantity=item.quantity, quantity=item.quantity,
payment_method=payment_method, payment_method=payment_method,
) )
) for item in self.items.select_related("product")
return sales ]
def get_e_transaction_data(self) -> list[tuple[str, Any]]: def get_e_transaction_data(self) -> list[tuple[str, str]]:
user = self.user user = self.user
if not hasattr(user, "customer"): if not hasattr(user, "customer"):
raise Customer.DoesNotExist raise Customer.DoesNotExist
@@ -201,7 +178,7 @@ class InvoiceQueryset(models.QuerySet):
def annotate_total(self) -> Self: def annotate_total(self) -> Self:
"""Annotate the queryset with the total amount of each invoice. """Annotate the queryset with the total amount of each invoice.
The total amount is the sum of (product_unit_price * quantity) The total amount is the sum of (unit_price * quantity)
for all items related to the invoice. for all items related to the invoice.
""" """
# aggregates within subqueries require a little bit of black magic, # aggregates within subqueries require a little bit of black magic,
@@ -211,7 +188,7 @@ class InvoiceQueryset(models.QuerySet):
total=Subquery( total=Subquery(
InvoiceItem.objects.filter(invoice_id=OuterRef("pk")) InvoiceItem.objects.filter(invoice_id=OuterRef("pk"))
.values("invoice_id") .values("invoice_id")
.annotate(total=Sum(F("product_unit_price") * F("quantity"))) .annotate(total=Sum(F("unit_price") * F("quantity")))
.values("total") .values("total")
) )
) )
@@ -221,11 +198,7 @@ class Invoice(models.Model):
"""Invoices are generated once the payment has been validated.""" """Invoices are generated once the payment has been validated."""
user = models.ForeignKey( user = models.ForeignKey(
User, User, related_name="invoices", verbose_name=_("user"), on_delete=models.CASCADE
related_name="invoices",
verbose_name=_("user"),
blank=False,
on_delete=models.CASCADE,
) )
date = models.DateTimeField(_("date"), auto_now=True) date = models.DateTimeField(_("date"), auto_now=True)
validated = models.BooleanField(_("validated"), default=False) validated = models.BooleanField(_("validated"), default=False)
@@ -246,53 +219,44 @@ class Invoice(models.Model):
if self.validated: if self.validated:
raise DataError(_("Invoice already validated")) raise DataError(_("Invoice already validated"))
customer, _created = Customer.get_or_create(user=self.user) customer, _created = Customer.get_or_create(user=self.user)
eboutic = Counter.objects.filter(type="EBOUTIC").first() kwargs = {
for i in self.items.all(): "counter": get_eboutic(),
if i.type_id == settings.SITH_COUNTER_PRODUCTTYPE_REFILLING: "customer": customer,
new = Refilling( "date": self.date,
counter=eboutic, "payment_method": Selling.PaymentMethod.CARD,
customer=customer, }
operator=self.user, for i in self.items.select_related("product"):
amount=i.product_unit_price * i.quantity, if i.product.product_type_id == settings.SITH_COUNTER_PRODUCTTYPE_REFILLING:
payment_method=Refilling.PaymentMethod.CARD, Refilling.objects.create(
date=self.date, **kwargs, operator=self.user, amount=i.unit_price * i.quantity
) )
new.save()
else: else:
product = Product.objects.filter(id=i.product_id).first() Selling.objects.create(
new = Selling( **kwargs,
label=i.product_name, label=i.label,
counter=eboutic, club_id=i.product.club_id,
club=product.club, product=i.product,
product=product,
seller=self.user, seller=self.user,
customer=customer, unit_price=i.unit_price,
unit_price=i.product_unit_price,
quantity=i.quantity, quantity=i.quantity,
payment_method=Selling.PaymentMethod.CARD,
date=self.date,
) )
new.save()
self.validated = True self.validated = True
self.save() self.save()
class AbstractBaseItem(models.Model): class AbstractBaseItem(models.Model):
product_id = models.IntegerField(_("product id")) product = models.ForeignKey(
product_name = models.CharField(_("product name"), max_length=255) Product, verbose_name=_("product"), on_delete=models.PROTECT
type_id = models.IntegerField(_("product type id")) )
product_unit_price = CurrencyField(_("unit price")) label = models.CharField(_("product name"), max_length=255)
unit_price = CurrencyField(_("unit price"))
quantity = models.PositiveIntegerField(_("quantity")) quantity = models.PositiveIntegerField(_("quantity"))
class Meta: class Meta:
abstract = True abstract = True
def __str__(self): def __str__(self):
return "Item: %s (%s) x%d" % ( return "Item: %s (%s) x%d" % (self.product.name, self.unit_price, self.quantity)
self.product_name,
self.product_unit_price,
self.quantity,
)
class BasketItem(AbstractBaseItem): class BasketItem(AbstractBaseItem):
@@ -301,21 +265,16 @@ class BasketItem(AbstractBaseItem):
) )
@classmethod @classmethod
def from_product(cls, product: Product, quantity: int, basket: Basket): def from_price(cls, price: Price, quantity: int, basket: Basket):
"""Create a BasketItem with the same characteristics as the """Create a BasketItem with the same characteristics as the
product passed in parameters, with the specified quantity. product price passed in parameters, with the specified quantity.
Warning:
the basket field is not filled, so you must set
it yourself before saving the model.
""" """
return cls( return cls(
basket=basket, basket=basket,
product_id=product.id, label=price.full_label,
product_name=product.name, product_id=price.product_id,
type_id=product.product_type_id,
quantity=quantity, quantity=quantity,
product_unit_price=product.selling_price, unit_price=price.amount,
) )

View File

@@ -1,13 +1,15 @@
export {}; export {};
interface BasketItem { interface BasketItem {
id: number; priceId: number;
name: string; name: string;
quantity: number; quantity: number;
// biome-ignore lint/style/useNamingConvention: the python code is snake_case unitPrice: number;
unit_price: number;
} }
// increment the key number if the data schema of the cached basket changes
const BASKET_CACHE_KEY = "basket1";
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
Alpine.data("basket", (lastPurchaseTime?: number) => ({ Alpine.data("basket", (lastPurchaseTime?: number) => ({
basket: [] as BasketItem[], basket: [] as BasketItem[],
@@ -30,24 +32,24 @@ document.addEventListener("alpine:init", () => {
// It's quite tricky to manually apply attributes to the management part // It's quite tricky to manually apply attributes to the management part
// of a formset so we dynamically apply it here // of a formset so we dynamically apply it here
this.$refs.basketManagementForm this.$refs.basketManagementForm
.querySelector("#id_form-TOTAL_FORMS") .getElementById("#id_form-TOTAL_FORMS")
.setAttribute(":value", "basket.length"); .setAttribute(":value", "basket.length");
}, },
loadBasket(): BasketItem[] { loadBasket(): BasketItem[] {
if (localStorage.basket === undefined) { if (localStorage.getItem(BASKET_CACHE_KEY) === null) {
return []; return [];
} }
try { try {
return JSON.parse(localStorage.basket); return JSON.parse(localStorage.getItem(BASKET_CACHE_KEY));
} catch (_err) { } catch (_err) {
return []; return [];
} }
}, },
saveBasket() { saveBasket() {
localStorage.basket = JSON.stringify(this.basket); localStorage.setItem(BASKET_CACHE_KEY, JSON.stringify(this.basket));
localStorage.basketTimestamp = Date.now(); localStorage.setItem("basketTimestamp", Date.now().toString());
}, },
/** /**
@@ -56,7 +58,7 @@ document.addEventListener("alpine:init", () => {
*/ */
getTotal() { getTotal() {
return this.basket.reduce( return this.basket.reduce(
(acc: number, item: BasketItem) => acc + item.quantity * item.unit_price, (acc: number, item: BasketItem) => acc + item.quantity * item.unitPrice,
0, 0,
); );
}, },
@@ -74,7 +76,7 @@ document.addEventListener("alpine:init", () => {
* @param itemId the id of the item to remove * @param itemId the id of the item to remove
*/ */
remove(itemId: number) { remove(itemId: number) {
const index = this.basket.findIndex((e: BasketItem) => e.id === itemId); const index = this.basket.findIndex((e: BasketItem) => e.priceId === itemId);
if (index < 0) { if (index < 0) {
return; return;
@@ -83,7 +85,7 @@ document.addEventListener("alpine:init", () => {
if (this.basket[index].quantity === 0) { if (this.basket[index].quantity === 0) {
this.basket = this.basket.filter( this.basket = this.basket.filter(
(e: BasketItem) => e.id !== this.basket[index].id, (e: BasketItem) => e.priceId !== this.basket[index].id,
); );
} }
}, },
@@ -104,11 +106,10 @@ document.addEventListener("alpine:init", () => {
*/ */
createItem(id: number, name: string, price: number): BasketItem { createItem(id: number, name: string, price: number): BasketItem {
const newItem = { const newItem = {
id, priceId: id,
name, name,
quantity: 0, quantity: 0,
// biome-ignore lint/style/useNamingConvention: the python code is snake_case unitPrice: price,
unit_price: price,
} as BasketItem; } as BasketItem;
this.basket.push(newItem); this.basket.push(newItem);
@@ -125,7 +126,7 @@ document.addEventListener("alpine:init", () => {
* @param price The unit price of the product * @param price The unit price of the product
*/ */
addFromCatalog(id: number, name: string, price: number) { addFromCatalog(id: number, name: string, price: number) {
let item = this.basket.find((e: BasketItem) => e.id === id); let item = this.basket.find((e: BasketItem) => e.priceId === id);
// if the item is not in the basket, we create it // if the item is not in the basket, we create it
// else we add + 1 to it // else we add + 1 to it

View File

@@ -32,9 +32,9 @@
<tbody> <tbody>
{% for item in basket.items.all() %} {% for item in basket.items.all() %}
<tr> <tr>
<td>{{ item.product_name }}</td> <td>{{ item.label }}</td>
<td>{{ item.quantity }}</td> <td>{{ item.quantity }}</td>
<td>{{ item.product_unit_price }} €</td> <td>{{ item.unit_price }} €</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

View File

@@ -51,15 +51,15 @@
</span> </span>
</li> </li>
<template x-for="(item, index) in Object.values(basket)" :key="item.id"> <template x-for="(item, index) in Object.values(basket)" :key="item.priceId">
<li class="item-row" x-show="item.quantity > 0"> <li class="item-row" x-show="item.quantity > 0">
<div class="item-quantity"> <div class="item-quantity">
<i class="fa fa-minus fa-xs" @click="remove(item.id)"></i> <i class="fa fa-minus fa-xs" @click="remove(item.priceId)"></i>
<span x-text="item.quantity"></span> <span x-text="item.quantity"></span>
<i class="fa fa-plus" @click="add(item)"></i> <i class="fa fa-plus" @click="add(item)"></i>
</div> </div>
<span class="item-name" x-text="item.name"></span> <span class="item-name" x-text="item.name"></span>
<span class="item-price" x-text="(item.unit_price * item.quantity).toFixed(2) + ' €'"></span> <span class="item-price" x-text="(item.unitPrice * item.quantity).toFixed(2) + ' €'"></span>
<input <input
type="hidden" type="hidden"
@@ -71,9 +71,9 @@
> >
<input <input
type="hidden" type="hidden"
:value="item.id" :value="item.priceId"
:id="`id_form-${index}-id`" :id="`id_form-${index}-price_id`"
:name="`form-${index}-id`" :name="`form-${index}-price_id`"
required required
readonly readonly
> >
@@ -116,95 +116,40 @@
</span> </span>
</div> </div>
{% endif %} {% endif %}
{% for prices in categories %}
{% set category = prices[0].product.product_type %}
<section> <section>
<div class="category-header"> <div class="category-header">
<h3 class="margin-bottom">{% trans %}Eurockéennes 2025 partnership{% endtrans %}</h3> <h3>{{ category.name }}</h3>
{% if user.is_subscribed %} {% if category.comment %}
<div id="eurock-partner" style=" <p><i>{{ category.comment }}</i></p>
min-height: 600px;
background-color: lightgrey;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
gap: 10px;
">
<p style="text-align: center;">
{% trans trimmed %}
Our partner uses Weezevent to sell tickets.
Weezevent may collect user info according to
its own privacy policy.
By clicking the accept button you consent to
their terms of services.
{% endtrans %}
</p>
<a href="https://weezevent.com/fr/politique-de-confidentialite/">{% trans %}Privacy policy{% endtrans %}</a>
<button
hx-get="{{ url("eboutic:eurock") }}"
hx-target="#eurock-partner"
hx-swap="outerHTML"
hx-trigger="click, load[document.cookie.includes('weezevent_accept=true')]"
@htmx:after-request="document.cookie = 'weezevent_accept=true'"
>{% trans %}Accept{% endtrans %}
</button>
</div>
{% else %}
<p>
{%- trans trimmed %}
You must be subscribed to benefit from the partnership with the Eurockéennes.
{% endtrans -%}
</p>
<p>
{%- trans trimmed %}
This partnership offers a discount of up to 33%
on tickets for Friday, Saturday and Sunday,
as well as the 3-day package from Friday to Sunday.
{% endtrans -%}
</p>
{% endif %}
</div>
</section>
{% for priority_groups in products|groupby('order') %}
{% for category, items in priority_groups.list|groupby('category') %}
{% if items|count > 0 %}
<section>
{# I would have wholeheartedly directly used the header element instead
but it has already been made messy in core/style.scss #}
<div class="category-header">
<h3>{{ category }}</h3>
{% if items[0].category_comment %}
<p><i>{{ items[0].category_comment }}</i></p>
{% endif %} {% endif %}
</div> </div>
<div class="product-group"> <div class="product-group">
{% for p in items %} {% for price in prices %}
<button <button
id="{{ p.id }}" id="{{ price.id }}"
class="card product-button clickable shadow" class="card product-button clickable shadow"
:class="{selected: basket.some((i) => i.id === {{ p.id }})}" :class="{selected: basket.some((i) => i.priceId === {{ price.id }})}"
@click='addFromCatalog({{ p.id }}, {{ p.name|tojson }}, {{ p.selling_price }})' @click='addFromCatalog({{ price.id }}, {{ price.full_label|tojson }}, {{ price.amount }})'
> >
{% if p.icon %} {% if price.product.icon %}
<img <img
class="card-image" class="card-image"
src="{{ p.icon.url }}" src="{{ price.product.icon.url }}"
alt="image de {{ p.name }}" alt="image de {{ price.full_label }}"
> >
{% else %} {% else %}
<i class="fa-regular fa-image fa-2x card-image"></i> <i class="fa-regular fa-image fa-2x card-image"></i>
{% endif %} {% endif %}
<div class="card-content"> <div class="card-content">
<h4 class="card-title">{{ p.name }}</h4> <h4 class="card-title">{{ price.full_label }}</h4>
<p>{{ p.selling_price }} €</p> <p>{{ price.amount }} €</p>
</div> </div>
</button> </button>
{% endfor %} {% endfor %}
</div> </div>
</section> </section>
{% endif %}
{% endfor %}
{% else %} {% else %}
<p>{% trans %}There are no items available for sale{% endtrans %}</p> <p>{% trans %}There are no items available for sale{% endtrans %}</p>
{% endfor %} {% endfor %}

View File

@@ -1,16 +0,0 @@
<a title="Logiciel billetterie en ligne"
href="https://www.weezevent.com?c=sys_widget"
class="weezevent-widget-integration"
target="_blank"
data-src="https://widget.weezevent.com/ticket/8aaba226-f7a3-4192-a64e-72ff8f5b35b7?id_evenement=1419869&locale=fr-FR&code=28747"
data-width="650"
data-height="600"
data-resize="1"
data-nopb="0"
data-type="neo"
data-width_auto="1"
data-noscroll="0"
data-id="1419869">
Billetterie Weezevent
</a>
<script type="text/javascript" src="https://widget.weezevent.com/weez.js" async defer></script>

View File

@@ -0,0 +1,17 @@
<a
title="Logiciel billetterie en ligne"
href="https://widget.weezevent.com/ticket/6ef65533-f5b0-4571-9d21-1f1bc63921f0?id_evenement=1211855&locale=fr-FR&code=34146"
class="weezevent-widget-integration"
target="_blank"
data-src="https://widget.weezevent.com/ticket/6ef65533-f5b0-4571-9d21-1f1bc63921f0?id_evenement=1211855&locale=fr-FR&code=34146"
data-width="650"
data-height="600"
data-resize="1"
data-nopb="0"
data-type="neo"
data-width_auto="1"
data-noscroll="0"
data-id="1211855">
Billetterie Weezevent
</a>
<script type="text/javascript" src="https://widget.weezevent.com/weez.js" async defer></script>

View File

@@ -11,7 +11,12 @@ 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
from counter.baker_recipes import product_recipe, refill_recipe, sale_recipe from counter.baker_recipes import (
price_recipe,
product_recipe,
refill_recipe,
sale_recipe,
)
from counter.models import ( from counter.models import (
Counter, Counter,
Customer, Customer,
@@ -147,29 +152,29 @@ class TestEboutic(TestCase):
product_type = baker.make(ProductType) product_type = baker.make(ProductType)
cls.snack = product_recipe.make( cls.snack = price_recipe.make(
selling_price=1.5, special_selling_price=1, product_type=product_type amount=1.5, product=product_recipe.make(product_type=product_type)
) )
cls.beer = product_recipe.make( cls.beer = price_recipe.make(
limit_age=18, product=product_recipe.make(limit_age=18, product_type=product_type),
selling_price=2.5, amount=2.5,
special_selling_price=1,
product_type=product_type,
) )
cls.not_in_counter = product_recipe.make( cls.not_in_counter = price_recipe.make(
selling_price=3.5, product_type=product_type product=product_recipe.make(product_type=product_type), amount=3.5
)
cls.cotiz = price_recipe.make(
amount=10, product=product_recipe.make(product_type=product_type)
) )
cls.cotiz = product_recipe.make(selling_price=10, product_type=product_type)
cls.group_public.products.add(cls.snack, cls.beer, cls.not_in_counter) cls.group_public.prices.add(cls.snack, cls.beer, cls.not_in_counter)
cls.group_cotiz.products.add(cls.cotiz) cls.group_cotiz.prices.add(cls.cotiz)
cls.subscriber.groups.add(cls.group_cotiz, cls.group_public) cls.subscriber.groups.add(cls.group_cotiz, cls.group_public)
cls.new_customer.groups.add(cls.group_public) cls.new_customer.groups.add(cls.group_public)
cls.new_customer_adult.groups.add(cls.group_public) cls.new_customer_adult.groups.add(cls.group_public)
cls.eboutic = get_eboutic() cls.eboutic = get_eboutic()
cls.eboutic.products.add(cls.cotiz, cls.beer, cls.snack) cls.eboutic.products.add(cls.cotiz.product, cls.beer.product, cls.snack.product)
@classmethod @classmethod
def set_age(cls, user: User, age: int): def set_age(cls, user: User, age: int):
@@ -253,7 +258,7 @@ class TestEboutic(TestCase):
self.submit_basket([BasketItem(self.snack.id, 2)]), self.submit_basket([BasketItem(self.snack.id, 2)]),
reverse("eboutic:checkout", kwargs={"basket_id": 1}), reverse("eboutic:checkout", kwargs={"basket_id": 1}),
) )
assert Basket.objects.get(id=1).total == self.snack.selling_price * 2 assert Basket.objects.get(id=1).total == self.snack.amount * 2
self.client.force_login(self.new_customer_adult) self.client.force_login(self.new_customer_adult)
assertRedirects( assertRedirects(
@@ -263,8 +268,7 @@ class TestEboutic(TestCase):
reverse("eboutic:checkout", kwargs={"basket_id": 2}), reverse("eboutic:checkout", kwargs={"basket_id": 2}),
) )
assert ( assert (
Basket.objects.get(id=2).total Basket.objects.get(id=2).total == self.snack.amount * 2 + self.beer.amount
== self.snack.selling_price * 2 + self.beer.selling_price
) )
self.client.force_login(self.subscriber) self.client.force_login(self.subscriber)
@@ -280,7 +284,5 @@ class TestEboutic(TestCase):
) )
assert ( assert (
Basket.objects.get(id=3).total Basket.objects.get(id=3).total
== self.snack.selling_price * 2 == self.snack.amount * 2 + self.beer.amount + self.cotiz.amount
+ self.beer.selling_price
+ self.cotiz.selling_price
) )

View File

@@ -16,7 +16,7 @@ from model_bakery import baker
from pytest_django.asserts import assertRedirects from pytest_django.asserts import assertRedirects
from core.baker_recipes import old_subscriber_user, subscriber_user from core.baker_recipes import old_subscriber_user, subscriber_user
from counter.baker_recipes import product_recipe from counter.baker_recipes import price_recipe, product_recipe
from counter.models import Product, ProductType, Selling from counter.models import Product, ProductType, Selling
from counter.tests.test_counter import force_refill_user from counter.tests.test_counter import force_refill_user
from eboutic.models import Basket, BasketItem from eboutic.models import Basket, BasketItem
@@ -32,23 +32,22 @@ class TestPaymentBase(TestCase):
cls.basket = baker.make(Basket, user=cls.customer) cls.basket = baker.make(Basket, user=cls.customer)
cls.refilling = product_recipe.make( cls.refilling = product_recipe.make(
product_type_id=settings.SITH_COUNTER_PRODUCTTYPE_REFILLING, product_type_id=settings.SITH_COUNTER_PRODUCTTYPE_REFILLING,
selling_price=15, prices=[price_recipe.make(amount=15)],
) )
product_type = baker.make(ProductType) product_type = baker.make(ProductType)
cls.snack = product_recipe.make( cls.snack = product_recipe.make(
selling_price=1.5, special_selling_price=1, product_type=product_type product_type=product_type, prices=[price_recipe.make(amount=1.5)]
) )
cls.beer = product_recipe.make( cls.beer = product_recipe.make(
limit_age=18, limit_age=18,
selling_price=2.5,
special_selling_price=1,
product_type=product_type, product_type=product_type,
prices=[price_recipe.make(amount=2.5)],
) )
BasketItem.from_product(cls.snack, 1, cls.basket).save() BasketItem.from_price(cls.snack.prices.first(), 1, cls.basket).save()
BasketItem.from_product(cls.beer, 2, cls.basket).save() BasketItem.from_price(cls.beer.prices.first(), 2, cls.basket).save()
class TestPaymentSith(TestPaymentBase): class TestPaymentSith(TestPaymentBase):
@@ -116,13 +115,13 @@ class TestPaymentSith(TestPaymentBase):
assert len(sellings) == 2 assert len(sellings) == 2
assert sellings[0].payment_method == Selling.PaymentMethod.SITH_ACCOUNT assert sellings[0].payment_method == Selling.PaymentMethod.SITH_ACCOUNT
assert sellings[0].quantity == 1 assert sellings[0].quantity == 1
assert sellings[0].unit_price == self.snack.selling_price assert sellings[0].unit_price == self.snack.prices.first().amount
assert sellings[0].counter.type == "EBOUTIC" assert sellings[0].counter.type == "EBOUTIC"
assert sellings[0].product == self.snack assert sellings[0].product == self.snack
assert sellings[1].payment_method == Selling.PaymentMethod.SITH_ACCOUNT assert sellings[1].payment_method == Selling.PaymentMethod.SITH_ACCOUNT
assert sellings[1].quantity == 2 assert sellings[1].quantity == 2
assert sellings[1].unit_price == self.beer.selling_price assert sellings[1].unit_price == self.beer.prices.first().amount
assert sellings[1].counter.type == "EBOUTIC" assert sellings[1].counter.type == "EBOUTIC"
assert sellings[1].product == self.beer assert sellings[1].product == self.beer
@@ -146,7 +145,7 @@ class TestPaymentSith(TestPaymentBase):
) )
def test_refilling_in_basket(self): def test_refilling_in_basket(self):
BasketItem.from_product(self.refilling, 1, self.basket).save() BasketItem.from_price(self.refilling.prices.first(), 1, self.basket).save()
self.client.force_login(self.customer) self.client.force_login(self.customer)
force_refill_user(self.customer, self.basket.total + 1) force_refill_user(self.customer, self.basket.total + 1)
self.customer.customer.refresh_from_db() self.customer.customer.refresh_from_db()
@@ -191,8 +190,8 @@ class TestPaymentCard(TestPaymentBase):
def test_buy_success(self): def test_buy_success(self):
response = self.client.get(self.generate_bank_valid_answer(self.basket)) response = self.client.get(self.generate_bank_valid_answer(self.basket))
assert response.status_code == 200 assert response.status_code == 200
assert response.content.decode("utf-8") == "Payment successful" assert response.content.decode() == "Payment successful"
assert Basket.objects.filter(id=self.basket.id).first() is None assert not Basket.objects.filter(id=self.basket.id).exists()
sellings = Selling.objects.filter(customer=self.customer.customer).order_by( sellings = Selling.objects.filter(customer=self.customer.customer).order_by(
"quantity" "quantity"
@@ -200,13 +199,13 @@ class TestPaymentCard(TestPaymentBase):
assert len(sellings) == 2 assert len(sellings) == 2
assert sellings[0].payment_method == Selling.PaymentMethod.CARD assert sellings[0].payment_method == Selling.PaymentMethod.CARD
assert sellings[0].quantity == 1 assert sellings[0].quantity == 1
assert sellings[0].unit_price == self.snack.selling_price assert sellings[0].unit_price == self.snack.prices.first().amount
assert sellings[0].counter.type == "EBOUTIC" assert sellings[0].counter.type == "EBOUTIC"
assert sellings[0].product == self.snack assert sellings[0].product == self.snack
assert sellings[1].payment_method == Selling.PaymentMethod.CARD assert sellings[1].payment_method == Selling.PaymentMethod.CARD
assert sellings[1].quantity == 2 assert sellings[1].quantity == 2
assert sellings[1].unit_price == self.beer.selling_price assert sellings[1].unit_price == self.beer.prices.first().amount
assert sellings[1].counter.type == "EBOUTIC" assert sellings[1].counter.type == "EBOUTIC"
assert sellings[1].product == self.beer assert sellings[1].product == self.beer
@@ -216,7 +215,9 @@ class TestPaymentCard(TestPaymentBase):
assert not customer.subscriptions.first().is_valid_now() assert not customer.subscriptions.first().is_valid_now()
basket = baker.make(Basket, user=customer) basket = baker.make(Basket, user=customer)
BasketItem.from_product(Product.objects.get(code="2SCOTIZ"), 1, basket).save() BasketItem.from_price(
Product.objects.get(code="2SCOTIZ").prices.first(), 1, basket
).save()
response = self.client.get(self.generate_bank_valid_answer(basket)) response = self.client.get(self.generate_bank_valid_answer(basket))
assert response.status_code == 200 assert response.status_code == 200
@@ -228,12 +229,13 @@ class TestPaymentCard(TestPaymentBase):
assert subscription.location == "EBOUTIC" assert subscription.location == "EBOUTIC"
def test_buy_refilling(self): def test_buy_refilling(self):
BasketItem.from_product(self.refilling, 2, self.basket).save() price = self.refilling.prices.first()
BasketItem.from_price(price, 2, self.basket).save()
response = self.client.get(self.generate_bank_valid_answer(self.basket)) response = self.client.get(self.generate_bank_valid_answer(self.basket))
assert response.status_code == 200 assert response.status_code == 200
self.customer.customer.refresh_from_db() self.customer.customer.refresh_from_db()
assert self.customer.customer.amount == self.refilling.selling_price * 2 assert self.customer.customer.amount == price.amount * 2
def test_multiple_responses(self): def test_multiple_responses(self):
bank_response = self.generate_bank_valid_answer(self.basket) bank_response = self.generate_bank_valid_answer(self.basket)
@@ -253,17 +255,17 @@ class TestPaymentCard(TestPaymentBase):
self.basket.delete() self.basket.delete()
response = self.client.get(bank_response) response = self.client.get(bank_response)
assert response.status_code == 500 assert response.status_code == 500
assert ( assert response.text == (
response.text "Basket processing failed with error: "
== "Basket processing failed with error: SuspiciousOperation('Basket does not exists')" "SuspiciousOperation('Basket does not exists')"
) )
def test_altered_basket(self): def test_altered_basket(self):
bank_response = self.generate_bank_valid_answer(self.basket) bank_response = self.generate_bank_valid_answer(self.basket)
BasketItem.from_product(self.snack, 1, self.basket).save() BasketItem.from_price(self.snack.prices.first(), 1, self.basket).save()
response = self.client.get(bank_response) response = self.client.get(bank_response)
assert response.status_code == 500 assert response.status_code == 500
assert ( assert response.text == (
response.text == "Basket processing failed with error: " "Basket processing failed with error: "
"SuspiciousOperation('Basket total and amount do not match')" "SuspiciousOperation('Basket total and amount do not match')"
) )

View File

@@ -31,7 +31,6 @@ from eboutic.views import (
EbouticMainView, EbouticMainView,
EbouticPayWithSith, EbouticPayWithSith,
EtransactionAutoAnswer, EtransactionAutoAnswer,
EurockPartnerFragment,
payment_result, payment_result,
) )
@@ -51,5 +50,4 @@ urlpatterns = [
EtransactionAutoAnswer.as_view(), EtransactionAutoAnswer.as_view(),
name="etransation_autoanswer", name="etransation_autoanswer",
), ),
path("eurock/", EurockPartnerFragment.as_view(), name="eurock"),
] ]

View File

@@ -17,6 +17,7 @@ from __future__ import annotations
import base64 import base64
import contextlib import contextlib
import itertools
import json import json
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@@ -28,9 +29,7 @@ from cryptography.hazmat.primitives.serialization import load_pem_public_key
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import ( from django.contrib.auth.mixins import LoginRequiredMixin
LoginRequiredMixin,
)
from django.contrib.messages.views import SuccessMessageMixin from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import SuspiciousOperation, ValidationError from django.core.exceptions import SuspiciousOperation, ValidationError
from django.db import DatabaseError, transaction from django.db import DatabaseError, transaction
@@ -42,29 +41,22 @@ from django.shortcuts import redirect, render
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.decorators.http import require_GET from django.views.decorators.http import require_GET
from django.views.generic import DetailView, FormView, TemplateView, UpdateView, View from django.views.generic import DetailView, FormView, UpdateView, View
from django.views.generic.edit import SingleObjectMixin from django.views.generic.edit import SingleObjectMixin
from django_countries.fields import Country from django_countries.fields import Country
from core.auth.mixins import CanViewMixin, IsSubscriberMixin from core.auth.mixins import CanViewMixin
from core.views.mixins import FragmentMixin, UseFragmentsMixin from core.views.mixins import FragmentMixin, UseFragmentsMixin
from counter.forms import BaseBasketForm, BasketProductForm, BillingInfoForm from counter.forms import BaseBasketForm, BasketItemForm, BillingInfoForm
from counter.models import ( from counter.models import (
BillingInfo, BillingInfo,
Customer, Customer,
Product, Price,
Refilling, Refilling,
Selling, Selling,
get_eboutic, get_eboutic,
) )
from eboutic.models import ( from eboutic.models import Basket, BasketItem, BillingInfoState, Invoice, InvoiceItem
Basket,
BasketItem,
BillingInfoState,
Invoice,
InvoiceItem,
get_eboutic_products,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
@@ -78,7 +70,7 @@ class BaseEbouticBasketForm(BaseBasketForm):
EbouticBasketForm = forms.formset_factory( EbouticBasketForm = forms.formset_factory(
BasketProductForm, formset=BaseEbouticBasketForm, absolute_max=None, min_num=1 BasketItemForm, formset=BaseEbouticBasketForm, absolute_max=None, min_num=1
) )
@@ -88,7 +80,6 @@ class EbouticMainView(LoginRequiredMixin, FormView):
The purchasable products are those of the eboutic which The purchasable products are those of the eboutic which
belong to a category of products of a product category belong to a category of products of a product category
(orphan products are inaccessible). (orphan products are inaccessible).
""" """
template_name = "eboutic/eboutic_main.jinja" template_name = "eboutic/eboutic_main.jinja"
@@ -99,7 +90,7 @@ class EbouticMainView(LoginRequiredMixin, FormView):
kwargs["form_kwargs"] = { kwargs["form_kwargs"] = {
"customer": self.customer, "customer": self.customer,
"counter": get_eboutic(), "counter": get_eboutic(),
"allowed_products": {product.id: product for product in self.products}, "allowed_prices": {price.id: price for price in self.prices},
} }
return kwargs return kwargs
@@ -110,19 +101,25 @@ class EbouticMainView(LoginRequiredMixin, FormView):
with transaction.atomic(): with transaction.atomic():
self.basket = Basket.objects.create(user=self.request.user) self.basket = Basket.objects.create(user=self.request.user)
for form in formset: BasketItem.objects.bulk_create(
BasketItem.from_product( [
form.product, form.cleaned_data["quantity"], self.basket BasketItem.from_price(
).save() form.price, form.cleaned_data["quantity"], self.basket
self.basket.save() )
for form in formset
]
)
return super().form_valid(formset) return super().form_valid(formset)
def get_success_url(self): def get_success_url(self):
return reverse("eboutic:checkout", kwargs={"basket_id": self.basket.id}) return reverse("eboutic:checkout", kwargs={"basket_id": self.basket.id})
@cached_property @cached_property
def products(self) -> list[Product]: def prices(self) -> list[Price]:
return get_eboutic_products(self.request.user) return get_eboutic().get_prices_for(
self.customer,
order_by=["product__product_type__order", "product_id", "amount"],
)
@cached_property @cached_property
def customer(self) -> Customer: def customer(self) -> Customer:
@@ -130,7 +127,12 @@ class EbouticMainView(LoginRequiredMixin, FormView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context["products"] = self.products context["categories"] = [
list(i[1])
for i in itertools.groupby(
self.prices, key=lambda p: p.product.product_type_id
)
]
context["customer_amount"] = self.request.user.account_balance context["customer_amount"] = self.request.user.account_balance
purchases = ( purchases = (
@@ -267,11 +269,8 @@ class EbouticPayWithSith(CanViewMixin, SingleObjectMixin, View):
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
basket = self.get_object() basket = self.get_object()
refilling = settings.SITH_COUNTER_PRODUCTTYPE_REFILLING refilling = settings.SITH_COUNTER_PRODUCTTYPE_REFILLING
if basket.items.filter(type_id=refilling).exists(): if basket.items.filter(product__product_type_id=refilling).exists():
messages.error( messages.error(self.request, _("You can't buy a refilling with sith money"))
self.request,
_("You can't buy a refilling with sith money"),
)
return redirect("eboutic:payment_result", "failure") return redirect("eboutic:payment_result", "failure")
eboutic = get_eboutic() eboutic = get_eboutic()
@@ -326,22 +325,23 @@ class EtransactionAutoAnswer(View):
raise SuspiciousOperation( raise SuspiciousOperation(
"Basket total and amount do not match" "Basket total and amount do not match"
) )
i = Invoice() i = Invoice.objects.create(user=b.user)
i.user = b.user InvoiceItem.objects.bulk_create(
i.payment_method = "CARD" [
i.save()
for it in b.items.all():
InvoiceItem( InvoiceItem(
invoice=i, invoice=i,
product_id=it.product_id, product_id=item.product_id,
product_name=it.product_name, label=item.label,
type_id=it.type_id, unit_price=item.unit_price,
product_unit_price=it.product_unit_price, quantity=item.quantity,
quantity=it.quantity, )
).save() for item in b.items.all()
]
)
i.validate() i.validate()
b.delete() b.delete()
except Exception as e: except Exception as e:
sentry_sdk.capture_exception(e)
return HttpResponse( return HttpResponse(
"Basket processing failed with error: " + repr(e), status=500 "Basket processing failed with error: " + repr(e), status=500
) )
@@ -350,7 +350,3 @@ class EtransactionAutoAnswer(View):
return HttpResponse( return HttpResponse(
"Payment failed with error: " + request.GET["Error"], status=202 "Payment failed with error: " + request.GET["Error"], status=202
) )
class EurockPartnerFragment(IsSubscriberMixin, TemplateView):
template_name = "eboutic/eurock_fragment.jinja"

View File

@@ -146,7 +146,7 @@
<label for="{{ input_id }}"> <label for="{{ input_id }}">
{%- endif %} {%- endif %}
<figure> <figure>
{%- if user.can_view(candidature.user) %} {%- if user.is_viewable %}
{% if candidature.user.profile_pict %} {% if candidature.user.profile_pict %}
<img class="candidate__picture" src="{{ candidature.user.profile_pict.get_download_url() }}" alt="{% trans %}Profile{% endtrans %}"> <img class="candidate__picture" src="{{ candidature.user.profile_pict.get_download_url() }}" alt="{% trans %}Profile{% endtrans %}">
{% else %} {% else %}

View File

@@ -46,7 +46,7 @@
</section> </section>
{%- endfor %} {%- endfor %}
{% if is_paginated %} {% if is_paginated %}
{{ paginate_jinja(request, page_obj, paginator) }} {{ paginate_jinja(page_obj, paginator) }}
{% endif %} {% endif %}
{%- endblock %} {%- endblock %}

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.is_board return m.role > settings.SITH_MAXIMUM_FREE_ROLE
return False return False
def check_loop(self): def check_loop(self):

View File

@@ -27,7 +27,7 @@
</p> </p>
{{ display_search_bar(request) }} {{ display_search_bar(request) }}
{{ paginate_jinja(request, msgs, msgs.paginator) }} {{ paginate_jinja(msgs, msgs.paginator) }}
<main class="message-list"> <main class="message-list">
{% for m in msgs %} {% for m in msgs %}
@@ -44,9 +44,7 @@
<p><a class="ib button" href="{{ url('forum:new_message', topic_id=topic.id) }}">{% trans %}Reply{% endtrans %}</a></p> <p><a class="ib button" href="{{ url('forum:new_message', topic_id=topic.id) }}">{% trans %}Reply{% endtrans %}</a></p>
{% if is_paginated %} {{ paginate_jinja(msgs, msgs.paginator) }}
{{ paginate_jinja(request, msgs, msgs.paginator) }}
{% endif %}
</div> </div>
{% endblock %} {% endblock %}

Some files were not shown because too many files have changed in this diff Show More