From f6f31af975bd41bee8dd421d85491f8bbf790370 Mon Sep 17 00:00:00 2001 From: imperosol Date: Sun, 7 Jun 2026 14:16:12 +0200 Subject: [PATCH 1/7] enforce max amount on sith account --- counter/fields.py | 53 +++++++++++++++++-- ..._customer_amount_alter_refilling_amount.py | 30 +++++++++++ counter/models.py | 18 ++++--- sith/settings.py | 2 + 4 files changed, 92 insertions(+), 11 deletions(-) create mode 100644 counter/migrations/0042_alter_customer_amount_alter_refilling_amount.py diff --git a/counter/fields.py b/counter/fields.py index a212059d..0bde4801 100644 --- a/counter/fields.py +++ b/counter/fields.py @@ -1,22 +1,67 @@ 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): + 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/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..a34f20b1 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( + _("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(_("amount"), min_value=0.01) operator = models.ForeignKey( User, related_name="refillings_as_operator", diff --git a/sith/settings.py b/sith/settings.py index ce872c7d..0081b221 100644 --- a/sith/settings.py +++ b/sith/settings.py @@ -503,6 +503,8 @@ SITH_ACCOUNT_INACTIVITY_DELTA = relativedelta(years=2) SITH_ACCOUNT_DUMP_DELTA = timedelta(days=30) """timedelta between the warning mail and the actual account dump""" +SITH_ACCOUNT_MAX_MONEY = 250 # € + # Defines which product type is the refilling type, # and thus increases the account amount SITH_COUNTER_PRODUCTTYPE_REFILLING = env.int( From 5e553d91a844275bdee0ed690a5773300adde3e8 Mon Sep 17 00:00:00 2001 From: imperosol Date: Sun, 7 Jun 2026 14:28:21 +0200 Subject: [PATCH 2/7] max amount for counter refills --- counter/forms.py | 15 +++++--- counter/models.py | 8 +++++ .../counter-product-select-index.ts | 2 +- .../bundled/counter/counter-click-index.ts | 35 ++++++++++++------- counter/templates/counter/counter_click.jinja | 2 +- counter/views/click.py | 31 +++++++++------- 6 files changed, 60 insertions(+), 33 deletions(-) diff --git a/counter/forms.py b/counter/forms.py index 6641a80e..18efa724 100644 --- a/counter/forms.py +++ b/counter/forms.py @@ -6,6 +6,7 @@ from datetime import date, datetime, timezone 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 @@ -168,18 +169,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 +189,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): diff --git a/counter/models.py b/counter/models.py index a34f20b1..fb180191 100644 --- a/counter/models.py +++ b/counter/models.py @@ -881,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() 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..bf07f1b4 100644 --- a/counter/templates/counter/counter_click.jinja +++ b/counter/templates/counter/counter_click.jinja @@ -182,7 +182,7 @@ {% if refilling_fragment %}
{{ refilling_fragment }}
diff --git a/counter/views/click.py b/counter/views/click.py index 949031c8..7633327d 100644 --- a/counter/views/click.py +++ b/counter/views/click.py @@ -24,7 +24,7 @@ from django.shortcuts import get_object_or_404, redirect, resolve_url from django.urls import reverse from django.utils.safestring import SafeString from django.utils.translation import gettext as _ -from django.views.generic import FormView +from django.views.generic import CreateView, FormView from django.views.generic.detail import SingleObjectMixin from ninja.main import HttpRequest @@ -32,7 +32,14 @@ from core.auth.mixins import CanViewMixin from core.models import User from core.views.mixins import FragmentMixin, UseFragmentsMixin from counter.forms import BasketForm, RefillForm -from counter.models import Counter, Customer, ProductFormula, ReturnableProduct, Selling +from counter.models import ( + Counter, + Customer, + ProductFormula, + Refilling, + ReturnableProduct, + Selling, +) from counter.utils import is_logged_in_counter from counter.views.mixins import CounterTabsMixin from counter.views.student_card import StudentCardFormFragment @@ -219,9 +226,10 @@ class CounterClick( return kwargs -class RefillingCreateView(FragmentMixin, FormView): +class RefillingCreateView(FragmentMixin, CreateView): """This is a fragment only view which integrates with counter_click.jinja""" + model = Refilling form_class = RefillForm template_name = "counter/fragments/create_refill.jinja" @@ -242,23 +250,20 @@ class RefillingCreateView(FragmentMixin, FormView): ): raise PermissionDenied - self.operator = get_operator(request, self.counter, self.customer) - return super().dispatch(request, *args, **kwargs) def render_fragment(self, request, **kwargs) -> SafeString: self.customer = kwargs.pop("customer") self.counter = kwargs.pop("counter") + self.object = None return super().render_fragment(request, **kwargs) - def form_valid(self, form): - res = super().form_valid(form) - form.clean() - form.instance.counter = self.counter - form.instance.operator = self.operator - form.instance.customer = self.customer - form.instance.save() - return res + def get_form_kwargs(self): + return super().get_form_kwargs() | { + "counter": self.counter, + "operator": get_operator(self.request, self.counter, self.customer), + "customer": self.customer, + } def get_context_data(self, **kwargs): kwargs = super().get_context_data(**kwargs) From 39bbbc88785558078a59b134d306d3116d8b1886 Mon Sep 17 00:00:00 2001 From: imperosol Date: Sun, 7 Jun 2026 14:28:49 +0200 Subject: [PATCH 3/7] autofocus input on counter refill --- counter/templates/counter/counter_click.jinja | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/counter/templates/counter/counter_click.jinja b/counter/templates/counter/counter_click.jinja index bf07f1b4..d18f025e 100644 --- a/counter/templates/counter/counter_click.jinja +++ b/counter/templates/counter/counter_click.jinja @@ -176,7 +176,11 @@ -
+
{% trans %}Refilling{% endtrans %} {% if object.type == "BAR" %} {% if refilling_fragment %} From d41a3a524a5d1c0273b3cc8635422c9e052b6fa3 Mon Sep 17 00:00:00 2001 From: imperosol Date: Thu, 11 Jun 2026 14:21:50 +0200 Subject: [PATCH 4/7] max amount for eboutic refills --- counter/fields.py | 3 +- counter/forms.py | 36 ++++++++++--------- counter/models.py | 4 +-- counter/views/click.py | 8 ++--- .../static/bundled/eboutic/eboutic-index.ts | 31 +++++++++++----- eboutic/templates/eboutic/eboutic_main.jinja | 25 +++++++++++-- eboutic/views.py | 30 +++++++++++++--- 7 files changed, 97 insertions(+), 40 deletions(-) diff --git a/counter/fields.py b/counter/fields.py index 0bde4801..caf3a584 100644 --- a/counter/fields.py +++ b/counter/fields.py @@ -32,7 +32,8 @@ class CurrencyField(models.DecimalField): res.append(MinValueValidator(self.min_value)) return [*super().validators, *res] - def check(self, **kwargs): + 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: diff --git a/counter/forms.py b/counter/forms.py index 18efa724..1794e2ba 100644 --- a/counter/forms.py +++ b/counter/forms.py @@ -565,16 +565,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) @@ -609,6 +600,11 @@ class BasketItemForm(forms.Form): class BaseBasketForm(forms.BaseFormSet): + 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 != {}] @@ -617,8 +613,9 @@ 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_enough_money() + self._check_refills() def _check_forms_have_errors(self): if any(len(form.errors) > 0 for form in self): @@ -629,12 +626,12 @@ 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): + def _check_enough_money(self): self.total_price = sum([data["total_price"] for data in self.cleaned_data]) - if self.total_price > customer.amount: + if self.total_price > self.customer.amount: raise forms.ValidationError(_("Not enough money")) - def _check_recorded_products(self, customer: Customer): + def _check_recorded_products(self): """Check for, among other things, ecocups and pitchers""" items = defaultdict(int) for form in self.forms: @@ -643,7 +640,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: @@ -662,6 +659,13 @@ class BaseBasketForm(forms.BaseFormSet): % ", ".join([str(p) for p in limit_reached]) ) + def _check_refills(self): + refill_type_id = settings.SITH_COUNTER_PRODUCTTYPE_REFILLING + if any(f.price.product.product_type_id == refill_type_id for f in self.forms): + raise ValidationError( + _("Refill bonds cannot be purchased outside of the eboutic") + ) + BasketForm = forms.formset_factory( BasketItemForm, formset=BaseBasketForm, absolute_max=None, min_num=1 diff --git a/counter/models.py b/counter/models.py index fb180191..cd88e7e5 100644 --- a/counter/models.py +++ b/counter/models.py @@ -827,7 +827,7 @@ class Refilling(models.Model): counter = models.ForeignKey( Counter, related_name="refillings", blank=False, on_delete=models.CASCADE ) - amount = CurrencyField(_("amount"), min_value=0.01) + amount: CurrencyField = CurrencyField(_("amount"), min_value=0.01) operator = models.ForeignKey( User, related_name="refillings_as_operator", @@ -883,7 +883,7 @@ class Refilling(models.Model): def clean(self): super().clean() - if self.amount + self.customer.amount > settings.SITH_ACCOUNT_MAX_MONEY: + 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} diff --git a/counter/views/click.py b/counter/views/click.py index 7633327d..9ec678ae 100644 --- a/counter/views/click.py +++ b/counter/views/click.py @@ -73,13 +73,13 @@ class CounterClick( current_tab = "counter" def get_form_kwargs(self): - kwargs = super().get_form_kwargs() - kwargs["form_kwargs"] = { + return super().get_form_kwargs() | { "customer": self.customer, "counter": self.object, - "allowed_prices": {price.id: price for price in self.prices}, + "form_kwargs": { + "allowed_prices": {price.id: price for price in self.prices} + }, } - return kwargs def dispatch(self, request, *args, **kwargs): self.customer = get_object_or_404(Customer, user_id=self.kwargs["user_id"]) diff --git a/eboutic/static/bundled/eboutic/eboutic-index.ts b/eboutic/static/bundled/eboutic/eboutic-index.ts index 59a258e3..e2bbe9ec 100644 --- a/eboutic/static/bundled/eboutic/eboutic-index.ts +++ b/eboutic/static/bundled/eboutic/eboutic-index.ts @@ -5,10 +5,11 @@ interface BasketItem { name: string; quantity: number; unitPrice: number; + isRefill: boolean; } const BASKET_CACHE_KEY = "basket"; -const BASKET_CACHE_VERSION = 1; +const BASKET_CACHE_VERSION = 2; document.addEventListener("alpine:init", () => { Alpine.data("basket", (validPrices: number[], lastPurchaseTime?: number) => ({ @@ -21,7 +22,7 @@ document.addEventListener("alpine:init", () => { }); document .getElementById("id_form-TOTAL_FORMS") - .setAttribute(":value", "basket.length"); + ?.setAttribute(":value", "basket.length"); }, loadBasket(): BasketItem[] { @@ -32,8 +33,8 @@ document.addEventListener("alpine:init", () => { return []; } if ( - lastPurchaseTime !== null && - localStorage.basketTimestamp !== undefined && + lastPurchaseTime && + localStorage.basketTimestamp && new Date(lastPurchaseTime) >= new Date(Number.parseInt(localStorage.basketTimestamp, 10)) ) { @@ -64,6 +65,15 @@ document.addEventListener("alpine:init", () => { ); }, + getTotalRefill() { + return this.basket + .filter((item) => item.isRefill) + .reduce( + (acc: number, item: BasketItem) => acc + item.quantity * item.unitPrice, + 0, + ); + }, + /** * Add 1 to the quantity of an item in the basket * @param {BasketItem} item @@ -86,7 +96,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.priceId !== this.basket[index].priceId, ); } }, @@ -103,14 +113,16 @@ document.addEventListener("alpine:init", () => { * @param id The id of the product to add * @param name The name of the product * @param price The unit price of the product + * @param isRefill true if the product is a refill bond * @returns The created item */ - createItem(id: number, name: string, price: number): BasketItem { + createItem(id: number, name: string, price: number, isRefill: boolean): BasketItem { const newItem = { priceId: id, name, quantity: 0, unitPrice: price, + isRefill, } as BasketItem; this.basket.push(newItem); @@ -125,16 +137,17 @@ document.addEventListener("alpine:init", () => { * @param id The id of the product to add * @param name The name of the product * @param price The unit price of the product + * @param isRefill true if the product is a refill bond */ - addFromCatalog(id: number, name: string, price: number) { - let item = this.basket.find((e: BasketItem) => e.priceId === id); + addFromCatalog(id: number, name: string, price: number, isRefill: boolean) { + const item = this.basket.find((e: BasketItem) => e.priceId === id); // if the item is not in the basket, we create it // else we add + 1 to it if (item) { this.add(item); } else { - item = this.createItem(id, name, price); + this.createItem(id, name, price, isRefill); } }, })); diff --git a/eboutic/templates/eboutic/eboutic_main.jinja b/eboutic/templates/eboutic/eboutic_main.jinja index 4962d5ce..fe5227d7 100644 --- a/eboutic/templates/eboutic/eboutic_main.jinja +++ b/eboutic/templates/eboutic/eboutic_main.jinja @@ -58,6 +58,17 @@ {% endif %} +
    {# Starting money #}
  • @@ -109,9 +120,12 @@ {% trans %}Clear{% endtrans %} - @@ -199,7 +213,12 @@ id="{{ price.id }}" class="card clickable shadow" :class="{selected: basket.some((i) => i.priceId === {{ price.id }})}" - @click='addFromCatalog({{ price.id }}, {{ price.full_label|tojson }}, {{ price.amount }})' + @click='addFromCatalog( + {{ price.id }}, + {{ price.full_label|tojson }}, + {{ price.amount }}, + {{ (price.product.product_type_id == settings.SITH_COUNTER_PRODUCTTYPE_REFILLING)|lower }} + )' {% if price.sold_out %}disabled{% endif %} > {% if price.product.icon %} diff --git a/eboutic/views.py b/eboutic/views.py index ed3e1311..4ca7785e 100644 --- a/eboutic/views.py +++ b/eboutic/views.py @@ -70,6 +70,26 @@ class BaseEbouticBasketForm(BaseBasketForm): # Disable money check ... + def _check_refills(self): + """Check that this basket won't put customer balance above the limit.""" + refill_type_id = settings.SITH_COUNTER_PRODUCTTYPE_REFILLING + total_refill = sum( + f.price.amount * f.cleaned_data["quantity"] + for f in self.forms + if f.price.product.product_type_id == refill_type_id + ) + total_other = sum( + f.price.amount * f.cleaned_data["quantity"] + for f in self.forms + if f.price.product.product_type_id != refill_type_id + ) + limit = settings.SITH_ACCOUNT_MAX_MONEY + if (total_refill - total_other + self.customer.amount) > limit: + raise ValidationError( + _("There cannot be more than %(money)d€ on an AE account") + % {"money": limit} + ) + EbouticBasketForm = forms.formset_factory( BasketItemForm, formset=BaseEbouticBasketForm, absolute_max=None, min_num=1 @@ -88,15 +108,15 @@ class EbouticMainView(LoginRequiredMixin, FormView): form_class = EbouticBasketForm def get_form_kwargs(self): - kwargs = super().get_form_kwargs() - kwargs["form_kwargs"] = { + return super().get_form_kwargs() | { "customer": self.customer, "counter": get_eboutic(), - "allowed_prices": { - price.id: price for price in self.prices if not price.sold_out + "form_kwargs": { + "allowed_prices": { + price.id: price for price in self.prices if not price.sold_out + } }, } - return kwargs def form_valid(self, formset): if len(formset) == 0: From 867362fb518b93838d2d5c3f2e6728587cf5318d Mon Sep 17 00:00:00 2001 From: imperosol Date: Thu, 11 Jun 2026 14:22:27 +0200 Subject: [PATCH 5/7] add translations --- locale/fr/LC_MESSAGES/django.po | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 862fbde5..ba21caeb 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-06-05 13:39+0200\n" +"POT-Creation-Date: 2026-06-10 20:18+0200\n" "PO-Revision-Date: 2016-07-18\n" "Last-Translator: Maréchal \n" @@ -3314,6 +3314,11 @@ msgstr "" "Cet utilisateur a atteint sa limite de déconsigne pour les produits " "suivants : %s" +#: counter/forms.py +msgid "Refill bonds cannot be purchased outside of the eboutic" +msgstr "" +"Les bons de rechargement ne peuvent pas être achetés en dehors de l'eboutic" + #: counter/management/commands/dump_accounts.py msgid "Your AE account has been emptied" msgstr "Votre compte AE a été vidé" @@ -3519,6 +3524,11 @@ msgstr "méthode de paiement" msgid "refilling" msgstr "rechargement" +#: counter/models.py eboutic/views.py +#, python-format +msgid "There cannot be more than %(money)d€ on an AE account" +msgstr "Il ne peut pas y avoir plus de %(money)d€ sur un compte AE" + #: counter/models.py msgid "Sith account" msgstr "Compte utilisateur" @@ -4428,6 +4438,15 @@ msgstr "Payer avec un compte AE" msgid "The online shop of the association." msgstr "La boutique en ligne de l'association." +#: eboutic/templates/eboutic/eboutic_main.jinja +#, python-format +msgid "" +"You cannot purchase the current basket, because it would put your AE account " +"balance above the %(limit)s€ limit" +msgstr "" +"Vous ne pouvez pas finaliser le panier actuel, parce que le solde de votre " +"compte AE passerait au-dessus de la limite de %(limit)s€." + #: eboutic/templates/eboutic/eboutic_main.jinja msgid "Clear" msgstr "Vider" From 998efc7c6b156f41b0beedec90fe3315edd46e4b Mon Sep 17 00:00:00 2001 From: imperosol Date: Thu, 11 Jun 2026 14:22:38 +0200 Subject: [PATCH 6/7] add tests --- core/tests/test_user.py | 10 ++++++++-- counter/tests/test_counter.py | 19 ++++++++++++++++--- counter/tests/test_customer.py | 3 ++- eboutic/tests/test_basket.py | 21 +++++++++++++++++++++ 4 files changed, 47 insertions(+), 6 deletions(-) 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/tests/test_counter.py b/counter/tests/test_counter.py index aa771b25..e7682848 100644 --- a/counter/tests/test_counter.py +++ b/counter/tests/test_counter.py @@ -144,6 +144,8 @@ class TestRefilling(TestFullClickBase): assert self.updated_amount(self.customer) == 0 def test_refilling_no_refer_fail(self): + """Check that the refill fails is the HTTP_REFERER header is missing""" + def refill(): return self.client.post( reverse( @@ -157,13 +159,13 @@ class TestRefilling(TestFullClickBase): ) self.client.force_login(self.club_admin) - assert refill() + assert refill().status_code == 403 self.client.force_login(self.root) - assert refill() + assert refill().status_code == 403 self.client.force_login(self.subscriber) - assert refill() + assert refill().status_code == 403 assert self.updated_amount(self.customer) == 0 @@ -199,6 +201,17 @@ class TestRefilling(TestFullClickBase): == 404 ) + def test_refilling_above_limit_fails(self): + """Test that it's forbidden to refill a customer above the limit.""" + self.login_in_bar() + limit = settings.SITH_ACCOUNT_MAX_MONEY + # create a refilling to check that current balance is taken into account + baker.make(Refilling, customer=self.customer.customer, amount=limit // 2) + response = self.refill_user(self.customer, self.counter, (limit // 2) + 1) + assert response.status_code == 200 # no redirect = failure + self.customer.customer.refresh_from_db() + assert self.updated_amount(self.customer) == limit // 2 + def test_refilling_counter_success(self): self.login_in_bar() diff --git a/counter/tests/test_customer.py b/counter/tests/test_customer.py index 5a194824..ca1b2b85 100644 --- a/counter/tests/test_customer.py +++ b/counter/tests/test_customer.py @@ -15,6 +15,7 @@ from core.models import User from counter.baker_recipes import product_recipe, refill_recipe, sale_recipe from counter.models import ( Counter, + CounterSellers, Customer, Refilling, ReturnableProduct, @@ -38,7 +39,7 @@ class TestStudentCard(TestCase): cls.subscriber = subscriber_user.make() cls.counter = baker.make(Counter, type="BAR") - cls.counter.sellers.add(cls.barmen) + CounterSellers.objects.create(counter=cls.counter, user=cls.barmen) cls.club_counter = baker.make(Counter) role = baker.make(ClubRole, club=cls.club_counter.club, is_board=True) diff --git a/eboutic/tests/test_basket.py b/eboutic/tests/test_basket.py index 5df6df6d..2dc296d1 100644 --- a/eboutic/tests/test_basket.py +++ b/eboutic/tests/test_basket.py @@ -278,6 +278,27 @@ class TestEboutic(TestCase): ) assert Basket.objects.count() == 2 + def test_refill_limit(self): + """Test that an eboutic basket cannot refill an account above the limit.""" + self.client.force_login(self.subscriber) + product = product_recipe.make( + product_type_id=settings.SITH_COUNTER_PRODUCTTYPE_REFILLING, + counters=[self.eboutic], + ) + price = price_recipe.make( + product=product, + groups=[self.group_cotiz], + amount=settings.SITH_ACCOUNT_MAX_MONEY // 10, + ) + + response = self.submit_basket([BasketItem(price.id, 10)]) + assert Basket.objects.count() == 1 + assertRedirects(response, reverse("eboutic:checkout", kwargs={"basket_id": 1})) + + response = self.submit_basket([BasketItem(price.id, 11)]) + assert Basket.objects.count() == 1 + assert response.status_code == 200 # no redirect = form validation failed + def test_create_basket(self): self.client.force_login(self.new_customer) assertRedirects( From caa2bf66be2fad53569ba34b6b0cdac4c544857e Mon Sep 17 00:00:00 2001 From: imperosol Date: Thu, 11 Jun 2026 18:13:03 +0200 Subject: [PATCH 7/7] apply review comments --- counter/forms.py | 45 ++++++++++++++----- counter/models.py | 2 +- counter/tests/test_counter.py | 13 ++++++ .../static/bundled/eboutic/eboutic-index.ts | 10 +++-- eboutic/templates/eboutic/eboutic_main.jinja | 4 +- eboutic/views.py | 24 +--------- locale/fr/LC_MESSAGES/django.po | 15 +++---- sith/settings.py | 4 ++ 8 files changed, 66 insertions(+), 51 deletions(-) diff --git a/counter/forms.py b/counter/forms.py index 1794e2ba..cd99c95a 100644 --- a/counter/forms.py +++ b/counter/forms.py @@ -3,6 +3,7 @@ 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 @@ -11,6 +12,7 @@ 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 @@ -600,6 +602,10 @@ 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 @@ -614,8 +620,7 @@ class BaseBasketForm(forms.BaseFormSet): self._check_forms_have_errors() self._check_product_are_unique() self._check_recorded_products() - self._check_enough_money() - self._check_refills() + self._check_account_balance() def _check_forms_have_errors(self): if any(len(form.errors) > 0 for form in self): @@ -626,10 +631,33 @@ class BaseBasketForm(forms.BaseFormSet): if len(price_ids) != len(self.forms): raise forms.ValidationError(_("Duplicated product entries.")) - def _check_enough_money(self): - self.total_price = sum([data["total_price"] for data in self.cleaned_data]) - if self.total_price > self.customer.amount: + @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_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""" @@ -659,13 +687,6 @@ class BaseBasketForm(forms.BaseFormSet): % ", ".join([str(p) for p in limit_reached]) ) - def _check_refills(self): - refill_type_id = settings.SITH_COUNTER_PRODUCTTYPE_REFILLING - if any(f.price.product.product_type_id == refill_type_id for f in self.forms): - raise ValidationError( - _("Refill bonds cannot be purchased outside of the eboutic") - ) - BasketForm = forms.formset_factory( BasketItemForm, formset=BaseBasketForm, absolute_max=None, min_num=1 diff --git a/counter/models.py b/counter/models.py index cd88e7e5..cda5ac1b 100644 --- a/counter/models.py +++ b/counter/models.py @@ -99,7 +99,7 @@ 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: CurrencyField = CurrencyField( _("amount"), max_value=settings.SITH_ACCOUNT_MAX_MONEY, default=0 ) diff --git a/counter/tests/test_counter.py b/counter/tests/test_counter.py index e7682848..69f3bff6 100644 --- a/counter/tests/test_counter.py +++ b/counter/tests/test_counter.py @@ -535,6 +535,19 @@ class TestCounterClick(TestFullClickBase): assert self.updated_amount(self.customer) == Decimal(10) + def test_unrecord_above_limit_fails(self): + """Test that it's forbidden to give back a recorded product + if it puts the account balance above the limit. + """ + self.login_in_bar() + limit = settings.SITH_ACCOUNT_MAX_MONEY + # put the account balance just at the limit + baker.make(Refilling, customer=self.customer.customer, amount=limit) + response = self.submit_basket(self.customer, [BasketItem(self.dcons.id, 1)]) + assert response.status_code == 200 # no redirect = failure + self.customer.customer.refresh_from_db() + assert self.updated_amount(self.customer) == limit + def test_annotate_has_barman_queryset(self): """Test if the custom queryset method `annotate_has_barman` works as intended.""" counters = Counter.objects.annotate_has_barman(self.barmen) diff --git a/eboutic/static/bundled/eboutic/eboutic-index.ts b/eboutic/static/bundled/eboutic/eboutic-index.ts index e2bbe9ec..50a7f715 100644 --- a/eboutic/static/bundled/eboutic/eboutic-index.ts +++ b/eboutic/static/bundled/eboutic/eboutic-index.ts @@ -65,11 +65,15 @@ document.addEventListener("alpine:init", () => { ); }, - getTotalRefill() { + /** + * Get the total of money that would be added to the AE account on basket purchase. + */ + getTotalAdded() { return this.basket - .filter((item) => item.isRefill) + .filter((item) => item.isRefill || item.unitPrice < 0) .reduce( - (acc: number, item: BasketItem) => acc + item.quantity * item.unitPrice, + (acc: number, item: BasketItem) => + acc + Math.abs(item.quantity * item.unitPrice), 0, ); }, diff --git a/eboutic/templates/eboutic/eboutic_main.jinja b/eboutic/templates/eboutic/eboutic_main.jinja index fe5227d7..78f5429c 100644 --- a/eboutic/templates/eboutic/eboutic_main.jinja +++ b/eboutic/templates/eboutic/eboutic_main.jinja @@ -58,7 +58,7 @@ {% endif %} -