10 Commits

Author SHA1 Message Date
imperosol
3700fbb214 remove settings.SITH_MAXIMUM_FREE_ROLE 2026-03-21 22:38:35 +01:00
imperosol
b2b8d24003 adapt club members pages to new club roles framework 2026-03-21 22:38:35 +01:00
imperosol
d3c6402e28 adapt tests to new club roles framework 2026-03-21 22:38:35 +01:00
imperosol
74679c86d1 change on_delete constraint for club pages 2026-03-20 16:28:51 +01:00
imperosol
5275c30480 adapt populate and populate_more 2026-03-20 16:28:51 +01:00
imperosol
7e6d5fc435 add ClubRole model 2026-03-20 16:28:51 +01:00
thomas girod
ffa0b94408 Merge pull request #1319 from ae-utbm/show-my-stats
show user stats to subscribers if show_my_stats is enabled
2026-03-20 13:49:48 +01:00
thomas girod
22a1f4ba07 Merge pull request #1317 from ae-utbm/remove-settings
remove unused settings
2026-03-20 13:47:22 +01:00
imperosol
1c0b89bfc7 show user stats to subscribers if show_my_stats is enabled 2026-03-14 16:23:56 +01:00
imperosol
f1a60e589a remove unused settings 2026-03-12 10:26:40 +01:00
66 changed files with 1495 additions and 1469 deletions

View File

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

View File

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

View File

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

View File

