2 Commits

Author SHA1 Message Date
imperosol 26585aa521 feat: basket timeout 2026-05-20 13:52:57 +02:00
imperosol 99600341b0 improve $notifications 2026-05-20 13:52:34 +02:00
18 changed files with 230 additions and 67 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
<title>{% trans %}Slideshow{% endtrans %}</title>
<link rel="shortcut icon" href="{{ static('core/img/favicon.ico') }}">
<link href="{{ static('css/slideshow.scss') }}" rel="stylesheet" type="text/css" />
<script type="module" src="{{ static('bundled/alpine-index.js') }}"></script>
<script type="module" src="{{ static('bundled/alpine-index.ts') }}"></script>
<script type="module" src="{{ static('bundled/com/slideshow-index.ts') }}"></script>
</head>
<body x-data="slideshow([
-12
View File
@@ -1,12 +0,0 @@
import sort from "@alpinejs/sort";
import Alpine from "alpinejs";
import { limitedChoices } from "#core:alpine/limited-choices.ts";
import { alpinePlugin as notificationPlugin } from "#core:utils/notifications.ts";
Alpine.plugin([sort, limitedChoices]);
Alpine.magic("notifications", notificationPlugin);
window.Alpine = Alpine;
window.addEventListener("DOMContentLoaded", () => {
Alpine.start();
});
+21
View File
@@ -0,0 +1,21 @@
import sort from "@alpinejs/sort";
import { Alpine } from "alpinejs";
import { limitedChoices } from "#core:alpine/limited-choices";
import {
type NotificationPlugin,
alpinePlugin as notificationPlugin,
} from "#core:utils/notifications";
declare module "alpinejs" {
interface Magics<T> {
$notifications: NotificationPlugin;
}
}
Alpine.plugin([sort, limitedChoices]);
Alpine.magic("notifications", notificationPlugin);
// biome-ignore lint/style/useNamingConvention: it's how it's named
Object.assign(window, { Alpine });
window.addEventListener("DOMContentLoaded", () => {
Alpine.start();
});
+32 -17
View File
@@ -1,30 +1,40 @@
import { Alpine } from "alpinejs";
export enum NotificationLevel {
Error = "error",
Warning = "warning",
Success = "success",
}
export function createNotification(message: string, level: NotificationLevel) {
const element = document.getElementById("quick-notifications");
if (element === null) {
return false;
}
return element.dispatchEvent(
new CustomEvent("quick-notification-add", {
detail: { text: message, tag: level },
}),
);
export interface NotificationPlugin {
error: (message: string) => void;
warning: (message: string) => void;
success: (message: string) => void;
clear: () => void;
addMany: (notifs: Notification[]) => void;
getAll: () => Notification[];
}
export function deleteNotifications() {
const element = document.getElementById("quick-notifications");
if (element === null) {
return false;
}
return element.dispatchEvent(new CustomEvent("quick-notification-delete"));
export interface Notification {
tag: NotificationLevel;
text: string;
}
export function alpinePlugin() {
Alpine.store("notifications", [] as Notification[]);
function createNotification(message: string, level: NotificationLevel) {
(Alpine.store("notifications") as Notification[]).push({ text: message, tag: level });
}
function deleteNotifications() {
Alpine.store("notifications", []);
}
function getNotifications() {
return Alpine.store("notifications") as Notification[];
}
export function alpinePlugin(): NotificationPlugin {
return {
error: (message: string) => createNotification(message, NotificationLevel.Error),
warning: (message: string) =>
@@ -32,5 +42,10 @@ export function alpinePlugin() {
success: (message: string) =>
createNotification(message, NotificationLevel.Success),
clear: () => deleteNotifications(),
addMany: (notifs: Notification[]) =>
notifs.forEach((n) => {
createNotification(n.text, n.tag);
}),
getAll: () => getNotifications(),
};
}
+1 -1
View File
@@ -37,7 +37,7 @@
<script src="{{ url('javascript-catalog') }}"></script>
<script type="module" src="{{ static("bundled/core/navbar-index.ts") }}"></script>
<script type="module" src="{{ static("bundled/core/components/include-index.ts") }}"></script>
<script type="module" src="{{ static('bundled/alpine-index.js') }}"></script>
<script type="module" src="{{ static('bundled/alpine-index.ts') }}"></script>
<script type="module" src="{{ static('bundled/htmx-index.js') }}"></script>
<script type="module" src="{{ static('bundled/country-flags-index.ts') }}"></script>
<script type="module" src="{{ static('bundled/core/tooltips-index.ts') }}"></script>
+4 -6
View File
@@ -1,15 +1,13 @@
<div id="quick-notifications"
x-data='{
messages: [
x-data='{ messages: $notifications.getAll() }'
x-init='$notifications.addMany([
{%- for message in messages -%}
{%- if not message.extra_tags -%}
{ tag: {{ message.tags|string|tojson }}, text: {{ message|string|tojson }} },
{%- endif -%}
{%- endfor -%}
]
}'
@quick-notification-add="(e) => messages.push(e?.detail)"
@quick-notification-delete="messages = []">
])'
>
<template x-for="(message, index) in messages">
<div class="alert" :class="`alert-${message.tag}`" x-transition>
<span class="alert-main" x-text="message.text"></span>
+2 -2
View File
@@ -35,8 +35,8 @@ les fichiers sont à mettre dans un dossier `static/bundled` de l'application à
Pour accéder au fichier, il faut utiliser `static` comme pour le reste mais en ajouter `bundled/` comme prefix.
```jinja
{# Example pour ajouter sith/core/bundled/alpine-index.js #}
<script type="module" src="{{ static('bundled/alpine-index.js') }}"></script>
{# Example pour ajouter sith/core/bundled/alpine-index.ts #}
<script type="module" src="{{ static('bundled/alpine-index.ts') }}"></script>
<script type="module" src="{{ static('bundled/other-index.ts') }}"></script>
```
+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:
+20
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,10 @@ 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
):
@@ -133,9 +138,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 +171,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,
},
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();
}
},
}));
@@ -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"
{% 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>
+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):
+18 -3
View File
@@ -39,6 +39,8 @@ 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
@@ -187,9 +189,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,6 +255,15 @@ 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())
@@ -268,9 +277,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 +302,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")
+10 -1
View File
@@ -6,7 +6,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-05-12 09:52+0200\n"
"POT-Creation-Date: 2026-05-15 11:46+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"
@@ -4441,6 +4441,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
@@ -566,6 +566,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(
+1 -1
View File
@@ -32,7 +32,7 @@ class JsBundlerManifestEntry:
# because that's what the user types when importing statics and that's what django gives us
# This is really similar to what we are doing in the bundler, it uses a similar algorithm
# Example:
# core/static/bundled/alpine-index.js -> bundled/alpine-index.js
# core/static/bundled/alpine-index.ts -> bundled/alpine-index.ts
# core/static/bundled/components/include-index.ts -> core/static/bundled/components/include-index.ts
def get_relative_src_name(name: str) -> str:
original_path = Path(name)