diff --git a/counter/forms.py b/counter/forms.py index 27dc74d7..c7df1d9d 100644 --- a/counter/forms.py +++ b/counter/forms.py @@ -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 +) diff --git a/counter/models.py b/counter/models.py index 8581b19d..3515e081 100644 --- a/counter/models.py +++ b/counter/models.py @@ -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. diff --git a/counter/tests/test_counter.py b/counter/tests/test_counter.py index 02575884..5b96a471 100644 --- a/counter/tests/test_counter.py +++ b/counter/tests/test_counter.py @@ -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 diff --git a/counter/views/click.py b/counter/views/click.py index 4da38643..02c0bdaa 100644 --- a/counter/views/click.py +++ b/counter/views/click.py @@ -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 ): diff --git a/eboutic/README.md b/eboutic/README.md index a4feb639..fa68016b 100644 --- a/eboutic/README.md +++ b/eboutic/README.md @@ -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": , "name": , "quantity": , "unit_price": }, - {"id": , "name": , "quantity": , "unit_price": }, - -] -``` -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 diff --git a/eboutic/api.py b/eboutic/api.py index 3c2a1dc2..2041fb87 100644 --- a/eboutic/api.py +++ b/eboutic/api.py @@ -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: diff --git a/eboutic/forms.py b/eboutic/forms.py deleted file mode 100644 index e15714cf..00000000 --- a/eboutic/forms.py +++ /dev/null @@ -1,128 +0,0 @@ -# -# Copyright 2022 -# - Maréchal 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': , 'quantity': , - 'name': , 'unit_price': }, ...]`. 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 diff --git a/eboutic/models.py b/eboutic/models.py index b6daaf34..839f8fec 100644 --- a/eboutic/models.py +++ b/eboutic/models.py @@ -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, diff --git a/eboutic/static/bundled/eboutic/makecommand-index.ts b/eboutic/static/bundled/eboutic/checkout-index.ts similarity index 56% rename from eboutic/static/bundled/eboutic/makecommand-index.ts rename to eboutic/static/bundled/eboutic/checkout-index.ts index 47e81cb6..cb4be7f0 100644 --- a/eboutic/static/bundled/eboutic/makecommand-index.ts +++ b/eboutic/static/bundled/eboutic/checkout-index.ts @@ -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; diff --git a/eboutic/static/bundled/eboutic/eboutic-index.ts b/eboutic/static/bundled/eboutic/eboutic-index.ts index 1ff9a2da..434d839a 100644 --- a/eboutic/static/bundled/eboutic/eboutic-index.ts +++ b/eboutic/static/bundled/eboutic/eboutic-index.ts @@ -8,55 +8,55 @@ interface BasketItem { unit_price: number; } -const BASKET_ITEMS_COOKIE_NAME: string = "basket_items"; - -/** - * 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; - } - - const found = document.cookie - .split(";") - .map((c) => c.trim()) - .find((c) => c.startsWith(`${name}=`)); - - 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) { - 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)); -} - document.addEventListener("alpine:init", () => { - Alpine.data("basket", () => ({ - items: getStartingItems() as BasketItem[], + Alpine.data("basket", (lastPurchaseTime?: number) => ({ + basket: [] as BasketItem[], + + 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 = []; + } + } + + // 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"); + }, + + loadBasket(): BasketItem[] { + if (localStorage.basket === undefined) { + return []; + } + try { + return JSON.parse(localStorage.basket); + } catch (_err) { + return []; + } + }, + + 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 diff --git a/eboutic/static/eboutic/css/eboutic.css b/eboutic/static/eboutic/css/eboutic.css index 6ca6beef..57e56c6a 100644 --- a/eboutic/static/eboutic/css/eboutic.css +++ b/eboutic/static/eboutic/css/eboutic.css @@ -61,6 +61,7 @@ word-break: break-word; width: 100%; line-height: 100%; + white-space: normal; } #eboutic .fa-plus, diff --git a/eboutic/templates/eboutic/eboutic_makecommand.jinja b/eboutic/templates/eboutic/eboutic_checkout.jinja similarity index 91% rename from eboutic/templates/eboutic/eboutic_makecommand.jinja rename to eboutic/templates/eboutic/eboutic_checkout.jinja index 1869988f..5ceeafc2 100644 --- a/eboutic/templates/eboutic/eboutic_makecommand.jinja +++ b/eboutic/templates/eboutic/eboutic_checkout.jinja @@ -9,7 +9,7 @@ {% endblock %} {% block additional_js %} - + {% endblock %} {% block content %} @@ -19,7 +19,7 @@ let billingInfos = {{ billing_infos|safe }}; -
+

