6 Commits

Author SHA1 Message Date
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
21 changed files with 66 additions and 351 deletions
+1 -6
View File
@@ -29,12 +29,7 @@
align-items: center;
gap: 20px;
&:disabled {
background-color: darken($primary-neutral-light-color, 5%);
opacity: 65%;
}
&.clickable:not(:disabled):hover {
&.clickable:hover {
background-color: darken($primary-neutral-light-color, 5%);
}
+1 -1
View File
@@ -23,7 +23,7 @@
border-radius: 5px;
color: black;
&:not(.link-like):not(:disabled):hover {
&:hover {
background: hsl(0, 0%, 83%);
}
}
-1
View File
@@ -409,7 +409,6 @@ class ProductForm(forms.ModelForm):
"club",
"limit_age",
"tray",
"clic_limit",
"archived",
]
help_texts = {
@@ -1,24 +0,0 @@
# 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",
),
),
]
+15 -47
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 Literal, Self
from typing import TYPE_CHECKING, Literal, Self
from dict2xml import dict2xml
from django.conf import settings
@@ -34,7 +34,6 @@ 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
@@ -48,6 +47,9 @@ 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()
@@ -351,38 +353,6 @@ 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,
)
.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."""
@@ -400,7 +370,8 @@ 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,
@@ -417,21 +388,13 @@ class Product(models.Model):
tray = models.BooleanField(
_("tray price"), help_text=_("Buy five, get the sixth free"), default=False
)
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,
buying_groups = models.ManyToManyField(
Group, related_name="products", verbose_name=_("buying groups"), 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")
@@ -770,8 +733,10 @@ 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) -> PriceQuerySet:
return (
def get_prices_for(
self, customer: Customer, *, order_by: Sequence[str] | None = None
) -> list[Price]:
qs = (
Price.objects.filter(
product__counters=self, product__product_type__isnull=False
)
@@ -779,6 +744,9 @@ 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,7 +118,6 @@
</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 = list(counter.get_prices_for(customer))
customer_prices = counter.get_prices_for(customer)
assert unarchived_prices == customer_prices
+2 -60
View File
@@ -1,5 +1,3 @@
import itertools
from datetime import timedelta
from io import BytesIO
from typing import Callable
from uuid import uuid4
@@ -10,7 +8,6 @@ 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
@@ -19,10 +16,9 @@ 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, sale_recipe
from counter.baker_recipes import product_recipe
from counter.forms import ProductForm, ProductPriceFormSet
from counter.models import Price, Product, ProductType, Selling
from eboutic.models import Basket, BasketItem
from counter.models import Price, Product, ProductType
@pytest.mark.django_db
@@ -226,57 +222,3 @@ 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 - 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
self.prices = list(obj.get_prices_for(self.customer))
self.prices = obj.get_prices_for(self.customer)
return super().dispatch(request, *args, **kwargs)
+1 -10
View File
@@ -1,6 +1,3 @@
from typing import Any
from ninja import Status
from ninja_extra import ControllerBase, api_controller, route
from ninja_extra.exceptions import NotFound
@@ -11,19 +8,13 @@ from eboutic.models import Basket
@api_controller("/etransaction", permissions=[CanView])
class EtransactionInfoController(ControllerBase):
@route.get(
"/data/{basket_id}",
url_name="etransaction_data",
response={200: dict[str, Any], 410: str},
)
@route.get("/data/{basket_id}", url_name="etransaction_data")
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:
-20
View File
@@ -24,7 +24,6 @@ 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
@@ -96,10 +95,6 @@ class Basket(models.Model):
]
)
@property
def is_expired(self) -> bool:
return (self.date + settings.SITH_EBOUTIC_BASKET_TIMEOUT) <= now()
def generate_sales(
self, counter, seller: User, payment_method: Selling.PaymentMethod
):
@@ -138,20 +133,9 @@ 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")
@@ -171,10 +155,6 @@ 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,71 +1,21 @@
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, basket: Basket) => ({
Alpine.data("etransaction", (initialData, basketId: number) => ({
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({
// biome-ignore lint/style/useNamingConvention: api is in snake_case
path: { basket_id: basket.id },
path: {
// biome-ignore lint/style/useNamingConvention: api is in snake_case
basket_id: basketId,
},
});
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();
}
},
}));
+11 -17
View File
@@ -11,7 +11,7 @@ const BASKET_CACHE_KEY = "basket";
const BASKET_CACHE_VERSION = 1;
document.addEventListener("alpine:init", () => {
Alpine.data("basket", (validPrices: number[], lastPurchaseTime?: number) => ({
Alpine.data("basket", (lastPurchaseTime?: number) => ({
basket: [] as BasketItem[],
init() {
@@ -19,6 +19,15 @@ 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");
@@ -28,22 +37,7 @@ document.addEventListener("alpine:init", () => {
const cached = versionedLocalStorage.getItem<BasketItem[]>(BASKET_CACHE_KEY, {
version: BASKET_CACHE_VERSION,
});
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));
return cached ?? [];
},
saveBasket() {
@@ -21,7 +21,6 @@
hx-swap="outerHTML"
hx-target="#billing-infos-fragment"
x-show="collapsed"
x-cloak
>
{% csrf_token %}
{{ form.as_p() }}
@@ -15,10 +15,11 @@
{% block content %}
<h3>{% trans %}Eboutic{% endtrans %}</h3>
<div x-data='etransaction(
{{ billing_infos|tojson }},
{ id: {{ basket.id }}, timeout: new Date('{{ basket.date + settings.SITH_EBOUTIC_BASKET_TIMEOUT }}') }
)'>
<script type="text/javascript">
let billingInfos = {{ billing_infos|safe }};
</script>
<div x-data="etransaction(billingInfos, {{ basket.id }})">
<p>{% trans %}Basket: {% endtrans %}</p>
<table>
<thead>
@@ -71,11 +72,7 @@
x-cloak
type="submit"
id="bank-submit-button"
{% if basket.is_expired %}
disabled="disabled"
{% else %}
:disabled="!isCbAvailable"
{% endif %}
:disabled="!isCbAvailable"
class="btn btn-blue"
value="{% trans %}Pay with credit card{% endtrans %}"
/>
@@ -96,16 +93,7 @@
{% else %}
<form method="post" action="{{ url('eboutic:pay_with_sith', basket_id=basket.id) }}" name="sith-pay-form">
{% csrf_token %}
<input
{% if basket.is_expired %}
disabled="disabled"
{% else %}
:disabled="!isSithAvailable"
{% endif %}
class="btn btn-blue"
type="submit"
value="{% trans %}Pay with Sith account{% endtrans %}"
/>
<input class="btn btn-blue" type="submit" value="{% trans %}Pay with Sith account{% endtrans %}"/>
</form>
{% endif %}
</div>
+2 -12
View File
@@ -30,13 +30,7 @@
{% block content %}
<h1 id="eboutic-title">{% trans %}Eboutic{% endtrans %}</h1>
<div
id="eboutic"
x-data="basket(
[{% for prices in categories %}{% for p in prices %}{{ p.id }},{% endfor %}{% endfor %}],
{{ last_purchase_time }},
)"
>
<div id="eboutic" x-data="basket({{ last_purchase_time }})">
<div id="basket">
<h3>Panier</h3>
<form method="post" action="">
@@ -193,10 +187,9 @@
{% for price in prices %}
<button
id="{{ price.id }}"
class="card clickable shadow"
class="card product-button 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
@@ -209,9 +202,6 @@
{% 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>
+6 -22
View File
@@ -3,7 +3,6 @@ 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
@@ -106,7 +105,7 @@ class TestPaymentSith(TestPaymentBase):
),
reverse("eboutic:payment_result", kwargs={"result": "success"}),
)
assert not Basket.objects.filter(id=self.basket.id).exists()
assert Basket.objects.filter(id=self.basket.id).first() is None
self.customer.customer.refresh_from_db()
assert self.customer.customer.amount == Decimal(1)
@@ -140,7 +139,10 @@ class TestPaymentSith(TestPaymentBase):
assert len(messages) == 1
assert messages[0].level == DEFAULT_LEVELS["ERROR"]
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):
BasketItem.from_price(self.refilling.prices.first(), 1, self.basket).save()
@@ -155,7 +157,7 @@ class TestPaymentSith(TestPaymentBase):
response,
reverse("eboutic:payment_result", kwargs={"result": "failure"}),
)
assert not Basket.objects.filter(id=self.basket.id).exists()
assert Basket.objects.filter(id=self.basket.id).first() is not None
messages = list(get_messages(response.wsgi_request))
assert messages[0].level == DEFAULT_LEVELS["ERROR"]
assert (
@@ -165,24 +167,6 @@ 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):
+11 -33
View File
@@ -33,14 +33,12 @@ 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 Exists, OuterRef, Subquery
from django.db.models import 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
@@ -92,9 +90,7 @@ class EbouticMainView(LoginRequiredMixin, FormView):
kwargs["form_kwargs"] = {
"customer": self.customer,
"counter": get_eboutic(),
"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},
}
return kwargs
@@ -120,14 +116,9 @@ class EbouticMainView(LoginRequiredMixin, FormView):
@cached_property
def prices(self) -> list[Price]:
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")
return get_eboutic().get_prices_for(
self.customer,
order_by=["product__product_type__order", "product_id", "amount"],
)
@cached_property
@@ -196,7 +187,9 @@ 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:
@@ -262,19 +255,10 @@ class EbouticCheckout(CanViewMixin, UseFragmentsMixin, DetailView):
kwargs["customer_amount"] = None
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):
kwargs["billing_infos"] = json.dumps(
dict(self.object.get_e_transaction_data())
)
with contextlib.suppress(BillingInfo.DoesNotExist):
kwargs["billing_infos"] = json.dumps(
dict(self.object.get_e_transaction_data())
)
return kwargs
@@ -284,14 +268,9 @@ 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()
@@ -309,7 +288,6 @@ 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")
+1 -10
View File
@@ -6,7 +6,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-05-15 11:46+0200\n"
"POT-Creation-Date: 2026-05-12 11:12+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"
@@ -4505,15 +4505,6 @@ 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"
+1 -5
View File
@@ -7,7 +7,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-05-17 10:03+0200\n"
"POT-Creation-Date: 2026-04-17 22:42+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,10 +263,6 @@ 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,11 +571,6 @@ 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(