@@ -2,12 +2,15 @@
import django.db.models.deletion
import django.db.models.functions.datetime
from django.conf import settings
from django.db import migrations, models
from django.db.migrations.state import StateApps
from django.db.models import Q
from django.utils.timezone import localdate
# 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):
"""Attach the existing meta groups to the clubs.
@@ -46,10 +49,7 @@ def migrate_meta_groups(apps: StateApps, schema_editor):
).select_related("user")
club.members_group.users.set([m.user for m in memberships])
club.board_group.users.set(
[
m.user
for m in memberships.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE)
]
[m.user for m in memberships.filter(role__gt=MAXIMUM_FREE_ROLE)]
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ from django.urls import reverse
from model_bakery import baker
from pytest_django.asserts import assertHTMLEqual, assertRedirects
from club.models import Club, Membership
from club.models import Club, ClubRole, Membership
from core.baker_recipes import subscriber_user
from core.markdown import markdown
from core.models import PageRev, User
@@ -59,7 +59,12 @@ def test_page_revision(client: Client):
def test_edit_page(client: Client):
club = baker.make(Club)
user = subscriber_user.make()
baker.make(Membership, user=user, club=club, role=3)
baker.make(
Membership,
user=user,
club=club,
role=baker.make(ClubRole, club=club, is_board=True),
)
client.force_login(user)
url = reverse("club:club_edit_page", kwargs={"club_id": club.id})
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.recipe import Recipe
from club.models import Club, Membership
from club.models import Club, ClubRole, Membership
from club.schemas import UserMembershipSchema
from core.baker_recipes import subscriber_user
from core.models import Page
@@ -19,7 +19,10 @@ class TestFetchClub(TestCase):
pages = baker.make(Page, _quantity=3, _bulk_create=True)
clubs = baker.make(Club, page=iter(pages), _quantity=3, _bulk_create=True)
recipe = Recipe(
Membership, user=cls.user, start_date=localdate() - timedelta(days=2)
Membership,
user=cls.user,
start_date=localdate() - timedelta(days=2),
role=baker.make(ClubRole),
)
cls.members = Membership.objects.bulk_create(
[

View File

@@ -28,7 +28,6 @@ import csv
import itertools
from typing import TYPE_CHECKING, Any
from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import NON_FIELD_ERRORS, PermissionDenied, ValidationError
@@ -318,7 +317,7 @@ class ClubMembersView(
membership = self.object.get_membership_for(self.request.user)
if (
membership
and membership.role <= settings.SITH_MAXIMUM_FREE_ROLE
and not membership.role.is_board
and not self.request.user.has_perm("club.add_membership")
):
# Simple club members won't see the form anymore.
@@ -343,8 +342,8 @@ class ClubMembersView(
kwargs["members"] = list(
self.object.members.ongoing()
.annotate(is_editable=Q(id__in=editable))
.order_by("-role")
.select_related("user")
.order_by("role__order")
.select_related("user", "role")
)
kwargs["can_end_membership"] = len(editable) > 0
return kwargs
@@ -372,8 +371,8 @@ class ClubOldMembersView(ClubTabsMixin, PermissionRequiredMixin, DetailView):
return super().get_context_data(**kwargs) | {
"old_members": (
self.object.members.exclude(end_date=None)
.order_by("-role", "description", "-end_date")
.select_related("user")
.order_by("role__order", "description", "-end_date")
.select_related("user", "role")
)
}
@@ -724,9 +723,7 @@ class MailingAutoGenerationView(View):
def get(self, request, *args, **kwargs):
club = self.mailing.club
self.mailing.subscriptions.all().delete()
members = club.members.filter(
role__gte=settings.SITH_CLUB_ROLES_ID["Board member"]
).exclude(end_date__lte=timezone.now())
members = club.members.ongoing().filter(role__is_board=True)
for member in members.all():
MailingSubscription(user=member.user, mailing=self.mailing).save()
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 pytest_django.asserts import assertNumQueries, assertRedirects
from club.models import Club, Membership
from club.models import Club, ClubRole, Membership
from com.models import News, NewsDate, Poster, Sith, Weekmail, WeekmailArticle
from core.baker_recipes import subscriber_user
from core.models import AnonymousUser, Group, User
@@ -214,7 +214,8 @@ class TestNewsCreation(TestCase):
def setUpTestData(cls):
cls.club = baker.make(Club)
cls.user = subscriber_user.make()
baker.make(Membership, user=cls.user, club=cls.club, role=5)
role = baker.make(ClubRole, club=cls.club, is_board=True)
baker.make(Membership, user=cls.user, club=cls.club, role=role)
def setUp(self):
self.client.force_login(self.user)

View File

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

View File

@@ -123,7 +123,7 @@ class GroupController(ControllerBase):
)
@paginate(PageNumberPaginationExtra, page_size=50)
def search_group(self, search: Annotated[str, MinLen(1)]):
return Group.objects.filter(name__icontains=search).order_by("name").values()
return Group.objects.filter(name__icontains=search).values()
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.utils.timezone import localdate, now
from model_bakery import seq
from model_bakery.recipe import Recipe, related
from model_bakery.recipe import Recipe, foreign_key, related
from club.models import Membership
from club.models import ClubRole, Membership
from core.models import Group, User
from subscription.models import Subscription
@@ -52,7 +52,9 @@ ae_board_membership = Recipe(
Membership,
start_date=now() - timedelta(days=30),
club_id=settings.SITH_MAIN_CLUB_ID,
role=settings.SITH_CLUB_ROLES_ID["Board member"],
role=foreign_key(
Recipe(ClubRole, club_id=settings.SITH_MAIN_CLUB_ID, is_board=True)
),
)
board_user = Recipe(

View File

@@ -36,19 +36,12 @@ from django.utils import timezone
from django.utils.timezone import localdate
from PIL import Image
from club.models import Club, Membership
from club.models import Club, ClubRole, Membership
from com.ics_calendar import IcsCalendar
from com.models import News, NewsDate, Sith, Weekmail
from core.models import BanGroup, Group, Page, PageRev, SithFile, User
from core.utils import resize_image
from counter.models import (
Counter,
Price,
Product,
ProductType,
ReturnableProduct,
StudentCard,
)
from counter.models import Counter, Product, ProductType, ReturnableProduct, StudentCard
from election.models import Candidature, Election, ElectionList, Role
from forum.models import Forum
from pedagogy.models import UE
@@ -69,6 +62,13 @@ class PopulatedGroups(NamedTuple):
campus_admin: Group
class PopulatedClubs(NamedTuple):
ae: Club
troll: Club
pdf: Club
refound: Club
class Command(BaseCommand):
ROOT_PATH: ClassVar[Path] = Path(__file__).parent.parent.parent.parent
SAS_FIXTURE_PATH: ClassVar[Path] = (
@@ -118,28 +118,16 @@ class Command(BaseCommand):
club_root = SithFile.objects.create(name="clubs", owner=root)
sas = SithFile.objects.create(name="SAS", owner=root)
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",
)
clubs = self._create_clubs()
self.reset_index("club")
for bar_id, bar_name in settings.SITH_COUNTER_BARS:
Counter(id=bar_id, name=bar_name, club=bar_club, type="BAR").save()
Counter(id=bar_id, name=bar_name, club=clubs.pdf, type="BAR").save()
self.reset_index("counter")
counters = [
Counter(name="Eboutic", club=main_club, type="EBOUTIC"),
Counter(name="AE", club=main_club, type="OFFICE"),
Counter(name="Vidage comptes AE", club=main_club, type="OFFICE"),
Counter(name="Eboutic", club=clubs.ae, type="EBOUTIC"),
Counter(name="AE", club=clubs.ae, type="OFFICE"),
Counter(name="Vidage comptes AE", club=clubs.ae, type="OFFICE"),
]
Counter.objects.bulk_create(counters)
bar_groups = []
@@ -322,68 +310,165 @@ class Command(BaseCommand):
self._create_subscription(tutu)
StudentCard(uid="9A89B82018B0A0", customer=sli.customer).save()
# Clubs
Club.objects.create(
name="Bibo'UT", address="46 de la Boustifaille", parent=main_club
Membership.objects.create(
user=skia, club=clubs.ae, role=clubs.ae.roles.get(name="Respo Info")
)
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(
user=comunity,
club=bar_club,
club=clubs.pdf,
start_date=localdate(),
role=settings.SITH_CLUB_ROLES_ID["Board member"],
role=clubs.pdf.roles.get(name="Membre du bureau"),
)
Membership.objects.create(
user=sli,
club=troll,
role=9,
club=clubs.troll,
role=clubs.troll.roles.get(name="Vice-Président⸱e"),
description="Padawan Troll",
start_date=localdate() - timedelta(days=17),
)
Membership.objects.create(
user=krophil,
club=troll,
role=10,
club=clubs.troll,
role=clubs.troll.roles.get(name="Président⸱e"),
description="Maitre Troll",
start_date=localdate() - timedelta(days=200),
)
Membership.objects.create(
user=skia,
club=troll,
role=2,
club=clubs.troll,
role=clubs.troll.roles.get(name="Membre du bureau"),
description="Grand Ancien Troll",
start_date=localdate() - timedelta(days=400),
end_date=localdate() - timedelta(days=86),
)
Membership.objects.create(
user=richard,
club=troll,
role=2,
club=clubs.troll,
role=clubs.troll.roles.get(name="Membre du bureau"),
description="",
start_date=localdate() - timedelta(days=200),
end_date=localdate() - timedelta(days=100),
)
self._create_products(groups, main_club, refound)
p = ProductType.objects.create(name="Bières bouteilles")
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)
Counter.objects.create(name="Carte AE", club=refound, type="OFFICE")
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)
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
Counter.sellers.through.objects.bulk_create(
[
Counter.sellers.through(counter_id=1, user=skia), # MDE
Counter.sellers.through(counter_id=2, user=krophil), # Foyer
Counter.sellers.through(counter_id=2, user=krophil),
Counter.sellers.through(counter=mde, user=skia),
]
)
@@ -397,7 +482,7 @@ class Command(BaseCommand):
end_date="7942-06-12 10:28:45+01",
)
el.view_groups.add(groups.public)
el.edit_groups.add(main_club.board_group)
el.edit_groups.add(clubs.ae.board_group)
el.candidature_groups.add(groups.subscribers)
el.vote_groups.add(groups.subscribers)
liste = ElectionList.objects.create(title="Candidature Libre", election=el)
@@ -470,7 +555,7 @@ class Command(BaseCommand):
title="Apero barman",
summary="Viens boire un coup avec les barmans",
content="Glou glou glou glou glou glou glou",
club=bar_club,
club=clubs.pdf,
author=subscriber,
is_published=True,
moderator=skia,
@@ -488,7 +573,7 @@ class Command(BaseCommand):
content=(
"Viens donc t'enjailler avec les autres barmans aux frais du BdF! \\o/"
),
club=bar_club,
club=clubs.pdf,
author=subscriber,
is_published=True,
moderator=skia,
@@ -504,7 +589,7 @@ class Command(BaseCommand):
title="Repas fromager",
summary="Wien manger du l'bon fromeug'",
content="Fô viendre mangey d'la bonne fondue!",
club=bar_club,
club=clubs.pdf,
author=subscriber,
is_published=True,
moderator=skia,
@@ -520,7 +605,7 @@ class Command(BaseCommand):
title="SdF",
summary="Enjoy la fin des finaux!",
content="Viens faire la fête avec tout plein de gens!",
club=bar_club,
club=clubs.pdf,
author=subscriber,
is_published=True,
moderator=skia,
@@ -538,7 +623,7 @@ class Command(BaseCommand):
summary="Viens jouer!",
content="Rejoins la fine équipe du Troll Penché et viens "
"t'amuser le Vendredi soir!",
club=troll,
club=clubs.troll,
author=subscriber,
is_published=True,
moderator=skia,
@@ -639,131 +724,6 @@ 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):
path = self.SAS_FIXTURE_PATH / "Family" / f"{user.username}.jpg"
file = resize_image(Image.open(path), 400, "WEBP")
@@ -800,6 +760,52 @@ class Command(BaseCommand):
)
s.save()
def _create_clubs(self) -> PopulatedClubs:
ae = Club.objects.create(
id=1, name="AE", address="6 Boulevard Anatole France, 90000 Belfort"
)
ae.board_group.permissions.add(
*Permission.objects.filter(
codename__in=["view_subscription", "add_subscription", "add_membership"]
)
)
pdf = Club.objects.create(
id=settings.SITH_PDF_CLUB_ID,
name="PdF",
address="6 Boulevard Anatole France, 90000 Belfort",
)
troll = Club.objects.create(
name="Troll Penché", address="Terre Du Milieu", parent=ae
)
refound = Club.objects.create(
name="Carte AE", address="Jamais imprimée", parent=ae
)
roles = []
presidency_roles = ["Président⸱e", "Vice-Président⸱e"]
board_roles = [
"Trésorier⸱e",
"Secrétaire",
"Respo Info",
"Respo Com",
"Membre du bureau",
]
simple_roles = ["Membre actif⸱ve", "Curieux⸱euse"]
for club in ae, pdf, troll, refound:
for i, role in enumerate(presidency_roles):
roles.append(
ClubRole(
club=club, order=i, name=role, is_presidency=True, is_board=True
)
)
for i, role in enumerate(board_roles, start=len(presidency_roles)):
roles.append(ClubRole(club=club, order=i, name=role, is_board=True))
for i, role in enumerate(
simple_roles, start=len(presidency_roles) + len(board_roles)
):
roles.append(ClubRole(club=club, order=i, name=role))
ClubRole.objects.bulk_create(roles)
return PopulatedClubs(ae=ae, troll=troll, pdf=pdf, refound=refound)
def _create_groups(self) -> PopulatedGroups:
perms = Permission.objects.all()

View File

@@ -11,13 +11,12 @@ from django.db.models import Count, Exists, Min, OuterRef, Subquery
from django.utils.timezone import localdate, make_aware, now
from faker import Faker
from club.models import Club, Membership
from club.models import Club, ClubRole, Membership
from core.models import Group, User, UserBan
from counter.models import (
Counter,
Customer,
Permanency,
Price,
Product,
ProductType,
Refilling,
@@ -173,20 +172,25 @@ class Command(BaseCommand):
Customer.objects.bulk_create(customers, ignore_conflicts=True)
def make_club(self, club: Club, members: list[User], old_members: list[User]):
def zip_roles(users: list[User]) -> Iterator[tuple[User, int]]:
roles = iter(sorted(settings.SITH_CLUB_ROLES.keys(), reverse=True))
roles: list[ClubRole] = list(club.roles.all())
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
while (role := next(roles)) > 2:
for _role in important_roles:
# one member for each major role
yield users[user_idx], role
yield users[user_idx], _role
user_idx += 1
for _ in range(int(0.3 * (len(users) - user_idx))):
# 30% of the remaining in the board
yield users[user_idx], 2
yield users[user_idx], simple_board_role
user_idx += 1
for remaining in users[user_idx + 1 :]:
# everything else is a simple member
yield remaining, 1
yield remaining, random.choices(member_roles, weights=(0.8, 0.2))[0]
memberships = []
old_members = old_members.copy()
@@ -198,19 +202,14 @@ class Command(BaseCommand):
start_date=start,
end_date=self.faker.past_date(start),
user=old,
role=random.choice(list(settings.SITH_CLUB_ROLES.keys())),
role=random.choice(roles),
club=club,
)
)
for member, role in zip_roles(members):
start = self.faker.past_date("-1y")
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)
Membership._add_club_groups(memberships)
@@ -279,7 +278,6 @@ class Command(BaseCommand):
# 2/3 of the products are owned by AE
clubs = [ae, ae, ae, ae, ae, ae, *other_clubs]
products = []
prices = []
buying_groups = []
selling_places = []
for _ in range(200):
@@ -290,28 +288,25 @@ class Command(BaseCommand):
product_type=random.choice(categories),
code="".join(self.faker.random_letters(length=random.randint(4, 8))),
purchase_price=price,
selling_price=price,
special_selling_price=price - min(0.5, price),
club=random.choice(clubs),
limit_age=0 if random.random() > 0.2 else 18,
archived=self.faker.boolean(60),
archived=bool(random.random() > 0.7),
)
products.append(product)
for i in range(random.randint(0, 3)):
product_price = Price(
amount=price, product=product, is_always_shown=self.faker.boolean()
)
# 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])
# there will be products without buying groups
# but there are also such products in the real database
buying_groups.extend(
Product.buying_groups.through(product=product, group=group)
for group in random.sample(groups, k=random.randint(0, 3))
)
selling_places.extend(
Counter.products.through(counter=counter, product=product)
for counter in random.sample(counters, random.randint(0, 4))
)
Product.objects.bulk_create(products)
Price.objects.bulk_create(prices)
Price.groups.through.objects.bulk_create(buying_groups)
Product.buying_groups.through.objects.bulk_create(buying_groups)
Counter.products.through.objects.bulk_create(selling_places)
def create_sales(self, sellers: list[User]):
@@ -325,7 +320,7 @@ class Command(BaseCommand):
)
)
)
prices = list(Price.objects.select_related("product").all())
products = list(Product.objects.all())
counters = list(
Counter.objects.filter(name__in=["Foyer", "MDE", "La Gommette"])
)
@@ -335,14 +330,14 @@ class Command(BaseCommand):
# the longer the customer has existed, the higher the mean of nb_products
mu = 5 + (now().year - customer.since.year) * 2
nb_sales = max(0, int(random.normalvariate(mu=mu, sigma=mu * 5)))
favoured_prices = random.sample(prices, k=(random.randint(1, 5)))
favoured_products = random.sample(products, k=(random.randint(1, 5)))
favoured_counter = random.choice(counters)
this_customer_sales = []
for _ in range(nb_sales):
price = (
random.choice(favoured_prices)
product = (
random.choice(favoured_products)
if random.random() > 0.7
else random.choice(prices)
else random.choice(products)
)
counter = (
favoured_counter
@@ -351,11 +346,11 @@ class Command(BaseCommand):
)
this_customer_sales.append(
Selling(
product=price.product,
product=product,
counter=counter,
club_id=price.product.club_id,
club_id=product.club_id,
quantity=random.randint(1, 5),
unit_price=price.amount,
unit_price=product.selling_price,
seller=random.choice(sellers),
customer=customer,
date=make_aware(

View File

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

View File

@@ -11,7 +11,7 @@ from django.utils.timezone import now
from model_bakery import baker
from pytest_django.asserts import assertHTMLEqual, assertRedirects
from club.models import Club
from club.models import Club, Membership
from core.baker_recipes import board_user, subscriber_user
from core.markdown import markdown
from core.models import AnonymousUser, Page, PageRev, User
@@ -122,6 +122,9 @@ def test_page_revision_club_redirection(client: Client):
@pytest.mark.django_db
def test_viewable_by():
# 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()
view_groups = [
[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."""
user: User = subscriber_user.make()
item_recipe = Recipe(InvoiceItem, invoice=foreign_key(Recipe(Invoice, user=user)))
item_recipe.make(_quantity=3, quantity=1, unit_price=5)
item_recipe.make(_quantity=1, quantity=1, unit_price=5)
item_recipe.make(_quantity=2, quantity=1, unit_price=iter([5, 8]))
item_recipe.make(_quantity=3, quantity=1, product_unit_price=5)
item_recipe.make(_quantity=1, quantity=1, product_unit_price=5)
item_recipe.make(_quantity=2, quantity=1, product_unit_price=iter([5, 8]))
res = list(
Invoice.objects.filter(user=user)
.annotate_total()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,149 +0,0 @@
# 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 timezone as tz
from decimal import Decimal
from typing import TYPE_CHECKING, Literal, Self
from typing import Literal, Self
from dict2xml import dict2xml
from django.conf import settings
@@ -47,9 +47,6 @@ from core.utils import get_start_of_semester
from counter.fields import CurrencyField
from subscription.models import Subscription
if TYPE_CHECKING:
from collections.abc import Sequence
def get_eboutic() -> Counter:
return Counter.objects.filter(type="EBOUTIC").order_by("id").first()
@@ -160,7 +157,14 @@ class Customer(models.Model):
@property
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()
if subscription is None:
return False
@@ -359,13 +363,13 @@ class Product(models.Model):
QUANTITY_FOR_TRAY_PRICE = 6
name = models.CharField(_("name"), max_length=64)
description = models.TextField(_("description"), blank=True, default="")
description = models.TextField(_("description"), default="")
product_type = models.ForeignKey(
ProductType,
related_name="products",
verbose_name=_("product type"),
null=True,
blank=False,
blank=True,
on_delete=models.SET_NULL,
)
code = models.CharField(_("code"), max_length=16, blank=True)
@@ -373,6 +377,11 @@ class Product(models.Model):
_("purchase price"),
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(
height=70,
force_format="WEBP",
@@ -385,9 +394,7 @@ class Product(models.Model):
Club, related_name="products", verbose_name=_("club"), on_delete=models.CASCADE
)
limit_age = models.IntegerField(_("limit age"), default=0)
tray = models.BooleanField(
_("tray price"), help_text=_("Buy five, get the sixth free"), default=False
)
tray = models.BooleanField(_("tray price"), default=False)
buying_groups = models.ManyToManyField(
Group, related_name="products", verbose_name=_("buying groups"), blank=True
)
@@ -412,77 +419,41 @@ class Product(models.Model):
pk=settings.SITH_GROUP_ACCOUNTING_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.
class PriceQuerySet(models.QuerySet):
def for_user(self, user: User) -> Self:
age = user.age
if user.is_banned_alcohol:
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,
)
This must be not confused with the Customer.can_buy()
method as the present method returns an information
about the relation between a User and a Product,
whereas the other tells something about a Customer
(and not a user, they are not the same model).
Returns:
True if the user can buy this product else False
class Price(models.Model):
amount = CurrencyField(_("amount"))
product = models.ForeignKey(
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)
Warning:
This performs a db query, thus you can quickly have
a N+1 queries problem if you call it in a loop.
Hopefully, you can avoid that if you prefetch the buying_groups :
objects = PriceQuerySet.as_manager()
class Meta:
verbose_name = _("price")
def __str__(self):
if not self.label:
return f"{self.product.name} ({self.amount}€)"
return f"{self.product.name} {self.label} ({self.amount}€)"
```python
user = User.objects.get(username="foobar")
products = [
p
for p in Product.objects.prefetch_related("buying_groups")
if p.can_be_sold_to(user)
]
```
"""
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
def full_label(self):
if not self.label:
return self.product.name
return f"{self.product.name} \u2013 {self.label}"
def profit(self):
return self.selling_price - self.purchase_price
class ProductFormula(models.Model):
@@ -503,6 +474,18 @@ class ProductFormula(models.Model):
def __str__(self):
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):
def annotate_has_barman(self, user: User) -> Self:
@@ -600,7 +583,7 @@ class Counter(models.Model):
if user.is_anonymous:
return False
mem = self.club.get_membership_for(user)
if mem and mem.role >= settings.SITH_CLUB_ROLES_ID["Treasurer"]:
if mem and mem.role.is_presidency:
return True
return user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)
@@ -733,20 +716,35 @@ class Counter(models.Model):
# but they share the same primary key
return self.type == "BAR" and any(b.pk == customer.pk for b in self.barmen_list)
def get_prices_for(
self, customer: Customer, *, order_by: Sequence[str] | None = None
) -> list[Price]:
qs = (
Price.objects.filter(
product__counters=self, product__product_type__isnull=False
def get_products_for(self, customer: Customer) -> list[Product]:
"""
Get all allowed products for the provided customer on this counter
Prices will be annotated
"""
products = (
self.products.filter(archived=False)
.select_related("product_type")
.prefetch_related("buying_groups")
)
.for_user(customer.user)
.select_related("product", "product__product_type")
.prefetch_related("groups")
)
if order_by:
qs = qs.order_by(*order_by)
return list(qs)
# Only include age appropriate products
age = customer.user.age
if customer.user.is_banned_alcohol:
age = min(age, 17)
products = products.filter(limit_age__lte=age)
# 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):
@@ -1027,9 +1025,7 @@ class Selling(models.Model):
event = self.product.eticket.event_title or _("Unknown event")
subject = _("Eticket bought for the event %(event)s") % {"event": event}
message_html = _(
"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."
"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."
) % {
"event": event,
"url": (

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,13 @@
import { showSaveFilePicker } from "native-file-system-adapter";
import type TomSelect from "tom-select";
import { paginated } from "#core:utils/api";
import { csv } from "#core:utils/csv";
import { getCurrentUrlParams, History, updateQueryString } from "#core:utils/history";
import type { NestedKeyOf } from "#core:utils/types";
import { paginated } from "#core:utils/api.ts";
import { csv } from "#core:utils/csv.ts";
import {
getCurrentUrlParams,
History,
updateQueryString,
} from "#core:utils/history.ts";
import type { NestedKeyOf } from "#core:utils/types.ts";
import {
type ProductSchema,
type ProductSearchProductsDetailedData,
@@ -16,9 +20,6 @@ type GroupedProducts = Record<ProductType, ProductSchema[]>;
const defaultPageSize = 100;
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.
*/
@@ -33,7 +34,7 @@ const csvColumns = [
"purchase_price",
"selling_price",
"archived",
] as NestedKeyOf<ProductWithPriceSchema>[];
] as NestedKeyOf<ProductSchema>[];
/**
* Title of the csv columns.
@@ -174,16 +175,7 @@ document.addEventListener("alpine:init", () => {
this.nbPages > 1
? await paginated(productSearchProductsDetailed, this.getQueryParams())
: Object.values<ProductSchema[]>(this.products).flat();
// 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, {
const content = csv.stringify(products, {
columns: csvColumns,
titleRow: csvColumnTitles,
});

View File

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

View File

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

View File

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

View File

@@ -39,49 +39,6 @@
{% 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 %}
{% if object %}
<h2>{% trans name=object %}Edit product {{ name }}{% endtrans %}</h2>
@@ -92,54 +49,7 @@
{% endif %}
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ 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>
{{ form.as_p() }}
<br />
@@ -154,7 +64,7 @@
</em>
</p>
<div x-data="dynamicFormSet({ prefix: '{{ form.action_formset.prefix }}' })" class="margin-bottom">
<div x-data="dynamicFormSet" class="margin-bottom">
{{ form.action_formset.management_form }}
<div x-ref="formContainer">
{%- for f in form.action_formset.forms -%}
@@ -168,7 +78,6 @@
<i class="fa fa-plus"></i>{% trans %}Add action{% endtrans %}
</button>
</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>
</form>
{% endblock %}

View File

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

View File

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

View File

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

View File

@@ -3,14 +3,13 @@ import string
from datetime import timedelta
import pytest
from django.conf import settings
from django.contrib.auth.base_user import make_password
from django.test import Client, TestCase
from django.urls import reverse
from django.utils.timezone import now
from model_bakery import baker
from club.models import Membership
from club.models import ClubRole, Membership
from core.baker_recipes import board_user, subscriber_user
from core.models import User
from counter.baker_recipes import product_recipe, refill_recipe, sale_recipe
@@ -42,11 +41,12 @@ class TestStudentCard(TestCase):
cls.counter.sellers.add(cls.barmen)
cls.club_counter = baker.make(Counter)
role = baker.make(ClubRole, club=cls.club_counter.club, is_board=True)
baker.make(
Membership,
start_date=now() - timedelta(days=30),
club=cls.club_counter.club,
role=settings.SITH_CLUB_ROLES_ID["Board member"],
role=role,
user=cls.club_admin,
)
@@ -341,7 +341,7 @@ def test_update_balance():
def test_update_returnable_balance():
ReturnableProduct.objects.all().delete()
customer = baker.make(Customer)
products = product_recipe.make(_quantity=4, _bulk_create=True)
products = product_recipe.make(selling_price=0, _quantity=4, _bulk_create=True)
returnables = [
baker.make(
ReturnableProduct, product=products[0], returned_product=products[1]

View File

@@ -7,7 +7,12 @@ from counter.forms import ProductFormulaForm
class TestFormulaForm(TestCase):
@classmethod
def setUpTestData(cls):
cls.products = product_recipe.make(_quantity=3, _bulk_create=True)
cls.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,
)
def test_ok(self):
form = ProductFormulaForm(
@@ -21,6 +26,23 @@ class TestFormulaForm(TestCase):
assert formula.result == self.products[0]
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):
form = ProductFormulaForm(
data={

View File

@@ -9,7 +9,6 @@ from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import Client, TestCase
from django.urls import reverse
from model_bakery import baker
from model_bakery.recipe import Recipe
from PIL import Image
from pytest_django.asserts import assertNumQueries, assertRedirects
@@ -17,8 +16,8 @@ from club.models import Club
from core.baker_recipes import board_user, subscriber_user
from core.models import Group, User
from counter.baker_recipes import product_recipe
from counter.forms import ProductForm, ProductPriceFormSet
from counter.models import Price, Product, ProductType
from counter.forms import ProductForm
from counter.models import Product, ProductFormula, ProductType
@pytest.mark.django_db
@@ -82,11 +81,11 @@ def test_fetch_product_access(
def test_fetch_product_nb_queries(client: Client):
client.force_login(baker.make(User, is_superuser=True))
cache.clear()
with assertNumQueries(6):
with assertNumQueries(5):
# - 2 for authentication
# - 1 for pagination
# - 1 for the actual request
# - 2 to prefetch the related prices and groups
# - 1 to prefetch the related buying_groups
client.get(reverse("api:search_products_detailed"))
@@ -108,21 +107,48 @@ class TestCreateProduct(TestCase):
"selling_price": 1.0,
"special_selling_price": 1.0,
"limit_age": 0,
"price-TOTAL_FORMS": 0,
"price-INITIAL_FORMS": 0,
"action-TOTAL_FORMS": 0,
"action-INITIAL_FORMS": 0,
"form-TOTAL_FORMS": 0,
"form-INITIAL_FORMS": 0,
}
def test_form_simple(self):
def test_form(self):
form = ProductForm(data=self.data)
assert form.is_valid()
instance = form.save()
assert instance.club == self.club
assert instance.product_type == self.product_type
assert instance.name == "foo"
assert instance.selling_price == 1.0
def test_view_simple(self):
def test_form_with_product_from_formula(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)
url = reverse("counter:new_product")
response = self.client.get(url)
@@ -133,92 +159,3 @@ class TestCreateProduct(TestCase):
assert product.name == "foo"
assert product.club == self.club
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"] = {
"customer": self.customer,
"counter": self.object,
"allowed_prices": {price.id: price for price in self.prices},
"allowed_products": {product.id: product for product in self.products},
}
return kwargs
@@ -103,7 +103,7 @@ class CounterClick(
):
return redirect(obj) # Redirect to counter
self.prices = obj.get_prices_for(self.customer)
self.products = obj.get_products_for(self.customer)
return super().dispatch(request, *args, **kwargs)
@@ -121,31 +121,32 @@ class CounterClick(
# This is important because some items have a negative price
# Negative priced items gives money to the customer and should
# be processed first so that we don't throw a not enough money error
for form in sorted(formset, key=lambda form: form.price.amount):
for form in sorted(formset, key=lambda form: form.product.price):
self.request.session["last_basket"].append(
f"{form.cleaned_data['quantity']} x {form.price.full_label}"
f"{form.cleaned_data['quantity']} x {form.product.name}"
)
common_kwargs = {
"product": form.price.product,
"club_id": form.price.product.club_id,
"counter": self.object,
"seller": operator,
"customer": self.customer,
}
Selling(
**common_kwargs,
label=form.price.full_label,
unit_price=form.price.amount,
label=form.product.name,
product=form.product,
club=form.product.club,
counter=self.object,
unit_price=form.product.price,
quantity=form.cleaned_data["quantity"]
- form.cleaned_data["bonus_quantity"],
seller=operator,
customer=self.customer,
).save()
if form.cleaned_data["bonus_quantity"] > 0:
Selling(
**common_kwargs,
label=f"{form.price.full_label} (Plateau)",
label=f"{form.product.name} (Plateau)",
product=form.product,
club=form.product.club,
counter=self.object,
unit_price=0,
quantity=form.cleaned_data["bonus_quantity"],
seller=operator,
customer=self.customer,
).save()
self.customer.update_returnable_balance()
@@ -206,13 +207,14 @@ class CounterClick(
def get_context_data(self, **kwargs):
"""Add customer to the context."""
kwargs = super().get_context_data(**kwargs)
kwargs["prices"] = self.prices
kwargs["products"] = self.products
kwargs["formulas"] = ProductFormula.objects.filter(
result__in=[p.product_id for p in self.prices]
result__in=self.products
).prefetch_related("products")
kwargs["categories"] = defaultdict(list)
for price in self.prices:
kwargs["categories"][price.product.product_type].append(price)
for product in kwargs["products"]:
if product.product_type:
kwargs["categories"][product.product_type].append(product)
kwargs["customer"] = self.customer
kwargs["cancel_url"] = self.get_success_url()

View File

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

View File

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

View File

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

View File

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

View File

@@ -51,15 +51,15 @@
</span>
</li>
<template x-for="(item, index) in Object.values(basket)" :key="item.priceId">
<template x-for="(item, index) in Object.values(basket)" :key="item.id">
<li class="item-row" x-show="item.quantity > 0">
<div class="item-quantity">
<i class="fa fa-minus fa-xs" @click="remove(item.priceId)"></i>
<i class="fa fa-minus fa-xs" @click="remove(item.id)"></i>
<span x-text="item.quantity"></span>
<i class="fa fa-plus" @click="add(item)"></i>
</div>
<span class="item-name" x-text="item.name"></span>
<span class="item-price" x-text="(item.unitPrice * item.quantity).toFixed(2) + ' €'"></span>
<span class="item-price" x-text="(item.unit_price * item.quantity).toFixed(2) + ' €'"></span>
<input
type="hidden"
@@ -71,9 +71,9 @@
>
<input
type="hidden"
:value="item.priceId"
:id="`id_form-${index}-price_id`"
:name="`form-${index}-price_id`"
:value="item.id"
:id="`id_form-${index}-id`"
:name="`form-${index}-id`"
required
readonly
>
@@ -116,40 +116,45 @@
</span>
</div>
{% endif %}
{% for prices in categories %}
{% set category = prices[0].product.product_type %}
{% 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.name }}</h3>
{% if category.comment %}
<p><i>{{ category.comment }}</i></p>
<h3>{{ category }}</h3>
{% if items[0].category_comment %}
<p><i>{{ items[0].category_comment }}</i></p>
{% endif %}
</div>
<div class="product-group">
{% for price in prices %}
{% for p in items %}
<button
id="{{ price.id }}"
id="{{ p.id }}"
class="card product-button clickable shadow"
:class="{selected: basket.some((i) => i.priceId === {{ price.id }})}"
@click='addFromCatalog({{ price.id }}, {{ price.full_label|tojson }}, {{ price.amount }})'
:class="{selected: basket.some((i) => i.id === {{ p.id }})}"
@click='addFromCatalog({{ p.id }}, {{ p.name|tojson }}, {{ p.selling_price }})'
>
{% if price.product.icon %}
{% if p.icon %}
<img
class="card-image"
src="{{ price.product.icon.url }}"
alt="image de {{ price.full_label }}"
src="{{ p.icon.url }}"
alt="image de {{ p.name }}"
>
{% else %}
<i class="fa-regular fa-image fa-2x card-image"></i>
{% endif %}
<div class="card-content">
<h4 class="card-title">{{ price.full_label }}</h4>
<p>{{ price.amount }} €</p>
<h4 class="card-title">{{ p.name }}</h4>
<p>{{ p.selling_price }} €</p>
</div>
</button>
{% endfor %}
</div>
</section>
{% endif %}
{% endfor %}
{% else %}
<p>{% trans %}There are no items available for sale{% endtrans %}</p>
{% endfor %}

View File

@@ -11,12 +11,7 @@ from pytest_django.asserts import assertRedirects
from core.baker_recipes import subscriber_user
from core.models import Group, User
from counter.baker_recipes import (
price_recipe,
product_recipe,
refill_recipe,
sale_recipe,
)
from counter.baker_recipes import product_recipe, refill_recipe, sale_recipe
from counter.models import (
Counter,
Customer,
@@ -152,29 +147,29 @@ class TestEboutic(TestCase):
product_type = baker.make(ProductType)
cls.snack = price_recipe.make(
amount=1.5, product=product_recipe.make(product_type=product_type)
cls.snack = product_recipe.make(
selling_price=1.5, special_selling_price=1, product_type=product_type
)
cls.beer = price_recipe.make(
product=product_recipe.make(limit_age=18, product_type=product_type),
amount=2.5,
cls.beer = product_recipe.make(
limit_age=18,
selling_price=2.5,
special_selling_price=1,
product_type=product_type,
)
cls.not_in_counter = price_recipe.make(
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.not_in_counter = product_recipe.make(
selling_price=3.5, product_type=product_type
)
cls.cotiz = product_recipe.make(selling_price=10, product_type=product_type)
cls.group_public.prices.add(cls.snack, cls.beer, cls.not_in_counter)
cls.group_cotiz.prices.add(cls.cotiz)
cls.group_public.products.add(cls.snack, cls.beer, cls.not_in_counter)
cls.group_cotiz.products.add(cls.cotiz)
cls.subscriber.groups.add(cls.group_cotiz, cls.group_public)
cls.new_customer.groups.add(cls.group_public)
cls.new_customer_adult.groups.add(cls.group_public)
cls.eboutic = get_eboutic()
cls.eboutic.products.add(cls.cotiz.product, cls.beer.product, cls.snack.product)
cls.eboutic.products.add(cls.cotiz, cls.beer, cls.snack)
@classmethod
def set_age(cls, user: User, age: int):
@@ -258,7 +253,7 @@ class TestEboutic(TestCase):
self.submit_basket([BasketItem(self.snack.id, 2)]),
reverse("eboutic:checkout", kwargs={"basket_id": 1}),
)
assert Basket.objects.get(id=1).total == self.snack.amount * 2
assert Basket.objects.get(id=1).total == self.snack.selling_price * 2
self.client.force_login(self.new_customer_adult)
assertRedirects(
@@ -268,7 +263,8 @@ class TestEboutic(TestCase):
reverse("eboutic:checkout", kwargs={"basket_id": 2}),
)
assert (
Basket.objects.get(id=2).total == self.snack.amount * 2 + self.beer.amount
Basket.objects.get(id=2).total
== self.snack.selling_price * 2 + self.beer.selling_price
)
self.client.force_login(self.subscriber)
@@ -284,5 +280,7 @@ class TestEboutic(TestCase):
)
assert (
Basket.objects.get(id=3).total
== self.snack.amount * 2 + self.beer.amount + self.cotiz.amount
== self.snack.selling_price * 2
+ 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 core.baker_recipes import old_subscriber_user, subscriber_user
from counter.baker_recipes import price_recipe, product_recipe
from counter.baker_recipes import product_recipe
from counter.models import Product, ProductType, Selling
from counter.tests.test_counter import force_refill_user
from eboutic.models import Basket, BasketItem
@@ -32,22 +32,23 @@ class TestPaymentBase(TestCase):
cls.basket = baker.make(Basket, user=cls.customer)
cls.refilling = product_recipe.make(
product_type_id=settings.SITH_COUNTER_PRODUCTTYPE_REFILLING,
prices=[price_recipe.make(amount=15)],
selling_price=15,
)
product_type = baker.make(ProductType)
cls.snack = product_recipe.make(
product_type=product_type, prices=[price_recipe.make(amount=1.5)]
selling_price=1.5, special_selling_price=1, product_type=product_type
)
cls.beer = product_recipe.make(
limit_age=18,
selling_price=2.5,
special_selling_price=1,
product_type=product_type,
prices=[price_recipe.make(amount=2.5)],
)
BasketItem.from_price(cls.snack.prices.first(), 1, cls.basket).save()
BasketItem.from_price(cls.beer.prices.first(), 2, cls.basket).save()
BasketItem.from_product(cls.snack, 1, cls.basket).save()
BasketItem.from_product(cls.beer, 2, cls.basket).save()
class TestPaymentSith(TestPaymentBase):
@@ -115,13 +116,13 @@ class TestPaymentSith(TestPaymentBase):
assert len(sellings) == 2
assert sellings[0].payment_method == Selling.PaymentMethod.SITH_ACCOUNT
assert sellings[0].quantity == 1
assert sellings[0].unit_price == self.snack.prices.first().amount
assert sellings[0].unit_price == self.snack.selling_price
assert sellings[0].counter.type == "EBOUTIC"
assert sellings[0].product == self.snack
assert sellings[1].payment_method == Selling.PaymentMethod.SITH_ACCOUNT
assert sellings[1].quantity == 2
assert sellings[1].unit_price == self.beer.prices.first().amount
assert sellings[1].unit_price == self.beer.selling_price
assert sellings[1].counter.type == "EBOUTIC"
assert sellings[1].product == self.beer
@@ -145,7 +146,7 @@ class TestPaymentSith(TestPaymentBase):
)
def test_refilling_in_basket(self):
BasketItem.from_price(self.refilling.prices.first(), 1, self.basket).save()
BasketItem.from_product(self.refilling, 1, self.basket).save()
self.client.force_login(self.customer)
force_refill_user(self.customer, self.basket.total + 1)
self.customer.customer.refresh_from_db()
@@ -190,8 +191,8 @@ class TestPaymentCard(TestPaymentBase):
def test_buy_success(self):
response = self.client.get(self.generate_bank_valid_answer(self.basket))
assert response.status_code == 200
assert response.content.decode() == "Payment successful"
assert not Basket.objects.filter(id=self.basket.id).exists()
assert response.content.decode("utf-8") == "Payment successful"
assert Basket.objects.filter(id=self.basket.id).first() is None
sellings = Selling.objects.filter(customer=self.customer.customer).order_by(
"quantity"
@@ -199,13 +200,13 @@ class TestPaymentCard(TestPaymentBase):
assert len(sellings) == 2
assert sellings[0].payment_method == Selling.PaymentMethod.CARD
assert sellings[0].quantity == 1
assert sellings[0].unit_price == self.snack.prices.first().amount
assert sellings[0].unit_price == self.snack.selling_price
assert sellings[0].counter.type == "EBOUTIC"
assert sellings[0].product == self.snack
assert sellings[1].payment_method == Selling.PaymentMethod.CARD
assert sellings[1].quantity == 2
assert sellings[1].unit_price == self.beer.prices.first().amount
assert sellings[1].unit_price == self.beer.selling_price
assert sellings[1].counter.type == "EBOUTIC"
assert sellings[1].product == self.beer
@@ -215,9 +216,7 @@ class TestPaymentCard(TestPaymentBase):
assert not customer.subscriptions.first().is_valid_now()
basket = baker.make(Basket, user=customer)
BasketItem.from_price(
Product.objects.get(code="2SCOTIZ").prices.first(), 1, basket
).save()
BasketItem.from_product(Product.objects.get(code="2SCOTIZ"), 1, basket).save()
response = self.client.get(self.generate_bank_valid_answer(basket))
assert response.status_code == 200
@@ -229,13 +228,12 @@ class TestPaymentCard(TestPaymentBase):
assert subscription.location == "EBOUTIC"
def test_buy_refilling(self):
price = self.refilling.prices.first()
BasketItem.from_price(price, 2, self.basket).save()
BasketItem.from_product(self.refilling, 2, self.basket).save()
response = self.client.get(self.generate_bank_valid_answer(self.basket))
assert response.status_code == 200
self.customer.customer.refresh_from_db()
assert self.customer.customer.amount == price.amount * 2
assert self.customer.customer.amount == self.refilling.selling_price * 2
def test_multiple_responses(self):
bank_response = self.generate_bank_valid_answer(self.basket)
@@ -255,17 +253,17 @@ class TestPaymentCard(TestPaymentBase):
self.basket.delete()
response = self.client.get(bank_response)
assert response.status_code == 500
assert response.text == (
"Basket processing failed with error: "
"SuspiciousOperation('Basket does not exists')"
assert (
response.text
== "Basket processing failed with error: SuspiciousOperation('Basket does not exists')"
)
def test_altered_basket(self):
bank_response = self.generate_bank_valid_answer(self.basket)
BasketItem.from_price(self.snack.prices.first(), 1, self.basket).save()
BasketItem.from_product(self.snack, 1, self.basket).save()
response = self.client.get(bank_response)
assert response.status_code == 500
assert response.text == (
"Basket processing failed with error: "
assert (
response.text == "Basket processing failed with error: "
"SuspiciousOperation('Basket total and amount do not match')"
)

View File

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

View File

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

View File

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

View File

@@ -56,7 +56,7 @@ msgstr "nom"
msgid "owner"
msgstr "propriétaire"
#: api/models.py core/models.py counter/models.py
#: api/models.py core/models.py
msgid "groups"
msgstr "groupes"
@@ -2998,6 +2998,24 @@ msgstr ""
"Décrivez le produit. Si c'est un click pour un évènement, donnez quelques "
"détails dessus, comme la date (en incluant l'année)."
#: counter/forms.py
#, python-format
msgid ""
"This product is a formula. Its price cannot be greater than the price of the "
"products constituting it, which is %(price)s"
msgstr ""
"Ce produit est une formule. Son prix ne peut pas être supérieur au prix des "
"produits qui la constituent, soit %(price)s €."
#: counter/forms.py
#, python-format
msgid ""
"This product is a formula. Its special price cannot be greater than the "
"price of the products constituting it, which is %(price)s"
msgstr ""
"Ce produit est une formule. Son prix spécial ne peut pas être supérieur au "
"prix des produits qui la constituent, soit %(price)s €."
#: counter/forms.py
msgid ""
"The same product cannot be at the same time the result and a part of the "
@@ -3006,13 +3024,19 @@ msgstr ""
"Un même produit ne peut pas être à la fois le résultat et un élément de la "
"formule."
#: counter/forms.py
msgid ""
"The result cannot be more expensive than the total of the other products."
msgstr ""
"Le résultat ne peut pas être plus cher que le total des autres produits."
#: counter/forms.py
msgid "Refound this account"
msgstr "Rembourser ce compte"
#: counter/forms.py
msgid "The selected product isn't available for this user"
msgstr "Le produit sélectionné n'est pas disponible pour cet utilisateur"
msgstr "Le produit sélectionné n'est pas disponnible pour cet utilisateur"
#: counter/forms.py
msgid "Submitted basket is invalid"
@@ -3126,6 +3150,18 @@ msgstr "prix d'achat"
msgid "Initial cost of purchasing the product"
msgstr "Coût initial d'achat du produit"
#: counter/models.py
msgid "selling price"
msgstr "prix de vente"
#: counter/models.py
msgid "special selling price"
msgstr "prix de vente spécial"
#: counter/models.py
msgid "Price for barmen during their permanence"
msgstr "Prix pour les barmen durant leur permanence"
#: counter/models.py
msgid "icon"
msgstr "icône"
@@ -3138,10 +3174,6 @@ msgstr "âge limite"
msgid "tray price"
msgstr "prix plateau"
#: counter/models.py
msgid "Buy five, get the sixth free"
msgstr "Pour cinq achetés, le sixième offert"
#: counter/models.py
msgid "buying groups"
msgstr "groupe d'achat"
@@ -3154,35 +3186,10 @@ msgstr "archivé"
msgid "updated at"
msgstr "mis à jour le"
#: counter/models.py eboutic/models.py
#: counter/models.py
msgid "product"
msgstr "produit"
#: counter/models.py
msgid "always show"
msgstr "toujours montrer"
#: counter/models.py
msgid ""
"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."
msgstr ""
"Si cette option est activée, les gens verront ce prix et pourront le payer, "
"même si un autre moins cher existe. Dans le cas contraire, le prix sera "
"visible uniquement s'il s'agit du prix disponible le plus faible."
#: counter/models.py
msgid ""
"A short label for easier differentiation if a user can see multiple prices."
msgstr ""
"Un court libellé pour faciliter la différentiation si un utilisateur peut "
"voir plusieurs prix."
#: counter/models.py
msgid "price"
msgstr "prix"
#: counter/models.py
msgid "products"
msgstr "produits"
@@ -3661,6 +3668,10 @@ msgstr ""
msgid "New formula"
msgstr "Nouvelle formule"
#: counter/templates/counter/formula_list.jinja
msgid "instead of"
msgstr "au lieu de"
#: counter/templates/counter/fragments/create_student_card.jinja
msgid "No student card registered."
msgstr "Aucune carte étudiante enregistrée."
@@ -3783,10 +3794,6 @@ msgstr ""
"votre cotisation. Si vous ne renouvelez pas votre cotisation, il n'y aura "
"aucune conséquence autre que le retrait de l'argent de votre compte."
#: counter/templates/counter/product_form.jinja
msgid "Remove price"
msgstr "Retirer le prix"
#: counter/templates/counter/product_form.jinja
msgid "Remove this action"
msgstr "Retirer cette action"
@@ -3808,14 +3815,6 @@ msgstr "Dernière mise à jour"
msgid "Product creation"
msgstr "Création de produit"
#: counter/templates/counter/product_form.jinja
msgid "Prices"
msgstr "Prix"
#: counter/templates/counter/product_form.jinja
msgid "Add a price"
msgstr "Ajouter un prix"
#: counter/templates/counter/product_form.jinja
msgid "Automatic actions"
msgstr "Actions automatiques"
@@ -4067,10 +4066,18 @@ msgstr "validé"
msgid "Invoice already validated"
msgstr "Facture déjà validée"
#: eboutic/models.py
msgid "product id"
msgstr "ID du produit"
#: eboutic/models.py
msgid "product name"
msgstr "nom du produit"
#: eboutic/models.py
msgid "product type id"
msgstr "id du type du produit"
#: eboutic/models.py
msgid "basket"
msgstr "panier"

View File

@@ -33,6 +33,8 @@ class TestMergeUser(TestCase):
cls.club = baker.make(Club)
cls.eboutic = Counter.objects.get(name="Eboutic")
cls.barbar = Product.objects.get(code="BARB")
cls.barbar.selling_price = 2
cls.barbar.save()
cls.root = User.objects.get(username="root")
cls.to_keep = User.objects.create(
username="to_keep", password="plop", email="u.1@utbm.fr"

View File

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

View File

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

View File

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