9 Commits

Author SHA1 Message Date
imperosol d7f04f40d8 add translations 2026-05-23 15:02:24 +02:00
imperosol cac8518a32 add doc 2026-05-23 15:02:24 +02:00
imperosol db945d3498 test sold out items in eboutic 2026-05-23 15:02:24 +02:00
imperosol 140d1ec81f clean invalid items from eboutic baskets 2026-05-23 14:55:09 +02:00
imperosol 839e1883f9 exclude products over clic limit from eboutic 2026-05-23 14:55:09 +02:00
imperosol 9211464712 add clic limit to product form 2026-05-23 14:55:09 +02:00
imperosol c64032ce26 add field Product.clic_limit 2026-05-23 14:55:09 +02:00
imperosol 32b9b6e23b 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-23 14:55:09 +02:00
imperosol 503ea75a1d feat: basket timeout 2026-05-23 14:55:09 +02:00
24 changed files with 461 additions and 90 deletions
+6 -1
View File
@@ -29,7 +29,12 @@
align-items: center;
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%);
}
+1 -1
View File
@@ -23,7 +23,7 @@
border-radius: 5px;
color: black;
&:hover {
&:not(.link-like):not(:disabled):hover {
background: hsl(0, 0%, 83%);
}
}
+1 -1
View File
@@ -10,7 +10,7 @@
<template x-for="(message, index) in $notifications.getAll()">
<div class="alert" :class="`alert-${message.tag}`" x-transition>
<span class="alert-main" x-text="message.text"></span>
<span class="clickable" @click="messages = messages.filter((item, i) => i !== index)">
<span class="clickable" @click="$store.notifications = $store.notifications.filter((item, i) => i !== index)">
<i class="fa fa-close"></i>
</span>
</div>
+1
View File
@@ -409,6 +409,7 @@ class ProductForm(forms.ModelForm):
"club",
"limit_age",
"tray",
"clic_limit",
"archived",
]
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",
),
),
]
+49 -15
View File
@@ -22,7 +22,7 @@ import string
from datetime import date, datetime, timedelta
from datetime import timezone as tz
from decimal import Decimal
from typing import TYPE_CHECKING, Literal, Self
from typing import Literal, Self
from dict2xml import dict2xml
from django.conf import settings
@@ -34,6 +34,7 @@ from django.forms import ValidationError
from django.urls import reverse
from django.utils import timezone
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 PeriodicTask
from django_countries.fields import CountryField
@@ -47,9 +48,6 @@ from core.utils import get_start_of_semester
from counter.fields import CurrencyField
from subscription.models import Subscription
if TYPE_CHECKING:
from collections.abc import Sequence
def get_eboutic() -> Counter:
return Counter.objects.filter(type="EBOUTIC").order_by("id").first()
@@ -353,6 +351,40 @@ class ProductType(OrderedModel):
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
- settings.SITH_EBOUTIC_ETRANSACTION_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):
"""A product, with all its related information."""
@@ -370,8 +402,7 @@ class Product(models.Model):
)
code = models.CharField(_("code"), max_length=16, blank=True)
purchase_price = CurrencyField(
_("purchase price"),
help_text=_("Initial cost of purchasing the product"),
_("purchase price"), help_text=_("Initial cost of purchasing the product")
)
icon = ResizedImageField(
height=70,
@@ -388,13 +419,21 @@ class Product(models.Model):
tray = models.BooleanField(
_("tray price"), help_text=_("Buy five, get the sixth free"), default=False
)
buying_groups = models.ManyToManyField(
Group, related_name="products", verbose_name=_("buying groups"), blank=True
clic_limit = models.PositiveSmallIntegerField(
_("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)
created_at = models.DateTimeField(_("created at"), auto_now_add=True)
updated_at = models.DateTimeField(_("updated at"), auto_now=True)
objects = ProductQuerySet.as_manager()
class Meta:
verbose_name = _("product")
@@ -733,10 +772,8 @@ class Counter(models.Model):
# but they share the same primary key
return self.type == "BAR" and any(b.pk == customer.pk for b in self.barmen_list)
def get_prices_for(
self, customer: Customer, *, order_by: Sequence[str] | None = None
) -> list[Price]:
qs = (
def get_prices_for(self, customer: Customer) -> PriceQuerySet:
return (
Price.objects.filter(
product__counters=self, product__product_type__isnull=False
)
@@ -744,9 +781,6 @@ class Counter(models.Model):
.select_related("product", "product__product_type")
.prefetch_related("groups")
)
if order_by:
qs = qs.order_by(*order_by)
return list(qs)
class CounterSellers(models.Model):
@@ -118,6 +118,7 @@
</div>
</div>
</fieldset>
<fieldset><div>{{ form.clic_limit.as_field_group() }}</div></fieldset>
<fieldset><div>{{ form.counters.as_field_group() }}</div></fieldset>
<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)),
groups=[group],
)
customer_prices = counter.get_prices_for(customer)
customer_prices = list(counter.get_prices_for(customer))
assert unarchived_prices == customer_prices
+62 -2
View File
@@ -1,3 +1,5 @@
import itertools
from datetime import timedelta
from io import BytesIO
from typing import Callable
from uuid import uuid4
@@ -8,6 +10,7 @@ from django.core.cache import cache
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import Client, TestCase
from django.urls import reverse
from django.utils.timezone import now
from model_bakery import baker
from model_bakery.recipe import Recipe
from PIL import Image
@@ -16,9 +19,10 @@ from pytest_django.asserts import assertNumQueries, assertRedirects
from club.models import Club
from core.baker_recipes import board_user, subscriber_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.models import Price, Product, ProductType
from counter.models import Price, Product, ProductType, Selling
from eboutic.models import Basket, BasketItem
@pytest.mark.django_db
@@ -222,3 +226,59 @@ def test_price_for_user():
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[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
- settings.SITH_EBOUTIC_ETRANSACTION_TIMEOUT,
)
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
self.prices = obj.get_prices_for(self.customer)
self.prices = list(obj.get_prices_for(self.customer))
return super().dispatch(request, *args, **kwargs)
+31
View File
@@ -1,4 +1,6 @@
## Fonctionnement général
La boutique en ligne nécessite une interaction
avec la banque pour son fonctionnement.
@@ -9,3 +11,32 @@ Nous ne pouvons donc que vous redirigez vers la doc du crédit
agricole :
[https://www.ca-moncommerce.com/espace-client-mon-commerce/up2pay-e-transactions/ma-documentation/](https://www.ca-moncommerce.com/espace-client-mon-commerce/up2pay-e-transactions/ma-documentation/)
## Limite de clic et expiration des paniers
Certains produits peuvent avoir un quota de vente.
Une fois ce dernier atteint, il ne doit plus être possible de les acheter.
Pour éviter que cette limite soit dépassée si jamais plusieurs utilisateurs
commandent et achètent ce produit à peu près en même temps,
un produit est considéré comme « réservé » une fois placé dans un panier.
La création du panier s'effectue lors de la soumission du formulaire sur l'eboutic.
Une fois la transaction accomplie, le panier est supprimé.
Cependant, il reste un problème :
que faire des utilisateurs qui créent un panier, mais ne terminent
pas la transaction ?
Pour résoudre ce cas, les paniers ont une durée de validité,
définie dans le `settings.py`, grâce à deux variables :
- `settings.SITH_EBOUTIC_BASKET_TIMEOUT` :
le temps pendant lequel un utilisateur peut payer avec son compte AE
ou démarrer une etransaction
- `settings.SITH_EBOUTIC_ETRANSACTION_TIMEOUT` :
le temps alloué à l'utilisateur pour effectuer une etransaction ;
au-delà de cette durée, la banque refusera le paiement
et notifiera le sith de l'erreur.
Une fois expiré le temps défini par
`settings.SITH_EBOUTIC_BASKET_TIMEOUT + settings.SITH_EBOUTIC_ETRANSACTION_TIMEOUT`,
les produits contenus dans le panier sont à nouveau
disponibles à la vente.
+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.exceptions import NotFound
@@ -8,13 +11,19 @@ from eboutic.models import Basket
@api_controller("/etransaction", permissions=[CanView])
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):
"""Generate the data to pay an eboutic command with paybox.
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)
if basket.is_expired:
return Status(410, "This basket is expired.")
try:
return dict(basket.get_e_transaction_data())
except BillingInfo.DoesNotExist as e:
+29
View File
@@ -24,6 +24,7 @@ from django.conf import settings
from django.db import DataError, models
from django.db.models import F, OuterRef, Subquery, Sum
from django.utils.functional import cached_property
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from core.models import User
@@ -95,6 +96,19 @@ class Basket(models.Model):
]
)
@property
def is_expired(self) -> bool:
"""Return True if this basket is expired.
An expired basket can no longer be used tp pay with sith account
or to start an etransaction.
Warnings:
Users have an additional time if they pay with an etransaction,
so an expired basket may be purchased after its expiration in that case.
"""
return (self.date + settings.SITH_EBOUTIC_BASKET_TIMEOUT) <= now()
def generate_sales(
self, counter, seller: User, payment_method: Selling.PaymentMethod
):
@@ -133,9 +147,20 @@ class Basket(models.Model):
]
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
if not hasattr(user, "customer"):
raise Customer.DoesNotExist
if self.is_expired:
raise ValueError("This method cannot be called on an expired basket.")
customer = user.customer
if (
not hasattr(user.customer, "billing_infos")
@@ -155,6 +180,10 @@ class Basket(models.Model):
("PBX_IDENTIFIANT", settings.SITH_EBOUTIC_PBX_IDENTIFIANT),
("PBX_TOTAL", str(int(self.total * 100))),
("PBX_DEVISE", "978"), # This is Euro
(
"PBX_DISPLAY",
str(int(settings.SITH_EBOUTIC_ETRANSACTION_TIMEOUT.total_seconds())),
),
("PBX_CMD", str(self.id)),
("PBX_PORTEUR", user.email),
("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";
interface Basket {
id: number;
timeout: Date;
}
document.addEventListener("alpine:init", () => {
Alpine.data("etransaction", (initialData, basketId: number) => ({
Alpine.data("etransaction", (initialData, basket: Basket) => ({
data: initialData,
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() {
if (new Date() > basket.timeout) {
// refresh etransaction data only if the basket is still valid.
this.timeoutBasket();
return;
}
this.isCbAvailable = false;
const res = await etransactioninfoFetchEtransactionData({
path: {
// biome-ignore lint/style/useNamingConvention: api is in snake_case
basket_id: basketId,
},
// biome-ignore lint/style/useNamingConvention: api is in snake_case
path: { basket_id: basket.id },
});
if (res.response.ok) {
this.data = res.data;
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;
document.addEventListener("alpine:init", () => {
Alpine.data("basket", (lastPurchaseTime?: number) => ({
Alpine.data("basket", (validPrices: number[], lastPurchaseTime?: number) => ({
basket: [] as BasketItem[],
init() {
@@ -19,15 +19,6 @@ document.addEventListener("alpine:init", () => {
this.$watch("basket", () => {
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
.getElementById("id_form-TOTAL_FORMS")
.setAttribute(":value", "basket.length");
@@ -37,7 +28,22 @@ document.addEventListener("alpine:init", () => {
const cached = versionedLocalStorage.getItem<BasketItem[]>(BASKET_CACHE_KEY, {
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() {
@@ -21,6 +21,7 @@
hx-swap="outerHTML"
hx-target="#billing-infos-fragment"
x-show="collapsed"
x-cloak
>
{% csrf_token %}
{{ form.as_p() }}
@@ -15,11 +15,10 @@
{% block content %}
<h3>{% trans %}Eboutic{% endtrans %}</h3>
<script type="text/javascript">
let billingInfos = {{ billing_infos|safe }};
</script>
<div x-data="etransaction(billingInfos, {{ basket.id }})">
<div x-data='etransaction(
{{ billing_infos|tojson }},
{ id: {{ basket.id }}, timeout: new Date("{{ basket.date + settings.SITH_EBOUTIC_BASKET_TIMEOUT }}") }
)'>
<p>{% trans %}Basket: {% endtrans %}</p>
<table>
<thead>
@@ -72,7 +71,11 @@
x-cloak
type="submit"
id="bank-submit-button"
:disabled="!isCbAvailable"
{% if basket.is_expired %}
disabled="disabled"
{% else %}
:disabled="!isCbAvailable"
{% endif %}
class="btn btn-blue"
value="{% trans %}Pay with credit card{% endtrans %}"
/>
@@ -93,7 +96,16 @@
{% else %}
<form method="post" action="{{ url('eboutic:pay_with_sith', basket_id=basket.id) }}" name="sith-pay-form">
{% 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>
{% endif %}
</div>
+16 -2
View File
@@ -30,7 +30,17 @@
{% block content %}
<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 -%}
{% if not p.sold_out %}{{ p.id }},{% endif %}
{%- endfor -%}
{%- endfor -%}],
{{ last_purchase_time }},
)"
>
<div id="basket">
<h3>Panier</h3>
<form method="post" action="">
@@ -187,9 +197,10 @@
{% for price in prices %}
<button
id="{{ price.id }}"
class="card product-button clickable shadow"
class="card clickable shadow"
:class="{selected: basket.some((i) => i.priceId === {{ price.id }})}"
@click='addFromCatalog({{ price.id }}, {{ price.full_label|tojson }}, {{ price.amount }})'
{% if price.sold_out %}disabled{% endif %}
>
{% if price.product.icon %}
<img
@@ -202,6 +213,9 @@
{% endif %}
<div class="card-content">
<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>
</div>
</button>
+47 -21
View File
@@ -1,14 +1,19 @@
import re
from datetime import datetime, timezone
import freezegun
import pytest
from bs4 import BeautifulSoup
from django.conf import settings
from django.http import HttpResponse
from django.test import TestCase
from django.test.client import Client
from django.urls import reverse
from django.utils.timezone import localdate
from django.utils.timezone import localdate, now
from model_bakery import baker
from pytest_django.asserts import assertRedirects
import eboutic.models
from core.baker_recipes import subscriber_user
from core.models import Group, User
from counter.baker_recipes import (
@@ -130,9 +135,11 @@ def test_eboutic_basket_expiry(
_bulk_create=True,
)
soup = BeautifulSoup(client.get(reverse("eboutic:main")).text, "lxml")
assert (
f'x-data="basket({int(expected.timestamp() * 1000) if expected else "null"})"'
in client.get(reverse("eboutic:main")).text
# remove any space from the value before asserting
re.sub(r"\s+", "", soup.find(id="eboutic").attrs["x-data"])
== f"basket([],{int(expected.timestamp() * 1000) if expected else 'null'},)"
)
@@ -231,26 +238,45 @@ class TestEboutic(TestCase):
def test_add_forbidden_product(self):
self.client.force_login(self.new_customer)
response = self.submit_basket([BasketItem(self.beer.id, 1)])
assert response.status_code == 200
assert Basket.objects.first() is None
for product in self.beer, self.cotiz, self.not_in_counter:
response = self.submit_basket([BasketItem(product.id, 1)])
assert response.status_code == 200
assert not Basket.objects.exists()
response = self.submit_basket([BasketItem(self.cotiz.id, 1)])
def test_sold_out_product(self):
sold_out = product_recipe.make(
clic_limit=3, counters=[self.eboutic], product_type=baker.make(ProductType)
)
price = price_recipe.make(product=sold_out, groups=[self.group_cotiz], amount=0)
sale_recipe.make(
product=sold_out,
customer=self.subscriber.customer,
unit_price=0,
quantity=1,
)
baker.make(
eboutic.models.BasketItem,
basket=baker.make(Basket),
product=sold_out,
quantity=2,
)
self.client.force_login(self.subscriber)
response = self.submit_basket([BasketItem(price.id, 1)])
assert response.status_code == 200
assert Basket.objects.first() is None
response = self.submit_basket([BasketItem(self.not_in_counter.id, 1)])
assert response.status_code == 200
assert Basket.objects.first() is None
self.client.force_login(self.new_customer)
response = self.submit_basket([BasketItem(self.cotiz.id, 1)])
assert response.status_code == 200
assert Basket.objects.first() is None
response = self.submit_basket([BasketItem(self.not_in_counter.id, 1)])
assert response.status_code == 200
assert Basket.objects.first() is None
assert Basket.objects.count() == 1
with freezegun.freeze_time(
now()
+ settings.SITH_EBOUTIC_BASKET_TIMEOUT
+ settings.SITH_EBOUTIC_ETRANSACTION_TIMEOUT
):
# after a while, unpaid basket items should expire and make the
# product available again.
response = self.submit_basket([BasketItem(price.id, 1)])
assertRedirects(
response,
reverse("eboutic:checkout", kwargs={"basket_id": Basket.objects.last().id}),
)
assert Basket.objects.count() == 2
def test_create_basket(self):
self.client.force_login(self.new_customer)
+22 -6
View File
@@ -3,6 +3,7 @@ import urllib
from decimal import Decimal
from typing import TYPE_CHECKING
import freezegun
from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
from cryptography.hazmat.primitives.hashes import SHA1
@@ -105,7 +106,7 @@ class TestPaymentSith(TestPaymentBase):
),
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()
assert self.customer.customer.amount == Decimal(1)
@@ -139,10 +140,7 @@ class TestPaymentSith(TestPaymentBase):
assert len(messages) == 1
assert messages[0].level == DEFAULT_LEVELS["ERROR"]
assert messages[0].message == "Solde insuffisant"
assert Basket.objects.contains(self.basket), (
"After an unsuccessful request, the basket should be kept"
)
assert not Basket.objects.filter(id=self.basket.id).exists()
def test_refilling_in_basket(self):
BasketItem.from_price(self.refilling.prices.first(), 1, self.basket).save()
@@ -157,7 +155,7 @@ class TestPaymentSith(TestPaymentBase):
response,
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))
assert messages[0].level == DEFAULT_LEVELS["ERROR"]
assert (
@@ -167,6 +165,24 @@ class TestPaymentSith(TestPaymentBase):
self.customer.customer.refresh_from_db()
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):
def generate_bank_valid_answer(self, basket: Basket):
+33 -11
View File
@@ -33,12 +33,14 @@ from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import SuspiciousOperation, ValidationError
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.utils import cached_property
from django.http import HttpResponse
from django.shortcuts import redirect, render
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.views.decorators.http import require_GET
from django.views.generic import DetailView, FormView, TemplateView, UpdateView, View
@@ -90,7 +92,9 @@ class EbouticMainView(LoginRequiredMixin, FormView):
kwargs["form_kwargs"] = {
"customer": self.customer,
"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
@@ -116,9 +120,14 @@ class EbouticMainView(LoginRequiredMixin, FormView):
@cached_property
def prices(self) -> list[Price]:
return get_eboutic().get_prices_for(
self.customer,
order_by=["product__product_type__order", "product_id", "amount"],
eboutic = get_eboutic()
sold_out_subquery = ~Exists(
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
@@ -187,9 +196,7 @@ class BillingInfoFormFragment(
def get_initial(self):
if self.object is None:
return {
"country": Country(code="FR"),
}
return {"country": Country(code="FR")}
return {}
def render_fragment(self, request, **kwargs) -> SafeString:
@@ -255,10 +262,19 @@ class EbouticCheckout(CanViewMixin, UseFragmentsMixin, DetailView):
kwargs["customer_amount"] = None
kwargs["billing_infos"] = {}
with contextlib.suppress(BillingInfo.DoesNotExist):
kwargs["billing_infos"] = json.dumps(
dict(self.object.get_e_transaction_data())
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):
kwargs["billing_infos"] = json.dumps(
dict(self.object.get_e_transaction_data())
)
return kwargs
@@ -268,9 +284,14 @@ class EbouticPayWithSith(CanViewMixin, SingleObjectMixin, View):
def post(self, request, *args, **kwargs):
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
if basket.items.filter(product__product_type_id=refilling).exists():
messages.error(self.request, _("You can't buy a refilling with sith money"))
basket.delete()
return redirect("eboutic:payment_result", "failure")
eboutic = get_eboutic()
@@ -288,6 +309,7 @@ class EbouticPayWithSith(CanViewMixin, SingleObjectMixin, View):
except DatabaseError as e:
sentry_sdk.capture_exception(e)
except ValidationError as e:
basket.delete()
messages.error(self.request, e.message)
return redirect("eboutic:payment_result", "failure")
+24 -3
View File
@@ -6,7 +6,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-05-12 11:12+0200\n"
"POT-Creation-Date: 2026-05-23 12:15+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"
@@ -3408,8 +3408,16 @@ msgid "Buy five, get the sixth free"
msgstr "Pour cinq achetés, le sixième offert"
#: counter/models.py
msgid "buying groups"
msgstr "groupe d'achat"
msgid "clic limit"
msgstr "limite de clic"
#: counter/models.py
msgid ""
"If a limit is set, the product won't be purchasable anymore on the eboutic "
"once the latter is reached."
msgstr ""
"Si une limite est donnée, le produit ne sera plus achetable sur l'eboutic "
"une fois celle-ci atteinte."
#: counter/models.py election/models.py
msgid "archived"
@@ -4462,6 +4470,10 @@ msgstr ""
"billets du vendredi, du samedi et du dimanche, ainsi qu'au forfait 3 jours, "
"du vendredi au dimanche."
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid "Product sold out"
msgstr "Produit épuisé"
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid "There are no items available for sale"
msgstr "Aucun article n'est disponible à la vente"
@@ -4505,6 +4517,15 @@ msgstr ""
"souhaitez payer par carte, vous devez rajouter un numéro de téléphone aux "
"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
msgid "You can't buy a refilling with sith money"
msgstr "Vous ne pouvez pas acheter un rechargement avec de l'argent du sith"
+5 -1
View File
@@ -7,7 +7,7 @@
msgid ""
msgstr ""
"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"
"Last-Translator: Sli <antoine@bartuccio.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"
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
msgid "pictures.%(extension)s"
msgstr "photos.%(extension)s"
+5
View File
@@ -571,6 +571,11 @@ SITH_BARMAN_TIMEOUT = 30
# Minutes to delete the last operations
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
SITH_EBOUTIC_CB_ENABLED = env.bool("SITH_EBOUTIC_CB_ENABLED", default=True)
SITH_EBOUTIC_ET_URL = env.str(