mirror of
https://github.com/ae-utbm/sith.git
synced 2025-04-29 21:06:45 +00:00
Merge pull request #1085 from ae-utbm/eboutic
Don't use cookies for processing eboutic baskets
This commit is contained in:
commit
df26ab4d50
112
counter/forms.py
112
counter/forms.py
@ -1,4 +1,7 @@
|
|||||||
|
import math
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
|
from django.db.models import Q
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from phonenumber_field.widgets import RegionalPhoneNumberWidget
|
from phonenumber_field.widgets import RegionalPhoneNumberWidget
|
||||||
|
|
||||||
@ -261,3 +264,112 @@ class CloseCustomerAccountForm(forms.Form):
|
|||||||
widget=AutoCompleteSelectUser,
|
widget=AutoCompleteSelectUser,
|
||||||
queryset=User.objects.all(),
|
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
|
||||||
|
)
|
||||||
|
@ -47,6 +47,10 @@ from counter.fields import CurrencyField
|
|||||||
from subscription.models import Subscription
|
from subscription.models import Subscription
|
||||||
|
|
||||||
|
|
||||||
|
def get_eboutic() -> Counter:
|
||||||
|
return Counter.objects.filter(type="EBOUTIC").order_by("id").first()
|
||||||
|
|
||||||
|
|
||||||
class CustomerQuerySet(models.QuerySet):
|
class CustomerQuerySet(models.QuerySet):
|
||||||
def update_amount(self) -> int:
|
def update_amount(self) -> int:
|
||||||
"""Update the amount of all customers selected by this queryset.
|
"""Update the amount of all customers selected by this queryset.
|
||||||
|
@ -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):
|
class TestFullClickBase(TestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
@ -226,11 +235,11 @@ class TestCounterClick(TestFullClickBase):
|
|||||||
cls.banned_counter_customer = subscriber_user.make()
|
cls.banned_counter_customer = subscriber_user.make()
|
||||||
cls.banned_alcohol_customer = subscriber_user.make()
|
cls.banned_alcohol_customer = subscriber_user.make()
|
||||||
|
|
||||||
cls.set_age(cls.customer, 20)
|
set_age(cls.customer, 20)
|
||||||
cls.set_age(cls.barmen, 20)
|
set_age(cls.barmen, 20)
|
||||||
cls.set_age(cls.club_admin, 20)
|
set_age(cls.club_admin, 20)
|
||||||
cls.set_age(cls.banned_alcohol_customer, 20)
|
set_age(cls.banned_alcohol_customer, 20)
|
||||||
cls.set_age(cls.underage_customer, 17)
|
set_age(cls.underage_customer, 17)
|
||||||
|
|
||||||
cls.banned_alcohol_customer.ban_groups.add(
|
cls.banned_alcohol_customer.ban_groups.add(
|
||||||
BanGroup.objects.get(pk=settings.SITH_GROUP_BANNED_ALCOHOL_ID)
|
BanGroup.objects.get(pk=settings.SITH_GROUP_BANNED_ALCOHOL_ID)
|
||||||
@ -278,11 +287,6 @@ class TestCounterClick(TestFullClickBase):
|
|||||||
{"username": used_barman.username, "password": "plop"},
|
{"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(
|
def submit_basket(
|
||||||
self,
|
self,
|
||||||
user: User,
|
user: User,
|
||||||
@ -306,9 +310,6 @@ class TestCounterClick(TestFullClickBase):
|
|||||||
data,
|
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):
|
def test_click_eboutic_failure(self):
|
||||||
eboutic = baker.make(Counter, type="EBOUTIC")
|
eboutic = baker.make(Counter, type="EBOUTIC")
|
||||||
self.client.force_login(self.club_admin)
|
self.client.force_login(self.club_admin)
|
||||||
@ -318,7 +319,7 @@ class TestCounterClick(TestFullClickBase):
|
|||||||
assert res.status_code == 404
|
assert res.status_code == 404
|
||||||
|
|
||||||
def test_click_office_success(self):
|
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)
|
self.client.force_login(self.club_admin)
|
||||||
res = self.submit_basket(
|
res = self.submit_basket(
|
||||||
self.customer, [BasketItem(self.stamps.id, 5)], counter=self.club_counter
|
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")
|
assert self.updated_amount(self.customer) == Decimal("2.5")
|
||||||
|
|
||||||
# Test no special price on office counter
|
# 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(
|
res = self.submit_basket(
|
||||||
self.club_admin, [BasketItem(self.stamps.id, 1)], counter=self.club_counter
|
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")
|
assert self.updated_amount(self.club_admin) == Decimal("8.5")
|
||||||
|
|
||||||
def test_click_bar_success(self):
|
def test_click_bar_success(self):
|
||||||
self.refill_user(self.customer, 10)
|
force_refill_user(self.customer, 10)
|
||||||
self.login_in_bar(self.barmen)
|
self.login_in_bar(self.barmen)
|
||||||
res = self.submit_basket(
|
res = self.submit_basket(
|
||||||
self.customer, [BasketItem(self.beer.id, 2), BasketItem(self.snack.id, 1)]
|
self.customer, [BasketItem(self.beer.id, 2), BasketItem(self.snack.id, 1)]
|
||||||
@ -347,7 +348,7 @@ class TestCounterClick(TestFullClickBase):
|
|||||||
|
|
||||||
# Test barmen special price
|
# Test barmen special price
|
||||||
|
|
||||||
self.refill_user(self.barmen, 10)
|
force_refill_user(self.barmen, 10)
|
||||||
|
|
||||||
assert (
|
assert (
|
||||||
self.submit_basket(self.barmen, [BasketItem(self.beer.id, 1)])
|
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")
|
assert self.updated_amount(self.barmen) == Decimal("9")
|
||||||
|
|
||||||
def test_click_tray_price(self):
|
def test_click_tray_price(self):
|
||||||
self.refill_user(self.customer, 20)
|
force_refill_user(self.customer, 20)
|
||||||
self.login_in_bar(self.barmen)
|
self.login_in_bar(self.barmen)
|
||||||
|
|
||||||
# Not applying tray price
|
# Not applying tray price
|
||||||
@ -373,7 +374,7 @@ class TestCounterClick(TestFullClickBase):
|
|||||||
self.login_in_bar()
|
self.login_in_bar()
|
||||||
|
|
||||||
for user in [self.underage_customer, self.banned_alcohol_customer]:
|
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
|
# Buy product without age limit
|
||||||
res = self.submit_basket(user, [BasketItem(self.snack.id, 2)])
|
res = self.submit_basket(user, [BasketItem(self.snack.id, 2)])
|
||||||
@ -394,7 +395,7 @@ class TestCounterClick(TestFullClickBase):
|
|||||||
self.banned_counter_customer,
|
self.banned_counter_customer,
|
||||||
self.customer_old_can_not_buy,
|
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)])
|
resp = self.submit_basket(user, [BasketItem(self.snack.id, 2)])
|
||||||
assert resp.status_code == 302
|
assert resp.status_code == 302
|
||||||
assert resp.url == resolve_url(self.counter)
|
assert resp.url == resolve_url(self.counter)
|
||||||
@ -410,7 +411,7 @@ class TestCounterClick(TestFullClickBase):
|
|||||||
|
|
||||||
def test_click_allowed_old_subscriber(self):
|
def test_click_allowed_old_subscriber(self):
|
||||||
self.login_in_bar()
|
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(
|
res = self.submit_basket(
|
||||||
self.customer_old_can_buy, [BasketItem(self.snack.id, 2)]
|
self.customer_old_can_buy, [BasketItem(self.snack.id, 2)]
|
||||||
)
|
)
|
||||||
@ -420,7 +421,7 @@ class TestCounterClick(TestFullClickBase):
|
|||||||
|
|
||||||
def test_click_wrong_counter(self):
|
def test_click_wrong_counter(self):
|
||||||
self.login_in_bar()
|
self.login_in_bar()
|
||||||
self.refill_user(self.customer, 10)
|
force_refill_user(self.customer, 10)
|
||||||
res = self.submit_basket(
|
res = self.submit_basket(
|
||||||
self.customer, [BasketItem(self.snack.id, 2)], counter=self.other_counter
|
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")
|
assert self.updated_amount(self.customer) == Decimal("10")
|
||||||
|
|
||||||
def test_click_not_connected(self):
|
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)])
|
res = self.submit_basket(self.customer, [BasketItem(self.snack.id, 2)])
|
||||||
assertRedirects(res, self.counter.get_absolute_url())
|
assertRedirects(res, self.counter.get_absolute_url())
|
||||||
|
|
||||||
@ -456,15 +457,29 @@ class TestCounterClick(TestFullClickBase):
|
|||||||
assert self.updated_amount(self.customer) == Decimal("10")
|
assert self.updated_amount(self.customer) == Decimal("10")
|
||||||
|
|
||||||
def test_click_product_not_in_counter(self):
|
def test_click_product_not_in_counter(self):
|
||||||
self.refill_user(self.customer, 10)
|
force_refill_user(self.customer, 10)
|
||||||
self.login_in_bar()
|
self.login_in_bar()
|
||||||
|
|
||||||
res = self.submit_basket(self.customer, [BasketItem(self.stamps.id, 2)])
|
res = self.submit_basket(self.customer, [BasketItem(self.stamps.id, 2)])
|
||||||
assert res.status_code == 200
|
assert res.status_code == 200
|
||||||
assert self.updated_amount(self.customer) == Decimal("10")
|
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):
|
def test_click_product_invalid(self):
|
||||||
self.refill_user(self.customer, 10)
|
force_refill_user(self.customer, 10)
|
||||||
self.login_in_bar()
|
self.login_in_bar()
|
||||||
|
|
||||||
for item in [
|
for item in [
|
||||||
@ -472,14 +487,12 @@ class TestCounterClick(TestFullClickBase):
|
|||||||
BasketItem(self.beer.id, -1),
|
BasketItem(self.beer.id, -1),
|
||||||
BasketItem(None, 1),
|
BasketItem(None, 1),
|
||||||
BasketItem(self.beer.id, None),
|
BasketItem(self.beer.id, None),
|
||||||
BasketItem(None, None),
|
|
||||||
]:
|
]:
|
||||||
assert self.submit_basket(self.customer, [item]).status_code == 200
|
assert self.submit_basket(self.customer, [item]).status_code == 200
|
||||||
|
|
||||||
assert self.updated_amount(self.customer) == Decimal("10")
|
assert self.updated_amount(self.customer) == Decimal("10")
|
||||||
|
|
||||||
def test_click_not_enough_money(self):
|
def test_click_not_enough_money(self):
|
||||||
self.refill_user(self.customer, 10)
|
force_refill_user(self.customer, 10)
|
||||||
self.login_in_bar()
|
self.login_in_bar()
|
||||||
res = self.submit_basket(
|
res = self.submit_basket(
|
||||||
self.customer,
|
self.customer,
|
||||||
@ -509,7 +522,7 @@ class TestCounterClick(TestFullClickBase):
|
|||||||
assert self.updated_amount(self.customer) == 0
|
assert self.updated_amount(self.customer) == 0
|
||||||
|
|
||||||
def test_recordings(self):
|
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)
|
self.login_in_bar(self.barmen)
|
||||||
res = self.submit_basket(self.customer, [BasketItem(self.cons.id, 3)])
|
res = self.submit_basket(self.customer, [BasketItem(self.cons.id, 3)])
|
||||||
assert res.status_code == 302
|
assert res.status_code == 302
|
||||||
|
@ -12,23 +12,14 @@
|
|||||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
import math
|
|
||||||
|
|
||||||
from django.core.exceptions import PermissionDenied
|
from django.core.exceptions import PermissionDenied
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.forms import (
|
|
||||||
BaseFormSet,
|
|
||||||
Form,
|
|
||||||
IntegerField,
|
|
||||||
ValidationError,
|
|
||||||
formset_factory,
|
|
||||||
)
|
|
||||||
from django.http import Http404
|
from django.http import Http404
|
||||||
from django.shortcuts import get_object_or_404, redirect, resolve_url
|
from django.shortcuts import get_object_or_404, redirect, resolve_url
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.safestring import SafeString
|
from django.utils.safestring import SafeString
|
||||||
from django.utils.translation import gettext_lazy as _
|
|
||||||
from django.views.generic import FormView
|
from django.views.generic import FormView
|
||||||
from django.views.generic.detail import SingleObjectMixin
|
from django.views.generic.detail import SingleObjectMixin
|
||||||
from ninja.main import HttpRequest
|
from ninja.main import HttpRequest
|
||||||
@ -36,11 +27,10 @@ from ninja.main import HttpRequest
|
|||||||
from core.auth.mixins import CanViewMixin
|
from core.auth.mixins import CanViewMixin
|
||||||
from core.models import User
|
from core.models import User
|
||||||
from core.views.mixins import FragmentMixin, UseFragmentsMixin
|
from core.views.mixins import FragmentMixin, UseFragmentsMixin
|
||||||
from counter.forms import RefillForm
|
from counter.forms import BasketForm, RefillForm
|
||||||
from counter.models import (
|
from counter.models import (
|
||||||
Counter,
|
Counter,
|
||||||
Customer,
|
Customer,
|
||||||
Product,
|
|
||||||
ReturnableProduct,
|
ReturnableProduct,
|
||||||
Selling,
|
Selling,
|
||||||
)
|
)
|
||||||
@ -57,113 +47,6 @@ def get_operator(request: HttpRequest, counter: Counter, customer: Customer) ->
|
|||||||
return counter.get_random_barman()
|
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(
|
class CounterClick(
|
||||||
CounterTabsMixin, UseFragmentsMixin, CanViewMixin, SingleObjectMixin, FormView
|
CounterTabsMixin, UseFragmentsMixin, CanViewMixin, SingleObjectMixin, FormView
|
||||||
):
|
):
|
||||||
|
@ -32,48 +32,39 @@ susnommés afin de comprendre comment celui-ci marche.
|
|||||||
|
|
||||||
Cette application contient les vues suivantes :
|
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
|
Cette vue effectue un filtrage des produits à montrer à l'utilisateur en
|
||||||
fonction de ce qu'il a le droit d'acheter.
|
fonction de ce qu'il a le droit d'acheter.
|
||||||
Si cette vue est appelée lors d'une redirection parce qu'une erreur
|
Elle est en charge de récupérer le formulaire de création d'un panier et
|
||||||
est survenue au cours de la navigation sur la boutique, il est possible
|
redirige alors vers la vue de checkout.
|
||||||
de donner les messages d'erreur à donner à l'utilisateur dans la session
|
|
||||||
avec la clef ``"errors"``.
|
|
||||||
- ``payment_result`` (GET) : retourne une page assez simple disant à l'utilisateur
|
- ``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
|
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.
|
lorsque l'utilisateur paye son panier avec son argent du compte AE.
|
||||||
- ``EbouticCommand`` (POST) : traite la soumission d'un panier par l'utilisateur.
|
- ``EbouticCheckout`` (GET/POST) : Page récapitulant le contenu d'un panier.
|
||||||
Lors de l'appel de cette vue, la requête doit contenir un cookie avec l'état
|
Permet de sélectionner le moyen de paiement et de mettre à jour ses coordonnées
|
||||||
du panier à valider. Ce panier doit strictement être de la forme :
|
de paiement par carte bancaire.
|
||||||
```
|
- ``PayWithSith`` (POST) : paie le panier avec l'argent présent sur le compte
|
||||||
[
|
|
||||||
{"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
|
|
||||||
AE. Redirige vers `payment_result`.
|
AE. Redirige vers `payment_result`.
|
||||||
- ``ETransactionAutoAnswer`` (GET) : vue destinée à communiquer avec le service
|
- ``ETransactionAutoAnswer`` (GET) : vue destinée à communiquer avec le service
|
||||||
de paiement bancaire pour valider ou non le paiement de l'utilisateur.
|
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
|
# Les templates
|
||||||
|
|
||||||
- ``eboutic_payment_result.jinja`` : très court template contenant juste
|
- ``eboutic_payment_result.jinja`` : très court template contenant juste
|
||||||
un message pour dire à l'utilisateur si son achat s'est bien déroulé.
|
un message pour dire à l'utilisateur si son achat s'est bien déroulé.
|
||||||
Retourné par la vue ``payment_result``.
|
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.
|
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
|
- ``eboutic_main.jinja`` : le plus gros template de cette application. Contient
|
||||||
une interface pour que l'utilisateur puisse consulter les produits et remplir
|
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.
|
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
|
À 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.
|
(AlpineJS, plus précisément) édite en même temps le localStorage du navigateur.
|
||||||
Au moment de la validation du panier, ce cookie est envoyé au serveur pour
|
Cette vue fabrique dynamiquement un formulaire qui sera soumis au serveur.
|
||||||
vérifier que la commande est valide et payer.
|
|
||||||
|
|
||||||
# Les modèles
|
# Les modèles
|
||||||
|
|
||||||
|
@ -1,22 +1,20 @@
|
|||||||
from ninja_extra import ControllerBase, api_controller, route
|
from ninja_extra import ControllerBase, api_controller, route
|
||||||
from ninja_extra.exceptions import NotFound
|
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 counter.models import BillingInfo
|
||||||
from eboutic.models import Basket
|
from eboutic.models import Basket
|
||||||
|
|
||||||
|
|
||||||
@api_controller("/etransaction", permissions=[IsAuthenticated])
|
@api_controller("/etransaction", permissions=[CanView])
|
||||||
class EtransactionInfoController(ControllerBase):
|
class EtransactionInfoController(ControllerBase):
|
||||||
@route.get("/data", url_name="etransaction_data")
|
@route.get("/data/{basket_id}", url_name="etransaction_data")
|
||||||
def fetch_etransaction_data(self):
|
def fetch_etransaction_data(self, basket_id: int):
|
||||||
"""Generate the data to pay an eboutic command with paybox.
|
"""Generate the data to pay an eboutic command with paybox.
|
||||||
|
|
||||||
The data is generated with the basket that is used by the current session.
|
The data is generated with the basket that is used by the current session.
|
||||||
"""
|
"""
|
||||||
basket = Basket.from_session(self.context.request.session)
|
basket: Basket = self.get_object_or_exception(Basket, pk=basket_id)
|
||||||
if basket is None:
|
|
||||||
raise NotFound
|
|
||||||
try:
|
try:
|
||||||
return dict(basket.get_e_transaction_data())
|
return dict(basket.get_e_transaction_data())
|
||||||
except BillingInfo.DoesNotExist as e:
|
except BillingInfo.DoesNotExist as e:
|
||||||
|
128
eboutic/forms.py
128
eboutic/forms.py
@ -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
|
|
@ -28,18 +28,27 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
|
|
||||||
from core.models import User
|
from core.models import User
|
||||||
from counter.fields import CurrencyField
|
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]:
|
def get_eboutic_products(user: User) -> list[Product]:
|
||||||
products = (
|
products = (
|
||||||
Counter.objects.get(type="EBOUTIC")
|
get_eboutic()
|
||||||
.products.filter(product_type__isnull=False)
|
.products.filter(product_type__isnull=False)
|
||||||
.filter(archived=False)
|
.filter(archived=False)
|
||||||
.filter(limit_age__lte=user.age)
|
.filter(limit_age__lte=user.age)
|
||||||
.annotate(order=F("product_type__order"))
|
.annotate(order=F("product_type__order"))
|
||||||
.annotate(category=F("product_type__name"))
|
.annotate(category=F("product_type__name"))
|
||||||
.annotate(category_comment=F("product_type__comment"))
|
.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`
|
.prefetch_related("buying_groups") # <-- used in `Product.can_be_sold_to`
|
||||||
)
|
)
|
||||||
return [p for p in products if p.can_be_sold_to(user)]
|
return [p for p in products if p.can_be_sold_to(user)]
|
||||||
@ -84,6 +93,9 @@ class Basket(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.user}'s basket ({self.items.all().count()} items)"
|
return f"{self.user}'s basket ({self.items.all().count()} items)"
|
||||||
|
|
||||||
|
def can_be_viewed_by(self, user):
|
||||||
|
return self.user == user
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def contains_refilling_item(self) -> bool:
|
def contains_refilling_item(self) -> bool:
|
||||||
return self.items.filter(
|
return self.items.filter(
|
||||||
@ -98,13 +110,6 @@ class Basket(models.Model):
|
|||||||
)["total"]
|
)["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):
|
def generate_sales(self, counter, seller: User, payment_method: str):
|
||||||
"""Generate a list of sold items corresponding to the items
|
"""Generate a list of sold items corresponding to the items
|
||||||
of this basket WITHOUT saving them NOR deleting the basket.
|
of this basket WITHOUT saving them NOR deleting the basket.
|
||||||
@ -139,7 +144,7 @@ class Basket(models.Model):
|
|||||||
club=product.club,
|
club=product.club,
|
||||||
product=product,
|
product=product,
|
||||||
seller=seller,
|
seller=seller,
|
||||||
customer=self.user.customer,
|
customer=Customer.get_or_create(self.user)[0],
|
||||||
unit_price=item.product_unit_price,
|
unit_price=item.product_unit_price,
|
||||||
quantity=item.quantity,
|
quantity=item.quantity,
|
||||||
payment_method=payment_method,
|
payment_method=payment_method,
|
||||||
|
@ -1,13 +1,18 @@
|
|||||||
import { etransactioninfoFetchEtransactionData } from "#openapi";
|
import { etransactioninfoFetchEtransactionData } from "#openapi";
|
||||||
|
|
||||||
document.addEventListener("alpine:init", () => {
|
document.addEventListener("alpine:init", () => {
|
||||||
Alpine.data("etransaction", (initialData) => ({
|
Alpine.data("etransaction", (initialData, basketId: number) => ({
|
||||||
data: initialData,
|
data: initialData,
|
||||||
isCbAvailable: Object.keys(initialData).length > 0,
|
isCbAvailable: Object.keys(initialData).length > 0,
|
||||||
|
|
||||||
async fill() {
|
async fill() {
|
||||||
this.isCbAvailable = false;
|
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) {
|
if (res.response.ok) {
|
||||||
this.data = res.data;
|
this.data = res.data;
|
||||||
this.isCbAvailable = true;
|
this.isCbAvailable = true;
|
@ -8,55 +8,55 @@ interface BasketItem {
|
|||||||
unit_price: number;
|
unit_price: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BASKET_ITEMS_COOKIE_NAME: string = "basket_items";
|
document.addEventListener("alpine:init", () => {
|
||||||
|
Alpine.data("basket", (lastPurchaseTime?: number) => ({
|
||||||
|
basket: [] as BasketItem[],
|
||||||
|
|
||||||
/**
|
init() {
|
||||||
* Search for a cookie by name
|
this.basket = this.loadBasket();
|
||||||
* @param name Name of the cookie to get
|
this.$watch("basket", () => {
|
||||||
* @returns the value of the cookie or null if it does not exist, undefined if not found
|
this.saveBasket();
|
||||||
*/
|
});
|
||||||
function getCookie(name: string): string | null | undefined {
|
|
||||||
if (!document.cookie || document.cookie.length === 0) {
|
// Invalidate basket if a purchase was made
|
||||||
return null;
|
if (lastPurchaseTime !== null && localStorage.basketTimestamp !== undefined) {
|
||||||
|
if (
|
||||||
|
new Date(lastPurchaseTime) >=
|
||||||
|
new Date(Number.parseInt(localStorage.basketTimestamp))
|
||||||
|
) {
|
||||||
|
this.basket = [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const found = document.cookie
|
// It's quite tricky to manually apply attributes to the management part
|
||||||
.split(";")
|
// of a formset so we dynamically apply it here
|
||||||
.map((c) => c.trim())
|
this.$refs.basketManagementForm
|
||||||
.find((c) => c.startsWith(`${name}=`));
|
.querySelector("#id_form-TOTAL_FORMS")
|
||||||
|
.setAttribute(":value", "basket.length");
|
||||||
|
},
|
||||||
|
|
||||||
return found === undefined ? undefined : decodeURIComponent(found.split("=")[1]);
|
loadBasket(): BasketItem[] {
|
||||||
}
|
if (localStorage.basket === undefined) {
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 [];
|
return [];
|
||||||
}
|
}
|
||||||
// Django cookie backend converts `,` to `\054`
|
try {
|
||||||
let parsed = JSON.parse(cookie.replace(/\\054/g, ","));
|
return JSON.parse(localStorage.basket);
|
||||||
if (typeof parsed === "string") {
|
} catch (_err) {
|
||||||
// In some conditions, a second parsing is needed
|
return [];
|
||||||
parsed = JSON.parse(parsed);
|
|
||||||
}
|
}
|
||||||
const res = Array.isArray(parsed) ? parsed : [];
|
},
|
||||||
return res.filter((i) => !!document.getElementById(i.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener("alpine:init", () => {
|
saveBasket() {
|
||||||
Alpine.data("basket", () => ({
|
localStorage.basket = JSON.stringify(this.basket);
|
||||||
items: getStartingItems() as BasketItem[],
|
localStorage.basketTimestamp = Date.now();
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the total price of the basket
|
* Get the total price of the basket
|
||||||
* @returns {number} The total price of the basket
|
* @returns {number} The total price of the basket
|
||||||
*/
|
*/
|
||||||
getTotal() {
|
getTotal() {
|
||||||
return this.items.reduce(
|
return this.basket.reduce(
|
||||||
(acc: number, item: BasketItem) => acc + item.quantity * item.unit_price,
|
(acc: number, item: BasketItem) => acc + item.quantity * item.unit_price,
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
@ -68,7 +68,6 @@ document.addEventListener("alpine:init", () => {
|
|||||||
*/
|
*/
|
||||||
add(item: BasketItem) {
|
add(item: BasketItem) {
|
||||||
item.quantity++;
|
item.quantity++;
|
||||||
this.setCookies();
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -76,39 +75,25 @@ document.addEventListener("alpine:init", () => {
|
|||||||
* @param itemId the id of the item to remove
|
* @param itemId the id of the item to remove
|
||||||
*/
|
*/
|
||||||
remove(itemId: number) {
|
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) {
|
if (index < 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.items[index].quantity -= 1;
|
this.basket[index].quantity -= 1;
|
||||||
|
|
||||||
if (this.items[index].quantity === 0) {
|
if (this.basket[index].quantity === 0) {
|
||||||
this.items = this.items.filter(
|
this.basket = this.basket.filter(
|
||||||
(e: BasketItem) => e.id !== this.items[index].id,
|
(e: BasketItem) => e.id !== this.basket[index].id,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.setCookies();
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove all the items from the basket & cleans the catalog CSS classes
|
* Remove all the items from the basket & cleans the catalog CSS classes
|
||||||
*/
|
*/
|
||||||
clearBasket() {
|
clearBasket() {
|
||||||
this.items = [];
|
this.basket = [];
|
||||||
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`;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -127,7 +112,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
unit_price: price,
|
unit_price: price,
|
||||||
} as BasketItem;
|
} as BasketItem;
|
||||||
|
|
||||||
this.items.push(newItem);
|
this.basket.push(newItem);
|
||||||
this.add(newItem);
|
this.add(newItem);
|
||||||
|
|
||||||
return newItem;
|
return newItem;
|
||||||
@ -141,7 +126,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
* @param price The unit price of the product
|
* @param price The unit price of the product
|
||||||
*/
|
*/
|
||||||
addFromCatalog(id: number, name: string, price: number) {
|
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
|
// if the item is not in the basket, we create it
|
||||||
// else we add + 1 to it
|
// else we add + 1 to it
|
||||||
|
@ -61,6 +61,7 @@
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
line-height: 100%;
|
line-height: 100%;
|
||||||
|
white-space: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
#eboutic .fa-plus,
|
#eboutic .fa-plus,
|
||||||
|
@ -9,7 +9,7 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block additional_js %}
|
{% 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 %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
@ -19,7 +19,7 @@
|
|||||||
let billingInfos = {{ billing_infos|safe }};
|
let billingInfos = {{ billing_infos|safe }};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div x-data="etransaction(billingInfos)">
|
<div x-data="etransaction(billingInfos, {{ basket.id }})">
|
||||||
<p>{% trans %}Basket: {% endtrans %}</p>
|
<p>{% trans %}Basket: {% endtrans %}</p>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
@ -82,9 +82,8 @@
|
|||||||
{% elif basket.total > user.account_balance %}
|
{% elif basket.total > user.account_balance %}
|
||||||
<p>{% trans %}AE account payment disabled because you do not have enough money remaining.{% endtrans %}</p>
|
<p>{% trans %}AE account payment disabled because you do not have enough money remaining.{% endtrans %}</p>
|
||||||
{% else %}
|
{% 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 %}
|
{% 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 %}"/>
|
<input class="btn btn-blue" type="submit" value="{% trans %}Pay with Sith account{% endtrans %}"/>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
@ -21,13 +21,28 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1 id="eboutic-title">{% trans %}Eboutic{% endtrans %}</h1>
|
<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">
|
<div id="basket">
|
||||||
<h3>Panier</h3>
|
<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 alert-red">
|
||||||
<div class="alert-main">
|
<div class="alert-main">
|
||||||
{% for error in errors %}
|
{% for error in form.non_form_errors() + form.errors %}
|
||||||
<p style="margin: 0">{{ error }}</p>
|
<p style="margin: 0">{{ error }}</p>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
@ -43,7 +58,8 @@
|
|||||||
<strong>{{ "%0.2f"|format(customer_amount) }} €</strong>
|
<strong>{{ "%0.2f"|format(customer_amount) }} €</strong>
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</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">
|
<li class="item-row" x-show="item.quantity > 0">
|
||||||
<div class="item-quantity">
|
<div class="item-quantity">
|
||||||
<i class="fa fa-minus fa-xs" @click="remove(item.id)"></i>
|
<i class="fa fa-minus fa-xs" @click="remove(item.id)"></i>
|
||||||
@ -52,6 +68,24 @@
|
|||||||
</div>
|
</div>
|
||||||
<span class="item-name" x-text="item.name"></span>
|
<span class="item-name" x-text="item.name"></span>
|
||||||
<span class="item-price" x-text="(item.unit_price * item.quantity).toFixed(2) + ' €'"></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>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
{# Total price #}
|
{# Total price #}
|
||||||
@ -61,17 +95,16 @@
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="catalog-buttons">
|
<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>
|
<i class="fa fa-trash"></i>
|
||||||
{% trans %}Clear{% endtrans %}
|
{% trans %}Clear{% endtrans %}
|
||||||
</button>
|
</button>
|
||||||
<form method="get" action="{{ url('eboutic:command') }}">
|
|
||||||
<button class="btn btn-blue">
|
<button class="btn btn-blue">
|
||||||
<i class="fa fa-check"></i>
|
<i class="fa fa-check"></i>
|
||||||
<input type="submit" value="{% trans %}Validate{% endtrans %}"/>
|
<input type="submit" value="{% trans %}Validate{% endtrans %}"/>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div id="catalog">
|
<div id="catalog">
|
||||||
{% if not request.user.date_of_birth %}
|
{% if not request.user.date_of_birth %}
|
||||||
@ -108,7 +141,7 @@
|
|||||||
{% trans trimmed %}
|
{% trans trimmed %}
|
||||||
Our partner uses Weezevent to sell tickets.
|
Our partner uses Weezevent to sell tickets.
|
||||||
Weezevent may collect user info according to
|
Weezevent may collect user info according to
|
||||||
it's own privacy policy.
|
its own privacy policy.
|
||||||
By clicking the accept button you consent to
|
By clicking the accept button you consent to
|
||||||
their terms of services.
|
their terms of services.
|
||||||
{% endtrans %}
|
{% endtrans %}
|
||||||
@ -158,7 +191,7 @@
|
|||||||
<button
|
<button
|
||||||
id="{{ p.id }}"
|
id="{{ p.id }}"
|
||||||
class="card product-button clickable shadow"
|
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 }})'
|
@click='addFromCatalog({{ p.id }}, {{ p.name|tojson }}, {{ p.selling_price }})'
|
||||||
>
|
>
|
||||||
{% if p.icon %}
|
{% if p.icon %}
|
||||||
|
@ -4,6 +4,14 @@
|
|||||||
<h3>{% trans %}Eboutic{% endtrans %}</h3>
|
<h3>{% trans %}Eboutic{% endtrans %}</h3>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
{% if messages %}
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="alert alert-{{ message.tags }}">
|
||||||
|
{{ message }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if success %}
|
{% if success %}
|
||||||
{% trans %}Payment successful{% endtrans %}
|
{% trans %}Payment successful{% endtrans %}
|
||||||
{% else %}
|
{% else %}
|
||||||
|
179
eboutic/tests/test_basket.py
Normal file
179
eboutic/tests/test_basket.py
Normal 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
|
||||||
|
)
|
269
eboutic/tests/test_payment.py
Normal file
269
eboutic/tests/test_payment.py
Normal 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')"
|
||||||
|
)
|
@ -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
|
|
@ -27,11 +27,11 @@ from django.urls import path, register_converter
|
|||||||
from eboutic.converters import PaymentResultConverter
|
from eboutic.converters import PaymentResultConverter
|
||||||
from eboutic.views import (
|
from eboutic.views import (
|
||||||
BillingInfoFormFragment,
|
BillingInfoFormFragment,
|
||||||
EbouticCommand,
|
EbouticCheckout,
|
||||||
|
EbouticMainView,
|
||||||
|
EbouticPayWithSith,
|
||||||
EtransactionAutoAnswer,
|
EtransactionAutoAnswer,
|
||||||
EurokPartnerFragment,
|
EurokPartnerFragment,
|
||||||
eboutic_main,
|
|
||||||
pay_with_sith,
|
|
||||||
payment_result,
|
payment_result,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -39,10 +39,12 @@ register_converter(PaymentResultConverter, "res")
|
|||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
# Subscription views
|
# Subscription views
|
||||||
path("", eboutic_main, name="main"),
|
path("", EbouticMainView.as_view(), name="main"),
|
||||||
path("command/", EbouticCommand.as_view(), name="command"),
|
path("checkout/<int:basket_id>", EbouticCheckout.as_view(), name="checkout"),
|
||||||
path("billing-infos/", BillingInfoFormFragment.as_view(), name="billing_infos"),
|
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("pay/<res:result>/", payment_result, name="payment_result"),
|
||||||
path("eurok/", EurokPartnerFragment.as_view(), name="eurok"),
|
path("eurok/", EurokPartnerFragment.as_view(), name="eurok"),
|
||||||
path(
|
path(
|
||||||
|
192
eboutic/views.py
192
eboutic/views.py
@ -18,7 +18,6 @@ from __future__ import annotations
|
|||||||
import base64
|
import base64
|
||||||
import contextlib
|
import contextlib
|
||||||
import json
|
import json
|
||||||
from datetime import datetime
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import sentry_sdk
|
import sentry_sdk
|
||||||
@ -33,23 +32,23 @@ from django.contrib.auth.mixins import (
|
|||||||
LoginRequiredMixin,
|
LoginRequiredMixin,
|
||||||
)
|
)
|
||||||
from django.contrib.messages.views import SuccessMessageMixin
|
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 import DatabaseError, transaction
|
||||||
|
from django.db.models.fields import forms
|
||||||
from django.db.utils import cached_property
|
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.shortcuts import redirect, render
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.decorators import method_decorator
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.views.decorators.http import require_GET, require_POST
|
from django.views.decorators.http import require_GET
|
||||||
from django.views.generic import TemplateView, UpdateView, View
|
from django.views.generic import DetailView, FormView, TemplateView, UpdateView, View
|
||||||
|
from django.views.generic.edit import SingleObjectMixin
|
||||||
from django_countries.fields import Country
|
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 core.views.mixins import FragmentMixin, UseFragmentsMixin
|
||||||
from counter.forms import BillingInfoForm
|
from counter.forms import BaseBasketForm, BillingInfoForm, ProductForm
|
||||||
from counter.models import BillingInfo, Counter, Customer, Product
|
from counter.models import BillingInfo, Customer, Product, Selling, get_eboutic
|
||||||
from eboutic.forms import BasketForm
|
|
||||||
from eboutic.models import (
|
from eboutic.models import (
|
||||||
Basket,
|
Basket,
|
||||||
BasketItem,
|
BasketItem,
|
||||||
@ -58,39 +57,82 @@ from eboutic.models import (
|
|||||||
InvoiceItem,
|
InvoiceItem,
|
||||||
get_eboutic_products,
|
get_eboutic_products,
|
||||||
)
|
)
|
||||||
from eboutic.schemas import PurchaseItemList, PurchaseItemSchema
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
|
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
|
||||||
from django.utils.html import SafeString
|
from django.utils.html import SafeString
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
class BaseEbouticBasketForm(BaseBasketForm):
|
||||||
@require_GET
|
def _check_enough_money(self, *args, **kwargs):
|
||||||
def eboutic_main(request: HttpRequest) -> HttpResponse:
|
# Disable money check
|
||||||
"""Main view of the eboutic application.
|
...
|
||||||
|
|
||||||
Return an Http response whose content is of type text/html.
|
|
||||||
The latter represents the page from which a user can see
|
EbouticBasketForm = forms.formset_factory(
|
||||||
the catalogue of products that he can buy and fill
|
ProductForm, formset=BaseEbouticBasketForm, absolute_max=None, min_num=1
|
||||||
his shopping cart.
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EbouticMainView(LoginRequiredMixin, FormView):
|
||||||
|
"""Main view of the eboutic application.
|
||||||
|
|
||||||
The purchasable products are those of the eboutic which
|
The purchasable products are those of the eboutic which
|
||||||
belong to a category of products of a product category
|
belong to a category of products of a product category
|
||||||
(orphan products are inaccessible).
|
(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)
|
template_name = "eboutic/eboutic_main.jinja"
|
||||||
context = {
|
form_class = EbouticBasketForm
|
||||||
"errors": errors,
|
|
||||||
"products": products,
|
def get_form_kwargs(self):
|
||||||
"customer_amount": request.user.account_balance,
|
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
|
@require_GET
|
||||||
@ -166,48 +208,15 @@ class BillingInfoFormFragment(
|
|||||||
return self.request.path
|
return self.request.path
|
||||||
|
|
||||||
|
|
||||||
class EbouticCommand(LoginRequiredMixin, UseFragmentsMixin, TemplateView):
|
class EbouticCheckout(CanViewMixin, UseFragmentsMixin, DetailView):
|
||||||
template_name = "eboutic/eboutic_makecommand.jinja"
|
model = Basket
|
||||||
basket: Basket
|
pk_url_kwarg = "basket_id"
|
||||||
|
context_object_name = "basket"
|
||||||
|
template_name = "eboutic/eboutic_checkout.jinja"
|
||||||
fragments = {
|
fragments = {
|
||||||
"billing_infos_form": BillingInfoFormFragment,
|
"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):
|
def get_context_data(self, **kwargs):
|
||||||
kwargs = super().get_context_data(**kwargs)
|
kwargs = super().get_context_data(**kwargs)
|
||||||
if hasattr(self.request.user, "customer"):
|
if hasattr(self.request.user, "customer"):
|
||||||
@ -215,32 +224,31 @@ class EbouticCommand(LoginRequiredMixin, UseFragmentsMixin, TemplateView):
|
|||||||
kwargs["customer_amount"] = customer.amount
|
kwargs["customer_amount"] = customer.amount
|
||||||
else:
|
else:
|
||||||
kwargs["customer_amount"] = None
|
kwargs["customer_amount"] = None
|
||||||
kwargs["basket"] = self.basket
|
|
||||||
kwargs["billing_infos"] = {}
|
kwargs["billing_infos"] = {}
|
||||||
|
|
||||||
with contextlib.suppress(BillingInfo.DoesNotExist):
|
with contextlib.suppress(BillingInfo.DoesNotExist):
|
||||||
kwargs["billing_infos"] = json.dumps(
|
kwargs["billing_infos"] = json.dumps(
|
||||||
dict(self.basket.get_e_transaction_data())
|
dict(self.object.get_e_transaction_data())
|
||||||
)
|
)
|
||||||
return kwargs
|
return kwargs
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
class EbouticPayWithSith(CanViewMixin, SingleObjectMixin, View):
|
||||||
@require_POST
|
model = Basket
|
||||||
def pay_with_sith(request):
|
pk_url_kwarg = "basket_id"
|
||||||
basket = Basket.from_session(request.session)
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
basket = self.get_object()
|
||||||
refilling = settings.SITH_COUNTER_PRODUCTTYPE_REFILLING
|
refilling = settings.SITH_COUNTER_PRODUCTTYPE_REFILLING
|
||||||
if basket is None or basket.items.filter(type_id=refilling).exists():
|
if basket.items.filter(type_id=refilling).exists():
|
||||||
return redirect("eboutic:main")
|
messages.error(
|
||||||
c = Customer.objects.filter(user__id=basket.user_id).first()
|
self.request,
|
||||||
if c is None:
|
_("You can't buy a refilling with sith money"),
|
||||||
return redirect("eboutic:main")
|
)
|
||||||
if c.amount < basket.total:
|
return redirect("eboutic:payment_result", "failure")
|
||||||
res = redirect("eboutic:payment_result", "failure")
|
|
||||||
res.delete_cookie("basket_items", "/eboutic")
|
eboutic = get_eboutic()
|
||||||
return res
|
sales = basket.generate_sales(eboutic, basket.user, "SITH_ACCOUNT")
|
||||||
eboutic = Counter.objects.get(type="EBOUTIC")
|
|
||||||
sales = basket.generate_sales(eboutic, c.user, "SITH_ACCOUNT")
|
|
||||||
try:
|
try:
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
# Selling.save has some important business logic in it.
|
# Selling.save has some important business logic in it.
|
||||||
@ -248,18 +256,12 @@ def pay_with_sith(request):
|
|||||||
for sale in sales:
|
for sale in sales:
|
||||||
sale.save()
|
sale.save()
|
||||||
basket.delete()
|
basket.delete()
|
||||||
request.session.pop("basket_id", None)
|
return redirect("eboutic:payment_result", "success")
|
||||||
res = redirect("eboutic:payment_result", "success")
|
|
||||||
except DatabaseError as e:
|
except DatabaseError as e:
|
||||||
with sentry_sdk.push_scope() as scope:
|
sentry_sdk.capture_exception(e)
|
||||||
scope.user = {"username": request.user.username}
|
except ValidationError as e:
|
||||||
scope.set_extra("someVariable", e.__repr__())
|
messages.error(self.request, e.message)
|
||||||
sentry_sdk.capture_message(
|
return redirect("eboutic:payment_result", "failure")
|
||||||
f"Erreur le {datetime.now()} dans eboutic.pay_with_sith"
|
|
||||||
)
|
|
||||||
res = redirect("eboutic:payment_result", "failure")
|
|
||||||
res.delete_cookie("basket_items", "/eboutic")
|
|
||||||
return res
|
|
||||||
|
|
||||||
|
|
||||||
class EtransactionAutoAnswer(View):
|
class EtransactionAutoAnswer(View):
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
msgid ""
|
msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"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"
|
"PO-Revision-Date: 2016-07-18\n"
|
||||||
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
|
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
|
||||||
"Language-Team: AE info <ae.info@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
|
#: core/templates/core/base/navbar.jinja counter/models.py
|
||||||
#: counter/templates/counter/counter_list.jinja
|
#: counter/templates/counter/counter_list.jinja
|
||||||
|
#: eboutic/templates/eboutic/eboutic_checkout.jinja
|
||||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
#: eboutic/templates/eboutic/eboutic_main.jinja
|
||||||
#: eboutic/templates/eboutic/eboutic_makecommand.jinja
|
|
||||||
#: eboutic/templates/eboutic/eboutic_payment_result.jinja sith/settings.py
|
#: eboutic/templates/eboutic/eboutic_payment_result.jinja sith/settings.py
|
||||||
msgid "Eboutic"
|
msgid "Eboutic"
|
||||||
msgstr "Eboutic"
|
msgstr "Eboutic"
|
||||||
@ -2882,6 +2882,30 @@ msgstr ""
|
|||||||
msgid "Refound this account"
|
msgid "Refound this account"
|
||||||
msgstr "Rembourser ce compte"
|
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
|
#: counter/management/commands/dump_accounts.py
|
||||||
msgid "Your AE account has been emptied"
|
msgid "Your AE account has been emptied"
|
||||||
msgstr "Votre compte AE a été vidé"
|
msgstr "Votre compte AE a été vidé"
|
||||||
@ -2906,10 +2930,6 @@ msgstr "client"
|
|||||||
msgid "customers"
|
msgid "customers"
|
||||||
msgstr "clients"
|
msgstr "clients"
|
||||||
|
|
||||||
#: counter/models.py counter/views/click.py
|
|
||||||
msgid "Not enough money"
|
|
||||||
msgstr "Solde insuffisant"
|
|
||||||
|
|
||||||
#: counter/models.py
|
#: counter/models.py
|
||||||
msgid "First name"
|
msgid "First name"
|
||||||
msgstr "Prénom"
|
msgstr "Prénom"
|
||||||
@ -3278,7 +3298,7 @@ msgid "Go"
|
|||||||
msgstr "Valider"
|
msgstr "Valider"
|
||||||
|
|
||||||
#: counter/templates/counter/counter_click.jinja
|
#: counter/templates/counter/counter_click.jinja
|
||||||
#: eboutic/templates/eboutic/eboutic_makecommand.jinja
|
#: eboutic/templates/eboutic/eboutic_checkout.jinja
|
||||||
msgid "Basket: "
|
msgid "Basket: "
|
||||||
msgstr "Panier : "
|
msgstr "Panier : "
|
||||||
|
|
||||||
@ -3672,26 +3692,6 @@ msgstr "Montant du chèque"
|
|||||||
msgid "Check quantity"
|
msgid "Check quantity"
|
||||||
msgstr "Nombre de chèque"
|
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
|
#: counter/views/eticket.py
|
||||||
msgid "people(s)"
|
msgid "people(s)"
|
||||||
msgstr "personne(s)"
|
msgstr "personne(s)"
|
||||||
@ -3729,19 +3729,6 @@ msgstr "Types de produit"
|
|||||||
msgid "%(name)s has no registered student card"
|
msgid "%(name)s has no registered student card"
|
||||||
msgstr "%(name)s n'a pas de carte étudiante enregistrée"
|
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
|
#: eboutic/models.py
|
||||||
msgid "validated"
|
msgid "validated"
|
||||||
msgstr "validé"
|
msgstr "validé"
|
||||||
@ -3779,15 +3766,44 @@ msgstr "Informations de facturation"
|
|||||||
msgid "Validate"
|
msgid "Validate"
|
||||||
msgstr "Valider"
|
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_main.jinja
|
||||||
#: eboutic/templates/eboutic/eboutic_makecommand.jinja
|
|
||||||
msgid "Current account amount: "
|
msgid "Current account amount: "
|
||||||
msgstr "Solde actuel : "
|
msgstr "Solde actuel : "
|
||||||
|
|
||||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
#: eboutic/templates/eboutic/eboutic_checkout.jinja
|
||||||
#: eboutic/templates/eboutic/eboutic_makecommand.jinja
|
msgid "Remaining account amount: "
|
||||||
msgid "Basket amount: "
|
msgstr "Solde restant : "
|
||||||
msgstr "Valeur du panier : "
|
|
||||||
|
#: 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
|
#: eboutic/templates/eboutic/eboutic_main.jinja
|
||||||
msgid "Clear"
|
msgid "Clear"
|
||||||
@ -3815,12 +3831,13 @@ msgstr "Partenariat Eurockéennes 2025"
|
|||||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
#: eboutic/templates/eboutic/eboutic_main.jinja
|
||||||
msgid ""
|
msgid ""
|
||||||
"Our partner uses Weezevent to sell tickets. Weezevent may collect user info "
|
"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."
|
"consent to their terms of services."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Notre partenaire utilises Wezevent pour vendre ses billets. Weezevent peut collecter des informatinos utilisateur "
|
"Notre partenaire utilises Wezevent pour vendre ses billets. Weezevent peut "
|
||||||
"conformément à sa propre politique de confidentialité. En cliquant sur le bouton d'acceptation vous "
|
"collecter des informations utilisateur conformément à sa propre politique de "
|
||||||
"consentez à leurs termes de service."
|
"confidentialité. En cliquant sur le bouton d'acceptation vous consentez à "
|
||||||
|
"leurs termes de service."
|
||||||
|
|
||||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
#: eboutic/templates/eboutic/eboutic_main.jinja
|
||||||
msgid "Privacy policy"
|
msgid "Privacy policy"
|
||||||
@ -3852,35 +3869,6 @@ msgstr ""
|
|||||||
msgid "There are no items available for sale"
|
msgid "There are no items available for sale"
|
||||||
msgstr "Aucun article n'est disponible à la vente"
|
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
|
#: eboutic/templates/eboutic/eboutic_payment_result.jinja
|
||||||
msgid "Payment successful"
|
msgid "Payment successful"
|
||||||
msgstr "Le paiement a été effectué"
|
msgstr "Le paiement a été effectué"
|
||||||
@ -3893,6 +3881,10 @@ msgstr "Le paiement a échoué"
|
|||||||
msgid "Return to eboutic"
|
msgid "Return to eboutic"
|
||||||
msgstr "Retourner à l'eboutic"
|
msgstr "Retourner à l'eboutic"
|
||||||
|
|
||||||
|
#: eboutic/views.py
|
||||||
|
msgid "Your basket is empty"
|
||||||
|
msgstr "Votre panier est vide"
|
||||||
|
|
||||||
#: eboutic/views.py
|
#: eboutic/views.py
|
||||||
msgid "Billing info registration success"
|
msgid "Billing info registration success"
|
||||||
msgstr "Informations de facturation enregistrées"
|
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 "
|
"souhaitez payer par carte, vous devez rajouter un numéro de téléphone aux "
|
||||||
"données que vous aviez déjà fourni."
|
"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
|
#: election/models.py
|
||||||
msgid "start candidature"
|
msgid "start candidature"
|
||||||
msgstr "début des candidatures"
|
msgstr "début des candidatures"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user