Merge pull request #1085 from ae-utbm/eboutic

Don't use cookies for processing eboutic baskets
This commit is contained in:
Bartuccio Antoine 2025-04-23 15:56:44 +02:00 committed by GitHub
commit df26ab4d50
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 975 additions and 872 deletions

View File

@ -1,4 +1,7 @@
import math
from django import forms
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from phonenumber_field.widgets import RegionalPhoneNumberWidget
@ -261,3 +264,112 @@ class CloseCustomerAccountForm(forms.Form):
widget=AutoCompleteSelectUser,
queryset=User.objects.all(),
)
class ProductForm(forms.Form):
quantity = forms.IntegerField(min_value=1, required=True)
id = forms.IntegerField(min_value=0, required=True)
def __init__(
self,
customer: Customer,
counter: Counter,
allowed_products: dict[int, Product],
*args,
**kwargs,
):
self.customer = customer # Used by formset
self.counter = counter # Used by formset
self.allowed_products = allowed_products
super().__init__(*args, **kwargs)
def clean_id(self):
data = self.cleaned_data["id"]
# We store self.product so we can use it later on the formset validation
# And also in the global clean
self.product = self.allowed_products.get(data, None)
if self.product is None:
raise forms.ValidationError(
_("The selected product isn't available for this user")
)
return data
def clean(self):
cleaned_data = super().clean()
if len(self.errors) > 0:
return
# Compute prices
cleaned_data["bonus_quantity"] = 0
if self.product.tray:
cleaned_data["bonus_quantity"] = math.floor(
cleaned_data["quantity"] / Product.QUANTITY_FOR_TRAY_PRICE
)
cleaned_data["total_price"] = self.product.price * (
cleaned_data["quantity"] - cleaned_data["bonus_quantity"]
)
return cleaned_data
class BaseBasketForm(forms.BaseFormSet):
def clean(self):
self.forms = [form for form in self.forms if form.cleaned_data != {}]
if len(self.forms) == 0:
return
self._check_forms_have_errors()
self._check_product_are_unique()
self._check_recorded_products(self[0].customer)
self._check_enough_money(self[0].counter, self[0].customer)
def _check_forms_have_errors(self):
if any(len(form.errors) > 0 for form in self):
raise forms.ValidationError(_("Submitted basket is invalid"))
def _check_product_are_unique(self):
product_ids = {form.cleaned_data["id"] for form in self.forms}
if len(product_ids) != len(self.forms):
raise forms.ValidationError(_("Duplicated product entries."))
def _check_enough_money(self, counter: Counter, customer: Customer):
self.total_price = sum([data["total_price"] for data in self.cleaned_data])
if self.total_price > customer.amount:
raise forms.ValidationError(_("Not enough money"))
def _check_recorded_products(self, customer: Customer):
"""Check for, among other things, ecocups and pitchers"""
items = {
form.cleaned_data["id"]: form.cleaned_data["quantity"]
for form in self.forms
}
ids = list(items.keys())
returnables = list(
ReturnableProduct.objects.filter(
Q(product_id__in=ids) | Q(returned_product_id__in=ids)
).annotate_balance_for(customer)
)
limit_reached = []
for returnable in returnables:
returnable.balance += items.get(returnable.product_id, 0)
for returnable in returnables:
dcons = items.get(returnable.returned_product_id, 0)
returnable.balance -= dcons
if dcons and returnable.balance < -returnable.max_return:
limit_reached.append(returnable.returned_product)
if limit_reached:
raise forms.ValidationError(
_(
"This user have reached his recording limit "
"for the following products : %s"
)
% ", ".join([str(p) for p in limit_reached])
)
BasketForm = forms.formset_factory(
ProductForm, formset=BaseBasketForm, absolute_max=None, min_num=1
)

View File

