6 Commits

Author SHA1 Message Date
imperosol 64032f79f7 clean invalid items from eboutic baskets 2026-05-22 14:46:40 +02:00
imperosol 62900b8c2e exclude products over clic limit from eboutic 2026-05-22 14:44:35 +02:00
imperosol 2d135cdf6b add clic limit to product form 2026-05-22 11:48:38 +02:00
imperosol 7bd7baa52d add field Product.clic_limit 2026-05-22 11:48:38 +02:00
imperosol 8797c93ff7 remove Product.buying_groups
Savoir quel groupe a le droit d'acheter quel produit est maintenant déterminé avec le modèle `Price`. `Product.buying_groups` avait juste été laissé temporairement pour permettre un rollback si le déploiement des prix ne se passait pas bien. Comme il n'y a pas eu de problème, on peut maintenant le retirer.
2026-05-22 11:48:37 +02:00
imperosol 7ea9d51e1d feat: basket timeout 2026-05-22 11:36:46 +02:00
21 changed files with 351 additions and 66 deletions
+6 -1
View File
@@ -29,7 +29,12 @@
align-items: center; align-items: center;
gap: 20px; gap: 20px;
&.clickable:hover { &:disabled {
background-color: darken($primary-neutral-light-color, 5%);
opacity: 65%;
}
&.clickable:not(:disabled):hover {
background-color: darken($primary-neutral-light-color, 5%); background-color: darken($primary-neutral-light-color, 5%);
} }
+1 -1
View File
@@ -23,7 +23,7 @@
border-radius: 5px; border-radius: 5px;
color: black; color: black;
&:hover { &:not(.link-like):not(:disabled):hover {
background: hsl(0, 0%, 83%); background: hsl(0, 0%, 83%);
} }
} }
+1
View File
@@ -409,6 +409,7 @@ class ProductForm(forms.ModelForm):
"club", "club",
"limit_age", "limit_age",
"tray", "tray",
"clic_limit",
"archived", "archived",
] ]
help_texts = { help_texts = {
@@ -0,0 +1,24 @@
# Generated by Django 5.2.13 on 2026-05-13 11:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("counter", "0039_price")]
operations = [
migrations.RemoveField(model_name="product", name="buying_groups"),
migrations.AddField(
model_name="product",
name="clic_limit",
field=models.PositiveSmallIntegerField(
blank=True,
help_text=(
"If a limit is set, the product won't be purchasable "
"anymore once the latter is reached."
),
null=True,
verbose_name="clic limit",
),
),
]
+47 -15
View File
@@ -22,7 +22,7 @@ import string
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from datetime import timezone as tz from datetime import timezone as tz
from decimal import Decimal from decimal import Decimal
from typing import TYPE_CHECKING, Literal, Self from typing import Literal, Self
from dict2xml import dict2xml from dict2xml import dict2xml
from django.conf import settings from django.conf import settings
@@ -34,6 +34,7 @@ from django.forms import ValidationError
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.functional import cached_property from django.utils.functional import cached_property
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 PeriodicTask from django_celery_beat.models import PeriodicTask
from django_countries.fields import CountryField from django_countries.fields import CountryField
@@ -47,9 +48,6 @@ from core.utils import get_start_of_semester
from counter.fields import CurrencyField from counter.fields import CurrencyField
from subscription.models import Subscription from subscription.models import Subscription
if TYPE_CHECKING:
from collections.abc import Sequence
def get_eboutic() -> Counter: def get_eboutic() -> Counter:
return Counter.objects.filter(type="EBOUTIC").order_by("id").first() return Counter.objects.filter(type="EBOUTIC").order_by("id").first()
@@ -353,6 +351,38 @@ class ProductType(OrderedModel):
return user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) return user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)
class ProductQuerySet(models.QuerySet):
def under_clic_limit(self) -> Self:
"""Filter product which clic limit isn't reached yet.
The clic limit is reached when the amount of sales
and of items in a basket for less than 15 minutes
is greater or equal than `Product.clic_limit`.
"""
# import here to avoid circular import
from eboutic.models import BasketItem
nb_click_subquery = Subquery(
Selling.objects.filter(product_id=OuterRef("id"))
.values("product_id")
.annotate(res=Sum("quantity", default=0))
.values("res")[:1]
)
nb_basket_items_subquery = Subquery(
BasketItem.objects.filter(
product_id=OuterRef("id"),
basket__date__gt=now() - settings.SITH_EBOUTIC_BASKET_TIMEOUT,
)
.values("product_id")
.annotate(res=Sum("quantity"))
.values("res")[:1]
)
return self.annotate(
clicked=Coalesce(nb_click_subquery, 0),
reserved=Coalesce(nb_basket_items_subquery, 0),
).filter(Q(clic_limit=None) | Q(clic_limit__gt=(F("clicked") + F("reserved"))))
class Product(models.Model): class Product(models.Model):
"""A product, with all its related information.""" """A product, with all its related information."""
@@ -370,8 +400,7 @@ class Product(models.Model):
) )
code = models.CharField(_("code"), max_length=16, blank=True) code = models.CharField(_("code"), max_length=16, blank=True)
purchase_price = CurrencyField( purchase_price = CurrencyField(
_("purchase price"), _("purchase price"), help_text=_("Initial cost of purchasing the product")
help_text=_("Initial cost of purchasing the product"),
) )
icon = ResizedImageField( icon = ResizedImageField(
height=70, height=70,
@@ -388,13 +417,21 @@ class Product(models.Model):
tray = models.BooleanField( tray = models.BooleanField(
_("tray price"), help_text=_("Buy five, get the sixth free"), default=False _("tray price"), help_text=_("Buy five, get the sixth free"), default=False
) )
buying_groups = models.ManyToManyField( clic_limit = models.PositiveSmallIntegerField(
Group, related_name="products", verbose_name=_("buying groups"), blank=True _("clic limit"),
help_text=_(
"If a limit is set, the product won't be purchasable "
"anymore on the eboutic once the latter is reached."
),
null=True,
blank=True,
) )
archived = models.BooleanField(_("archived"), default=False) archived = models.BooleanField(_("archived"), default=False)
created_at = models.DateTimeField(_("created at"), auto_now_add=True) created_at = models.DateTimeField(_("created at"), auto_now_add=True)
updated_at = models.DateTimeField(_("updated at"), auto_now=True) updated_at = models.DateTimeField(_("updated at"), auto_now=True)
objects = ProductQuerySet.as_manager()
class Meta: class Meta:
verbose_name = _("product") verbose_name = _("product")
@@ -733,10 +770,8 @@ class Counter(models.Model):
# but they share the same primary key # but they share the same primary key
return self.type == "BAR" and any(b.pk == customer.pk for b in self.barmen_list) return self.type == "BAR" and any(b.pk == customer.pk for b in self.barmen_list)
def get_prices_for( def get_prices_for(self, customer: Customer) -> PriceQuerySet:
self, customer: Customer, *, order_by: Sequence[str] | None = None return (
) -> list[Price]:
qs = (
Price.objects.filter( Price.objects.filter(
product__counters=self, product__product_type__isnull=False product__counters=self, product__product_type__isnull=False
) )
@@ -744,9 +779,6 @@ class Counter(models.Model):
.select_related("product", "product__product_type") .select_related("product", "product__product_type")
.prefetch_related("groups") .prefetch_related("groups")
) )
if order_by:
qs = qs.order_by(*order_by)
return list(qs)
class CounterSellers(models.Model): class CounterSellers(models.Model):
@@ -118,6 +118,7 @@
</div> </div>
</div> </div>
</fieldset> </fieldset>
<fieldset><div>{{ form.clic_limit.as_field_group() }}</div></fieldset>
<fieldset><div>{{ form.counters.as_field_group() }}</div></fieldset> <fieldset><div>{{ form.counters.as_field_group() }}</div></fieldset>
<h3 class="margin-bottom">{% trans %}Prices{% endtrans %}</h3> <h3 class="margin-bottom">{% trans %}Prices{% endtrans %}</h3>
+1 -1
View File
@@ -596,7 +596,7 @@ class TestCounterClick(TestFullClickBase):
product=iter(_product_recipe.make(archived=False, _quantity=2)), product=iter(_product_recipe.make(archived=False, _quantity=2)),
groups=[group], groups=[group],
) )
customer_prices = counter.get_prices_for(customer) customer_prices = list(counter.get_prices_for(customer))
assert unarchived_prices == customer_prices assert unarchived_prices == customer_prices
+60 -2
View File
@@ -1,3 +1,5 @@
import itertools
from datetime import timedelta
from io import BytesIO from io import BytesIO
from typing import Callable from typing import Callable
from uuid import uuid4 from uuid import uuid4
@@ -8,6 +10,7 @@ from django.core.cache import cache
from django.core.files.uploadedfile import SimpleUploadedFile from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import Client, TestCase from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import now
from model_bakery import baker from model_bakery import baker
from model_bakery.recipe import Recipe from model_bakery.recipe import Recipe
from PIL import Image from PIL import Image
@@ -16,9 +19,10 @@ from pytest_django.asserts import assertNumQueries, assertRedirects
from club.models import Club from club.models import Club
from core.baker_recipes import board_user, subscriber_user from core.baker_recipes import board_user, subscriber_user
from core.models import Group, User from core.models import Group, User
from counter.baker_recipes import product_recipe from counter.baker_recipes import product_recipe, sale_recipe
from counter.forms import ProductForm, ProductPriceFormSet from counter.forms import ProductForm, ProductPriceFormSet
from counter.models import Price, Product, ProductType from counter.models import Price, Product, ProductType, Selling
from eboutic.models import Basket, BasketItem
@pytest.mark.django_db @pytest.mark.django_db
@@ -222,3 +226,57 @@ def test_price_for_user():
assert list(qs.for_user(users[0])) == [prices[0], prices[1], prices[4]] assert list(qs.for_user(users[0])) == [prices[0], prices[1], prices[4]]
assert list(qs.for_user(users[1])) == [prices[0], prices[4]] assert list(qs.for_user(users[1])) == [prices[0], prices[4]]
assert list(qs.for_user(users[2])) == [prices[0], prices[3]] assert list(qs.for_user(users[2])) == [prices[0], prices[3]]
class TestProductClicLimit(TestCase):
@classmethod
def setUpTestData(cls):
cls.products = product_recipe.make(
clic_limit=itertools.chain([5, 10, 15], itertools.repeat(None)),
_quantity=6,
_bulk_create=True,
)
cls.qs = Product.objects.filter(id__in=[p.id for p in cls.products])
def test_no_sales_or_basket(self):
"""Test that it works if no sales has been made yet"""
assert list(self.qs.under_clic_limit()) == self.products
def test_with_sales(self):
"""Test that it works when there are existing sales"""
sales = sale_recipe.make(
product=itertools.cycle(self.products),
_quantity=len(self.products) * 5,
_bulk_create=True,
)
Selling.objects.filter(id__in=[s.id for s in sales]).update(quantity=2)
assert list(self.qs.under_clic_limit()) == self.products[2:]
def test_with_sales_and_basket(self):
"""Test that it works when there are existing sales and basket items."""
sales = sale_recipe.make(
product=itertools.cycle(self.products),
_quantity=len(self.products) * 5,
_bulk_create=True,
)
Selling.objects.filter(id__in=[s.id for s in sales]).update(quantity=1)
basket = baker.make(
Basket, date=now() - settings.SITH_EBOUTIC_BASKET_TIMEOUT / 2
)
items = baker.make(
BasketItem,
product=itertools.cycle(self.products),
basket=basket,
_quantity=len(self.products) * 5,
)
BasketItem.objects.filter(id__in=[i.id for i in items]).update(quantity=1)
assert list(self.qs.under_clic_limit()) == self.products[2:]
# expired basket items shouldn't be accounted when computing clic limit
item = BasketItem.objects.filter(product=self.products[1])[0]
item.basket = baker.make(
Basket,
date=now() - settings.SITH_EBOUTIC_BASKET_TIMEOUT - timedelta(minutes=1),
)
item.save()
assert list(self.qs.under_clic_limit()) == self.products[1:]
+1 -1
View File
@@ -103,7 +103,7 @@ class CounterClick(
): ):
return redirect(obj) # Redirect to counter return redirect(obj) # Redirect to counter
self.prices = obj.get_prices_for(self.customer) self.prices = list(obj.get_prices_for(self.customer))
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
+10 -1
View File
@@ -1,3 +1,6 @@
from typing import Any
from ninja import Status
from ninja_extra import ControllerBase, api_controller, route from ninja_extra import ControllerBase, api_controller, route
from ninja_extra.exceptions import NotFound from ninja_extra.exceptions import NotFound
@@ -8,13 +11,19 @@ from eboutic.models import Basket
@api_controller("/etransaction", permissions=[CanView]) @api_controller("/etransaction", permissions=[CanView])
class EtransactionInfoController(ControllerBase): class EtransactionInfoController(ControllerBase):
@route.get("/data/{basket_id}", url_name="etransaction_data") @route.get(
"/data/{basket_id}",
url_name="etransaction_data",
response={200: dict[str, Any], 410: str},
)
def fetch_etransaction_data(self, basket_id: int): def fetch_etransaction_data(self, basket_id: int):
"""Generate the data to pay an eboutic command with paybox. """Generate the data to pay an eboutic command with paybox.
The data is generated with the basket that is used by the current session. The data is generated with the basket that is used by the current session.
""" """
basket: Basket = self.get_object_or_exception(Basket, pk=basket_id) basket: Basket = self.get_object_or_exception(Basket, pk=basket_id)
if basket.is_expired:
return Status(410, "This basket is expired.")
try: try:
return dict(basket.get_e_transaction_data()) return dict(basket.get_e_transaction_data())
except BillingInfo.DoesNotExist as e: except BillingInfo.DoesNotExist as e:
+20
View File
@@ -24,6 +24,7 @@ from django.conf import settings
from django.db import DataError, models from django.db import DataError, models
from django.db.models import F, OuterRef, Subquery, Sum from django.db.models import F, OuterRef, Subquery, Sum
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from core.models import User from core.models import User
@@ -95,6 +96,10 @@ class Basket(models.Model):
] ]
) )
@property
def is_expired(self) -> bool:
return (self.date + settings.SITH_EBOUTIC_BASKET_TIMEOUT) <= now()
def generate_sales( def generate_sales(
self, counter, seller: User, payment_method: Selling.PaymentMethod self, counter, seller: User, payment_method: Selling.PaymentMethod
): ):
@@ -133,9 +138,20 @@ class Basket(models.Model):
] ]
def get_e_transaction_data(self) -> list[tuple[str, str]]: def get_e_transaction_data(self) -> list[tuple[str, str]]:
"""Get data for etransaction payment.
Raises:
Customer.DoesNotExist: if the user linked to this basket
has no customer account
BillingInfo.DoesNotExist: if the user linked to this basket has no
billing infos, or incorrect billing infos.
ValueError: if this is called on a basket which payment delay is expired.
"""
user = self.user user = self.user
if not hasattr(user, "customer"): if not hasattr(user, "customer"):
raise Customer.DoesNotExist raise Customer.DoesNotExist
if self.is_expired:
raise ValueError("This method cannot be called on an expired basket.")
customer = user.customer customer = user.customer
if ( if (
not hasattr(user.customer, "billing_infos") not hasattr(user.customer, "billing_infos")
@@ -155,6 +171,10 @@ class Basket(models.Model):
("PBX_IDENTIFIANT", settings.SITH_EBOUTIC_PBX_IDENTIFIANT), ("PBX_IDENTIFIANT", settings.SITH_EBOUTIC_PBX_IDENTIFIANT),
("PBX_TOTAL", str(int(self.total * 100))), ("PBX_TOTAL", str(int(self.total * 100))),
("PBX_DEVISE", "978"), # This is Euro ("PBX_DEVISE", "978"), # This is Euro
(
"PBX_DISPLAY",
str(int(settings.SITH_EBOUTIC_ETRANSACTION_TIMEOUT.total_seconds())),
),
("PBX_CMD", str(self.id)), ("PBX_CMD", str(self.id)),
("PBX_PORTEUR", user.email), ("PBX_PORTEUR", user.email),
("PBX_RETOUR", "Amount:M;BasketID:R;Auto:A;Error:E;Sig:K"), ("PBX_RETOUR", "Amount:M;BasketID:R;Auto:A;Error:E;Sig:K"),
@@ -1,21 +1,71 @@
import { type Notification, NotificationLevel } from "#core:utils/notifications";
import { etransactioninfoFetchEtransactionData } from "#openapi"; import { etransactioninfoFetchEtransactionData } from "#openapi";
interface Basket {
id: number;
timeout: Date;
}
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
Alpine.data("etransaction", (initialData, basketId: number) => ({ Alpine.data("etransaction", (initialData, basket: Basket) => ({
data: initialData, data: initialData,
isCbAvailable: Object.keys(initialData).length > 0, isCbAvailable: Object.keys(initialData).length > 0,
isSithAvailable: true,
init() {
const now = new Date();
const timeout = basket.timeout.getTime() - now.getTime();
if (timeout <= 0) {
// basket was already outdated at initial page load
this.timeoutBasket();
} else {
setTimeout(() => this.timeoutBasket(), timeout);
}
},
/**
* Make this basket into a timeout state.
* All submission inputs are disabled, and an error message is displayed.
*/
timeoutBasket() {
this.isCbAvailable = false;
this.isSithAvailable = false;
const message = gettext("Basket expired");
const existingNotif: Notification | undefined = this.$notifications
.getAll()
.find(
(n: Notification) =>
n.tag === NotificationLevel.Error && n.message === message,
);
if (existingNotif === undefined) {
this.$notifications.error(message);
}
},
/**
* Refresh the data used for etransaction.
*
* Note: if this is called while the basket is expired, it will be a no-op
*/
async fill() { async fill() {
if (new Date() > basket.timeout) {
// refresh etransaction data only if the basket is still valid.
this.timeoutBasket();
return;
}
this.isCbAvailable = false; this.isCbAvailable = false;
const res = await etransactioninfoFetchEtransactionData({ const res = await etransactioninfoFetchEtransactionData({
path: {
// biome-ignore lint/style/useNamingConvention: api is in snake_case // biome-ignore lint/style/useNamingConvention: api is in snake_case
basket_id: basketId, path: { basket_id: basket.id },
},
}); });
if (res.response.ok) { if (res.response.ok) {
this.data = res.data; this.data = res.data;
this.isCbAvailable = true; this.isCbAvailable = true;
} else if (res.response.status === 410) {
// The basket is expired, so no payment method should be available at all.
// This shouldn't happen, because we don't send the request
// when the timeout is passed, but we are better safe than sorry
this.timeoutBasket();
} }
}, },
})); }));
+17 -11
View File
@@ -11,7 +11,7 @@ const BASKET_CACHE_KEY = "basket";
const BASKET_CACHE_VERSION = 1; const BASKET_CACHE_VERSION = 1;
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
Alpine.data("basket", (lastPurchaseTime?: number) => ({ Alpine.data("basket", (validPrices: number[], lastPurchaseTime?: number) => ({
basket: [] as BasketItem[], basket: [] as BasketItem[],
init() { init() {
@@ -19,15 +19,6 @@ document.addEventListener("alpine:init", () => {
this.$watch("basket", () => { this.$watch("basket", () => {
this.saveBasket(); this.saveBasket();
}); });
// Invalidate basket if a purchase was made
if (lastPurchaseTime !== null && localStorage.basketTimestamp !== undefined) {
if (
new Date(lastPurchaseTime) >=
new Date(Number.parseInt(localStorage.basketTimestamp, 10))
) {
this.basket = [];
}
}
document document
.getElementById("id_form-TOTAL_FORMS") .getElementById("id_form-TOTAL_FORMS")
.setAttribute(":value", "basket.length"); .setAttribute(":value", "basket.length");
@@ -37,7 +28,22 @@ document.addEventListener("alpine:init", () => {
const cached = versionedLocalStorage.getItem<BasketItem[]>(BASKET_CACHE_KEY, { const cached = versionedLocalStorage.getItem<BasketItem[]>(BASKET_CACHE_KEY, {
version: BASKET_CACHE_VERSION, version: BASKET_CACHE_VERSION,
}); });
return cached ?? []; if (!cached) {
return [];
}
if (
lastPurchaseTime !== null &&
localStorage.basketTimestamp !== undefined &&
new Date(lastPurchaseTime) >=
new Date(Number.parseInt(localStorage.basketTimestamp, 10))
) {
// Invalidate basket if a purchase was made
return [];
}
// The basket is cached and not expired, so return it,
// but without items that are invalid
// (e.g. because the product is archived, or sold out)
return cached.filter((item) => validPrices.includes(item.priceId));
}, },
saveBasket() { saveBasket() {
@@ -21,6 +21,7 @@
hx-swap="outerHTML" hx-swap="outerHTML"
hx-target="#billing-infos-fragment" hx-target="#billing-infos-fragment"
x-show="collapsed" x-show="collapsed"
x-cloak
> >
{% csrf_token %} {% csrf_token %}
{{ form.as_p() }} {{ form.as_p() }}
@@ -15,11 +15,10 @@
{% block content %} {% block content %}
<h3>{% trans %}Eboutic{% endtrans %}</h3> <h3>{% trans %}Eboutic{% endtrans %}</h3>
<script type="text/javascript"> <div x-data='etransaction(
let billingInfos = {{ billing_infos|safe }}; {{ billing_infos|tojson }},
</script> { id: {{ basket.id }}, timeout: new Date('{{ basket.date + settings.SITH_EBOUTIC_BASKET_TIMEOUT }}') }
)'>
<div x-data="etransaction(billingInfos, {{ basket.id }})">
<p>{% trans %}Basket: {% endtrans %}</p> <p>{% trans %}Basket: {% endtrans %}</p>
<table> <table>
<thead> <thead>
@@ -72,7 +71,11 @@
x-cloak x-cloak
type="submit" type="submit"
id="bank-submit-button" id="bank-submit-button"
{% if basket.is_expired %}
disabled="disabled"
{% else %}
:disabled="!isCbAvailable" :disabled="!isCbAvailable"
{% endif %}
class="btn btn-blue" class="btn btn-blue"
value="{% trans %}Pay with credit card{% endtrans %}" value="{% trans %}Pay with credit card{% endtrans %}"
/> />
@@ -93,7 +96,16 @@
{% else %} {% else %}
<form method="post" action="{{ url('eboutic:pay_with_sith', basket_id=basket.id) }}" name="sith-pay-form"> <form method="post" action="{{ url('eboutic:pay_with_sith', basket_id=basket.id) }}" name="sith-pay-form">
{% csrf_token %} {% csrf_token %}
<input class="btn btn-blue" type="submit" value="{% trans %}Pay with Sith account{% endtrans %}"/> <input
{% if basket.is_expired %}
disabled="disabled"
{% else %}
:disabled="!isSithAvailable"
{% endif %}
class="btn btn-blue"
type="submit"
value="{% trans %}Pay with Sith account{% endtrans %}"
/>
</form> </form>
{% endif %} {% endif %}
</div> </div>
+12 -2
View File
@@ -30,7 +30,13 @@
{% block content %} {% block content %}
<h1 id="eboutic-title">{% trans %}Eboutic{% endtrans %}</h1> <h1 id="eboutic-title">{% trans %}Eboutic{% endtrans %}</h1>
<div id="eboutic" x-data="basket({{ last_purchase_time }})"> <div
id="eboutic"
x-data="basket(
[{% for prices in categories %}{% for p in prices %}{{ p.id }},{% endfor %}{% endfor %}],
{{ last_purchase_time }},
)"
>
<div id="basket"> <div id="basket">
<h3>Panier</h3> <h3>Panier</h3>
<form method="post" action=""> <form method="post" action="">
@@ -187,9 +193,10 @@
{% for price in prices %} {% for price in prices %}
<button <button
id="{{ price.id }}" id="{{ price.id }}"
class="card product-button 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 }})'
{% if price.sold_out %}disabled{% endif %}
> >
{% if price.product.icon %} {% if price.product.icon %}
<img <img
@@ -202,6 +209,9 @@
{% endif %} {% endif %}
<div class="card-content"> <div class="card-content">
<h4 class="card-title">{{ price.full_label }}</h4> <h4 class="card-title">{{ price.full_label }}</h4>
{% if price.sold_out -%}
<p><em>{% trans %}Product sold out{% endtrans %}</em></p>
{%- endif %}
<p>{{ price.amount }} €</p> <p>{{ price.amount }} €</p>
</div> </div>
</button> </button>
+22 -6
View File
@@ -3,6 +3,7 @@ import urllib
from decimal import Decimal from decimal import Decimal
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import freezegun
from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15 from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
from cryptography.hazmat.primitives.hashes import SHA1 from cryptography.hazmat.primitives.hashes import SHA1
@@ -105,7 +106,7 @@ class TestPaymentSith(TestPaymentBase):
), ),
reverse("eboutic:payment_result", kwargs={"result": "success"}), reverse("eboutic:payment_result", kwargs={"result": "success"}),
) )
assert Basket.objects.filter(id=self.basket.id).first() is None assert not Basket.objects.filter(id=self.basket.id).exists()
self.customer.customer.refresh_from_db() self.customer.customer.refresh_from_db()
assert self.customer.customer.amount == Decimal(1) assert self.customer.customer.amount == Decimal(1)
@@ -139,10 +140,7 @@ class TestPaymentSith(TestPaymentBase):
assert len(messages) == 1 assert len(messages) == 1
assert messages[0].level == DEFAULT_LEVELS["ERROR"] assert messages[0].level == DEFAULT_LEVELS["ERROR"]
assert messages[0].message == "Solde insuffisant" assert messages[0].message == "Solde insuffisant"
assert not Basket.objects.filter(id=self.basket.id).exists()
assert Basket.objects.contains(self.basket), (
"After an unsuccessful request, the basket should be kept"
)
def test_refilling_in_basket(self): def test_refilling_in_basket(self):
BasketItem.from_price(self.refilling.prices.first(), 1, self.basket).save() BasketItem.from_price(self.refilling.prices.first(), 1, self.basket).save()
@@ -157,7 +155,7 @@ class TestPaymentSith(TestPaymentBase):
response, response,
reverse("eboutic:payment_result", kwargs={"result": "failure"}), reverse("eboutic:payment_result", kwargs={"result": "failure"}),
) )
assert Basket.objects.filter(id=self.basket.id).first() is not None assert not Basket.objects.filter(id=self.basket.id).exists()
messages = list(get_messages(response.wsgi_request)) messages = list(get_messages(response.wsgi_request))
assert messages[0].level == DEFAULT_LEVELS["ERROR"] assert messages[0].level == DEFAULT_LEVELS["ERROR"]
assert ( assert (
@@ -167,6 +165,24 @@ class TestPaymentSith(TestPaymentBase):
self.customer.customer.refresh_from_db() self.customer.customer.refresh_from_db()
assert self.customer.customer.amount == initial_account_balance assert self.customer.customer.amount == initial_account_balance
def test_basket_expired(self):
self.client.force_login(self.customer)
initial_account_balance = self.customer.customer.amount
with freezegun.freeze_time(settings.SITH_EBOUTIC_BASKET_TIMEOUT):
response = self.client.post(
reverse("eboutic:pay_with_sith", kwargs={"basket_id": self.basket.id})
)
assertRedirects(
response,
reverse("eboutic:payment_result", kwargs={"result": "failure"}),
)
messages = list(get_messages(response.wsgi_request))
assert messages[0].level == DEFAULT_LEVELS["ERROR"]
assert messages[0].message == "Panier expiré"
assert not Basket.objects.filter(id=self.basket.id).exists()
self.customer.customer.refresh_from_db()
assert self.customer.customer.amount == initial_account_balance
class TestPaymentCard(TestPaymentBase): class TestPaymentCard(TestPaymentBase):
def generate_bank_valid_answer(self, basket: Basket): def generate_bank_valid_answer(self, basket: Basket):
+30 -8
View File
@@ -33,12 +33,14 @@ from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.messages.views import SuccessMessageMixin from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import SuspiciousOperation, ValidationError from django.core.exceptions import SuspiciousOperation, ValidationError
from django.db import DatabaseError, transaction from django.db import DatabaseError, transaction
from django.db.models import Subquery from django.db.models import Exists, OuterRef, Subquery
from django.db.models.fields import forms from django.db.models.fields import forms
from django.db.utils import cached_property from django.db.utils import cached_property
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.urls import reverse from django.urls import reverse
from django.utils.formats import localize
from django.utils.timezone import localtime
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.decorators.http import require_GET from django.views.decorators.http import require_GET
from django.views.generic import DetailView, FormView, TemplateView, UpdateView, View from django.views.generic import DetailView, FormView, TemplateView, UpdateView, View
@@ -90,7 +92,9 @@ class EbouticMainView(LoginRequiredMixin, FormView):
kwargs["form_kwargs"] = { kwargs["form_kwargs"] = {
"customer": self.customer, "customer": self.customer,
"counter": get_eboutic(), "counter": get_eboutic(),
"allowed_prices": {price.id: price for price in self.prices}, "allowed_prices": {
price.id: price for price in self.prices if not price.sold_out
},
} }
return kwargs return kwargs
@@ -116,9 +120,14 @@ class EbouticMainView(LoginRequiredMixin, FormView):
@cached_property @cached_property
def prices(self) -> list[Price]: def prices(self) -> list[Price]:
return get_eboutic().get_prices_for( eboutic = get_eboutic()
self.customer, sold_out_subquery = ~Exists(
order_by=["product__product_type__order", "product_id", "amount"], eboutic.products.under_clic_limit().filter(id=OuterRef("product_id"))
)
return list(
eboutic.get_prices_for(self.customer)
.annotate(sold_out=sold_out_subquery)
.order_by("product__product_type__order", "product_id", "amount")
) )
@cached_property @cached_property
@@ -187,9 +196,7 @@ class BillingInfoFormFragment(
def get_initial(self): def get_initial(self):
if self.object is None: if self.object is None:
return { return {"country": Country(code="FR")}
"country": Country(code="FR"),
}
return {} return {}
def render_fragment(self, request, **kwargs) -> SafeString: def render_fragment(self, request, **kwargs) -> SafeString:
@@ -255,6 +262,15 @@ class EbouticCheckout(CanViewMixin, UseFragmentsMixin, DetailView):
kwargs["customer_amount"] = None kwargs["customer_amount"] = None
kwargs["billing_infos"] = {} kwargs["billing_infos"] = {}
if self.object.is_expired:
messages.error(self.request, _("Basket expired"))
else:
timeout = self.object.date + settings.SITH_EBOUTIC_BASKET_TIMEOUT
messages.warning(
self.request,
_("Basket available until %(until)s")
% {"until": localize(localtime(timeout).time())},
)
with contextlib.suppress(BillingInfo.DoesNotExist): with contextlib.suppress(BillingInfo.DoesNotExist):
kwargs["billing_infos"] = json.dumps( kwargs["billing_infos"] = json.dumps(
dict(self.object.get_e_transaction_data()) dict(self.object.get_e_transaction_data())
@@ -268,9 +284,14 @@ class EbouticPayWithSith(CanViewMixin, SingleObjectMixin, View):
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
basket = self.get_object() basket = self.get_object()
if basket.is_expired:
messages.error(self.request, _("Basket expired"))
basket.delete()
return redirect("eboutic:payment_result", "failure")
refilling = settings.SITH_COUNTER_PRODUCTTYPE_REFILLING refilling = settings.SITH_COUNTER_PRODUCTTYPE_REFILLING
if basket.items.filter(product__product_type_id=refilling).exists(): if basket.items.filter(product__product_type_id=refilling).exists():
messages.error(self.request, _("You can't buy a refilling with sith money")) messages.error(self.request, _("You can't buy a refilling with sith money"))
basket.delete()
return redirect("eboutic:payment_result", "failure") return redirect("eboutic:payment_result", "failure")
eboutic = get_eboutic() eboutic = get_eboutic()
@@ -288,6 +309,7 @@ class EbouticPayWithSith(CanViewMixin, SingleObjectMixin, View):
except DatabaseError as e: except DatabaseError as e:
sentry_sdk.capture_exception(e) sentry_sdk.capture_exception(e)
except ValidationError as e: except ValidationError as e:
basket.delete()
messages.error(self.request, e.message) messages.error(self.request, e.message)
return redirect("eboutic:payment_result", "failure") return redirect("eboutic:payment_result", "failure")
+10 -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-05-12 11:12+0200\n" "POT-Creation-Date: 2026-05-15 11:46+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"
@@ -4505,6 +4505,15 @@ msgstr ""
"souhaitez payer par carte, vous devez rajouter un numéro de téléphone aux " "souhaitez payer par carte, vous devez rajouter un numéro de téléphone aux "
"données que vous aviez déjà fourni." "données que vous aviez déjà fourni."
#: eboutic/views.py
msgid "Basket expired"
msgstr "Panier expiré"
#: eboutic/views.py
#, python-format
msgid "Basket available until %(until)s"
msgstr "Panier disponible jusqu'à %(until)s"
#: eboutic/views.py #: eboutic/views.py
msgid "You can't buy a refilling with sith money" msgid "You can't buy a refilling with sith money"
msgstr "Vous ne pouvez pas acheter un rechargement avec de l'argent du sith" msgstr "Vous ne pouvez pas acheter un rechargement avec de l'argent du sith"
+5 -1
View File
@@ -7,7 +7,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-17 22:42+0200\n" "POT-Creation-Date: 2026-05-17 10:03+0200\n"
"PO-Revision-Date: 2024-09-17 11:54+0200\n" "PO-Revision-Date: 2024-09-17 11:54+0200\n"
"Last-Translator: Sli <antoine@bartuccio.fr>\n" "Last-Translator: Sli <antoine@bartuccio.fr>\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -263,6 +263,10 @@ msgstr "Types de produits réordonnés !"
msgid "Product type reorganisation failed with status code : %d" msgid "Product type reorganisation failed with status code : %d"
msgstr "La réorganisation des types de produit a échoué avec le code : %d" msgstr "La réorganisation des types de produit a échoué avec le code : %d"
#: eboutic/static/bundled/eboutic/checkout-index.ts
msgid "Basket expired"
msgstr "Panier expiré"
#: sas/static/bundled/sas/pictures-download-index.ts #: sas/static/bundled/sas/pictures-download-index.ts
msgid "pictures.%(extension)s" msgid "pictures.%(extension)s"
msgstr "photos.%(extension)s" msgstr "photos.%(extension)s"
+5
View File
@@ -571,6 +571,11 @@ SITH_BARMAN_TIMEOUT = 30
# Minutes to delete the last operations # Minutes to delete the last operations
SITH_LAST_OPERATIONS_LIMIT = 10 SITH_LAST_OPERATIONS_LIMIT = 10
# time before a basket is considered expired
SITH_EBOUTIC_BASKET_TIMEOUT = timedelta(minutes=10)
# time that a user can spend on the CB payment page before it to timeout
SITH_EBOUTIC_ETRANSACTION_TIMEOUT = timedelta(minutes=10)
# ET variables # ET variables
SITH_EBOUTIC_CB_ENABLED = env.bool("SITH_EBOUTIC_CB_ENABLED", default=True) SITH_EBOUTIC_CB_ENABLED = env.bool("SITH_EBOUTIC_CB_ENABLED", default=True)
SITH_EBOUTIC_ET_URL = env.str( SITH_EBOUTIC_ET_URL = env.str(