mirror of
https://github.com/ae-utbm/sith.git
synced 2026-05-22 08:50:17 +00:00
Compare commits
6 Commits
master
...
clic-limit
| Author | SHA1 | Date | |
|---|---|---|---|
| 186498d904 | |||
| d4a2b7ec33 | |||
| d35a2945fc | |||
| dbe29669e6 | |||
| 26585aa521 | |||
| 99600341b0 |
@@ -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([
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
+47
-15
@@ -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,38 @@ 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."""
|
||||
|
||||
@@ -370,8 +400,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 +417,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 +770,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 +779,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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,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[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:]
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
// 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();
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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):
|
||||
|
||||
+26
-9
@@ -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
|
||||
@@ -116,9 +118,11 @@ 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()
|
||||
return list(
|
||||
eboutic.get_prices_for(self.customer)
|
||||
.filter(product__in=eboutic.products.under_clic_limit())
|
||||
.order_by("product__product_type__order", "product_id", "amount")
|
||||
)
|
||||
|
||||
@cached_property
|
||||
@@ -187,9 +191,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 +257,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 +279,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 +304,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")
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user