@ -47,6 +47,10 @@ from counter.fields import CurrencyField
from subscription.models import Subscription
def get_eboutic() -> Counter:
return Counter.objects.filter(type="EBOUTIC").order_by("id").first()
class CustomerQuerySet(models.QuerySet):
def update_amount(self) -> int:
"""Update the amount of all customers selected by this queryset.

View File

@ -46,6 +46,15 @@ from counter.models import (
)
def set_age(user: User, age: int):
user.date_of_birth = localdate().replace(year=localdate().year - age)
user.save()
def force_refill_user(user: User, amount: Decimal | int):
baker.make(Refilling, amount=amount, customer=user.customer, is_validated=False)
class TestFullClickBase(TestCase):
@classmethod
def setUpTestData(cls):
@ -226,11 +235,11 @@ class TestCounterClick(TestFullClickBase):
cls.banned_counter_customer = subscriber_user.make()
cls.banned_alcohol_customer = subscriber_user.make()
cls.set_age(cls.customer, 20)
cls.set_age(cls.barmen, 20)
cls.set_age(cls.club_admin, 20)
cls.set_age(cls.banned_alcohol_customer, 20)
cls.set_age(cls.underage_customer, 17)
set_age(cls.customer, 20)
set_age(cls.barmen, 20)
set_age(cls.club_admin, 20)
set_age(cls.banned_alcohol_customer, 20)
set_age(cls.underage_customer, 17)
cls.banned_alcohol_customer.ban_groups.add(
BanGroup.objects.get(pk=settings.SITH_GROUP_BANNED_ALCOHOL_ID)
@ -278,11 +287,6 @@ class TestCounterClick(TestFullClickBase):
{"username": used_barman.username, "password": "plop"},
)
@classmethod
def set_age(cls, user: User, age: int):
user.date_of_birth = localdate().replace(year=localdate().year - age)
user.save()
def submit_basket(
self,
user: User,
@ -306,9 +310,6 @@ class TestCounterClick(TestFullClickBase):
data,
)
def refill_user(self, user: User, amount: Decimal | int):
baker.make(Refilling, amount=amount, customer=user.customer, is_validated=False)
def test_click_eboutic_failure(self):
eboutic = baker.make(Counter, type="EBOUTIC")
self.client.force_login(self.club_admin)
@ -318,7 +319,7 @@ class TestCounterClick(TestFullClickBase):
assert res.status_code == 404
def test_click_office_success(self):
self.refill_user(self.customer, 10)
force_refill_user(self.customer, 10)
self.client.force_login(self.club_admin)
res = self.submit_basket(
self.customer, [BasketItem(self.stamps.id, 5)], counter=self.club_counter
@ -327,7 +328,7 @@ class TestCounterClick(TestFullClickBase):
assert self.updated_amount(self.customer) == Decimal("2.5")
# Test no special price on office counter
self.refill_user(self.club_admin, 10)
force_refill_user(self.club_admin, 10)
res = self.submit_basket(
self.club_admin, [BasketItem(self.stamps.id, 1)], counter=self.club_counter
)
@ -336,7 +337,7 @@ class TestCounterClick(TestFullClickBase):
assert self.updated_amount(self.club_admin) == Decimal("8.5")
def test_click_bar_success(self):
self.refill_user(self.customer, 10)
force_refill_user(self.customer, 10)
self.login_in_bar(self.barmen)
res = self.submit_basket(
self.customer, [BasketItem(self.beer.id, 2), BasketItem(self.snack.id, 1)]
@ -347,7 +348,7 @@ class TestCounterClick(TestFullClickBase):
# Test barmen special price
self.refill_user(self.barmen, 10)
force_refill_user(self.barmen, 10)
assert (
self.submit_basket(self.barmen, [BasketItem(self.beer.id, 1)])
@ -356,7 +357,7 @@ class TestCounterClick(TestFullClickBase):
assert self.updated_amount(self.barmen) == Decimal("9")
def test_click_tray_price(self):
self.refill_user(self.customer, 20)
force_refill_user(self.customer, 20)
self.login_in_bar(self.barmen)
# Not applying tray price
@ -373,7 +374,7 @@ class TestCounterClick(TestFullClickBase):
self.login_in_bar()
for user in [self.underage_customer, self.banned_alcohol_customer]:
self.refill_user(user, 10)
force_refill_user(user, 10)
# Buy product without age limit
res = self.submit_basket(user, [BasketItem(self.snack.id, 2)])
@ -394,7 +395,7 @@ class TestCounterClick(TestFullClickBase):
self.banned_counter_customer,
self.customer_old_can_not_buy,
]:
self.refill_user(user, 10)
force_refill_user(user, 10)
resp = self.submit_basket(user, [BasketItem(self.snack.id, 2)])
assert resp.status_code == 302
assert resp.url == resolve_url(self.counter)
@ -410,7 +411,7 @@ class TestCounterClick(TestFullClickBase):
def test_click_allowed_old_subscriber(self):
self.login_in_bar()
self.refill_user(self.customer_old_can_buy, 10)
force_refill_user(self.customer_old_can_buy, 10)
res = self.submit_basket(
self.customer_old_can_buy, [BasketItem(self.snack.id, 2)]
)
@ -420,7 +421,7 @@ class TestCounterClick(TestFullClickBase):
def test_click_wrong_counter(self):
self.login_in_bar()
self.refill_user(self.customer, 10)
force_refill_user(self.customer, 10)
res = self.submit_basket(
self.customer, [BasketItem(self.snack.id, 2)], counter=self.other_counter
)
@ -444,7 +445,7 @@ class TestCounterClick(TestFullClickBase):
assert self.updated_amount(self.customer) == Decimal("10")
def test_click_not_connected(self):
self.refill_user(self.customer, 10)
force_refill_user(self.customer, 10)
res = self.submit_basket(self.customer, [BasketItem(self.snack.id, 2)])
assertRedirects(res, self.counter.get_absolute_url())
@ -456,15 +457,29 @@ class TestCounterClick(TestFullClickBase):
assert self.updated_amount(self.customer) == Decimal("10")
def test_click_product_not_in_counter(self):
self.refill_user(self.customer, 10)
force_refill_user(self.customer, 10)
self.login_in_bar()
res = self.submit_basket(self.customer, [BasketItem(self.stamps.id, 2)])
assert res.status_code == 200
assert self.updated_amount(self.customer) == Decimal("10")
def test_basket_empty(self):
force_refill_user(self.customer, 10)
for basket in [
[],
[BasketItem(None, None)],
[BasketItem(None, None), BasketItem(None, None)],
]:
assertRedirects(
self.submit_basket(self.customer, basket),
self.counter.get_absolute_url(),
)
assert self.updated_amount(self.customer) == Decimal("10")
def test_click_product_invalid(self):
self.refill_user(self.customer, 10)
force_refill_user(self.customer, 10)
self.login_in_bar()
for item in [
@ -472,14 +487,12 @@ class TestCounterClick(TestFullClickBase):
BasketItem(self.beer.id, -1),
BasketItem(None, 1),
BasketItem(self.beer.id, None),
BasketItem(None, None),
]:
assert self.submit_basket(self.customer, [item]).status_code == 200
assert self.updated_amount(self.customer) == Decimal("10")
def test_click_not_enough_money(self):
self.refill_user(self.customer, 10)
force_refill_user(self.customer, 10)
self.login_in_bar()
res = self.submit_basket(
self.customer,
@ -509,7 +522,7 @@ class TestCounterClick(TestFullClickBase):
assert self.updated_amount(self.customer) == 0
def test_recordings(self):
self.refill_user(self.customer, self.cons.selling_price * 3)
force_refill_user(self.customer, self.cons.selling_price * 3)
self.login_in_bar(self.barmen)
res = self.submit_basket(self.customer, [BasketItem(self.cons.id, 3)])
assert res.status_code == 302

View File

@ -12,23 +12,14 @@
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
import math
from django.core.exceptions import PermissionDenied
from django.db import transaction
from django.db.models import Q
from django.forms import (
BaseFormSet,
Form,
IntegerField,
ValidationError,
formset_factory,
)
from django.http import Http404
from django.shortcuts import get_object_or_404, redirect, resolve_url
from django.urls import reverse
from django.utils.safestring import SafeString
from django.utils.translation import gettext_lazy as _
from django.views.generic import FormView
from django.views.generic.detail import SingleObjectMixin
from ninja.main import HttpRequest
@ -36,11 +27,10 @@ from ninja.main import HttpRequest
from core.auth.mixins import CanViewMixin
from core.models import User
from core.views.mixins import FragmentMixin, UseFragmentsMixin
from counter.forms import RefillForm
from counter.forms import BasketForm, RefillForm
from counter.models import (
Counter,
Customer,
Product,
ReturnableProduct,
Selling,
)
@ -57,113 +47,6 @@ def get_operator(request: HttpRequest, counter: Counter, customer: Customer) ->
return counter.get_random_barman()
class ProductForm(Form):
quantity = IntegerField(min_value=1)
id = IntegerField(min_value=0)
def __init__(
self,
customer: Customer,
counter: Counter,
allowed_products: dict[int, Product],
*args,
**kwargs,
):
self.customer = customer # Used by formset
self.counter = counter # Used by formset
self.allowed_products = allowed_products
super().__init__(*args, **kwargs)
def clean_id(self):
data = self.cleaned_data["id"]
# We store self.product so we can use it later on the formset validation
# And also in the global clean
self.product = self.allowed_products.get(data, None)
if self.product is None:
raise ValidationError(
_("The selected product isn't available for this user")
)
return data
def clean(self):
cleaned_data = super().clean()
if len(self.errors) > 0:
return
# Compute prices
cleaned_data["bonus_quantity"] = 0
if self.product.tray:
cleaned_data["bonus_quantity"] = math.floor(
cleaned_data["quantity"] / Product.QUANTITY_FOR_TRAY_PRICE
)
cleaned_data["total_price"] = self.product.price * (
cleaned_data["quantity"] - cleaned_data["bonus_quantity"]
)
return cleaned_data
class BaseBasketForm(BaseFormSet):
def clean(self):
if len(self.forms) == 0:
return
self._check_forms_have_errors()
self._check_product_are_unique()
self._check_recorded_products(self[0].customer)
self._check_enough_money(self[0].counter, self[0].customer)
def _check_forms_have_errors(self):
if any(len(form.errors) > 0 for form in self):
raise ValidationError(_("Submitted basket is invalid"))
def _check_product_are_unique(self):
product_ids = {form.cleaned_data["id"] for form in self.forms}
if len(product_ids) != len(self.forms):
raise ValidationError(_("Duplicated product entries."))
def _check_enough_money(self, counter: Counter, customer: Customer):
self.total_price = sum([data["total_price"] for data in self.cleaned_data])
if self.total_price > customer.amount:
raise ValidationError(_("Not enough money"))
def _check_recorded_products(self, customer: Customer):
"""Check for, among other things, ecocups and pitchers"""
items = {
form.cleaned_data["id"]: form.cleaned_data["quantity"]
for form in self.forms
}
ids = list(items.keys())
returnables = list(
ReturnableProduct.objects.filter(
Q(product_id__in=ids) | Q(returned_product_id__in=ids)
).annotate_balance_for(customer)
)
limit_reached = []
for returnable in returnables:
returnable.balance += items.get(returnable.product_id, 0)
for returnable in returnables:
dcons = items.get(returnable.returned_product_id, 0)
returnable.balance -= dcons
if dcons and returnable.balance < -returnable.max_return:
limit_reached.append(returnable.returned_product)
if limit_reached:
raise ValidationError(
_(
"This user have reached his recording limit "
"for the following products : %s"
)
% ", ".join([str(p) for p in limit_reached])
)
BasketForm = formset_factory(
ProductForm, formset=BaseBasketForm, absolute_max=None, min_num=1
)
class CounterClick(
CounterTabsMixin, UseFragmentsMixin, CanViewMixin, SingleObjectMixin, FormView
):

View File

@ -32,48 +32,39 @@ susnommés afin de comprendre comment celui-ci marche.
Cette application contient les vues suivantes :
- `eboutic_main` (GET) : la vue retournant la page principale de la boutique en ligne.
- `EbouticMainView` (GET/POST) : la vue retournant la page principale de la boutique en ligne.
Cette vue effectue un filtrage des produits à montrer à l'utilisateur en
fonction de ce qu'il a le droit d'acheter.
Si cette vue est appelée lors d'une redirection parce qu'une erreur
est survenue au cours de la navigation sur la boutique, il est possible
de donner les messages d'erreur à donner à l'utilisateur dans la session
avec la clef ``"errors"``.
Elle est en charge de récupérer le formulaire de création d'un panier et
redirige alors vers la vue de checkout.
- ``payment_result`` (GET) : retourne une page assez simple disant à l'utilisateur
si son paiement a échoué ou réussi. Cette vue est appelée par redirection
lorsque l'utilisateur paye son panier avec son argent du compte AE.
- ``EbouticCommand`` (POST) : traite la soumission d'un panier par l'utilisateur.
Lors de l'appel de cette vue, la requête doit contenir un cookie avec l'état
du panier à valider. Ce panier doit strictement être de la forme :
```
[
{"id": <int>, "name": <str>, "quantity": <int>, "unit_price": <float>},
{"id": <int>, "name": <str>, "quantity": <int>, "unit_price": <float>},
<etc.>
]
```
Si le panier est mal formaté ou contient des valeurs invalides,
une redirection est faite vers `eboutic_main`.
- ``pay_with_sith`` (POST) : paie le panier avec l'argent présent sur le compte
- ``EbouticCheckout`` (GET/POST) : Page récapitulant le contenu d'un panier.
Permet de sélectionner le moyen de paiement et de mettre à jour ses coordonnées
de paiement par carte bancaire.
- ``PayWithSith`` (POST) : paie le panier avec l'argent présent sur le compte
AE. Redirige vers `payment_result`.
- ``ETransactionAutoAnswer`` (GET) : vue destinée à communiquer avec le service
de paiement bancaire pour valider ou non le paiement de l'utilisateur.
- ``BillingInfoFormFragment`` (GET/POST) : vue destinée à gérer les informations de paiement de l'utilisateur courant.
# Les templates
- ``eboutic_payment_result.jinja`` : très court template contenant juste
un message pour dire à l'utilisateur si son achat s'est bien déroulé.
Retourné par la vue ``payment_result``.
- ``eboutic_makecommand.jinja`` : template contenant un résumé du panier et deux
- ``eboutic_checkout.jinja`` : template contenant un résumé du panier et deux
boutons, un pour payer avec le site AE et l'autre pour payer par carte bancaire.
Retourné par la vue ``EbouticCommand``
Retourné par la vue ``EbouticCheckout``
- ``eboutic_billing_info.jinja`` : formulaire de modification des coordonnées bancaires.
Elle permet également de mettre à jour ses coordonnées de paiement
- ``eboutic_main.jinja`` : le plus gros template de cette application. Contient
une interface pour que l'utilisateur puisse consulter les produits et remplir
son panier. Les opérations de remplissage du panier se font entièrement côté client.
À chaque clic pour ajouter ou retirer un élément du panier, le script JS
(AlpineJS, plus précisément) édite en même temps un cookie.
Au moment de la validation du panier, ce cookie est envoyé au serveur pour
vérifier que la commande est valide et payer.
(AlpineJS, plus précisément) édite en même temps le localStorage du navigateur.
Cette vue fabrique dynamiquement un formulaire qui sera soumis au serveur.
# Les modèles

View File

@ -1,22 +1,20 @@
from ninja_extra import ControllerBase, api_controller, route
from ninja_extra.exceptions import NotFound
from ninja_extra.permissions import IsAuthenticated
from core.auth.api_permissions import CanView
from counter.models import BillingInfo
from eboutic.models import Basket
@api_controller("/etransaction", permissions=[IsAuthenticated])
@api_controller("/etransaction", permissions=[CanView])
class EtransactionInfoController(ControllerBase):
@route.get("/data", url_name="etransaction_data")
def fetch_etransaction_data(self):
@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.from_session(self.context.request.session)
if basket is None:
raise NotFound
basket: Basket = self.get_object_or_exception(Basket, pk=basket_id)
try:
return dict(basket.get_e_transaction_data())
except BillingInfo.DoesNotExist as e:

View File

@ -1,128 +0,0 @@
#
# Copyright 2022
# - Maréchal <thgirod@hotmail.com
#
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
# http://ae.utbm.fr.
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License a published by the Free Software
# Foundation; either version 3 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
from functools import cached_property
from urllib.parse import unquote
from django.http import HttpRequest
from django.utils.translation import gettext as _
from pydantic import ValidationError
from eboutic.models import get_eboutic_products
from eboutic.schemas import PurchaseItemList, PurchaseItemSchema
class BasketForm:
"""Class intended to perform checks on the request sended to the server when
the user submits his basket from /eboutic/.
Because it must check an unknown number of fields, coming from a cookie
and needing some databases checks to be performed, inheriting from forms.Form
or using formset would have been likely to end in a big ball of wibbly-wobbly hacky stuff.
Thus this class is a pure standalone and performs its operations by its own means.
However, it still tries to share some similarities with a standard django Form.
Examples:
::
def my_view(request):
form = BasketForm(request)
form.clean()
if form.is_valid():
# perform operations
else:
errors = form.get_error_messages()
# return the cookie that was in the request, but with all
# incorrects elements removed
cookie = form.get_cleaned_cookie()
You can also use a little shortcut by directly calling `form.is_valid()`
without calling `form.clean()`. In this case, the latter method shall be
implicitly called.
"""
def __init__(self, request: HttpRequest):
self.user = request.user
self.cookies = request.COOKIES
self.error_messages = set()
self.correct_items = []
def clean(self) -> None:
"""Perform all the checks, but return nothing.
To know if the form is valid, the `is_valid()` method must be used.
The form shall be considered as valid if it meets all the following conditions :
- it contains a "basket_items" key in the cookies of the request given in the constructor
- this cookie is a list of objects formatted this way : `[{'id': <int>, 'quantity': <int>,
'name': <str>, 'unit_price': <float>}, ...]`. The order of the fields in each object does not matter
- all the ids are positive integers
- all the ids refer to products available in the EBOUTIC
- all the ids refer to products the user is allowed to buy
- all the quantities are positive integers
"""
try:
basket = PurchaseItemList.validate_json(
unquote(self.cookies.get("basket_items", "[]"))
)
except ValidationError:
self.error_messages.add(_("The request was badly formatted."))
return
if len(basket) == 0:
self.error_messages.add(_("Your basket is empty."))
return
existing_ids = {product.id for product in get_eboutic_products(self.user)}
for item in basket:
# check a product with this id does exist
if item.product_id in existing_ids:
self.correct_items.append(item)
else:
self.error_messages.add(
_(
"%(name)s : this product does not exist or may no longer be available."
)
% {"name": item.name}
)
continue
# this function does not return anything.
# instead, it fills a set containing the collected error messages
# an empty set means that no error was seen thus everything is ok
# and the form is valid.
# a non-empty set means there was at least one error thus
# the form is invalid
def is_valid(self) -> bool:
"""Return True if the form is correct else False.
If the `clean()` method has not been called beforehand, call it.
"""
if not self.error_messages and not self.correct_items:
self.clean()
return not self.error_messages
@cached_property
def errors(self) -> list[str]:
return list(self.error_messages)
@cached_property
def cleaned_data(self) -> list[PurchaseItemSchema]:
return self.correct_items

View File

@ -28,18 +28,27 @@ from django.utils.translation import gettext_lazy as _
from core.models import User
from counter.fields import CurrencyField
from counter.models import BillingInfo, Counter, Customer, Product, Refilling, Selling
from counter.models import (
BillingInfo,
Counter,
Customer,
Product,
Refilling,
Selling,
get_eboutic,
)
def get_eboutic_products(user: User) -> list[Product]:
products = (
Counter.objects.get(type="EBOUTIC")
get_eboutic()
.products.filter(product_type__isnull=False)
.filter(archived=False)
.filter(limit_age__lte=user.age)
.annotate(order=F("product_type__order"))
.annotate(category=F("product_type__name"))
.annotate(category_comment=F("product_type__comment"))
.annotate(price=F("selling_price")) # <-- selected price for basket validation
.prefetch_related("buying_groups") # <-- used in `Product.can_be_sold_to`
)
return [p for p in products if p.can_be_sold_to(user)]
@ -84,6 +93,9 @@ class Basket(models.Model):
def __str__(self):
return f"{self.user}'s basket ({self.items.all().count()} items)"
def can_be_viewed_by(self, user):
return self.user == user
@cached_property
def contains_refilling_item(self) -> bool:
return self.items.filter(
@ -98,13 +110,6 @@ class Basket(models.Model):
)["total"]
)
@classmethod
def from_session(cls, session) -> Basket | None:
"""The basket stored in the session object, if it exists."""
if "basket_id" in session:
return cls.objects.filter(id=session["basket_id"]).first()
return None
def generate_sales(self, counter, seller: User, payment_method: str):
"""Generate a list of sold items corresponding to the items
of this basket WITHOUT saving them NOR deleting the basket.
@ -139,7 +144,7 @@ class Basket(models.Model):
club=product.club,
product=product,
seller=seller,
customer=self.user.customer,
customer=Customer.get_or_create(self.user)[0],
unit_price=item.product_unit_price,
quantity=item.quantity,
payment_method=payment_method,