{% trans %}Basket: {% endtrans %}

@@ -82,9 +82,8 @@ {% elif basket.total > user.account_balance %}

{% trans %}AE account payment disabled because you do not have enough money remaining.{% endtrans %}

{% else %} - + {% csrf_token %} - {% endif %} diff --git a/eboutic/templates/eboutic/eboutic_main.jinja b/eboutic/templates/eboutic/eboutic_main.jinja index 71757436..97b4c8c4 100644 --- a/eboutic/templates/eboutic/eboutic_main.jinja +++ b/eboutic/templates/eboutic/eboutic_main.jinja @@ -21,57 +21,90 @@ {% block content %}

{% trans %}Eboutic{% endtrans %}

-
+ + {% if messages %} + {% for message in messages %} +
+ {{ message }} +
+ {% endfor %} + {% endif %} + +

Panier

- {% if errors %} -
-
- {% for error in errors %} -

{{ error }}

- {% endfor %} -
+
+ {% csrf_token %} +
+ {{ form.management_form }}
- {% endif %} -
    - {# Starting money #} -
  • - - {% trans %}Current account amount: {% endtrans %} - - - {{ "%0.2f"|format(customer_amount) }} € - -
  • - - {# Total price #} -
  • - {% trans %}Basket amount: {% endtrans %} - -
  • -
-
- - + + + {# Total price #} +
  • + {% trans %}Basket amount: {% endtrans %} + +
  • + +
    + - -
    +
    +
    {% 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 @@
    ", - response.text, - ) - self.assertInHTML( - "", - 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( - "", - 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 diff --git a/eboutic/urls.py b/eboutic/urls.py index 2286b6fd..54afd428 100644 --- a/eboutic/urls.py +++ b/eboutic/urls.py @@ -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/", 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/", EbouticPayWithSith.as_view(), name="pay_with_sith" + ), path("pay//", payment_result, name="payment_result"), path("eurok/", EurokPartnerFragment.as_view(), name="eurok"), path( diff --git a/eboutic/views.py b/eboutic/views.py index b5328719..869fe7e9 100644 --- a/eboutic/views.py +++ b/eboutic/views.py @@ -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, - } - return render(request, "eboutic/eboutic_main.jinja", context) + + 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 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,51 +224,44 @@ 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) - 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") - try: - with transaction.atomic(): - # Selling.save has some important business logic in it. - # Do not bulk_create this - for sale in sales: - sale.save() - basket.delete() - request.session.pop("basket_id", None) - res = 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" +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.items.filter(type_id=refilling).exists(): + messages.error( + self.request, + _("You can't buy a refilling with sith money"), ) - res = redirect("eboutic:payment_result", "failure") - res.delete_cookie("basket_items", "/eboutic") - return res + 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. + # Do not bulk_create this + for sale in sales: + sale.save() + basket.delete() + return redirect("eboutic:payment_result", "success") + except DatabaseError as e: + sentry_sdk.capture_exception(e) + except ValidationError as e: + messages.error(self.request, e.message) + return redirect("eboutic:payment_result", "failure") class EtransactionAutoAnswer(View): diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 15d0a99d..b76ba3e3 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -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 \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"
    Cotis 2 semestres128.00 €
    Barbar31.70 €
    Cotis 2 semestres128.00 €