7 Commits

Author SHA1 Message Date
imperosol caa2bf66be apply review comments 2026-06-11 18:18:12 +02:00
imperosol 998efc7c6b add tests 2026-06-11 14:22:38 +02:00
imperosol 867362fb51 add translations 2026-06-11 14:22:27 +02:00
imperosol d41a3a524a max amount for eboutic refills 2026-06-11 14:21:50 +02:00
imperosol 39bbbc8878 autofocus input on counter refill 2026-06-07 14:28:49 +02:00
imperosol 5e553d91a8 max amount for counter refills 2026-06-07 14:28:21 +02:00
imperosol f6f31af975 enforce max amount on sith account 2026-06-07 14:16:12 +02:00
18 changed files with 339 additions and 95 deletions
+8 -2
View File
@@ -200,7 +200,11 @@ class TestFilterInactive(TestCase):
] ]
sale_recipe.make(customer=cls.users[3].customer, date=time_active) sale_recipe.make(customer=cls.users[3].customer, date=time_active)
baker.make( 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) 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 @pytest.mark.django_db
def test_user_stats(client: Client): def test_user_stats(client: Client):
user = subscriber_user.make() 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] bars = [b[0] for b in settings.SITH_COUNTER_BARS]
baker.make( baker.make(
Permanency, Permanency,
+50 -4
View File
@@ -1,22 +1,68 @@
from decimal import Decimal from decimal import Decimal
from django.conf import settings from django.conf import settings
from django.core import checks
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import models
from django.utils.functional import cached_property
class CurrencyField(models.DecimalField): class CurrencyField(models.DecimalField):
"""Custom database field used for currency.""" """Custom database field used for currency."""
def __init__(self, *args, **kwargs): def __init__(
kwargs["max_digits"] = 12 self, verbose_name=None, name=None, min_value=None, max_value=None, **kwargs
kwargs["decimal_places"] = 2 ):
super().__init__(*args, **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): def to_python(self, value):
if value is None: if value is None:
return None return None
return super().to_python(value).quantize(Decimal("0.01")) 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: if settings.TESTING:
from model_bakery import baker from model_bakery import baker
+53 -23
View File
@@ -3,13 +3,16 @@ import math
import uuid import uuid
from collections import defaultdict from collections import defaultdict
from datetime import date, datetime, timezone from datetime import date, datetime, timezone
from typing import ClassVar
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django import forms from django import forms
from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db.models import Exists, OuterRef, Q from django.db.models import Exists, OuterRef, Q
from django.forms import BaseModelFormSet from django.forms import BaseModelFormSet
from django.http import HttpRequest from django.http import HttpRequest
from django.utils.functional import cached_property
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_celery_beat.models import ClockedSchedule from django_celery_beat.models import ClockedSchedule
@@ -168,18 +171,19 @@ class RefillForm(forms.ModelForm):
error_css_class = "error" error_css_class = "error"
required_css_class = "required" required_css_class = "required"
amount = forms.FloatField(
min_value=0, widget=forms.NumberInput(attrs={"class": "focus"})
)
class Meta: class Meta:
model = Refilling model = Refilling
fields = ["amount", "payment_method"] fields = ["amount", "payment_method"]
widgets = {"payment_method": forms.RadioSelect} 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) 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 = ( self.fields["payment_method"].choices = (
method method
for method in self.fields["payment_method"].choices for method in self.fields["payment_method"].choices
@@ -187,6 +191,9 @@ class RefillForm(forms.ModelForm):
) )
if self.fields["payment_method"].initial not in self.allowed_refilling_methods: if self.fields["payment_method"].initial not in self.allowed_refilling_methods:
self.fields["payment_method"].initial = self.allowed_refilling_methods[0] 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): class CounterEditForm(forms.ModelForm):
@@ -560,16 +567,7 @@ class BasketItemForm(forms.Form):
quantity = forms.IntegerField(min_value=1, required=True) quantity = forms.IntegerField(min_value=1, required=True)
price_id = forms.IntegerField(min_value=0, required=True) price_id = forms.IntegerField(min_value=0, required=True)
def __init__( def __init__(self, allowed_prices: dict[int, Price], *args, **kwargs):
self,
customer: Customer,
counter: Counter,
allowed_prices: dict[int, Price],
*args,
**kwargs,
):
self.customer = customer # Used by formset
self.counter = counter # Used by formset
self.allowed_prices = allowed_prices self.allowed_prices = allowed_prices
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@@ -604,6 +602,15 @@ class BasketItemForm(forms.Form):
class BaseBasketForm(forms.BaseFormSet): 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): def clean(self):
self.forms = [form for form in self.forms if form.cleaned_data != {}] self.forms = [form for form in self.forms if form.cleaned_data != {}]
@@ -612,8 +619,8 @@ class BaseBasketForm(forms.BaseFormSet):
self._check_forms_have_errors() self._check_forms_have_errors()
self._check_product_are_unique() self._check_product_are_unique()
self._check_recorded_products(self[0].customer) self._check_recorded_products()
self._check_enough_money(self[0].counter, self[0].customer) self._check_account_balance()
def _check_forms_have_errors(self): def _check_forms_have_errors(self):
if any(len(form.errors) > 0 for form in self): if any(len(form.errors) > 0 for form in self):
@@ -624,12 +631,35 @@ class BaseBasketForm(forms.BaseFormSet):
if len(price_ids) != len(self.forms): if len(price_ids) != len(self.forms):
raise forms.ValidationError(_("Duplicated product entries.")) raise forms.ValidationError(_("Duplicated product entries."))
def _check_enough_money(self, counter: Counter, customer: Customer): @cached_property
self.total_price = sum([data["total_price"] for data in self.cleaned_data]) def total_price(self):
if self.total_price > customer.amount: refill = settings.SITH_COUNTER_PRODUCTTYPE_REFILLING
raise forms.ValidationError(_("Not enough money")) 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""" """Check for, among other things, ecocups and pitchers"""
items = defaultdict(int) items = defaultdict(int)
for form in self.forms: for form in self.forms:
@@ -638,7 +668,7 @@ class BaseBasketForm(forms.BaseFormSet):
returnables = list( returnables = list(
ReturnableProduct.objects.filter( ReturnableProduct.objects.filter(
Q(product_id__in=ids) | Q(returned_product_id__in=ids) Q(product_id__in=ids) | Q(returned_product_id__in=ids)
).annotate_balance_for(customer) ).annotate_balance_for(self.customer)
) )
limit_reached = [] limit_reached = []
for returnable in returnables: for returnable in returnables:
@@ -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"
),
),
]
+19 -7
View File
@@ -28,7 +28,7 @@ from dict2xml import dict2xml
from django.conf import settings from django.conf import settings
from django.core.validators import MinLengthValidator from django.core.validators import MinLengthValidator
from django.db import models 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.db.models.functions import Coalesce, Concat, Length
from django.forms import ValidationError from django.forms import ValidationError
from django.urls import reverse from django.urls import reverse
@@ -99,7 +99,9 @@ class Customer(models.Model):
user = models.OneToOneField(User, primary_key=True, on_delete=models.CASCADE) user = models.OneToOneField(User, primary_key=True, on_delete=models.CASCADE)
account_id = models.CharField(_("account id"), max_length=10, unique=True) 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() objects = CustomerQuerySet.as_manager()
@@ -156,13 +158,15 @@ class Customer(models.Model):
unique_fields=["customer", "returnable"], unique_fields=["customer", "returnable"],
) )
@property @cached_property
def can_buy(self) -> bool: def can_buy(self) -> bool:
"""Check if whether this customer has the right to purchase any item.""" """Check if whether this customer has the right to purchase any item."""
subscription = self.user.subscriptions.order_by("subscription_end").last() subscription_end = self.user.subscriptions.aggregate(
if subscription is None: res=Max("subscription_end")
).get("res")
if subscription_end is None:
return False return False
return (date.today() - subscription.subscription_end) < timedelta(days=90) return (date.today() - subscription_end) < timedelta(days=90)
@classmethod @classmethod
def get_or_create(cls, user: User) -> tuple[Customer, bool]: def get_or_create(cls, user: User) -> tuple[Customer, bool]:
@@ -823,7 +827,7 @@ class Refilling(models.Model):
counter = models.ForeignKey( counter = models.ForeignKey(
Counter, related_name="refillings", blank=False, on_delete=models.CASCADE Counter, related_name="refillings", blank=False, on_delete=models.CASCADE
) )
amount = CurrencyField(_("amount")) amount: CurrencyField = CurrencyField(_("amount"), min_value=0.01)
operator = models.ForeignKey( operator = models.ForeignKey(
User, User,
related_name="refillings_as_operator", related_name="refillings_as_operator",
@@ -877,6 +881,14 @@ class Refilling(models.Model):
return False return False
return user.is_owner(self.counter) and self.payment_method != "CARD" 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): def delete(self, *args, **kwargs):
self.customer.amount -= self.amount self.customer.amount -= self.amount
self.customer.save() self.customer.save()
@@ -6,7 +6,7 @@ const productParsingRegex = /^(\d+x)?(.*)/i;
const codeParsingRegex = / \((\w+)\)$/; const codeParsingRegex = / \((\w+)\)$/;
function parseProduct(query: string): [number, string] { 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]]; return [Number.parseInt(parsed[1] || "1", 10), parsed[2]];
} }
@@ -3,7 +3,6 @@ import { BasketItem } from "#counter:counter/basket";
import type { import type {
CounterConfig, CounterConfig,
CounterItem, CounterItem,
ErrorMessage,
ProductFormula, ProductFormula,
} from "#counter:counter/types"; } from "#counter:counter/types";
import type { CounterProductSelect } from "./components/counter-product-select-index"; 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.codeField.widget.hook("after", "onOptionSelect", () => {
this.handleCode(); this.handleCode();
}); });
@@ -34,14 +33,14 @@ document.addEventListener("alpine:init", () => {
// of a formset so we dynamically apply it here // of a formset so we dynamically apply it here
this.$refs.basketManagementForm this.$refs.basketManagementForm
.querySelector("#id_form-TOTAL_FORMS") .querySelector("#id_form-TOTAL_FORMS")
.setAttribute(":value", "getBasketSize()"); ?.setAttribute(":value", "getBasketSize()");
}, },
removeFromBasket(id: string) { removeFromBasket(id: string) {
delete this.basket[id]; delete this.basket[id];
}, },
addToBasket(id: string, quantity: number): ErrorMessage { addToBasket(id: string, quantity: number) {
const item: BasketItem = const item: BasketItem =
this.basket[id] || new BasketItem(config.products[id], 0); this.basket[id] || new BasketItem(config.products[id], 0);
@@ -50,7 +49,7 @@ document.addEventListener("alpine:init", () => {
if (item.quantity <= 0) { if (item.quantity <= 0) {
delete this.basket[id]; delete this.basket[id];
return ""; return;
} }
this.basket[id] = item; this.basket[id] = item;
@@ -72,7 +71,7 @@ document.addEventListener("alpine:init", () => {
const products = new Set( const products = new Set(
Object.values(this.basket).map((item: BasketItem) => item.product.productId), 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)); return f.products.every((p: number) => products.has(p));
}); });
if (formula === undefined) { 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 // Now that the formula is found, remove the items composing it from the basket
for (const product of formula.products) { for (const product of formula.products) {
const key = Object.entries(this.basket).find( const item = Object.entries(this.basket).find(
([_, i]: [string, BasketItem]) => i.product.productId === product, ([_, i]: [string, BasketItem]) => i.product.productId === product,
)[0]; );
if (item === undefined) {
continue;
}
const key = item[0];
this.basket[key].quantity -= 1; this.basket[key].quantity -= 1;
if (this.basket[key].quantity <= 0) { if (this.basket[key].quantity <= 0) {
this.removeFromBasket(key); this.removeFromBasket(key);
@@ -92,7 +95,7 @@ document.addEventListener("alpine:init", () => {
const result = Object.values(config.products) const result = Object.values(config.products)
.filter((item: CounterItem) => item.productId === formula.result) .filter((item: CounterItem) => item.productId === formula.result)
.reduce((acc, curr) => (acc.price.amount < curr.price.amount ? acc : curr)); .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( this.alertMessage.display(
interpolate( interpolate(
gettext("Formula %(formula)s applied"), gettext("Formula %(formula)s applied"),
@@ -119,14 +122,18 @@ document.addEventListener("alpine:init", () => {
}, },
onRefillingSuccess(event: CustomEvent) { 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; return;
} }
this.customerBalance += Number.parseFloat( this.customerBalance += Number.parseFloat(
(event.detail.target.querySelector("#id_amount") as HTMLInputElement).value, (event.detail.target.querySelector("#id_amount") as HTMLInputElement).value,
); );
document.getElementById("selling-accordion").setAttribute("open", ""); document.getElementById("selling-accordion")?.setAttribute("open", "");
this.codeField.widget.focus(); this.codeField?.widget.focus();
}, },
finish() { finish() {
@@ -136,7 +143,7 @@ document.addEventListener("alpine:init", () => {
}); });
return; return;
} }
this.$refs.basketForm.submit(); (this.$refs.basketForm as HTMLFormElement).submit();
}, },
cancel() { cancel() {
@@ -144,6 +151,8 @@ document.addEventListener("alpine:init", () => {
}, },
handleCode() { handleCode() {
if (!this.codeField) throw Error("Unexpected null codeField.");
const [quantity, code] = this.codeField.getSelectedProduct() as [number, string]; const [quantity, code] = this.codeField.getSelectedProduct() as [number, string];
if (this.codeField.getOperationCodes().includes(code.toUpperCase())) { if (this.codeField.getOperationCodes().includes(code.toUpperCase())) {
@@ -176,13 +176,17 @@
</form> </form>
</div> </div>
</details> </details>
<details class="accordion" name="selling"> <details
class="accordion"
name="selling"
@toggle="if ($event.newState === 'open') $el.querySelector('input[type=number]')?.focus()"
>
<summary>{% trans %}Refilling{% endtrans %}</summary> <summary>{% trans %}Refilling{% endtrans %}</summary>
{% if object.type == "BAR" %} {% if object.type == "BAR" %}
{% if refilling_fragment %} {% if refilling_fragment %}
<div <div
class="accordion-content" class="accordion-content"
@htmx:after-request="onRefillingSuccess" @htmx:after-swap="onRefillingSuccess"
> >
{{ refilling_fragment }} {{ refilling_fragment }}
</div> </div>
+29 -3
View File
@@ -144,6 +144,8 @@ class TestRefilling(TestFullClickBase):
assert self.updated_amount(self.customer) == 0 assert self.updated_amount(self.customer) == 0
def test_refilling_no_refer_fail(self): def test_refilling_no_refer_fail(self):
"""Check that the refill fails is the HTTP_REFERER header is missing"""
def refill(): def refill():
return self.client.post( return self.client.post(
reverse( reverse(
@@ -157,13 +159,13 @@ class TestRefilling(TestFullClickBase):
) )
self.client.force_login(self.club_admin) self.client.force_login(self.club_admin)
assert refill() assert refill().status_code == 403
self.client.force_login(self.root) self.client.force_login(self.root)
assert refill() assert refill().status_code == 403
self.client.force_login(self.subscriber) self.client.force_login(self.subscriber)
assert refill() assert refill().status_code == 403
assert self.updated_amount(self.customer) == 0 assert self.updated_amount(self.customer) == 0
@@ -199,6 +201,17 @@ class TestRefilling(TestFullClickBase):
== 404 == 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): def test_refilling_counter_success(self):
self.login_in_bar() self.login_in_bar()
@@ -522,6 +535,19 @@ class TestCounterClick(TestFullClickBase):
assert self.updated_amount(self.customer) == Decimal(10) 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): def test_annotate_has_barman_queryset(self):
"""Test if the custom queryset method `annotate_has_barman` works as intended.""" """Test if the custom queryset method `annotate_has_barman` works as intended."""
counters = Counter.objects.annotate_has_barman(self.barmen) counters = Counter.objects.annotate_has_barman(self.barmen)
+2 -1
View File
@@ -15,6 +15,7 @@ from core.models import User
from counter.baker_recipes import product_recipe, refill_recipe, sale_recipe from counter.baker_recipes import product_recipe, refill_recipe, sale_recipe
from counter.models import ( from counter.models import (
Counter, Counter,
CounterSellers,
Customer, Customer,
Refilling, Refilling,
ReturnableProduct, ReturnableProduct,
@@ -38,7 +39,7 @@ class TestStudentCard(TestCase):
cls.subscriber = subscriber_user.make() cls.subscriber = subscriber_user.make()
cls.counter = baker.make(Counter, type="BAR") 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) cls.club_counter = baker.make(Counter)
role = baker.make(ClubRole, club=cls.club_counter.club, is_board=True) role = baker.make(ClubRole, club=cls.club_counter.club, is_board=True)
+22 -17
View File
@@ -24,7 +24,7 @@ from django.shortcuts import get_object_or_404, redirect, resolve_url
from django.urls import reverse from django.urls import reverse
from django.utils.safestring import SafeString from django.utils.safestring import SafeString
from django.utils.translation import gettext as _ 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 django.views.generic.detail import SingleObjectMixin
from ninja.main import HttpRequest from ninja.main import HttpRequest
@@ -32,7 +32,14 @@ from core.auth.mixins import CanViewMixin
from core.models import User from core.models import User
from core.views.mixins import FragmentMixin, UseFragmentsMixin from core.views.mixins import FragmentMixin, UseFragmentsMixin
from counter.forms import BasketForm, RefillForm 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.utils import is_logged_in_counter
from counter.views.mixins import CounterTabsMixin from counter.views.mixins import CounterTabsMixin
from counter.views.student_card import StudentCardFormFragment from counter.views.student_card import StudentCardFormFragment
@@ -66,13 +73,13 @@ class CounterClick(
current_tab = "counter" current_tab = "counter"
def get_form_kwargs(self): def get_form_kwargs(self):
kwargs = super().get_form_kwargs() return super().get_form_kwargs() | {
kwargs["form_kwargs"] = {
"customer": self.customer, "customer": self.customer,
"counter": self.object, "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): def dispatch(self, request, *args, **kwargs):
self.customer = get_object_or_404(Customer, user_id=self.kwargs["user_id"]) self.customer = get_object_or_404(Customer, user_id=self.kwargs["user_id"])
@@ -219,9 +226,10 @@ class CounterClick(
return kwargs return kwargs
class RefillingCreateView(FragmentMixin, FormView): class RefillingCreateView(FragmentMixin, CreateView):
"""This is a fragment only view which integrates with counter_click.jinja""" """This is a fragment only view which integrates with counter_click.jinja"""
model = Refilling
form_class = RefillForm form_class = RefillForm
template_name = "counter/fragments/create_refill.jinja" template_name = "counter/fragments/create_refill.jinja"
@@ -242,23 +250,20 @@ class RefillingCreateView(FragmentMixin, FormView):
): ):
raise PermissionDenied raise PermissionDenied
self.operator = get_operator(request, self.counter, self.customer)
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
def render_fragment(self, request, **kwargs) -> SafeString: def render_fragment(self, request, **kwargs) -> SafeString:
self.customer = kwargs.pop("customer") self.customer = kwargs.pop("customer")
self.counter = kwargs.pop("counter") self.counter = kwargs.pop("counter")
self.object = None
return super().render_fragment(request, **kwargs) return super().render_fragment(request, **kwargs)
def form_valid(self, form): def get_form_kwargs(self):
res = super().form_valid(form) return super().get_form_kwargs() | {
form.clean() "counter": self.counter,
form.instance.counter = self.counter "operator": get_operator(self.request, self.counter, self.customer),
form.instance.operator = self.operator "customer": self.customer,
form.instance.customer = self.customer }
form.instance.save()
return res
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)
@@ -5,10 +5,11 @@ interface BasketItem {
name: string; name: string;
quantity: number; quantity: number;
unitPrice: number; unitPrice: number;
isRefill: boolean;
} }
const BASKET_CACHE_KEY = "basket"; const BASKET_CACHE_KEY = "basket";
const BASKET_CACHE_VERSION = 1; const BASKET_CACHE_VERSION = 2;
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
Alpine.data("basket", (validPrices: number[], lastPurchaseTime?: number) => ({ Alpine.data("basket", (validPrices: number[], lastPurchaseTime?: number) => ({
@@ -21,7 +22,7 @@ document.addEventListener("alpine:init", () => {
}); });
document document
.getElementById("id_form-TOTAL_FORMS") .getElementById("id_form-TOTAL_FORMS")
.setAttribute(":value", "basket.length"); ?.setAttribute(":value", "basket.length");
}, },
loadBasket(): BasketItem[] { loadBasket(): BasketItem[] {
@@ -32,8 +33,8 @@ document.addEventListener("alpine:init", () => {
return []; return [];
} }
if ( if (
lastPurchaseTime !== null && lastPurchaseTime &&
localStorage.basketTimestamp !== undefined && localStorage.basketTimestamp &&
new Date(lastPurchaseTime) >= new Date(lastPurchaseTime) >=
new Date(Number.parseInt(localStorage.basketTimestamp, 10)) new Date(Number.parseInt(localStorage.basketTimestamp, 10))
) { ) {
@@ -64,6 +65,19 @@ document.addEventListener("alpine:init", () => {
); );
}, },
/**
* Get the total of money that would be added to the AE account on basket purchase.
*/
getTotalAdded() {
return this.basket
.filter((item) => item.isRefill || item.unitPrice < 0)
.reduce(
(acc: number, item: BasketItem) =>
acc + Math.abs(item.quantity * item.unitPrice),
0,
);
},
/** /**
* Add 1 to the quantity of an item in the basket * Add 1 to the quantity of an item in the basket
* @param {BasketItem} item * @param {BasketItem} item
@@ -86,7 +100,7 @@ document.addEventListener("alpine:init", () => {
if (this.basket[index].quantity === 0) { if (this.basket[index].quantity === 0) {
this.basket = this.basket.filter( this.basket = this.basket.filter(
(e: BasketItem) => e.priceId !== this.basket[index].id, (e: BasketItem) => e.priceId !== this.basket[index].priceId,
); );
} }
}, },
@@ -103,14 +117,16 @@ document.addEventListener("alpine:init", () => {
* @param id The id of the product to add * @param id The id of the product to add
* @param name The name of the product * @param name The name of the product
* @param price The unit price 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 * @returns The created item
*/ */
createItem(id: number, name: string, price: number): BasketItem { createItem(id: number, name: string, price: number, isRefill: boolean): BasketItem {
const newItem = { const newItem = {
priceId: id, priceId: id,
name, name,
quantity: 0, quantity: 0,
unitPrice: price, unitPrice: price,
isRefill,
} as BasketItem; } as BasketItem;
this.basket.push(newItem); this.basket.push(newItem);
@@ -125,16 +141,17 @@ document.addEventListener("alpine:init", () => {
* @param id The id of the product to add * @param id The id of the product to add
* @param name The name of the product * @param name The name of the product
* @param price The unit price 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) { addFromCatalog(id: number, name: string, price: number, isRefill: boolean) {
let item = this.basket.find((e: BasketItem) => e.priceId === id); const item = this.basket.find((e: BasketItem) => e.priceId === id);
// if the item is not in the basket, we create it // if the item is not in the basket, we create it
// else we add + 1 to it // else we add + 1 to it
if (item) { if (item) {
this.add(item); this.add(item);
} else { } else {
item = this.createItem(id, name, price); this.createItem(id, name, price, isRefill);
} }
}, },
})); }));
+22 -3
View File
@@ -58,6 +58,17 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
<template x-if="(getTotalAdded() + {{ customer_amount }}) > {{ settings.SITH_ACCOUNT_MAX_MONEY }}">
<div class="alert alert-red">
<div class="alert-main">
{% trans trimmed limit=settings.SITH_ACCOUNT_MAX_MONEY %}
You cannot purchase the current basket,
because it would put your AE account balance
above the {{ limit }}€ limit
{% endtrans %}
</div>
</div>
</template>
<ul class="item-list"> <ul class="item-list">
{# Starting money #} {# Starting money #}
<li> <li>
@@ -109,9 +120,12 @@
<i class="fa fa-trash"></i> <i class="fa fa-trash"></i>
{% trans %}Clear{% endtrans %} {% trans %}Clear{% endtrans %}
</button> </button>
<button class="btn btn-blue"> <button
class="btn btn-blue"
:disabled="(getTotalAdded() + {{ customer_amount }}) > {{ settings.SITH_ACCOUNT_MAX_MONEY }}"
>
<i class="fa fa-check"></i> <i class="fa fa-check"></i>
<input type="submit" value="{% trans %}Validate{% endtrans %}"/> {% trans %}Validate{% endtrans %}
</button> </button>
</div> </div>
</form> </form>
@@ -199,7 +213,12 @@
id="{{ price.id }}" id="{{ price.id }}"
class="card clickable shadow" class="card clickable shadow"
:class="{selected: basket.some((i) => i.priceId === {{ price.id }})}" :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.sold_out %}disabled{% endif %}
> >
{% if price.product.icon %} {% if price.product.icon %}
+21
View File
@@ -278,6 +278,27 @@ class TestEboutic(TestCase):
) )
assert Basket.objects.count() == 2 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): def test_create_basket(self):
self.client.force_login(self.new_customer) self.client.force_login(self.new_customer)
assertRedirects( assertRedirects(
+6 -8
View File
@@ -66,9 +66,7 @@ if TYPE_CHECKING:
class BaseEbouticBasketForm(BaseBasketForm): class BaseEbouticBasketForm(BaseBasketForm):
def _check_enough_money(self, *args, **kwargs): min_result_balance = None # user can pay by card, so no minimum enforced
# Disable money check
...
EbouticBasketForm = forms.formset_factory( EbouticBasketForm = forms.formset_factory(
@@ -88,15 +86,15 @@ class EbouticMainView(LoginRequiredMixin, FormView):
form_class = EbouticBasketForm form_class = EbouticBasketForm
def get_form_kwargs(self): def get_form_kwargs(self):
kwargs = super().get_form_kwargs() return super().get_form_kwargs() | {
kwargs["form_kwargs"] = {
"customer": self.customer, "customer": self.customer,
"counter": get_eboutic(), "counter": get_eboutic(),
"allowed_prices": { "form_kwargs": {
price.id: price for price in self.prices if not price.sold_out "allowed_prices": {
price.id: price for price in self.prices if not price.sold_out
}
}, },
} }
return kwargs
def form_valid(self, formset): def form_valid(self, formset):
if len(formset) == 0: if len(formset) == 0:
+15 -1
View File
@@ -6,7 +6,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "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" "PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n" "Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -3306,6 +3306,11 @@ msgstr "Saisie de produit dupliquée"
msgid "Not enough money" msgid "Not enough money"
msgstr "Solde insuffisant" msgstr "Solde insuffisant"
#: counter/forms.py counter/models.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/forms.py #: counter/forms.py
#, python-format #, python-format
msgid "" msgid ""
@@ -4428,6 +4433,15 @@ msgstr "Payer avec un compte AE"
msgid "The online shop of the association." msgid "The online shop of the association."
msgstr "La boutique en ligne de l'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 #: eboutic/templates/eboutic/eboutic_main.jinja
msgid "Clear" msgid "Clear"
msgstr "Vider" msgstr "Vider"
+1 -1
View File
@@ -77,7 +77,7 @@ tests = [
"pytest-cov>=7.1.0,<8.0.0", "pytest-cov>=7.1.0,<8.0.0",
"pytest-django>=4.12.0,<5.0.0", "pytest-django>=4.12.0,<5.0.0",
"model-bakery>=1.23.4,<2.0.0", "model-bakery>=1.23.4,<2.0.0",
"beautifulsoup4>=4.15.0,<5", "beautifulsoup4>=4.14.3,<5",
"lxml>=6.1.1,<7", "lxml>=6.1.1,<7",
] ]
docs = [ docs = [
+6
View File
@@ -503,6 +503,12 @@ SITH_ACCOUNT_INACTIVITY_DELTA = relativedelta(years=2)
SITH_ACCOUNT_DUMP_DELTA = timedelta(days=30) SITH_ACCOUNT_DUMP_DELTA = timedelta(days=30)
"""timedelta between the warning mail and the actual account dump""" """timedelta between the warning mail and the actual account dump"""
SITH_ACCOUNT_MAX_MONEY = 250 # €
"""Maximum amount of money a sith account can hold.
This amount is defined by the AE's Terms and Conditions of Sale.
"""
# Defines which product type is the refilling type, # Defines which product type is the refilling type,
# and thus increases the account amount # and thus increases the account amount
SITH_COUNTER_PRODUCTTYPE_REFILLING = env.int( SITH_COUNTER_PRODUCTTYPE_REFILLING = env.int(