View File

@ -1,13 +1,18 @@
import { etransactioninfoFetchEtransactionData } from "#openapi";
document.addEventListener("alpine:init", () => {
Alpine.data("etransaction", (initialData) => ({
Alpine.data("etransaction", (initialData, basketId: number) => ({
data: initialData,
isCbAvailable: Object.keys(initialData).length > 0,
async fill() {
this.isCbAvailable = false;
const res = await etransactioninfoFetchEtransactionData();
const res = await etransactioninfoFetchEtransactionData({
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;

View File

@ -8,55 +8,55 @@ interface BasketItem {
unit_price: number;
}
const BASKET_ITEMS_COOKIE_NAME: string = "basket_items";
document.addEventListener("alpine:init", () => {
Alpine.data("basket", (lastPurchaseTime?: number) => ({
basket: [] as BasketItem[],
/**
* Search for a cookie by name
* @param name Name of the cookie to get
* @returns the value of the cookie or null if it does not exist, undefined if not found
*/
function getCookie(name: string): string | null | undefined {
if (!document.cookie || document.cookie.length === 0) {
return null;
init() {
this.basket = this.loadBasket();
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))
) {
this.basket = [];
}
}
const found = document.cookie
.split(";")
.map((c) => c.trim())
.find((c) => c.startsWith(`${name}=`));
// It's quite tricky to manually apply attributes to the management part
// of a formset so we dynamically apply it here
this.$refs.basketManagementForm
.querySelector("#id_form-TOTAL_FORMS")
.setAttribute(":value", "basket.length");
},
return found === undefined ? undefined : decodeURIComponent(found.split("=")[1]);
}
/**
* Fetch the basket items from the associated cookie
* @returns the items in the basket
*/
function getStartingItems(): BasketItem[] {
const cookie = getCookie(BASKET_ITEMS_COOKIE_NAME);
if (!cookie) {
loadBasket(): BasketItem[] {
if (localStorage.basket === undefined) {
return [];
}
// Django cookie backend converts `,` to `\054`
let parsed = JSON.parse(cookie.replace(/\\054/g, ","));
if (typeof parsed === "string") {
// In some conditions, a second parsing is needed
parsed = JSON.parse(parsed);
}
const res = Array.isArray(parsed) ? parsed : [];
return res.filter((i) => !!document.getElementById(i.id));
try {
return JSON.parse(localStorage.basket);
} catch (_err) {
return [];
}
},
document.addEventListener("alpine:init", () => {
Alpine.data("basket", () => ({
items: getStartingItems() as BasketItem[],
saveBasket() {
localStorage.basket = JSON.stringify(this.basket);
localStorage.basketTimestamp = Date.now();
},
/**
* Get the total price of the basket
* @returns {number} The total price of the basket
*/
getTotal() {
return this.items.reduce(
return this.basket.reduce(
(acc: number, item: BasketItem) => acc + item.quantity * item.unit_price,
0,
);
@ -68,7 +68,6 @@ document.addEventListener("alpine:init", () => {
*/
add(item: BasketItem) {
item.quantity++;
this.setCookies();
},
/**
@ -76,39 +75,25 @@ document.addEventListener("alpine:init", () => {
* @param itemId the id of the item to remove
*/
remove(itemId: number) {
const index = this.items.findIndex((e: BasketItem) => e.id === itemId);
const index = this.basket.findIndex((e: BasketItem) => e.id === itemId);
if (index < 0) {
return;
}
this.items[index].quantity -= 1;
this.basket[index].quantity -= 1;
if (this.items[index].quantity === 0) {
this.items = this.items.filter(
(e: BasketItem) => e.id !== this.items[index].id,
if (this.basket[index].quantity === 0) {
this.basket = this.basket.filter(
(e: BasketItem) => e.id !== this.basket[index].id,
);
}
this.setCookies();
},
/**
* Remove all the items from the basket & cleans the catalog CSS classes
*/
clearBasket() {
this.items = [];
this.setCookies();
},
/**
* Set the cookie in the browser with the basket items
* ! the cookie survives an hour
*/
setCookies() {
if (this.items.length === 0) {
document.cookie = `${BASKET_ITEMS_COOKIE_NAME}=;Max-Age=0`;
} else {
document.cookie = `${BASKET_ITEMS_COOKIE_NAME}=${encodeURIComponent(JSON.stringify(this.items))};Max-Age=3600`;
}
this.basket = [];
},
/**
@ -127,7 +112,7 @@ document.addEventListener("alpine:init", () => {
unit_price: price,
} as BasketItem;
this.items.push(newItem);
this.basket.push(newItem);
this.add(newItem);
return newItem;
@ -141,7 +126,7 @@ document.addEventListener("alpine:init", () => {
* @param price The unit price of the product
*/
addFromCatalog(id: number, name: string, price: number) {
let item = this.items.find((e: BasketItem) => e.id === id);
let item = this.basket.find((e: BasketItem) => e.id === id);
// if the item is not in the basket, we create it
// else we add + 1 to it

View File

@ -61,6 +61,7 @@
word-break: break-word;
width: 100%;
line-height: 100%;
white-space: normal;
}
#eboutic .fa-plus,

View File

@ -9,7 +9,7 @@
{% endblock %}
{% block additional_js %}
<script type="module" src="{{ static('bundled/eboutic/makecommand-index.ts') }}"></script>
<script type="module" src="{{ static('bundled/eboutic/checkout-index.ts') }}"></script>
{% endblock %}
{% block content %}
@ -19,7 +19,7 @@
let billingInfos = {{ billing_infos|safe }};
</script>
<div x-data="etransaction(billingInfos)">
<div x-data="etransaction(billingInfos, {{ basket.id }})">
<p>{% trans %}Basket: {% endtrans %}</p>
<table>
<thead>
@ -82,9 +82,8 @@
{% elif basket.total > user.account_balance %}
<p>{% trans %}AE account payment disabled because you do not have enough money remaining.{% endtrans %}</p>
{% else %}
<form method="post" action="{{ url('eboutic:pay_with_sith') }}" name="sith-pay-form">
<form method="post" action="{{ url('eboutic:pay_with_sith', basket_id=basket.id) }}" name="sith-pay-form">
{% csrf_token %}
<input type="hidden" name="action" value="pay_with_sith_account">
<input class="btn btn-blue" type="submit" value="{% trans %}Pay with Sith account{% endtrans %}"/>
</form>
{% endif %}

View File

@ -21,13 +21,28 @@
{% block content %}
<h1 id="eboutic-title">{% trans %}Eboutic{% endtrans %}</h1>
<div id="eboutic" x-data="basket">
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }}">
{{ message }}
</div>
{% endfor %}
{% endif %}
<div id="eboutic" x-data="basket({{ last_purchase_time }})">
<div id="basket">
<h3>Panier</h3>
{% if errors %}
<form method="post" action="">
{% csrf_token %}
<div x-ref="basketManagementForm">
{{ form.management_form }}
</div>
{% if form.non_form_errors() or form.errors %}
<div class="alert alert-red">
<div class="alert-main">
{% for error in errors %}
{% for error in form.non_form_errors() + form.errors %}
<p style="margin: 0">{{ error }}</p>
{% endfor %}
</div>
@ -43,7 +58,8 @@
<strong>{{ "%0.2f"|format(customer_amount) }} €</strong>
</span>
</li>
<template x-for="item in items" :key="item.id">
<template x-for="(item, index) in Object.values(basket)" :key="item.id">
<li class="item-row" x-show="item.quantity > 0">
<div class="item-quantity">
<i class="fa fa-minus fa-xs" @click="remove(item.id)"></i>
@ -52,6 +68,24 @@
</div>
<span class="item-name" x-text="item.name"></span>
<span class="item-price" x-text="(item.unit_price * item.quantity).toFixed(2) + ' €'"></span>
<input
type="hidden"
:value="item.quantity"
:id="`id_form-${index}-quantity`"
:name="`form-${index}-quantity`"
required
readonly
>
<input
type="hidden"
:value="item.id"
:id="`id_form-${index}-id`"
:name="`form-${index}-id`"
required
readonly
>
</li>
</template>
{# Total price #}
@ -61,17 +95,16 @@
</li>
</ul>
<div class="catalog-buttons">
<button @click="clearBasket()" class="btn btn-grey">
<button @click.prevent="clearBasket()" class="btn btn-grey">
<i class="fa fa-trash"></i>
{% trans %}Clear{% endtrans %}
</button>
<form method="get" action="{{ url('eboutic:command') }}">
<button class="btn btn-blue">
<i class="fa fa-check"></i>
<input type="submit" value="{% trans %}Validate{% endtrans %}"/>
</button>
</form>
</div>
</form>
</div>
<div id="catalog">
{% if not request.user.date_of_birth %}
@ -108,7 +141,7 @@
{% trans trimmed %}
Our partner uses Weezevent to sell tickets.
Weezevent may collect user info according to
it's own privacy policy.
its own privacy policy.
By clicking the accept button you consent to
their terms of services.
{% endtrans %}
@ -158,7 +191,7 @@
<button
id="{{ p.id }}"
class="card product-button clickable shadow"
:class="{selected: items.some((i) => i.id === {{ p.id }})}"
:class="{selected: basket.some((i) => i.id === {{ p.id }})}"
@click='addFromCatalog({{ p.id }}, {{ p.name|tojson }}, {{ p.selling_price }})'
>
{% if p.icon %}

View File

@ -4,6 +4,14 @@
<h3>{% trans %}Eboutic{% endtrans %}</h3>
<div>
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }}">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% if success %}
{% trans %}Payment successful{% endtrans %}
{% else %}

View File

@ -0,0 +1,179 @@
import pytest
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 model_bakery import baker
from pytest_django.asserts import assertRedirects
from core.baker_recipes import subscriber_user
from core.models import Group, User
from counter.baker_recipes import product_recipe
from counter.models import Counter, ProductType, get_eboutic
from counter.tests.test_counter import BasketItem
from eboutic.models import Basket
@pytest.mark.django_db
def test_get_eboutic():
assert Counter.objects.get(name="Eboutic") == get_eboutic()
baker.make(Counter, type="EBOUTIC")
assert Counter.objects.get(name="Eboutic") == get_eboutic()
class TestEboutic(TestCase):
@classmethod
def setUpTestData(cls):
cls.group_cotiz = baker.make(Group)
cls.group_public = baker.make(Group)
cls.new_customer = baker.make(User)
cls.new_customer_adult = baker.make(User)
cls.subscriber = subscriber_user.make()
cls.set_age(cls.new_customer, 5)
cls.set_age(cls.new_customer_adult, 20)
cls.set_age(cls.subscriber, 20)
product_type = baker.make(ProductType)
cls.snack = product_recipe.make(
selling_price=1.5, special_selling_price=1, product_type=product_type
)
cls.beer = product_recipe.make(
limit_age=18,
selling_price=2.5,
special_selling_price=1,
product_type=product_type,
)
cls.not_in_counter = product_recipe.make(
selling_price=3.5, product_type=product_type
)
cls.cotiz = product_recipe.make(selling_price=10, product_type=product_type)
cls.group_public.products.add(cls.snack, cls.beer, cls.not_in_counter)
cls.group_cotiz.products.add(cls.cotiz)
cls.subscriber.groups.add(cls.group_cotiz, cls.group_public)
cls.new_customer.groups.add(cls.group_public)
cls.new_customer_adult.groups.add(cls.group_public)
cls.eboutic = get_eboutic()
cls.eboutic.products.add(cls.cotiz, cls.beer, cls.snack)
@classmethod
def set_age(cls, user: User, age: int):
user.date_of_birth = localdate().replace(year=localdate().year - age)
user.save()
def submit_basket(
self, basket: list[BasketItem], client: Client | None = None
) -> HttpResponse:
used_client: Client = client if client is not None else self.client
data = {
"form-TOTAL_FORMS": str(len(basket)),
"form-INITIAL_FORMS": "0",
}
for index, item in enumerate(basket):
data.update(item.to_form(index))
return used_client.post(reverse("eboutic:main"), data)
def test_submit_empty_basket(self):
self.client.force_login(self.subscriber)
for basket in [
[],
[BasketItem(None, None)],
[BasketItem(None, None), BasketItem(None, None)],
]:
response = self.submit_basket(basket)
assert response.status_code == 200
assert "Votre panier est vide" in response.text
def test_submit_invalid_basket(self):
self.client.force_login(self.subscriber)
for item in [
BasketItem(-1, 2),
BasketItem(self.snack.id, -1),
BasketItem(None, 1),
BasketItem(self.snack.id, None),
]:
response = self.submit_basket([item])
assert response.status_code == 200
def test_anonymous(self):
assertRedirects(
self.client.get(reverse("eboutic:main")),
reverse("core:login", query={"next": reverse("eboutic:main")}),
)
assertRedirects(
self.submit_basket([]),
reverse("core:login", query={"next": reverse("eboutic:main")}),
)
assertRedirects(
self.submit_basket([BasketItem(self.snack.id, 1)]),
reverse("core:login", query={"next": reverse("eboutic:main")}),
)
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
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
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
def test_create_basket(self):
self.client.force_login(self.new_customer)
assertRedirects(
self.submit_basket([BasketItem(self.snack.id, 2)]),
reverse("eboutic:checkout", kwargs={"basket_id": 1}),
)
assert Basket.objects.get(id=1).total == self.snack.selling_price * 2
self.client.force_login(self.new_customer_adult)
assertRedirects(
self.submit_basket(
[BasketItem(self.snack.id, 2), BasketItem(self.beer.id, 1)]
),
reverse("eboutic:checkout", kwargs={"basket_id": 2}),
)
assert (
Basket.objects.get(id=2).total
== self.snack.selling_price * 2 + self.beer.selling_price
)
self.client.force_login(self.subscriber)
assertRedirects(
self.submit_basket(
[
BasketItem(self.snack.id, 2),
BasketItem(self.beer.id, 1),
BasketItem(self.cotiz.id, 1),
]
),
reverse("eboutic:checkout", kwargs={"basket_id": 3}),
)
assert (
Basket.objects.get(id=3).total
== self.snack.selling_price * 2
+ self.beer.selling_price
+ self.cotiz.selling_price
)

View File

@ -0,0 +1,269 @@
import base64
import urllib
from decimal import Decimal
from typing import TYPE_CHECKING
from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
from cryptography.hazmat.primitives.hashes import SHA1
from cryptography.hazmat.primitives.serialization import load_pem_private_key
from django.conf import settings
from django.contrib.messages import get_messages
from django.contrib.messages.constants import DEFAULT_LEVELS
from django.test import TestCase
from django.urls import reverse
from model_bakery import baker
from pytest_django.asserts import assertRedirects
from core.baker_recipes import old_subscriber_user, subscriber_user
from counter.baker_recipes import product_recipe
from counter.models import Product, ProductType, Selling
from counter.tests.test_counter import force_refill_user
from eboutic.models import Basket, BasketItem
if TYPE_CHECKING:
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
class TestPaymentBase(TestCase):
@classmethod
def setUpTestData(cls):
cls.customer = subscriber_user.make()
cls.basket = baker.make(Basket, user=cls.customer)
cls.refilling = product_recipe.make(
product_type_id=settings.SITH_COUNTER_PRODUCTTYPE_REFILLING,
selling_price=15,
)
product_type = baker.make(ProductType)
cls.snack = product_recipe.make(
selling_price=1.5, special_selling_price=1, product_type=product_type
)
cls.beer = product_recipe.make(
limit_age=18,
selling_price=2.5,
special_selling_price=1,
product_type=product_type,
)
BasketItem.from_product(cls.snack, 1, cls.basket).save()
BasketItem.from_product(cls.beer, 2, cls.basket).save()
class TestPaymentSith(TestPaymentBase):
def test_anonymous(self):
response = self.client.post(
reverse("eboutic:pay_with_sith", kwargs={"basket_id": self.basket.id})
)
assert response.status_code == 403
assert Basket.objects.contains(self.basket), (
"After an unsuccessful request, the basket should be kept"
)
def test_unauthorized(self):
self.client.force_login(subscriber_user.make())
response = self.client.post(
reverse("eboutic:pay_with_sith", kwargs={"basket_id": self.basket.id})
)
assert response.status_code == 403
assert Basket.objects.contains(self.basket), (
"After an unsuccessful request, the basket should be kept"
)
def test_not_found(self):
self.client.force_login(self.customer)
response = self.client.post(
reverse("eboutic:pay_with_sith", kwargs={"basket_id": self.basket.id + 1})
)
assert response.status_code == 404
assert Basket.objects.contains(self.basket), (
"After an unsuccessful request, the basket should be kept"
)
def test_only_post_allowed(self):
self.client.force_login(self.customer)
force_refill_user(self.customer, self.basket.total + 1)
response = self.client.get(
reverse("eboutic:pay_with_sith", kwargs={"basket_id": self.basket.id})
)
assert response.status_code == 405
assert Basket.objects.contains(self.basket), (
"After an unsuccessful request, the basket should be kept"
)
self.customer.customer.refresh_from_db()
assert self.customer.customer.amount == self.basket.total + 1
def test_buy_success(self):
self.client.force_login(self.customer)
force_refill_user(self.customer, self.basket.total + 1)
assertRedirects(
self.client.post(
reverse("eboutic:pay_with_sith", kwargs={"basket_id": self.basket.id}),
),
reverse("eboutic:payment_result", kwargs={"result": "success"}),
)
assert Basket.objects.filter(id=self.basket.id).first() is None
self.customer.customer.refresh_from_db()
assert self.customer.customer.amount == Decimal("1")
sellings = Selling.objects.filter(customer=self.customer.customer).order_by(
"quantity"
)
assert len(sellings) == 2
assert sellings[0].payment_method == "SITH_ACCOUNT"
assert sellings[0].quantity == 1
assert sellings[0].unit_price == self.snack.selling_price
assert sellings[0].counter.type == "EBOUTIC"
assert sellings[0].product == self.snack
assert sellings[1].payment_method == "SITH_ACCOUNT"
assert sellings[1].quantity == 2
assert sellings[1].unit_price == self.beer.selling_price
assert sellings[1].counter.type == "EBOUTIC"
assert sellings[1].product == self.beer
def test_not_enough_money(self):
self.client.force_login(self.customer)
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 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"
)
def test_refilling_in_basket(self):
BasketItem.from_product(self.refilling, 1, self.basket).save()
self.client.force_login(self.customer)
force_refill_user(self.customer, self.basket.total + 1)
self.customer.customer.refresh_from_db()
initial_account_balance = self.customer.customer.amount
response = self.client.post(
reverse("eboutic:pay_with_sith", kwargs={"basket_id": self.basket.id})
)
assertRedirects(
response,
reverse("eboutic:payment_result", kwargs={"result": "failure"}),
)
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 (
messages[0].message
== "Vous ne pouvez pas acheter un rechargement avec de l'argent du sith"
)
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):
query = (
f"Amount={int(basket.total * 100)}&BasketID={basket.id}&Auto=42&Error=00000"
)
with open("./eboutic/tests/private_key.pem", "br") as f:
PRIVKEY = f.read()
with open("./eboutic/tests/public_key.pem") as f:
settings.SITH_EBOUTIC_PUB_KEY = f.read()
key: RSAPrivateKey = load_pem_private_key(PRIVKEY, None)
sig = key.sign(query.encode("utf-8"), PKCS1v15(), SHA1())
b64sig = base64.b64encode(sig).decode("ascii")
url = reverse("eboutic:etransation_autoanswer") + "?%s&Sig=%s" % (
query,
urllib.parse.quote_plus(b64sig),
)
return url
def test_buy_success(self):
response = self.client.get(self.generate_bank_valid_answer(self.basket))
assert response.status_code == 200
assert response.content.decode("utf-8") == "Payment successful"
assert Basket.objects.filter(id=self.basket.id).first() is None
sellings = Selling.objects.filter(customer=self.customer.customer).order_by(
"quantity"
)
assert len(sellings) == 2
assert sellings[0].payment_method == "CARD"
assert sellings[0].quantity == 1
assert sellings[0].unit_price == self.snack.selling_price
assert sellings[0].counter.type == "EBOUTIC"
assert sellings[0].product == self.snack
assert sellings[1].payment_method == "CARD"
assert sellings[1].quantity == 2
assert sellings[1].unit_price == self.beer.selling_price
assert sellings[1].counter.type == "EBOUTIC"
assert sellings[1].product == self.beer
def test_buy_subscribe_product(self):
customer = old_subscriber_user.make()
assert customer.subscriptions.count() == 1
assert not customer.subscriptions.first().is_valid_now()
basket = baker.make(Basket, user=customer)
BasketItem.from_product(Product.objects.get(code="2SCOTIZ"), 1, basket).save()
response = self.client.get(self.generate_bank_valid_answer(basket))
assert response.status_code == 200
assert customer.subscriptions.count() == 2
subscription = customer.subscriptions.order_by("-subscription_end").first()
assert subscription.is_valid_now()
assert subscription.subscription_type == "deux-semestres"
assert subscription.location == "EBOUTIC"
def test_buy_refilling(self):
BasketItem.from_product(self.refilling, 2, self.basket).save()
response = self.client.get(self.generate_bank_valid_answer(self.basket))
assert response.status_code == 200
self.customer.customer.refresh_from_db()
assert self.customer.customer.amount == self.refilling.selling_price * 2
def test_multiple_responses(self):
bank_response = self.generate_bank_valid_answer(self.basket)
response = self.client.get(bank_response)
assert response.status_code == 200
response = self.client.get(bank_response)
assert response.status_code == 500
assert (
response.text
== "Basket processing failed with error: SuspiciousOperation('Basket does not exists')"
)
def test_unknown_basket(self):
bank_response = self.generate_bank_valid_answer(self.basket)
self.basket.delete()
response = self.client.get(bank_response)
assert response.status_code == 500
assert (
response.text
== "Basket processing failed with error: SuspiciousOperation('Basket does not exists')"
)
def test_altered_basket(self):
bank_response = self.generate_bank_valid_answer(self.basket)
BasketItem.from_product(self.snack, 1, self.basket).save()
response = self.client.get(bank_response)
assert response.status_code == 500
assert (
response.text == "Basket processing failed with error: "
"SuspiciousOperation('Basket total and amount do not match')"
)

View File

@ -1,254 +0,0 @@
#
# Copyright 2016,2017
# - Skia <skia@libskia.so>
# - Maréchal <thgirod@hotmail.com
#
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
# http://ae.utbm.fr.
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License a published by the Free Software
# Foundation; either version 3 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
import base64
import json
import urllib
from typing import TYPE_CHECKING
from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15
from cryptography.hazmat.primitives.hashes import SHA1
from cryptography.hazmat.primitives.serialization import load_pem_private_key
from django.conf import settings
from django.db.models import Max
from django.test import TestCase
from django.urls import reverse
from core.models import User
from counter.models import Counter, Customer, Product, Selling
from eboutic.models import Basket, BasketItem
if TYPE_CHECKING:
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
class TestEboutic(TestCase):
@classmethod
def setUpTestData(cls):
cls.barbar = Product.objects.get(code="BARB")
cls.refill = Product.objects.get(code="15REFILL")
cls.cotis = Product.objects.get(code="1SCOTIZ")
cls.eboutic = Counter.objects.get(name="Eboutic")
cls.skia = User.objects.get(username="skia")
cls.subscriber = User.objects.get(username="subscriber")
cls.old_subscriber = User.objects.get(username="old_subscriber")
cls.public = User.objects.get(username="public")
def get_busy_basket(self, user) -> Basket:
"""Create and return a basket with 3 barbar and 1 cotis in it.
Edit the client session to store the basket id in it.
"""
session = self.client.session
basket = Basket.objects.create(user=user)
session["basket_id"] = basket.id
session.save()
BasketItem.from_product(self.barbar, 3, basket).save()
BasketItem.from_product(self.cotis, 1, basket).save()
return basket
def generate_bank_valid_answer(self) -> str:
basket = Basket.from_session(self.client.session)
basket_id = basket.id
amount = int(basket.total * 100)
query = f"Amount={amount}&BasketID={basket_id}&Auto=42&Error=00000"
with open("./eboutic/tests/private_key.pem", "br") as f:
PRIVKEY = f.read()
with open("./eboutic/tests/public_key.pem") as f:
settings.SITH_EBOUTIC_PUB_KEY = f.read()
key: RSAPrivateKey = load_pem_private_key(PRIVKEY, None)
sig = key.sign(query.encode("utf-8"), PKCS1v15(), SHA1())
b64sig = base64.b64encode(sig).decode("ascii")
url = reverse("eboutic:etransation_autoanswer") + "?%s&Sig=%s" % (
query,
urllib.parse.quote_plus(b64sig),
)
return url
def test_buy_with_sith_account(self):
self.client.force_login(self.subscriber)
self.subscriber.customer.amount = 100 # give money before test
self.subscriber.customer.save()
basket = self.get_busy_basket(self.subscriber)
amount = basket.total
response = self.client.post(reverse("eboutic:pay_with_sith"))
self.assertRedirects(response, "/eboutic/pay/success/")
new_balance = Customer.objects.get(user=self.subscriber).amount
assert float(new_balance) == 100 - amount
expected = 'basket_items=""; expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0; Path=/eboutic'
assert expected == self.client.cookies["basket_items"].OutputString()
def test_buy_with_sith_account_no_money(self):
self.client.force_login(self.subscriber)
basket = self.get_busy_basket(self.subscriber)
initial = basket.total - 1 # just not enough to complete the sale
self.subscriber.customer.amount = initial
self.subscriber.customer.save()
response = self.client.post(reverse("eboutic:pay_with_sith"))
self.assertRedirects(response, "/eboutic/pay/failure/")
new_balance = Customer.objects.get(user=self.subscriber).amount
assert float(new_balance) == initial
# this cookie should be removed after payment
expected = 'basket_items=""; expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0; Path=/eboutic'
assert expected == self.client.cookies["basket_items"].OutputString()
def test_submit_basket(self):
self.client.force_login(self.subscriber)
self.client.cookies["basket_items"] = """[
{"id": 2, "name": "Cotis 2 semestres", "quantity": 1, "unit_price": 28},
{"id": 4, "name": "Barbar", "quantity": 3, "unit_price": 1.7}
]"""
response = self.client.get(reverse("eboutic:command"))
assert response.status_code == 200
self.assertInHTML(
"<tr><td>Cotis 2 semestres</td><td>1</td><td>28.00 €</td></tr>",
response.text,
)
self.assertInHTML(
"<tr><td>Barbar</td><td>3</td><td>1.70 €</td></tr>",
response.text,
)
assert "basket_id" in self.client.session
basket = Basket.objects.get(id=self.client.session["basket_id"])
assert basket.items.count() == 2
barbar = basket.items.filter(product_name="Barbar").first()
assert barbar is not None
assert barbar.quantity == 3
cotis = basket.items.filter(product_name="Cotis 2 semestres").first()
assert cotis is not None
assert cotis.quantity == 1
assert basket.total == 3 * 1.7 + 28
def test_submit_empty_basket(self):
self.client.force_login(self.subscriber)
self.client.cookies["basket_items"] = "[]"
response = self.client.get(reverse("eboutic:command"))
self.assertRedirects(response, "/eboutic/")
def test_submit_invalid_basket(self):
self.client.force_login(self.subscriber)
max_id = Product.objects.aggregate(res=Max("id"))["res"]
self.client.cookies["basket_items"] = f"""[
{{"id": {max_id + 1}, "name": "", "quantity": 1, "unit_price": 28}}
]"""
response = self.client.get(reverse("eboutic:command"))
cookie = self.client.cookies["basket_items"].OutputString()
assert 'basket_items="[]"' in cookie
assert "Path=/eboutic" in cookie
self.assertRedirects(response, "/eboutic/")
def test_submit_basket_illegal_quantity(self):
self.client.force_login(self.subscriber)
self.client.cookies["basket_items"] = """[
{"id": 4, "name": "Barbar", "quantity": -1, "unit_price": 1.7}
]"""
response = self.client.get(reverse("eboutic:command"))
self.assertRedirects(response, "/eboutic/")
def test_buy_subscribe_product_with_credit_card(self):
self.client.force_login(self.old_subscriber)
response = self.client.get(
reverse("core:user_profile", kwargs={"user_id": self.old_subscriber.id})
)
assert "Non cotisant" in str(response.content)
self.client.cookies["basket_items"] = """[
{"id": 2, "name": "Cotis 2 semestres", "quantity": 1, "unit_price": 28}
]"""
response = self.client.get(reverse("eboutic:command"))
self.assertInHTML(
"<tr><td>Cotis 2 semestres</td><td>1</td><td>28.00 €</td></tr>",
response.text,
)
basket = Basket.objects.get(id=self.client.session["basket_id"])
assert basket.items.count() == 1
response = self.client.get(self.generate_bank_valid_answer())
assert response.status_code == 200
assert response.content.decode("utf-8") == "Payment successful"
subscriber = User.objects.get(id=self.old_subscriber.id)
assert subscriber.subscriptions.count() == 2
sub = subscriber.subscriptions.order_by("-subscription_end").first()
assert sub.is_valid_now()
assert sub.member == subscriber
assert sub.subscription_type == "deux-semestres"
assert sub.location == "EBOUTIC"
def test_buy_refill_product_with_credit_card(self):
self.client.force_login(self.subscriber)
# basket contains 1 refill item worth 15€
self.client.cookies["basket_items"] = json.dumps(
[{"id": 3, "name": "Rechargement 15 €", "quantity": 1, "unit_price": 15}]
)
initial_balance = self.subscriber.customer.amount
self.client.get(reverse("eboutic:command"))
url = self.generate_bank_valid_answer()
response = self.client.get(url)
assert response.status_code == 200
assert response.text == "Payment successful"
new_balance = Customer.objects.get(user=self.subscriber).amount
assert new_balance == initial_balance + 15
def test_alter_basket_after_submission(self):
self.client.force_login(self.subscriber)
self.client.cookies["basket_items"] = json.dumps(
[{"id": 4, "name": "Barbar", "quantity": 1, "unit_price": 1.7}]
)
self.client.get(reverse("eboutic:command"))
et_answer_url = self.generate_bank_valid_answer()
self.client.cookies["basket_items"] = json.dumps(
[ # alter basket
{"id": 4, "name": "Barbar", "quantity": 3, "unit_price": 1.7}
]
)
self.client.get(reverse("eboutic:command"))
response = self.client.get(et_answer_url)
assert response.status_code == 500
msg = (
"Basket processing failed with error: "
"SuspiciousOperation('Basket total and amount do not match'"
)
assert msg in response.content.decode("utf-8")
def test_buy_simple_product_with_credit_card(self):
self.client.force_login(self.subscriber)
self.client.cookies["basket_items"] = json.dumps(
[{"id": 4, "name": "Barbar", "quantity": 1, "unit_price": 1.7}]
)
self.client.get(reverse("eboutic:command"))
et_answer_url = self.generate_bank_valid_answer()
response = self.client.get(et_answer_url)
assert response.status_code == 200
assert response.content.decode("utf-8") == "Payment successful"
selling = (
Selling.objects.filter(customer=self.subscriber.customer)
.order_by("-date")
.first()
)
assert selling.payment_method == "CARD"
assert selling.quantity == 1
assert selling.unit_price == self.barbar.selling_price
assert selling.counter.type == "EBOUTIC"
assert selling.product == self.barbar

View File

@ -27,11 +27,11 @@ from django.urls import path, register_converter
from eboutic.converters import PaymentResultConverter
from eboutic.views import (
BillingInfoFormFragment,
EbouticCommand,
EbouticCheckout,
EbouticMainView,
EbouticPayWithSith,
EtransactionAutoAnswer,
EurokPartnerFragment,
eboutic_main,
pay_with_sith,
payment_result,
)
@ -39,10 +39,12 @@ register_converter(PaymentResultConverter, "res")
urlpatterns = [
# Subscription views
path("", eboutic_main, name="main"),
path("command/", EbouticCommand.as_view(), name="command"),
path("", EbouticMainView.as_view(), name="main"),
path("checkout/<int:basket_id>", EbouticCheckout.as_view(), name="checkout"),
path("billing-infos/", BillingInfoFormFragment.as_view(), name="billing_infos"),
path("pay/sith/", pay_with_sith, name="pay_with_sith"),
path(
"pay/sith/<int:basket_id>", EbouticPayWithSith.as_view(), name="pay_with_sith"
),
path("pay/<res:result>/", payment_result, name="payment_result"),
path("eurok/", EurokPartnerFragment.as_view(), name="eurok"),
path(

View File

@ -18,7 +18,6 @@ from __future__ import annotations
import base64
import contextlib
import json
from datetime import datetime
from typing import TYPE_CHECKING
import sentry_sdk
@ -33,23 +32,23 @@ from django.contrib.auth.mixins import (
LoginRequiredMixin,
)
from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import SuspiciousOperation
from django.core.exceptions import SuspiciousOperation, ValidationError
from django.db import DatabaseError, transaction
from django.db.models.fields import forms
from django.db.utils import cached_property
from django.http import HttpRequest, HttpResponse
from django.http import HttpResponse
from django.shortcuts import redirect, render
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.utils.translation import gettext_lazy as _
from django.views.decorators.http import require_GET, require_POST
from django.views.generic import TemplateView, UpdateView, View
from django.views.decorators.http import require_GET
from django.views.generic import DetailView, FormView, TemplateView, UpdateView, View
from django.views.generic.edit import SingleObjectMixin
from django_countries.fields import Country
from core.auth.mixins import IsSubscriberMixin
from core.auth.mixins import CanViewMixin, IsSubscriberMixin
from core.views.mixins import FragmentMixin, UseFragmentsMixin
from counter.forms import BillingInfoForm
from counter.models import BillingInfo, Counter, Customer, Product
from eboutic.forms import BasketForm
from counter.forms import BaseBasketForm, BillingInfoForm, ProductForm
from counter.models import BillingInfo, Customer, Product, Selling, get_eboutic
from eboutic.models import (
Basket,
BasketItem,
@ -58,39 +57,82 @@ from eboutic.models import (
InvoiceItem,
get_eboutic_products,
)
from eboutic.schemas import PurchaseItemList, PurchaseItemSchema
if TYPE_CHECKING:
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
from django.utils.html import SafeString
@login_required
@require_GET
def eboutic_main(request: HttpRequest) -> HttpResponse:
"""Main view of the eboutic application.
class BaseEbouticBasketForm(BaseBasketForm):
def _check_enough_money(self, *args, **kwargs):
# Disable money check
...
Return an Http response whose content is of type text/html.
The latter represents the page from which a user can see
the catalogue of products that he can buy and fill
his shopping cart.
EbouticBasketForm = forms.formset_factory(
ProductForm, formset=BaseEbouticBasketForm, absolute_max=None, min_num=1
)
class EbouticMainView(LoginRequiredMixin, FormView):
"""Main view of the eboutic application.
The purchasable products are those of the eboutic which
belong to a category of products of a product category
(orphan products are inaccessible).
If the session contains a key-value pair that associates "errors"
with a list of strings, this pair is removed from the session
and its value displayed to the user when the page is rendered.
"""
errors = request.session.pop("errors", None)
products = get_eboutic_products(request.user)
context = {
"errors": errors,
"products": products,
"customer_amount": request.user.account_balance,
template_name = "eboutic/eboutic_main.jinja"
form_class = EbouticBasketForm
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs["form_kwargs"] = {
"customer": self.customer,
"counter": get_eboutic(),
"allowed_products": {product.id: product for product in self.products},
}
return render(request, "eboutic/eboutic_main.jinja", context)
return kwargs
def form_valid(self, formset):
if len(formset) == 0:
formset.errors.append(_("Your basket is empty"))
return self.form_invalid(formset)
with transaction.atomic():
self.basket = Basket.objects.create(user=self.request.user)
for form in formset:
BasketItem.from_product(
form.product, form.cleaned_data["quantity"], self.basket
).save()
self.basket.save()
return super().form_valid(formset)
def get_success_url(self):
return reverse("eboutic:checkout", kwargs={"basket_id": self.basket.id})
@cached_property
def products(self) -> list[Product]:
return get_eboutic_products(self.request.user)
@cached_property
def customer(self) -> Customer:
return Customer.get_or_create(self.request.user)[0]
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["products"] = self.products
context["customer_amount"] = self.request.user.account_balance
last_purchase: Selling | None = (
self.customer.buyings.filter(counter__type="EBOUTIC")
.order_by("-date")
.first()
)
context["last_purchase_time"] = (
int(last_purchase.date.timestamp() * 1000) if last_purchase else "null"
)
return context
@require_GET
@ -166,48 +208,15 @@ class BillingInfoFormFragment(
return self.request.path
class EbouticCommand(LoginRequiredMixin, UseFragmentsMixin, TemplateView):
template_name = "eboutic/eboutic_makecommand.jinja"
basket: Basket
class EbouticCheckout(CanViewMixin, UseFragmentsMixin, DetailView):
model = Basket
pk_url_kwarg = "basket_id"
context_object_name = "basket"
template_name = "eboutic/eboutic_checkout.jinja"
fragments = {
"billing_infos_form": BillingInfoFormFragment,
}
@method_decorator(login_required)
def post(self, request, *args, **kwargs):
return redirect("eboutic:main")
def get(self, request: HttpRequest, *args, **kwargs):
form = BasketForm(request)
if not form.is_valid():
request.session["errors"] = form.errors
request.session.modified = True
res = redirect("eboutic:main")
res.set_cookie(
"basket_items",
PurchaseItemList.dump_json(form.cleaned_data, by_alias=True).decode(),
path="/eboutic",
)
return res
basket = Basket.from_session(request.session)
if basket is not None:
basket.items.all().delete()
else:
basket = Basket.objects.create(user=request.user)
request.session["basket_id"] = basket.id
request.session.modified = True
items: list[PurchaseItemSchema] = form.cleaned_data
pks = {item.product_id for item in items}
products = {p.pk: p for p in Product.objects.filter(pk__in=pks)}
db_items = []
for pk in pks:
quantity = sum(i.quantity for i in items if i.product_id == pk)
db_items.append(BasketItem.from_product(products[pk], quantity, basket))
BasketItem.objects.bulk_create(db_items)
self.basket = basket
return super().get(request)
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
if hasattr(self.request.user, "customer"):
@ -215,32 +224,31 @@ class EbouticCommand(LoginRequiredMixin, UseFragmentsMixin, TemplateView):
kwargs["customer_amount"] = customer.amount
else:
kwargs["customer_amount"] = None
kwargs["basket"] = self.basket
kwargs["billing_infos"] = {}
with contextlib.suppress(BillingInfo.DoesNotExist):
kwargs["billing_infos"] = json.dumps(
dict(self.basket.get_e_transaction_data())
dict(self.object.get_e_transaction_data())
)
return kwargs
@login_required
@require_POST
def pay_with_sith(request):
basket = Basket.from_session(request.session)
class EbouticPayWithSith(CanViewMixin, SingleObjectMixin, View):
model = Basket
pk_url_kwarg = "basket_id"
def post(self, request, *args, **kwargs):
basket = self.get_object()
refilling = settings.SITH_COUNTER_PRODUCTTYPE_REFILLING
if basket is None or basket.items.filter(type_id=refilling).exists():
return redirect("eboutic:main")
c = Customer.objects.filter(user__id=basket.user_id).first()
if c is None:
return redirect("eboutic:main")
if c.amount < basket.total:
res = redirect("eboutic:payment_result", "failure")
res.delete_cookie("basket_items", "/eboutic")
return res
eboutic = Counter.objects.get(type="EBOUTIC")
sales = basket.generate_sales(eboutic, c.user, "SITH_ACCOUNT")
if basket.items.filter(type_id=refilling).exists():
messages.error(
self.request,
_("You can't buy a refilling with sith money"),
)
return redirect("eboutic:payment_result", "failure")
eboutic = get_eboutic()
sales = basket.generate_sales(eboutic, basket.user, "SITH_ACCOUNT")
try:
with transaction.atomic():
# Selling.save has some important business logic in it.
@ -248,18 +256,12 @@ def pay_with_sith(request):
for sale in sales:
sale.save()
basket.delete()
request.session.pop("basket_id", None)
res = redirect("eboutic:payment_result", "success")
return redirect("eboutic:payment_result", "success")
except DatabaseError as e:
with sentry_sdk.push_scope() as scope:
scope.user = {"username": request.user.username}
scope.set_extra("someVariable", e.__repr__())
sentry_sdk.capture_message(
f"Erreur le {datetime.now()} dans eboutic.pay_with_sith"
)
res = redirect("eboutic:payment_result", "failure")
res.delete_cookie("basket_items", "/eboutic")
return res
sentry_sdk.capture_exception(e)
except ValidationError as e:
messages.error(self.request, e.message)
return redirect("eboutic:payment_result", "failure")
class EtransactionAutoAnswer(View):

View File

@ -6,7 +6,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-04-14 01:16+0200\n"
"POT-Creation-Date: 2025-04-15 23:39+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"
@ -1764,8 +1764,8 @@ msgstr "Photos"
#: core/templates/core/base/navbar.jinja counter/models.py
#: counter/templates/counter/counter_list.jinja
#: eboutic/templates/eboutic/eboutic_checkout.jinja
#: eboutic/templates/eboutic/eboutic_main.jinja
#: eboutic/templates/eboutic/eboutic_makecommand.jinja
#: eboutic/templates/eboutic/eboutic_payment_result.jinja sith/settings.py
msgid "Eboutic"
msgstr "Eboutic"
@ -2882,6 +2882,30 @@ msgstr ""
msgid "Refound this account"
msgstr "Rembourser ce compte"
#: counter/forms.py
msgid "The selected product isn't available for this user"
msgstr "Le produit sélectionné n'est pas disponnible pour cet utilisateur"
#: counter/forms.py
msgid "Submitted basket is invalid"
msgstr "Le panier envoyé est invalide"
#: counter/forms.py
msgid "Duplicated product entries."
msgstr "Saisie de produit dupliquée"
#: counter/forms.py counter/models.py
msgid "Not enough money"
msgstr "Solde insuffisant"
#: counter/forms.py
#, python-format
msgid ""
"This user have reached his recording limit for the following products : %s"
msgstr ""
"Cet utilisateur a atteint sa limite de déconsigne pour les produits "
"suivants : %s"
#: counter/management/commands/dump_accounts.py
msgid "Your AE account has been emptied"
msgstr "Votre compte AE a été vidé"
@ -2906,10 +2930,6 @@ msgstr "client"
msgid "customers"
msgstr "clients"
#: counter/models.py counter/views/click.py
msgid "Not enough money"
msgstr "Solde insuffisant"
#: counter/models.py
msgid "First name"
msgstr "Prénom"
@ -3278,7 +3298,7 @@ msgid "Go"
msgstr "Valider"
#: counter/templates/counter/counter_click.jinja
#: eboutic/templates/eboutic/eboutic_makecommand.jinja
#: eboutic/templates/eboutic/eboutic_checkout.jinja
msgid "Basket: "
msgstr "Panier : "
@ -3672,26 +3692,6 @@ msgstr "Montant du chèque"
msgid "Check quantity"
msgstr "Nombre de chèque"
#: counter/views/click.py
msgid "The selected product isn't available for this user"
msgstr "Le produit sélectionné n'est pas disponnible pour cet utilisateur"
#: counter/views/click.py
msgid "Submitted basket is invalid"
msgstr "Le panier envoyé est invalide"
#: counter/views/click.py
msgid "Duplicated product entries."
msgstr "Saisie de produit dupliquée"
#: counter/views/click.py
#, python-format
msgid ""
"This user have reached his recording limit for the following products : %s"
msgstr ""
"Cet utilisateur a atteint sa limite de déconsigne pour les produits "
"suivants : %s"
#: counter/views/eticket.py
msgid "people(s)"
msgstr "personne(s)"
@ -3729,19 +3729,6 @@ msgstr "Types de produit"
msgid "%(name)s has no registered student card"
msgstr "%(name)s n'a pas de carte étudiante enregistrée"
#: eboutic/forms.py
msgid "The request was badly formatted."
msgstr "La requête a été mal formatée."
#: eboutic/forms.py
msgid "Your basket is empty."
msgstr "Votre panier est vide"
#: eboutic/forms.py
#, python-format
msgid "%(name)s : this product does not exist or may no longer be available."
msgstr "%(name)s : ce produit n'existe pas ou n'est peut-être plus disponible."
#: eboutic/models.py
msgid "validated"
msgstr "validé"
@ -3779,15 +3766,44 @@ msgstr "Informations de facturation"
msgid "Validate"
msgstr "Valider"
#: eboutic/templates/eboutic/eboutic_checkout.jinja
msgid "Basket state"
msgstr "État du panier"
#: eboutic/templates/eboutic/eboutic_checkout.jinja
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid "Basket amount: "
msgstr "Valeur du panier : "
#: eboutic/templates/eboutic/eboutic_checkout.jinja
#: eboutic/templates/eboutic/eboutic_main.jinja
#: eboutic/templates/eboutic/eboutic_makecommand.jinja
msgid "Current account amount: "
msgstr "Solde actuel : "
#: eboutic/templates/eboutic/eboutic_main.jinja
#: eboutic/templates/eboutic/eboutic_makecommand.jinja
msgid "Basket amount: "
msgstr "Valeur du panier : "
#: eboutic/templates/eboutic/eboutic_checkout.jinja
msgid "Remaining account amount: "
msgstr "Solde restant : "
#: eboutic/templates/eboutic/eboutic_checkout.jinja
msgid "Pay with credit card"
msgstr "Payer avec une carte bancaire"
#: eboutic/templates/eboutic/eboutic_checkout.jinja
msgid ""
"AE account payment disabled because your basket contains refilling items."
msgstr ""
"Paiement par compte AE désactivé parce que votre panier contient des bons de "
"rechargement."
#: eboutic/templates/eboutic/eboutic_checkout.jinja
msgid ""
"AE account payment disabled because you do not have enough money remaining."
msgstr ""
"Paiement par compte AE désactivé parce que votre solde est insuffisant."
#: eboutic/templates/eboutic/eboutic_checkout.jinja
msgid "Pay with Sith account"
msgstr "Payer avec un compte AE"
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid "Clear"
@ -3815,12 +3831,13 @@ msgstr "Partenariat Eurockéennes 2025"
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid ""
"Our partner uses Weezevent to sell tickets. Weezevent may collect user info "
"according to it's own privacy policy. By clicking the accept button you "
"according to its own privacy policy. By clicking the accept button you "
"consent to their terms of services."
msgstr ""
"Notre partenaire utilises Wezevent pour vendre ses billets. Weezevent peut collecter des informatinos utilisateur "
"conformément à sa propre politique de confidentialité. En cliquant sur le bouton d'acceptation vous "
"consentez à leurs termes de service."
"Notre partenaire utilises Wezevent pour vendre ses billets. Weezevent peut "
"collecter des informations utilisateur conformément à sa propre politique de "
"confidentialité. En cliquant sur le bouton d'acceptation vous consentez à "
"leurs termes de service."
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid "Privacy policy"
@ -3852,35 +3869,6 @@ msgstr ""
msgid "There are no items available for sale"
msgstr "Aucun article n'est disponible à la vente"
#: eboutic/templates/eboutic/eboutic_makecommand.jinja
msgid "Basket state"
msgstr "État du panier"
#: eboutic/templates/eboutic/eboutic_makecommand.jinja
msgid "Remaining account amount: "
msgstr "Solde restant : "
#: eboutic/templates/eboutic/eboutic_makecommand.jinja
msgid "Pay with credit card"
msgstr "Payer avec une carte bancaire"
#: eboutic/templates/eboutic/eboutic_makecommand.jinja
msgid ""
"AE account payment disabled because your basket contains refilling items."
msgstr ""
"Paiement par compte AE désactivé parce que votre panier contient des bons de "
"rechargement."
#: eboutic/templates/eboutic/eboutic_makecommand.jinja
msgid ""
"AE account payment disabled because you do not have enough money remaining."
msgstr ""
"Paiement par compte AE désactivé parce que votre solde est insuffisant."
#: eboutic/templates/eboutic/eboutic_makecommand.jinja
msgid "Pay with Sith account"
msgstr "Payer avec un compte AE"
#: eboutic/templates/eboutic/eboutic_payment_result.jinja
msgid "Payment successful"
msgstr "Le paiement a été effectué"
@ -3893,6 +3881,10 @@ msgstr "Le paiement a échoué"
msgid "Return to eboutic"
msgstr "Retourner à l'eboutic"
#: eboutic/views.py
msgid "Your basket is empty"
msgstr "Votre panier est vide"
#: eboutic/views.py
msgid "Billing info registration success"
msgstr "Informations de facturation enregistrées"
@ -3916,6 +3908,10 @@ 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 "You can't buy a refilling with sith money"
msgstr "Vous ne pouvez pas acheter un rechargement avec de l'argent du sith"
#: election/models.py
msgid "start candidature"
msgstr "début des candidatures"