diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index de547dc2..0afbc963 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,7 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.15.13 + rev: v0.15.19 hooks: - id: ruff-check # just check the code, and print the errors - id: ruff-check # actually fix the fixable errors, but print nothing @@ -12,7 +12,7 @@ repos: rev: v0.6.1 hooks: - id: biome-check - additional_dependencies: ["@biomejs/biome@2.4.6"] + additional_dependencies: ["@biomejs/biome@2.5.1"] - repo: https://github.com/rtts/djhtml rev: 3.0.11 hooks: diff --git a/biome.json b/biome.json index bd41ee38..520f3241 100644 --- a/biome.json +++ b/biome.json @@ -17,7 +17,7 @@ "linter": { "enabled": true, "rules": { - "recommended": true, + "preset": "recommended", "style": { "useNamingConvention": "error" }, diff --git a/club/api.py b/club/api.py index 4a055e2c..cf1070d0 100644 --- a/club/api.py +++ b/club/api.py @@ -11,6 +11,7 @@ from club.models import Club, Membership from club.schemas import ( ClubSchema, ClubSearchFilterSchema, + MembershipFilterSchema, SimpleClubSchema, UserMembershipSchema, ) @@ -62,3 +63,43 @@ class UserClubController(ControllerBase): .filter(user=user) .select_related("club", "user", "role") ) + + +@api_controller("/clubs/members/") +class ClubMembershipController(ControllerBase): + @route.get( + "/new", + response=list[UserMembershipSchema], + auth=[ApiKeyAuth(), SessionAuth()], + permissions=[HasPerm("club.view_club")], + url_name="get_new_clubs_members_since_date", + ) + def fetch_new_club_members(self, filters: Query[MembershipFilterSchema]): + """give all the members of all clubs that have joined since a given date""" + memberships = ( + Membership.objects.ongoing() + .filter(start_date__gte=filters.since_date, end_date__isnull=True) + .select_related("user", "role", "club") + ) + if filters.clubs_id: + memberships = memberships.filter(club_id__in=filters.clubs_id) + + return memberships.order_by("start_date") + + @route.get( + "/former", + response=list[UserMembershipSchema], + auth=[ApiKeyAuth(), SessionAuth()], + permissions=[HasPerm("club.view_club")], + url_name="get_former_clubs_members_since_date", + ) + def fetch_former_club_members(self, filters: Query[MembershipFilterSchema]): + """give all the former members of all clubs that have left since a given date""" + memberships = Membership.objects.filter( + start_date__lt=filters.since_date, + end_date__gte=filters.since_date, + ).select_related("user", "role", "club") + if filters.clubs_id: + memberships = memberships.filter(club_id__in=filters.clubs_id) + + return memberships.order_by("start_date") diff --git a/club/schemas.py b/club/schemas.py index 99d05fc1..10ea2411 100644 --- a/club/schemas.py +++ b/club/schemas.py @@ -1,3 +1,4 @@ +from datetime import date from typing import Annotated from django.db.models import Q @@ -79,3 +80,9 @@ class UserMembershipSchema(ModelSchema): club: SimpleClubSchema role: ClubRoleSchema + user: SimpleUserSchema + + +class MembershipFilterSchema(FilterSchema): + since_date: Annotated[date, FilterLookup("date__lte")] + clubs_id: set[int] | None = None diff --git a/club/static/club/list.scss b/club/static/club/list.scss index 9fbf952f..030c7a56 100644 --- a/club/static/club/list.scss +++ b/club/static/club/list.scss @@ -45,3 +45,10 @@ } } } + +@media screen and (max-width: 575px){ + #club-list{ + padding-left: 0; + padding-right: 0; + } +} diff --git a/club/tests/test_club_membership_controller.py b/club/tests/test_club_membership_controller.py new file mode 100644 index 00000000..27aee901 --- /dev/null +++ b/club/tests/test_club_membership_controller.py @@ -0,0 +1,203 @@ +from datetime import timedelta + +from django.contrib.auth.models import Permission +from django.test import TestCase +from django.urls import reverse +from django.utils.timezone import localdate +from model_bakery import baker + +from club.models import Club, ClubRole, Membership +from core.baker_recipes import subscriber_user +from core.models import User + + +class TestMembershipAPI(TestCase): + @classmethod + def setUpTestData(cls): + cls.user = baker.make(User) + perm = Permission.objects.get(codename="view_club") + cls.user.user_permissions.add(perm) + cls.clubs = baker.make(Club, _quantity=3, is_active=True) + cls.roles = baker.make(ClubRole, _quantity=3, is_active=True) + cls.expectedNumQueries = 5 + + # Clean existing data to avoid side effects + Membership.objects.all().delete() + + cls.memberships = [ + # on going + Membership.objects.create( + club=cls.clubs[0], + user=subscriber_user.make(), + role=cls.roles[0], + start_date=localdate() - timedelta(weeks=1), + ), + # on going + Membership.objects.create( + club=cls.clubs[1], + user=subscriber_user.make(), + role=cls.roles[1], + start_date=localdate() - timedelta(days=1), + ), + # former + Membership.objects.create( + club=cls.clubs[1], + user=subscriber_user.make(), + role=cls.roles[2], + start_date=localdate() - timedelta(weeks=2), + end_date=localdate() - timedelta(days=6), + ), + # on going + Membership.objects.create( + club=cls.clubs[2], + user=subscriber_user.make(), + role=cls.roles[0], + start_date=localdate() - timedelta(weeks=3), + ), + # former + Membership.objects.create( + club=cls.clubs[1], + user=subscriber_user.make(), + role=cls.roles[2], + start_date=localdate() - timedelta(days=4), + end_date=localdate() - timedelta(days=3), + ), + # on going + Membership.objects.create( + club=cls.clubs[2], + user=subscriber_user.make(), + role=cls.roles[0], + start_date=localdate() - timedelta(days=1), + ), + # former + Membership.objects.create( + club=cls.clubs[0], + user=subscriber_user.make(), + role=cls.roles[0], + start_date=localdate() - timedelta(weeks=6), + end_date=localdate() - timedelta(days=3), + ), + # former + Membership.objects.create( + club=cls.clubs[2], + user=subscriber_user.make(), + role=cls.roles[0], + start_date=localdate() - timedelta(weeks=8), + end_date=localdate() - timedelta(days=6), + ), + # former + Membership.objects.create( + club=cls.clubs[1], + user=subscriber_user.make(), + role=cls.roles[0], + start_date=localdate() - timedelta(weeks=8), + end_date=localdate() - timedelta(weeks=7, days=5), + ), + ] + + +class TestNewMembershipAPI(TestMembershipAPI): + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.url = reverse("api:get_new_clubs_members_since_date") + + def test_new_membership_one_club(self): + self.client.force_login(self.user) + since_date = localdate() - timedelta(weeks=1) + arg = {"since_date": since_date, "clubs_id": self.clubs[0].id} + with self.assertNumQueries(self.expectedNumQueries): + response = self.client.get(self.url, query_params=arg) + assert response.status_code == 200 + data = response.json() + + membership_ids = [e["id"] for e in data] + expected_ids = [self.memberships[0].id] + assert membership_ids == expected_ids + + def test_new_membership_multiple_club(self): + self.client.force_login(self.user) + since_date = localdate() - timedelta(weeks=1) + arg = { + "since_date": since_date, + "clubs_id": [self.clubs[0].id, self.clubs[1].id], + } + with self.assertNumQueries(self.expectedNumQueries): + response = self.client.get(self.url, query_params=arg) + assert response.status_code == 200 + data = response.json() + + membership_ids = [e["id"] for e in data] + expected_ids = [self.memberships[0].id, self.memberships[1].id] + assert membership_ids == expected_ids + + def test_new_membership_all_clubs(self): + self.client.force_login(self.user) + since_date = localdate() - timedelta(weeks=1) + arg = {"since_date": since_date} + with self.assertNumQueries(self.expectedNumQueries): + response = self.client.get(self.url, query_params=arg) + assert response.status_code == 200 + data = response.json() + + membership_ids = [e["id"] for e in data] + expected_ids = [ + self.memberships[0].id, + self.memberships[1].id, + self.memberships[5].id, + ] + assert membership_ids == expected_ids + + +class TestFormerMembershipAPI(TestMembershipAPI): + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.url = reverse("api:get_former_clubs_members_since_date") + + def test_former_membership_one_club(self): + self.client.force_login(self.user) + since_date = localdate() - timedelta(weeks=1) + arg = {"since_date": since_date, "clubs_id": self.clubs[1].id} + with self.assertNumQueries(self.expectedNumQueries): + response = self.client.get(self.url, query_params=arg) + + assert response.status_code == 200 + data = response.json() + + membership_ids = [e["id"] for e in data] + expected_ids = [self.memberships[2].id] + assert membership_ids == expected_ids + + def test_new_membership_multiple_club(self): + self.client.force_login(self.user) + since_date = localdate() - timedelta(weeks=1) + arg = { + "since_date": since_date, + "clubs_id": [self.clubs[1].id, self.clubs[0].id], + } + with self.assertNumQueries(self.expectedNumQueries): + response = self.client.get(self.url, query_params=arg) + assert response.status_code == 200 + data = response.json() + + membership_ids = [e["id"] for e in data] + expected_ids = [self.memberships[6].id, self.memberships[2].id] + assert membership_ids == expected_ids + + def test_new_membership_all_clubs(self): + self.client.force_login(self.user) + since_date = localdate() - timedelta(weeks=1) + arg = {"since_date": since_date} + with self.assertNumQueries(self.expectedNumQueries): + response = self.client.get(self.url, query_params=arg) + assert response.status_code == 200 + data = response.json() + + membership_ids = [e["id"] for e in data] + expected_ids = [ + self.memberships[7].id, + self.memberships[6].id, + self.memberships[2].id, + ] + assert membership_ids == expected_ids diff --git a/club/tests/test_page.py b/club/tests/test_page.py index 6567a690..aeefe068 100644 --- a/club/tests/test_page.py +++ b/club/tests/test_page.py @@ -1,4 +1,5 @@ import pytest +from aemark import markdown from bs4 import BeautifulSoup from django.test import Client from django.urls import reverse @@ -7,7 +8,6 @@ from pytest_django.asserts import assertHTMLEqual, assertRedirects 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 diff --git a/com/schemas.py b/com/schemas.py index efc01f01..9af51e18 100644 --- a/com/schemas.py +++ b/com/schemas.py @@ -1,13 +1,13 @@ from datetime import datetime from typing import Annotated +from aemark import markdown from ninja import FilterLookup, FilterSchema, ModelSchema from ninja_extra import service_resolver from ninja_extra.context import RouteContext from club.schemas import ClubProfileSchema from com.models import News, NewsDate -from core.markdown import markdown class NewsDateFilterSchema(FilterSchema): diff --git a/com/tests/test_api.py b/com/tests/test_api.py index ce747347..d8c98acf 100644 --- a/com/tests/test_api.py +++ b/com/tests/test_api.py @@ -2,6 +2,7 @@ from datetime import timedelta from pathlib import Path import pytest +from aemark import markdown from django.conf import settings from django.contrib.auth.models import Permission from django.http import HttpResponse @@ -13,7 +14,6 @@ from pytest_django.asserts import assertNumQueries from com.ics_calendar import IcsCalendar from com.models import News, NewsDate -from core.markdown import markdown from core.models import User diff --git a/core/fixtures/SYNTAX.html b/core/fixtures/SYNTAX.html index 7b41e07c..f5b9f6cc 100644 --- a/core/fixtures/SYNTAX.html +++ b/core/fixtures/SYNTAX.html @@ -2,12 +2,9 @@
Le Markdown le plus standard se trouve documenté ici:
https://www.markdownguide.org/basic-syntax.
-Si cette page n'est pas exhaustive vis à vis de la syntaxe du site AE,
+Si cette page n’est pas exhaustive vis à vis de la syntaxe du site AE,
elle a au moins le mérite de bien documenter le Markdown original.
Le réel parseur du site AE est une version tunée de mistune.
-Les plus aventureux pourront aller lire ses tests
-afin d'en connaître la syntaxe le plus finement possible.
-En pratique, cette page devrait déjà résumer une bonne partie.
Le réel parseur du site AE est une version tunée de comrak.
**texte**__texte__~~texte~~~~***__texte__***~~<sup>texte</sup><sub>texte</sub><sup>texte</sup><sub>texte</sub>[nom du lien](page://nomDeLaPage)[nom du lien](page://nomDeLaPage)[nom du lien](options)| Titre | -Titre2 | -Titre3 | +Titre | +Titre2 | +Titre3 |
|---|---|---|---|---|---|
| test | -test | -test | +test | +test | +test |
| test | -test | -test | +test | +test | +test |
L'alignement dans les cellules est géré comme suit, avec les ':' sur la ligne en dessous du titre:
+L’alignement dans les cellules est géré comme suit, avec les ‘:’ sur la ligne en dessous du titre:
| Titre | Titre2 | Titre3 |
|:-------|:------:|-------:|
| gauche | centre | droite |
@@ -120,16 +117,16 @@ etc...
- Titre
- Titre2
- Titre3
+Titre
+Titre2
+Titre3
- gauche
- centre
- droite
+gauche
+centre
+droite
@@ -141,11 +138,11 @@ etc...


-
+

Image à 50% de la largeur de la page.

+

Image de 350 pixels de large.

+

Image de 350x100 pixels.
(devrait pouvoir détecter si vidéo ou non)
Il est possible d'intégrer de la syntaxe Markdown-AE dans un tel bloc.
+Il est possible d’intégrer de la syntaxe Markdown-AE dans un tel bloc.
On les crée comme ça1:
+On les crée comme ça1:
Je fais une note[^clef].
[^clef]: je note ensuite où je veux le contenu de ma clef qui apparaîtra quand même en bas
@@ -175,13 +172,15 @@ citation
Une ligne peut être créée avec une ligne contenant 4 tirets (----).
',
- ),
- (
- "[texte](page://tst-page)",
- 'texte',
+ '
',
),
+ ("[texte](page://tst-page)", 'texte'),
(
"",
- '
',
+ '
',
),
("", '
'),
(
"",
- '
',
+ '
',
),
- ("", '
'),
+ ("", '
'),
(
"",
- '
',
+ '
',
),
# when the image dimension has a wrong format, don't touch the url
("", '
'),
@@ -350,7 +347,7 @@ http://git.an
<guy>Bibou</guy>
-<script>alert('Guy');</script>
+ <script>alert('Guy');</script> """ assertInHTML(expected, response.text) diff --git a/core/tests/test_page.py b/core/tests/test_page.py index b530ba18..0a9ef5fd 100644 --- a/core/tests/test_page.py +++ b/core/tests/test_page.py @@ -2,6 +2,7 @@ from datetime import timedelta import freezegun import pytest +from aemark import markdown from bs4 import BeautifulSoup from django.conf import settings from django.contrib.auth.models import Permission @@ -13,7 +14,6 @@ from pytest_django.asserts import assertHTMLEqual, assertRedirects 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 diff --git a/core/tests/test_user.py b/core/tests/test_user.py index 752405b2..0a8a3b7b 100644 --- a/core/tests/test_user.py +++ b/core/tests/test_user.py @@ -200,7 +200,11 @@ class TestFilterInactive(TestCase): ] sale_recipe.make(customer=cls.users[3].customer, date=time_active) baker.make( - Refilling, customer=cls.users[4].customer, date=time_active, counter=counter + Refilling, + customer=cls.users[4].customer, + date=time_active, + counter=counter, + amount=1, ) sale_recipe.make(customer=cls.users[5].customer, date=time_inactive) @@ -455,7 +459,9 @@ def test_user_preferences(client: Client): @pytest.mark.django_db def test_user_stats(client: Client): user = subscriber_user.make() - baker.make(Refilling, customer=user.customer, amount=99999) + baker.make( + Refilling, customer=user.customer, amount=settings.SITH_ACCOUNT_MAX_MONEY + ) bars = [b[0] for b in settings.SITH_COUNTER_BARS] baker.make( Permanency, diff --git a/counter/fields.py b/counter/fields.py index a212059d..caf3a584 100644 --- a/counter/fields.py +++ b/counter/fields.py @@ -1,22 +1,68 @@ from decimal import Decimal from django.conf import settings +from django.core import checks +from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models +from django.utils.functional import cached_property class CurrencyField(models.DecimalField): """Custom database field used for currency.""" - def __init__(self, *args, **kwargs): - kwargs["max_digits"] = 12 - kwargs["decimal_places"] = 2 - super().__init__(*args, **kwargs) + def __init__( + self, verbose_name=None, name=None, min_value=None, max_value=None, **kwargs + ): + kwargs.update({"max_digits": 12, "decimal_places": 2}) + self.min_value = min_value + self.max_value = max_value + super().__init__(verbose_name, name, **kwargs) def to_python(self, value): if value is None: return None return super().to_python(value).quantize(Decimal("0.01")) + @cached_property + def validators(self): + res = [] + if self.max_value: + res.append(MaxValueValidator(self.max_value)) + if self.min_value: + res.append(MinValueValidator(self.min_value)) + return [*super().validators, *res] + + def check(self, **kwargs): # pragma: no cover + # this is executed during runserver, but won't run in prod + errors = super().check(**kwargs) + for name, val in ("min_value", self.min_value), ("max_value", self.max_value): + if not val: + continue + try: + float(val) + except ValueError: + errors.append( + checks.Error( + f"CurrencyField.{name} must be a valid float", + obj=self, + id="sith.E001", + ) + ) + return errors + + def formfield(self, **kwargs): + return super().formfield( + **{"min_value": self.min_value, "max_value": self.max_value, **kwargs} + ) + + def deconstruct(self): + name, path, args, kwargs = super().deconstruct() + if self.min_value is not None: + kwargs["min_value"] = self.min_value + if self.max_value is not None: + kwargs["max_value"] = self.max_value + return name, path, args, kwargs + if settings.TESTING: from model_bakery import baker diff --git a/counter/forms.py b/counter/forms.py index 6641a80e..018b7ed2 100644 --- a/counter/forms.py +++ b/counter/forms.py @@ -3,13 +3,16 @@ import math import uuid from collections import defaultdict from datetime import date, datetime, timezone +from typing import ClassVar from dateutil.relativedelta import relativedelta from django import forms +from django.conf import settings from django.core.exceptions import ValidationError from django.db.models import Exists, OuterRef, Q from django.forms import BaseModelFormSet from django.http import HttpRequest +from django.utils.functional import cached_property from django.utils.timezone import now from django.utils.translation import gettext_lazy as _ from django_celery_beat.models import ClockedSchedule @@ -39,6 +42,7 @@ from counter.models import ( Customer, Eticket, InvoiceCall, + Permanency, Price, Product, ProductFormula, @@ -151,12 +155,13 @@ class CounterLoginForm(LoginForm): raise ValidationError( message=_("You are not a barman of this counter."), code="not_barman" ) - if user in self.request.barmen: - message = ( - _("You are already logged in this counter.") - if user in self.counter.barmen_list - else _("You are already logged in another counter.") - ) + if Permanency.objects.filter(end=None, user=user).exists(): + if user in self.request.barmen: + message = _("You are already logged in this counter.") + elif user in self.counter.barmen_list: + message = _("You are already logged in another counter.") + else: + message = _("You are already logged on another device") raise ValidationError(message=message, code="already_logged_in") @@ -168,18 +173,19 @@ class RefillForm(forms.ModelForm): error_css_class = "error" required_css_class = "required" - amount = forms.FloatField( - min_value=0, widget=forms.NumberInput(attrs={"class": "focus"}) - ) class Meta: model = Refilling fields = ["amount", "payment_method"] widgets = {"payment_method": forms.RadioSelect} - def __init__(self, *args, **kwargs): + def __init__( + self, *args, counter: Counter, operator: User, customer: Customer, **kwargs + ): super().__init__(*args, **kwargs) - + max_value = settings.SITH_ACCOUNT_MAX_MONEY - customer.amount + # server-side max_value validation is done by Refilling.clean + self.fields["amount"].widget.attrs["max"] = max_value self.fields["payment_method"].choices = ( method for method in self.fields["payment_method"].choices @@ -187,6 +193,9 @@ class RefillForm(forms.ModelForm): ) if self.fields["payment_method"].initial not in self.allowed_refilling_methods: self.fields["payment_method"].initial = self.allowed_refilling_methods[0] + self.instance.counter = counter + self.instance.operator = operator + self.instance.customer = customer class CounterEditForm(forms.ModelForm): @@ -560,16 +569,7 @@ class BasketItemForm(forms.Form): quantity = forms.IntegerField(min_value=1, required=True) price_id = forms.IntegerField(min_value=0, required=True) - def __init__( - self, - customer: Customer, - counter: Counter, - allowed_prices: dict[int, Price], - *args, - **kwargs, - ): - self.customer = customer # Used by formset - self.counter = counter # Used by formset + def __init__(self, allowed_prices: dict[int, Price], *args, **kwargs): self.allowed_prices = allowed_prices super().__init__(*args, **kwargs) @@ -604,6 +604,15 @@ class BasketItemForm(forms.Form): class BaseBasketForm(forms.BaseFormSet): + # Minimum amount of money there must be on the account after the transaction + # If None, the min balance check is skipped + min_result_balance: ClassVar[int | None] = 0 + + def __init__(self, *args, customer: Customer, counter: Counter, **kwargs): + super().__init__(*args, **kwargs) + self.customer = customer + self.counter = counter + def clean(self): self.forms = [form for form in self.forms if form.cleaned_data != {}] @@ -612,8 +621,8 @@ class BaseBasketForm(forms.BaseFormSet): self._check_forms_have_errors() self._check_product_are_unique() - self._check_recorded_products(self[0].customer) - self._check_enough_money(self[0].counter, self[0].customer) + self._check_recorded_products() + self._check_account_balance() def _check_forms_have_errors(self): if any(len(form.errors) > 0 for form in self): @@ -624,12 +633,35 @@ class BaseBasketForm(forms.BaseFormSet): if len(price_ids) != len(self.forms): raise forms.ValidationError(_("Duplicated product entries.")) - def _check_enough_money(self, counter: Counter, customer: Customer): - self.total_price = sum([data["total_price"] for data in self.cleaned_data]) - if self.total_price > customer.amount: - raise forms.ValidationError(_("Not enough money")) + @cached_property + def total_price(self): + refill = settings.SITH_COUNTER_PRODUCTTYPE_REFILLING + total_other = sum( + form.cleaned_data["total_price"] + for form in self.forms + if form.price.product.product_type_id != refill + ) + total_refill = sum( + form.cleaned_data["total_price"] + for form in self.forms + if form.price.product.product_type_id == refill + ) + return total_other - total_refill - def _check_recorded_products(self, customer: Customer): + def _check_account_balance(self): + result_balance = self.customer.amount - self.total_price + if ( + self.min_result_balance is not None + and self.min_result_balance > result_balance + ): + raise forms.ValidationError(_("Not enough money")) + if result_balance > settings.SITH_ACCOUNT_MAX_MONEY: + raise ValidationError( + _("There cannot be more than %(money)d€ on an AE account") + % {"money": settings.SITH_ACCOUNT_MAX_MONEY} + ) + + def _check_recorded_products(self): """Check for, among other things, ecocups and pitchers""" items = defaultdict(int) for form in self.forms: @@ -638,7 +670,7 @@ class BaseBasketForm(forms.BaseFormSet): returnables = list( ReturnableProduct.objects.filter( Q(product_id__in=ids) | Q(returned_product_id__in=ids) - ).annotate_balance_for(customer) + ).annotate_balance_for(self.customer) ) limit_reached = [] for returnable in returnables: diff --git a/counter/middleware.py b/counter/middleware.py index 7483a331..5b9efb43 100644 --- a/counter/middleware.py +++ b/counter/middleware.py @@ -1,8 +1,7 @@ from typing import TYPE_CHECKING, Callable -from django.db.models import Exists, OuterRef from django.http import HttpRequest, HttpResponse -from django.utils.functional import SimpleLazyObject, empty +from django.utils.functional import SimpleLazyObject from core.models import User from counter.models import Permanency @@ -11,20 +10,31 @@ if TYPE_CHECKING: from django.contrib.sessions.backends.base import SessionBase -SESSION_BARMEN_KEY = "barmen_ids" +SESSION_PERMANENCES_KEY = "permanence_ids" def get_cached_barmen(request: HttpRequest) -> set[User]: if not hasattr(request, "_cached_barmen"): session: SessionBase = request.session - barmen_ids = session.get(SESSION_BARMEN_KEY, []) - if barmen_ids: - request._cached_barmen = set( - User.objects.filter( - Exists(Permanency.objects.filter(user=OuterRef("pk"), end=None)), - id__in=barmen_ids, - ) + + if session_ids := session.get(SESSION_PERMANENCES_KEY, None): + # Get ongoing permanences which id is in session. + # Note : we store permanence ids rather than user id to be sure + # not to wrongfully mark someone as logged here, + # even if it logged out then logged in elsewhere. + permanences = ( + Permanency.objects.filter(end=None, id__in=session_ids) + .order_by("id") + .select_related("user") ) + + # if the list of permanences occurring on this device has changed + # since the last page load, change the ids stored in session + real_ids = [p.id for p in permanences] + if real_ids != session_ids: + session[SESSION_PERMANENCES_KEY] = real_ids + + request._cached_barmen = {p.user for p in permanences} else: request._cached_barmen = set() @@ -53,12 +63,4 @@ class BarmenMiddleware: def __call__(self, request: HttpRequest): request.barmen = SimpleLazyObject(lambda: get_cached_barmen(request)) - response = self.get_response(request) - - if request.barmen._wrapped is not empty and { - b.id for b in request.barmen - } != set(request.session.get(SESSION_BARMEN_KEY, [])): - # update the session data only if `session.barmen` - # has been accessed and modified. - request.session[SESSION_BARMEN_KEY] = [b.id for b in request.barmen] - return response + return self.get_response(request) diff --git a/counter/migrations/0042_alter_customer_amount_alter_refilling_amount.py b/counter/migrations/0042_alter_customer_amount_alter_refilling_amount.py new file mode 100644 index 00000000..7c0c6625 --- /dev/null +++ b/counter/migrations/0042_alter_customer_amount_alter_refilling_amount.py @@ -0,0 +1,30 @@ +# Generated by Django 5.2.15 on 2026-06-07 12:08 + +from django.db import migrations + +import counter.fields + + +class Migration(migrations.Migration): + dependencies = [("counter", "0041_alter_billinginfo_country_and_more")] + + operations = [ + migrations.AlterField( + model_name="customer", + name="amount", + field=counter.fields.CurrencyField( + decimal_places=2, + default=0, + max_digits=12, + max_value=250, + verbose_name="amount", + ), + ), + migrations.AlterField( + model_name="refilling", + name="amount", + field=counter.fields.CurrencyField( + decimal_places=2, max_digits=12, min_value=0.01, verbose_name="amount" + ), + ), + ] diff --git a/counter/models.py b/counter/models.py index 1907d2fb..7f6db9b4 100644 --- a/counter/models.py +++ b/counter/models.py @@ -28,7 +28,7 @@ from dict2xml import dict2xml from django.conf import settings from django.core.validators import MinLengthValidator from django.db import models -from django.db.models import Exists, F, OuterRef, Q, QuerySet, Subquery, Sum, Value +from django.db.models import Exists, F, Max, OuterRef, Q, QuerySet, Subquery, Sum, Value from django.db.models.functions import Coalesce, Concat, Length from django.forms import ValidationError from django.urls import reverse @@ -99,7 +99,9 @@ class Customer(models.Model): user = models.OneToOneField(User, primary_key=True, on_delete=models.CASCADE) account_id = models.CharField(_("account id"), max_length=10, unique=True) - amount = CurrencyField(_("amount"), default=0) + amount: CurrencyField = CurrencyField( + _("amount"), max_value=settings.SITH_ACCOUNT_MAX_MONEY, default=0 + ) objects = CustomerQuerySet.as_manager() @@ -156,13 +158,15 @@ class Customer(models.Model): unique_fields=["customer", "returnable"], ) - @property + @cached_property def can_buy(self) -> bool: """Check if whether this customer has the right to purchase any item.""" - subscription = self.user.subscriptions.order_by("subscription_end").last() - if subscription is None: + subscription_end = self.user.subscriptions.aggregate( + res=Max("subscription_end") + ).get("res") + if subscription_end is None: return False - return (date.today() - subscription.subscription_end) < timedelta(days=90) + return (date.today() - subscription_end) < timedelta(days=90) @classmethod def get_or_create(cls, user: User) -> tuple[Customer, bool]: @@ -823,7 +827,7 @@ class Refilling(models.Model): counter = models.ForeignKey( Counter, related_name="refillings", blank=False, on_delete=models.CASCADE ) - amount = CurrencyField(_("amount")) + amount: CurrencyField = CurrencyField(_("amount"), min_value=0.01) operator = models.ForeignKey( User, related_name="refillings_as_operator", @@ -877,6 +881,14 @@ class Refilling(models.Model): return False return user.is_owner(self.counter) and self.payment_method != "CARD" + def clean(self): + super().clean() + if (self.amount + self.customer.amount) > settings.SITH_ACCOUNT_MAX_MONEY: + raise ValidationError( + _("There cannot be more than %(money)d€ on an AE account") + % {"money": settings.SITH_ACCOUNT_MAX_MONEY} + ) + def delete(self, *args, **kwargs): self.customer.amount -= self.amount self.customer.save() @@ -1105,7 +1117,7 @@ class Permanency(models.Model): on_delete=models.CASCADE, ) start = models.DateTimeField(_("start date")) - end = models.DateTimeField(_("end date"), null=True, db_index=True) + end = models.DateTimeField(_("end date"), null=True, blank=True, db_index=True) activity = models.DateTimeField(_("last activity date"), auto_now=True) class Meta: diff --git a/counter/static/bundled/counter/components/counter-product-select-index.ts b/counter/static/bundled/counter/components/counter-product-select-index.ts index d4e96a81..433ae9f9 100644 --- a/counter/static/bundled/counter/components/counter-product-select-index.ts +++ b/counter/static/bundled/counter/components/counter-product-select-index.ts @@ -6,7 +6,7 @@ const productParsingRegex = /^(\d+x)?(.*)/i; const codeParsingRegex = / \((\w+)\)$/; function parseProduct(query: string): [number, string] { - const parsed = productParsingRegex.exec(query); + const parsed = productParsingRegex.exec(query) as RegExpExecArray; return [Number.parseInt(parsed[1] || "1", 10), parsed[2]]; } diff --git a/counter/static/bundled/counter/counter-click-index.ts b/counter/static/bundled/counter/counter-click-index.ts index 7228f067..5504cd12 100644 --- a/counter/static/bundled/counter/counter-click-index.ts +++ b/counter/static/bundled/counter/counter-click-index.ts @@ -3,7 +3,6 @@ import { BasketItem } from "#counter:counter/basket"; import type { CounterConfig, CounterItem, - ErrorMessage, ProductFormula, } from "#counter:counter/types"; import type { CounterProductSelect } from "./components/counter-product-select-index"; @@ -24,7 +23,7 @@ document.addEventListener("alpine:init", () => { } } - this.codeField = this.$refs.codeField; + this.codeField = this.$refs.codeField as CounterProductSelect; this.codeField.widget.hook("after", "onOptionSelect", () => { this.handleCode(); }); @@ -34,14 +33,14 @@ document.addEventListener("alpine:init", () => { // of a formset so we dynamically apply it here this.$refs.basketManagementForm .querySelector("#id_form-TOTAL_FORMS") - .setAttribute(":value", "getBasketSize()"); + ?.setAttribute(":value", "getBasketSize()"); }, removeFromBasket(id: string) { delete this.basket[id]; }, - addToBasket(id: string, quantity: number): ErrorMessage { + addToBasket(id: string, quantity: number) { const item: BasketItem = this.basket[id] || new BasketItem(config.products[id], 0); @@ -50,7 +49,7 @@ document.addEventListener("alpine:init", () => { if (item.quantity <= 0) { delete this.basket[id]; - return ""; + return; } this.basket[id] = item; @@ -72,7 +71,7 @@ document.addEventListener("alpine:init", () => { const products = new Set( Object.values(this.basket).map((item: BasketItem) => item.product.productId), ); - const formula: ProductFormula = config.formulas.find((f: ProductFormula) => { + const formula = config.formulas.find((f: ProductFormula) => { return f.products.every((p: number) => products.has(p)); }); if (formula === undefined) { @@ -80,9 +79,13 @@ document.addEventListener("alpine:init", () => { } // 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( + const item = Object.entries(this.basket).find( ([_, i]: [string, BasketItem]) => i.product.productId === product, - )[0]; + ); + if (item === undefined) { + continue; + } + const key = item[0]; this.basket[key].quantity -= 1; if (this.basket[key].quantity <= 0) { this.removeFromBasket(key); @@ -92,7 +95,7 @@ document.addEventListener("alpine:init", () => { 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.addToBasket(result.price.id.toString(), 1); this.alertMessage.display( interpolate( gettext("Formula %(formula)s applied"), @@ -119,14 +122,18 @@ document.addEventListener("alpine:init", () => { }, onRefillingSuccess(event: CustomEvent) { - if (event.type !== "htmx:after-request" || event.detail.failed) { + if ( + event.type !== "htmx:after-swap" || + event.detail.failed || + event.detail.elt.querySelector(".errorlist") + ) { return; } this.customerBalance += Number.parseFloat( (event.detail.target.querySelector("#id_amount") as HTMLInputElement).value, ); - document.getElementById("selling-accordion").setAttribute("open", ""); - this.codeField.widget.focus(); + document.getElementById("selling-accordion")?.setAttribute("open", ""); + this.codeField?.widget.focus(); }, finish() { @@ -136,7 +143,7 @@ document.addEventListener("alpine:init", () => { }); return; } - this.$refs.basketForm.submit(); + (this.$refs.basketForm as HTMLFormElement).submit(); }, cancel() { @@ -144,6 +151,8 @@ document.addEventListener("alpine:init", () => { }, handleCode() { + if (!this.codeField) throw Error("Unexpected null codeField."); + const [quantity, code] = this.codeField.getSelectedProduct() as [number, string]; if (this.codeField.getOperationCodes().includes(code.toUpperCase())) { diff --git a/counter/templates/counter/counter_click.jinja b/counter/templates/counter/counter_click.jinja index e93b1110..d18f025e 100644 --- a/counter/templates/counter/counter_click.jinja +++ b/counter/templates/counter/counter_click.jinja @@ -176,13 +176,17 @@ -