Don't use cookies for processing eboutic baskets

This commit is contained in:
Antoine Bartuccio 2025-04-15 00:07:07 +02:00
parent 4fa83d0667
commit 262ed7eb4c
9 changed files with 323 additions and 459 deletions

View File

@ -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
)

View File

@ -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
): ):

View File

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

View File

@ -40,6 +40,7 @@ def get_eboutic_products(user: User) -> list[Product]:
.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"))
.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 +85,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(
@ -139,7 +143,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,

View File

@ -8,55 +8,44 @@ interface BasketItem {
unit_price: number; unit_price: number;
} }
const BASKET_ITEMS_COOKIE_NAME: string = "basket_items";
/**
* Search for a cookie by name
* @param name Name of the cookie to get
* @returns the value of the cookie or null if it does not exist, undefined if not found
*/
function getCookie(name: string): string | null | undefined {
if (!document.cookie || document.cookie.length === 0) {
return null;
}
const found = document.cookie
.split(";")
.map((c) => c.trim())
.find((c) => c.startsWith(`${name}=`));
return found === undefined ? undefined : decodeURIComponent(found.split("=")[1]);
}
/**
* Fetch the basket items from the associated cookie
* @returns the items in the basket
*/
function getStartingItems(): BasketItem[] {
const cookie = getCookie(BASKET_ITEMS_COOKIE_NAME);
if (!cookie) {
return [];
}
// Django cookie backend converts `,` to `\054`
let parsed = JSON.parse(cookie.replace(/\\054/g, ","));
if (typeof parsed === "string") {
// In some conditions, a second parsing is needed
parsed = JSON.parse(parsed);
}
const res = Array.isArray(parsed) ? parsed : [];
return res.filter((i) => !!document.getElementById(i.id));
}
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
Alpine.data("basket", () => ({ Alpine.data("basket", () => ({
items: getStartingItems() as BasketItem[], basket: [] as BasketItem[],
init() {
this.basket = this.loadBasket();
this.$watch("basket", () => {
this.saveBasket();
});
// It's quite tricky to manually apply attributes to the management part
// of a formset so we dynamically apply it here
this.$refs.basketManagementForm
.querySelector("#id_form-TOTAL_FORMS")
.setAttribute(":value", "basket.length");
},
loadBasket(): BasketItem[] {
if (localStorage.basket === undefined) {
return [];
}
try {
return JSON.parse(localStorage.basket);
} catch (_err) {
return [];
}
},
saveBasket() {
localStorage.basket = JSON.stringify(this.basket);
},
/** /**
* 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 +57,6 @@ document.addEventListener("alpine:init", () => {
*/ */
add(item: BasketItem) { add(item: BasketItem) {
item.quantity++; item.quantity++;
this.setCookies();
}, },
/** /**
@ -76,39 +64,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 basket 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 +101,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 +115,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

View File

@ -24,10 +24,16 @@
<div id="eboutic" x-data="basket"> <div id="eboutic" x-data="basket">
<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() %}
<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() %}
<p style="margin: 0">{{ error }}</p> <p style="margin: 0">{{ error }}</p>
{% endfor %} {% endfor %}
</div> </div>
@ -43,7 +49,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)">
<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 +59,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,11 +86,10 @@
</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 %}"/>
@ -158,7 +182,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 %}

View File

@ -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 %}

View File

@ -28,10 +28,10 @@ from eboutic.converters import PaymentResultConverter
from eboutic.views import ( from eboutic.views import (
BillingInfoFormFragment, BillingInfoFormFragment,
EbouticCommand, EbouticCommand,
EbouticCreateBasket,
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("", EbouticCreateBasket.as_view(), name="main"),
path("command/", EbouticCommand.as_view(), name="command"), path("command/<int:basket_id>", EbouticCommand.as_view(), name="command"),
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(

View File

@ -35,21 +35,21 @@ from django.contrib.auth.mixins import (
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
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, Counter, Customer, Product
from eboutic.forms import BasketForm
from eboutic.models import ( from eboutic.models import (
Basket, Basket,
BasketItem, BasketItem,
@ -58,39 +58,69 @@ 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 EbouticCreateBasket(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": Customer.get_or_create(self.request.user)[0],
"counter": Counter.objects.get(type="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:
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:command", kwargs={"basket_id": self.basket.id})
@cached_property
def products(self) -> list[Product]:
return get_eboutic_products(self.request.user)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["products"] = self.products
context["customer_amount"] = self.request.user.account_balance
return context
@require_GET @require_GET
@ -166,48 +196,15 @@ class BillingInfoFormFragment(
return self.request.path return self.request.path
class EbouticCommand(LoginRequiredMixin, UseFragmentsMixin, TemplateView): class EbouticCommand(CanViewMixin, UseFragmentsMixin, DetailView):
model = Basket
pk_url_kwarg = "basket_id"
context_object_name = "basket"
template_name = "eboutic/eboutic_makecommand.jinja" template_name = "eboutic/eboutic_makecommand.jinja"
basket: Basket
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 +212,32 @@ 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 http_method_names = ["post"]
def pay_with_sith(request): model = Basket
basket = Basket.from_session(request.session) pk_url_kwarg = "basket_id"
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():
messages.error(
self.request,
_("You can't buy a refilling with sith money"),
)
return redirect("eboutic:main") return redirect("eboutic:main")
c = Customer.objects.filter(user__id=basket.user_id).first()
if c is None:
return redirect("eboutic:main")
if c.amount < basket.total:
res = redirect("eboutic:payment_result", "failure")
res.delete_cookie("basket_items", "/eboutic")
return res
eboutic = Counter.objects.get(type="EBOUTIC") eboutic = Counter.objects.get(type="EBOUTIC")
sales = basket.generate_sales(eboutic, c.user, "SITH_ACCOUNT") sales = basket.generate_sales(eboutic, basket.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,8 +245,7 @@ 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: with sentry_sdk.push_scope() as scope:
scope.user = {"username": request.user.username} scope.user = {"username": request.user.username}
@ -257,9 +253,7 @@ def pay_with_sith(request):
sentry_sdk.capture_message( sentry_sdk.capture_message(
f"Erreur le {datetime.now()} dans eboutic.pay_with_sith" f"Erreur le {datetime.now()} dans eboutic.pay_with_sith"
) )
res = redirect("eboutic:payment_result", "failure") return redirect("eboutic:payment_result", "failure")
res.delete_cookie("basket_items", "/eboutic")
return res
class EtransactionAutoAnswer(View): class EtransactionAutoAnswer(View):