7 Commits

Author SHA1 Message Date
thomas girod 455b81cff6 Merge pull request #1424 from ae-utbm/taiste
Basket timeout, clic limit, club-election link, CGV, counter barmen and other
2026-06-06 09:46:41 +02:00
thomas girod 9ceb51a54e Merge pull request #1404 from ae-utbm/taiste
Club roles, club links, notifications and other
2026-05-22 09:43:08 +02:00
Titouan 790a1e15b1 Merge pull request #1383 from ae-utbm/taiste
MAJ cotisation, CI, style et fix
2026-05-11 13:03:22 +02:00
thomas girod b2ffcd3a37 Merge pull request #1365 from ae-utbm/taiste
Product prices, club list page rework and bug fixes
2026-05-01 19:14:03 +02:00
Titouan ca37996d6a Merge pull request #1332 from ae-utbm/taiste
Stats & Whitelist, Eurockéenne, fix pagination, Vite 8, delete unused settings
2026-03-29 16:35:16 +02:00
Titouan 173311c1d5 Merge pull request #1315 from ae-utbm/taiste
Product history, formula management, test election
2026-03-12 11:33:45 +01:00
thomas girod 2995823d6e Merge pull request #1293 from ae-utbm/taiste
Refactors, updates and db optimisations
2026-02-13 15:25:04 +01:00
17 changed files with 93 additions and 337 deletions
+2 -8
View File
@@ -200,11 +200,7 @@ 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,
amount=1,
Refilling, customer=cls.users[4].customer, date=time_active, counter=counter
)
sale_recipe.make(customer=cls.users[5].customer, date=time_inactive)
@@ -459,9 +455,7 @@ 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=settings.SITH_ACCOUNT_MAX_MONEY
)
baker.make(Refilling, customer=user.customer, amount=99999)
bars = [b[0] for b in settings.SITH_COUNTER_BARS]
baker.make(
Permanency,
+4 -50
View File
@@ -1,68 +1,22 @@
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, 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 __init__(self, *args, **kwargs):
kwargs["max_digits"] = 12
kwargs["decimal_places"] = 2
super().__init__(*args, **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
+22 -52
View File
@@ -3,16 +3,13 @@ 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
@@ -171,19 +168,18 @@ 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, counter: Counter, operator: User, customer: Customer, **kwargs
):
def __init__(self, *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 = (
method
for method in self.fields["payment_method"].choices
@@ -191,9 +187,6 @@ 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):
@@ -567,7 +560,16 @@ class BasketItemForm(forms.Form):
quantity = forms.IntegerField(min_value=1, required=True)
price_id = forms.IntegerField(min_value=0, required=True)
def __init__(self, allowed_prices: dict[int, Price], *args, **kwargs):
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
self.allowed_prices = allowed_prices
super().__init__(*args, **kwargs)
@@ -602,15 +604,6 @@ 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 != {}]
@@ -619,8 +612,8 @@ class BaseBasketForm(forms.BaseFormSet):
self._check_forms_have_errors()
self._check_product_are_unique()
self._check_recorded_products()
self._check_account_balance()
self._check_recorded_products(self[0].customer)
self._check_enough_money(self[0].counter, self[0].customer)
def _check_forms_have_errors(self):
if any(len(form.errors) > 0 for form in self):
@@ -631,35 +624,12 @@ class BaseBasketForm(forms.BaseFormSet):
if len(price_ids) != len(self.forms):
raise forms.ValidationError(_("Duplicated product entries."))
@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
):
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"))
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):
def _check_recorded_products(self, customer: Customer):
"""Check for, among other things, ecocups and pitchers"""
items = defaultdict(int)
for form in self.forms:
@@ -668,7 +638,7 @@ class BaseBasketForm(forms.BaseFormSet):
returnables = list(
ReturnableProduct.objects.filter(
Q(product_id__in=ids) | Q(returned_product_id__in=ids)
).annotate_balance_for(self.customer)
).annotate_balance_for(customer)
)
limit_reached = []
for returnable in returnables:
@@ -1,30 +0,0 @@
# 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"
),
),
]
+7 -19
View File
@@ -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, Max, OuterRef, Q, QuerySet, Subquery, Sum, Value
from django.db.models import Exists, F, 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,9 +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 = CurrencyField(
_("amount"), max_value=settings.SITH_ACCOUNT_MAX_MONEY, default=0
)
amount = CurrencyField(_("amount"), default=0)
objects = CustomerQuerySet.as_manager()
@@ -158,15 +156,13 @@ class Customer(models.Model):
unique_fields=["customer", "returnable"],
)
@cached_property
@property
def can_buy(self) -> bool:
"""Check if whether this customer has the right to purchase any item."""
subscription_end = self.user.subscriptions.aggregate(
res=Max("subscription_end")
).get("res")
if subscription_end is None:
subscription = self.user.subscriptions.order_by("subscription_end").last()
if subscription is None:
return False
return (date.today() - subscription_end) < timedelta(days=90)
return (date.today() - subscription.subscription_end) < timedelta(days=90)
@classmethod
def get_or_create(cls, user: User) -> tuple[Customer, bool]:
@@ -827,7 +823,7 @@ class Refilling(models.Model):
counter = models.ForeignKey(
Counter, related_name="refillings", blank=False, on_delete=models.CASCADE
)
amount: CurrencyField = CurrencyField(_("amount"), min_value=0.01)
amount = CurrencyField(_("amount"))
operator = models.ForeignKey(
User,
related_name="refillings_as_operator",
@@ -881,14 +877,6 @@ 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()
@@ -6,7 +6,7 @@ const productParsingRegex = /^(\d+x)?(.*)/i;
const codeParsingRegex = / \((\w+)\)$/;
function parseProduct(query: string): [number, string] {
const parsed = productParsingRegex.exec(query) as RegExpExecArray;
const parsed = productParsingRegex.exec(query);
return [Number.parseInt(parsed[1] || "1", 10), parsed[2]];
}
@@ -3,6 +3,7 @@ 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";
@@ -23,7 +24,7 @@ document.addEventListener("alpine:init", () => {
}
}
this.codeField = this.$refs.codeField as CounterProductSelect;
this.codeField = this.$refs.codeField;
this.codeField.widget.hook("after", "onOptionSelect", () => {
this.handleCode();
});
@@ -33,14 +34,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) {
addToBasket(id: string, quantity: number): ErrorMessage {
const item: BasketItem =
this.basket[id] || new BasketItem(config.products[id], 0);
@@ -49,7 +50,7 @@ document.addEventListener("alpine:init", () => {
if (item.quantity <= 0) {
delete this.basket[id];
return;
return "";
}
this.basket[id] = item;
@@ -71,7 +72,7 @@ document.addEventListener("alpine:init", () => {
const products = new Set(
Object.values(this.basket).map((item: BasketItem) => item.product.productId),
);
const formula = config.formulas.find((f: ProductFormula) => {
const formula: ProductFormula = config.formulas.find((f: ProductFormula) => {
return f.products.every((p: number) => products.has(p));
});
if (formula === undefined) {
@@ -79,13 +80,9 @@ 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 item = Object.entries(this.basket).find(
const key = Object.entries(this.basket).find(
([_, i]: [string, BasketItem]) => i.product.productId === product,
);
if (item === undefined) {
continue;
}
const key = item[0];
)[0];
this.basket[key].quantity -= 1;
if (this.basket[key].quantity <= 0) {
this.removeFromBasket(key);
@@ -95,7 +92,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.toString(), 1);
this.addToBasket(result.price.id, 1);
this.alertMessage.display(
interpolate(
gettext("Formula %(formula)s applied"),
@@ -122,18 +119,14 @@ document.addEventListener("alpine:init", () => {
},
onRefillingSuccess(event: CustomEvent) {
if (
event.type !== "htmx:after-swap" ||
event.detail.failed ||
event.detail.elt.querySelector(".errorlist")
) {
if (event.type !== "htmx:after-request" || event.detail.failed) {
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() {
@@ -143,7 +136,7 @@ document.addEventListener("alpine:init", () => {
});
return;
}
(this.$refs.basketForm as HTMLFormElement).submit();
this.$refs.basketForm.submit();
},
cancel() {
@@ -151,8 +144,6 @@ 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())) {
@@ -176,17 +176,13 @@
</form>
</div>
</details>
<details
class="accordion"
name="selling"
@toggle="if ($event.newState === 'open') $el.querySelector('input[type=number]')?.focus()"
>
<details class="accordion" name="selling">
<summary>{% trans %}Refilling{% endtrans %}</summary>
{% if object.type == "BAR" %}
{% if refilling_fragment %}
<div
class="accordion-content"
@htmx:after-swap="onRefillingSuccess"
@htmx:after-request="onRefillingSuccess"
>
{{ refilling_fragment }}
</div>
+3 -29
View File
@@ -144,8 +144,6 @@ 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(
@@ -159,13 +157,13 @@ class TestRefilling(TestFullClickBase):
)
self.client.force_login(self.club_admin)
assert refill().status_code == 403
assert refill()
self.client.force_login(self.root)
assert refill().status_code == 403
assert refill()
self.client.force_login(self.subscriber)
assert refill().status_code == 403
assert refill()
assert self.updated_amount(self.customer) == 0
@@ -201,17 +199,6 @@ 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()
@@ -535,19 +522,6 @@ 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)
+1 -2
View File
@@ -15,7 +15,6 @@ 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,
@@ -39,7 +38,7 @@ class TestStudentCard(TestCase):
cls.subscriber = subscriber_user.make()
cls.counter = baker.make(Counter, type="BAR")
CounterSellers.objects.create(counter=cls.counter, user=cls.barmen)
cls.counter.sellers.add(cls.barmen)
cls.club_counter = baker.make(Counter)
role = baker.make(ClubRole, club=cls.club_counter.club, is_board=True)
+17 -22
View File
@@ -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 CreateView, FormView
from django.views.generic import FormView
from django.views.generic.detail import SingleObjectMixin
from ninja.main import HttpRequest
@@ -32,14 +32,7 @@ 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,
Refilling,
ReturnableProduct,
Selling,
)
from counter.models import Counter, Customer, ProductFormula, ReturnableProduct, Selling
from counter.utils import is_logged_in_counter
from counter.views.mixins import CounterTabsMixin
from counter.views.student_card import StudentCardFormFragment
@@ -73,13 +66,13 @@ class CounterClick(
current_tab = "counter"
def get_form_kwargs(self):
return super().get_form_kwargs() | {
kwargs = super().get_form_kwargs()
kwargs["form_kwargs"] = {
"customer": self.customer,
"counter": self.object,
"form_kwargs": {
"allowed_prices": {price.id: price for price in self.prices}
},
"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"])
@@ -226,10 +219,9 @@ class CounterClick(
return kwargs
class RefillingCreateView(FragmentMixin, CreateView):
class RefillingCreateView(FragmentMixin, FormView):
"""This is a fragment only view which integrates with counter_click.jinja"""
model = Refilling
form_class = RefillForm
template_name = "counter/fragments/create_refill.jinja"
@@ -250,20 +242,23 @@ class RefillingCreateView(FragmentMixin, CreateView):
):
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 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 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_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
@@ -5,11 +5,10 @@ interface BasketItem {
name: string;
quantity: number;
unitPrice: number;
isRefill: boolean;
}
const BASKET_CACHE_KEY = "basket";
const BASKET_CACHE_VERSION = 2;
const BASKET_CACHE_VERSION = 1;
document.addEventListener("alpine:init", () => {
Alpine.data("basket", (validPrices: number[], lastPurchaseTime?: number) => ({
@@ -22,7 +21,7 @@ document.addEventListener("alpine:init", () => {
});
document
.getElementById("id_form-TOTAL_FORMS")
?.setAttribute(":value", "basket.length");
.setAttribute(":value", "basket.length");
},
loadBasket(): BasketItem[] {
@@ -33,8 +32,8 @@ document.addEventListener("alpine:init", () => {
return [];
}
if (
lastPurchaseTime &&
localStorage.basketTimestamp &&
lastPurchaseTime !== null &&
localStorage.basketTimestamp !== undefined &&
new Date(lastPurchaseTime) >=
new Date(Number.parseInt(localStorage.basketTimestamp, 10))
) {
@@ -65,19 +64,6 @@ 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
* @param {BasketItem} item
@@ -100,7 +86,7 @@ document.addEventListener("alpine:init", () => {
if (this.basket[index].quantity === 0) {
this.basket = this.basket.filter(
(e: BasketItem) => e.priceId !== this.basket[index].priceId,
(e: BasketItem) => e.priceId !== this.basket[index].id,
);
}
},
@@ -117,16 +103,14 @@ 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, isRefill: boolean): BasketItem {
createItem(id: number, name: string, price: number): BasketItem {
const newItem = {
priceId: id,
name,
quantity: 0,
unitPrice: price,
isRefill,
} as BasketItem;
this.basket.push(newItem);
@@ -141,17 +125,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
*/
addFromCatalog(id: number, name: string, price: number, isRefill: boolean) {
const item = this.basket.find((e: BasketItem) => e.priceId === id);
addFromCatalog(id: number, name: string, price: number) {
let 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 {
this.createItem(id, name, price, isRefill);
item = this.createItem(id, name, price);
}
},
}));
+3 -22
View File
@@ -58,17 +58,6 @@
</div>
</div>
{% 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">
{# Starting money #}
<li>
@@ -120,12 +109,9 @@
<i class="fa fa-trash"></i>
{% trans %}Clear{% endtrans %}
</button>
<button
class="btn btn-blue"
:disabled="(getTotalAdded() + {{ customer_amount }}) > {{ settings.SITH_ACCOUNT_MAX_MONEY }}"
>
<button class="btn btn-blue">
<i class="fa fa-check"></i>
{% trans %}Validate{% endtrans %}
<input type="submit" value="{% trans %}Validate{% endtrans %}"/>
</button>
</div>
</form>
@@ -213,12 +199,7 @@
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 }},
{{ (price.product.product_type_id == settings.SITH_COUNTER_PRODUCTTYPE_REFILLING)|lower }}
)'
@click='addFromCatalog({{ price.id }}, {{ price.full_label|tojson }}, {{ price.amount }})'
{% if price.sold_out %}disabled{% endif %}
>
{% if price.product.icon %}
-21
View File
@@ -278,27 +278,6 @@ 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(
+8 -6
View File
@@ -66,7 +66,9 @@ if TYPE_CHECKING:
class BaseEbouticBasketForm(BaseBasketForm):
min_result_balance = None # user can pay by card, so no minimum enforced
def _check_enough_money(self, *args, **kwargs):
# Disable money check
...
EbouticBasketForm = forms.formset_factory(
@@ -86,15 +88,15 @@ class EbouticMainView(LoginRequiredMixin, FormView):
form_class = EbouticBasketForm
def get_form_kwargs(self):
return super().get_form_kwargs() | {
kwargs = super().get_form_kwargs()
kwargs["form_kwargs"] = {
"customer": self.customer,
"counter": get_eboutic(),
"form_kwargs": {
"allowed_prices": {
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):
if len(formset) == 0:
+1 -15
View File
@@ -6,7 +6,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-06-10 20:18+0200\n"
"POT-Creation-Date: 2026-06-05 13:39+0200\n"
"PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -3306,11 +3306,6 @@ msgstr "Saisie de produit dupliquée"
msgid "Not enough money"
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
#, python-format
msgid ""
@@ -4433,15 +4428,6 @@ 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"
-6
View File
@@ -503,12 +503,6 @@ 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 # €
"""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,
# and thus increases the account amount
SITH_COUNTER_PRODUCTTYPE_REFILLING = env.int(