mirror of
https://github.com/ae-utbm/sith.git
synced 2025-05-19 08:47:29 +00:00
Don't use cookies for processing eboutic baskets
This commit is contained in:
parent
4fa83d0667
commit
262ed7eb4c
112
counter/forms.py
112
counter/forms.py
@ -1,4 +1,7 @@
|
||||
import math
|
||||
|
||||
from django import forms
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from phonenumber_field.widgets import RegionalPhoneNumberWidget
|
||||
|
||||
@ -261,3 +264,112 @@ class CloseCustomerAccountForm(forms.Form):
|
||||
widget=AutoCompleteSelectUser,
|
||||
queryset=User.objects.all(),
|
||||
)
|
||||
|
||||
|
||||
class ProductForm(forms.Form):
|
||||
quantity = forms.IntegerField(min_value=1, required=True)
|
||||
id = forms.IntegerField(min_value=0, required=True)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
customer: Customer,
|
||||
counter: Counter,
|
||||
allowed_products: dict[int, Product],
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
self.customer = customer # Used by formset
|
||||
self.counter = counter # Used by formset
|
||||
self.allowed_products = allowed_products
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def clean_id(self):
|
||||
data = self.cleaned_data["id"]
|
||||
|
||||
# We store self.product so we can use it later on the formset validation
|
||||
# And also in the global clean
|
||||
self.product = self.allowed_products.get(data, None)
|
||||
if self.product is None:
|
||||
raise forms.ValidationError(
|
||||
_("The selected product isn't available for this user")
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
if len(self.errors) > 0:
|
||||
return
|
||||
|
||||
# Compute prices
|
||||
cleaned_data["bonus_quantity"] = 0
|
||||
if self.product.tray:
|
||||
cleaned_data["bonus_quantity"] = math.floor(
|
||||
cleaned_data["quantity"] / Product.QUANTITY_FOR_TRAY_PRICE
|
||||
)
|
||||
cleaned_data["total_price"] = self.product.price * (
|
||||
cleaned_data["quantity"] - cleaned_data["bonus_quantity"]
|
||||
)
|
||||
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class BaseBasketForm(forms.BaseFormSet):
|
||||
def clean(self):
|
||||
self.forms = [form for form in self.forms if form.cleaned_data != {}]
|
||||
|
||||
if len(self.forms) == 0:
|
||||
return
|
||||
|
||||
self._check_forms_have_errors()
|
||||
self._check_product_are_unique()
|
||||
self._check_recorded_products(self[0].customer)
|
||||
self._check_enough_money(self[0].counter, self[0].customer)
|
||||
|
||||
def _check_forms_have_errors(self):
|
||||
if any(len(form.errors) > 0 for form in self):
|
||||
raise forms.ValidationError(_("Submitted basket is invalid"))
|
||||
|
||||
def _check_product_are_unique(self):
|
||||
product_ids = {form.cleaned_data["id"] for form in self.forms}
|
||||
if len(product_ids) != len(self.forms):
|
||||
raise forms.ValidationError(_("Duplicated product entries."))
|
||||
|
||||
def _check_enough_money(self, counter: Counter, customer: Customer):
|
||||
self.total_price = sum([data["total_price"] for data in self.cleaned_data])
|
||||
if self.total_price > customer.amount:
|
||||
raise forms.ValidationError(_("Not enough money"))
|
||||
|
||||
def _check_recorded_products(self, customer: Customer):
|
||||
"""Check for, among other things, ecocups and pitchers"""
|
||||
items = {
|
||||
form.cleaned_data["id"]: form.cleaned_data["quantity"]
|
||||
for form in self.forms
|
||||
}
|
||||
ids = list(items.keys())
|
||||
returnables = list(
|
||||
ReturnableProduct.objects.filter(
|
||||
Q(product_id__in=ids) | Q(returned_product_id__in=ids)
|
||||
).annotate_balance_for(customer)
|
||||
)
|
||||
limit_reached = []
|
||||
for returnable in returnables:
|
||||
returnable.balance += items.get(returnable.product_id, 0)
|
||||
for returnable in returnables:
|
||||
dcons = items.get(returnable.returned_product_id, 0)
|
||||
returnable.balance -= dcons
|
||||
if dcons and returnable.balance < -returnable.max_return:
|
||||
limit_reached.append(returnable.returned_product)
|
||||
if limit_reached:
|
||||
raise forms.ValidationError(
|
||||
_(
|
||||
"This user have reached his recording limit "
|
||||
"for the following products : %s"
|
||||
)
|
||||
% ", ".join([str(p) for p in limit_reached])
|
||||
)
|
||||
|
||||
|
||||
BasketForm = forms.formset_factory(
|
||||
ProductForm, formset=BaseBasketForm, absolute_max=None, min_num=1
|
||||
)
|
||||
|
@ -12,23 +12,14 @@
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||
#
|
||||
#
|
||||
import math
|
||||
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from django.forms import (
|
||||
BaseFormSet,
|
||||
Form,
|
||||
IntegerField,
|
||||
ValidationError,
|
||||
formset_factory,
|
||||
)
|
||||
from django.http import Http404
|
||||
from django.shortcuts import get_object_or_404, redirect, resolve_url
|
||||
from django.urls import reverse
|
||||
from django.utils.safestring import SafeString
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import FormView
|
||||
from django.views.generic.detail import SingleObjectMixin
|
||||
from ninja.main import HttpRequest
|
||||
@ -36,11 +27,10 @@ from ninja.main import HttpRequest
|
||||
from core.auth.mixins import CanViewMixin
|
||||
from core.models import User
|
||||
from core.views.mixins import FragmentMixin, UseFragmentsMixin
|
||||
from counter.forms import RefillForm
|
||||
from counter.forms import BasketForm, RefillForm
|
||||
from counter.models import (
|
||||
Counter,
|
||||
Customer,
|
||||
Product,
|
||||
ReturnableProduct,
|
||||
Selling,
|
||||
)
|
||||
@ -57,113 +47,6 @@ def get_operator(request: HttpRequest, counter: Counter, customer: Customer) ->
|
||||
return counter.get_random_barman()
|
||||
|
||||
|
||||
class ProductForm(Form):
|
||||
quantity = IntegerField(min_value=1)
|
||||
id = IntegerField(min_value=0)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
customer: Customer,
|
||||
counter: Counter,
|
||||
allowed_products: dict[int, Product],
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
self.customer = customer # Used by formset
|
||||
self.counter = counter # Used by formset
|
||||
self.allowed_products = allowed_products
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def clean_id(self):
|
||||
data = self.cleaned_data["id"]
|
||||
|
||||
# We store self.product so we can use it later on the formset validation
|
||||
# And also in the global clean
|
||||
self.product = self.allowed_products.get(data, None)
|
||||
if self.product is None:
|
||||
raise ValidationError(
|
||||
_("The selected product isn't available for this user")
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
if len(self.errors) > 0:
|
||||
return
|
||||
|
||||
# Compute prices
|
||||
cleaned_data["bonus_quantity"] = 0
|
||||
if self.product.tray:
|
||||
cleaned_data["bonus_quantity"] = math.floor(
|
||||
cleaned_data["quantity"] / Product.QUANTITY_FOR_TRAY_PRICE
|
||||
)
|
||||
cleaned_data["total_price"] = self.product.price * (
|
||||
cleaned_data["quantity"] - cleaned_data["bonus_quantity"]
|
||||
)
|
||||
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class BaseBasketForm(BaseFormSet):
|
||||
def clean(self):
|
||||
if len(self.forms) == 0:
|
||||
return
|
||||
|
||||
self._check_forms_have_errors()
|
||||
self._check_product_are_unique()
|
||||
self._check_recorded_products(self[0].customer)
|
||||
self._check_enough_money(self[0].counter, self[0].customer)
|
||||
|
||||
def _check_forms_have_errors(self):
|
||||
if any(len(form.errors) > 0 for form in self):
|
||||
raise ValidationError(_("Submitted basket is invalid"))
|
||||
|
||||
def _check_product_are_unique(self):
|
||||
product_ids = {form.cleaned_data["id"] for form in self.forms}
|
||||
if len(product_ids) != len(self.forms):
|
||||
raise ValidationError(_("Duplicated product entries."))
|
||||
|
||||
def _check_enough_money(self, counter: Counter, customer: Customer):
|
||||
self.total_price = sum([data["total_price"] for data in self.cleaned_data])
|
||||
if self.total_price > customer.amount:
|
||||
raise ValidationError(_("Not enough money"))
|
||||
|
||||
def _check_recorded_products(self, customer: Customer):
|
||||
"""Check for, among other things, ecocups and pitchers"""
|
||||
items = {
|
||||
form.cleaned_data["id"]: form.cleaned_data["quantity"]
|
||||
for form in self.forms
|
||||
}
|
||||
ids = list(items.keys())
|
||||
returnables = list(
|
||||
ReturnableProduct.objects.filter(
|
||||
Q(product_id__in=ids) | Q(returned_product_id__in=ids)
|
||||
).annotate_balance_for(customer)
|
||||
)
|
||||
limit_reached = []
|
||||
for returnable in returnables:
|
||||
returnable.balance += items.get(returnable.product_id, 0)
|
||||
for returnable in returnables:
|
||||
dcons = items.get(returnable.returned_product_id, 0)
|
||||
returnable.balance -= dcons
|
||||
if dcons and returnable.balance < -returnable.max_return:
|
||||
limit_reached.append(returnable.returned_product)
|
||||
if limit_reached:
|
||||
raise ValidationError(
|
||||
_(
|
||||
"This user have reached his recording limit "
|
||||
"for the following products : %s"
|
||||
)
|
||||
% ", ".join([str(p) for p in limit_reached])
|
||||
)
|
||||
|
||||
|
||||
BasketForm = formset_factory(
|
||||
ProductForm, formset=BaseBasketForm, absolute_max=None, min_num=1
|
||||
)
|
||||
|
||||
|
||||
class CounterClick(
|
||||
CounterTabsMixin, UseFragmentsMixin, CanViewMixin, SingleObjectMixin, FormView
|
||||
):
|
||||
|
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
|
@ -40,6 +40,7 @@ def get_eboutic_products(user: User) -> list[Product]:
|
||||
.annotate(order=F("product_type__order"))
|
||||
.annotate(category=F("product_type__name"))
|
||||
.annotate(category_comment=F("product_type__comment"))
|
||||
.annotate(price=F("selling_price"))
|
||||
.prefetch_related("buying_groups") # <-- used in `Product.can_be_sold_to`
|
||||
)
|
||||
return [p for p in products if p.can_be_sold_to(user)]
|
||||
@ -84,6 +85,9 @@ class Basket(models.Model):
|
||||
def __str__(self):
|
||||
return f"{self.user}'s basket ({self.items.all().count()} items)"
|
||||
|
||||
def can_be_viewed_by(self, user):
|
||||
return self.user == user
|
||||
|
||||
@cached_property
|
||||
def contains_refilling_item(self) -> bool:
|
||||
return self.items.filter(
|
||||
@ -139,7 +143,7 @@ class Basket(models.Model):
|
||||
club=product.club,
|
||||
product=product,
|
||||
seller=seller,
|
||||
customer=self.user.customer,
|
||||
customer=Customer.get_or_create(self.user)[0],
|
||||
unit_price=item.product_unit_price,
|
||||
quantity=item.quantity,
|
||||
payment_method=payment_method,
|
||||
|
@ -8,55 +8,44 @@ interface BasketItem {
|
||||
unit_price: number;
|
||||
}
|
||||
|
||||
const BASKET_ITEMS_COOKIE_NAME: string = "basket_items";
|
||||
|
||||
/**
|
||||
* Search for a cookie by name
|
||||
* @param name Name of the cookie to get
|
||||
* @returns the value of the cookie or null if it does not exist, undefined if not found
|
||||
*/
|
||||
function getCookie(name: string): string | null | undefined {
|
||||
if (!document.cookie || document.cookie.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const found = document.cookie
|
||||
.split(";")
|
||||
.map((c) => c.trim())
|
||||
.find((c) => c.startsWith(`${name}=`));
|
||||
|
||||
return found === undefined ? undefined : decodeURIComponent(found.split("=")[1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the basket items from the associated cookie
|
||||
* @returns the items in the basket
|
||||
*/
|
||||
function getStartingItems(): BasketItem[] {
|
||||
const cookie = getCookie(BASKET_ITEMS_COOKIE_NAME);
|
||||
if (!cookie) {
|
||||
return [];
|
||||
}
|
||||
// Django cookie backend converts `,` to `\054`
|
||||
let parsed = JSON.parse(cookie.replace(/\\054/g, ","));
|
||||
if (typeof parsed === "string") {
|
||||
// In some conditions, a second parsing is needed
|
||||
parsed = JSON.parse(parsed);
|
||||
}
|
||||
const res = Array.isArray(parsed) ? parsed : [];
|
||||
return res.filter((i) => !!document.getElementById(i.id));
|
||||
}
|
||||
|
||||
document.addEventListener("alpine:init", () => {
|
||||
Alpine.data("basket", () => ({
|
||||
items: getStartingItems() as BasketItem[],
|
||||
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
|
||||
* @returns {number} The total price of the basket
|
||||
*/
|
||||
getTotal() {
|
||||
return this.items.reduce(
|
||||
return this.basket.reduce(
|
||||
(acc: number, item: BasketItem) => acc + item.quantity * item.unit_price,
|
||||
0,
|
||||
);
|
||||
@ -68,7 +57,6 @@ document.addEventListener("alpine:init", () => {
|
||||
*/
|
||||
add(item: BasketItem) {
|
||||
item.quantity++;
|
||||
this.setCookies();
|
||||
},
|
||||
|
||||
/**
|
||||
@ -76,39 +64,25 @@ document.addEventListener("alpine:init", () => {
|
||||
* @param itemId the id of the item to remove
|
||||
*/
|
||||
remove(itemId: number) {
|
||||
const index = this.items.findIndex((e: BasketItem) => e.id === itemId);
|
||||
const index = this.basket.findIndex((e: BasketItem) => e.id === itemId);
|
||||
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
this.items[index].quantity -= 1;
|
||||
this.basket[index].quantity -= 1;
|
||||
|
||||
if (this.items[index].quantity === 0) {
|
||||
this.items = this.items.filter(
|
||||
(e: BasketItem) => e.id !== this.items[index].id,
|
||||
if (this.basket[index].quantity === 0) {
|
||||
this.basket = this.basket.filter(
|
||||
(e: BasketItem) => e.id !== this.basket[index].id,
|
||||
);
|
||||
}
|
||||
this.setCookies();
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove all the items from the basket & cleans the catalog CSS classes
|
||||
* Remove all the basket from the basket & cleans the catalog CSS classes
|
||||
*/
|
||||
clearBasket() {
|
||||
this.items = [];
|
||||
this.setCookies();
|
||||
},
|
||||
|
||||
/**
|
||||
* Set the cookie in the browser with the basket items
|
||||
* ! the cookie survives an hour
|
||||
*/
|
||||
setCookies() {
|
||||
if (this.items.length === 0) {
|
||||
document.cookie = `${BASKET_ITEMS_COOKIE_NAME}=;Max-Age=0`;
|
||||
} else {
|
||||
document.cookie = `${BASKET_ITEMS_COOKIE_NAME}=${encodeURIComponent(JSON.stringify(this.items))};Max-Age=3600`;
|
||||
}
|
||||
this.basket = [];
|
||||
},
|
||||
|
||||
/**
|
||||
@ -127,7 +101,7 @@ document.addEventListener("alpine:init", () => {
|
||||
unit_price: price,
|
||||
} as BasketItem;
|
||||
|
||||
this.items.push(newItem);
|
||||
this.basket.push(newItem);
|
||||
this.add(newItem);
|
||||
|
||||
return newItem;
|
||||
@ -141,7 +115,7 @@ document.addEventListener("alpine:init", () => {
|
||||
* @param price The unit price of the product
|
||||
*/
|
||||
addFromCatalog(id: number, name: string, price: number) {
|
||||
let item = this.items.find((e: BasketItem) => e.id === id);
|
||||
let item = this.basket.find((e: BasketItem) => e.id === id);
|
||||
|
||||
// if the item is not in the basket, we create it
|
||||
// else we add + 1 to it
|
||||
|
@ -24,48 +24,72 @@
|
||||
<div id="eboutic" x-data="basket">
|
||||
<div id="basket">
|
||||
<h3>Panier</h3>
|
||||
{% if errors %}
|
||||
<div class="alert alert-red">
|
||||
<div class="alert-main">
|
||||
{% for error in errors %}
|
||||
<p style="margin: 0">{{ error }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<form method="post" action="">
|
||||
{% csrf_token %}
|
||||
<div x-ref="basketManagementForm">
|
||||
{{ form.management_form }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<ul class="item-list">
|
||||
{# Starting money #}
|
||||
<li>
|
||||
<span class="item-name">
|
||||
<strong>{% trans %}Current account amount: {% endtrans %}</strong>
|
||||
</span>
|
||||
<span class="item-price">
|
||||
<strong>{{ "%0.2f"|format(customer_amount) }} €</strong>
|
||||
</span>
|
||||
</li>
|
||||
<template x-for="item in items" :key="item.id">
|
||||
<li class="item-row" x-show="item.quantity > 0">
|
||||
<div class="item-quantity">
|
||||
<i class="fa fa-minus fa-xs" @click="remove(item.id)"></i>
|
||||
<span x-text="item.quantity"></span>
|
||||
<i class="fa fa-plus" @click="add(item)"></i>
|
||||
|
||||
{% if form.non_form_errors() %}
|
||||
<div class="alert alert-red">
|
||||
<div class="alert-main">
|
||||
{% for error in form.non_form_errors() %}
|
||||
<p style="margin: 0">{{ error }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<span class="item-name" x-text="item.name"></span>
|
||||
<span class="item-price" x-text="(item.unit_price * item.quantity).toFixed(2) + ' €'"></span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<ul class="item-list">
|
||||
{# Starting money #}
|
||||
<li>
|
||||
<span class="item-name">
|
||||
<strong>{% trans %}Current account amount: {% endtrans %}</strong>
|
||||
</span>
|
||||
<span class="item-price">
|
||||
<strong>{{ "%0.2f"|format(customer_amount) }} €</strong>
|
||||
</span>
|
||||
</li>
|
||||
</template>
|
||||
{# Total price #}
|
||||
<li style="margin-top: 20px">
|
||||
<span class="item-name"><strong>{% trans %}Basket amount: {% endtrans %}</strong></span>
|
||||
<span x-text="getTotal().toFixed(2) + ' €'" class="item-price"></span>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="catalog-buttons">
|
||||
<button @click="clearBasket()" class="btn btn-grey">
|
||||
<i class="fa fa-trash"></i>
|
||||
{% trans %}Clear{% endtrans %}
|
||||
</button>
|
||||
<form method="get" action="{{ url('eboutic:command') }}">
|
||||
|
||||
<template x-for="(item, index) in Object.values(basket)">
|
||||
<li class="item-row" x-show="item.quantity > 0">
|
||||
<div class="item-quantity">
|
||||
<i class="fa fa-minus fa-xs" @click="remove(item.id)"></i>
|
||||
<span x-text="item.quantity"></span>
|
||||
<i class="fa fa-plus" @click="add(item)"></i>
|
||||
</div>
|
||||
<span class="item-name" x-text="item.name"></span>
|
||||
<span class="item-price" x-text="(item.unit_price * item.quantity).toFixed(2) + ' €'"></span>
|
||||
|
||||
<input
|
||||
type="hidden"
|
||||
:value="item.quantity"
|
||||
:id="`id_form-${index}-quantity`"
|
||||
:name="`form-${index}-quantity`"
|
||||
required
|
||||
readonly
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
:value="item.id"
|
||||
:id="`id_form-${index}-id`"
|
||||
:name="`form-${index}-id`"
|
||||
required
|
||||
readonly
|
||||
>
|
||||
|
||||
</li>
|
||||
</template>
|
||||
{# Total price #}
|
||||
<li style="margin-top: 20px">
|
||||
<span class="item-name"><strong>{% trans %}Basket amount: {% endtrans %}</strong></span>
|
||||
<span x-text="getTotal().toFixed(2) + ' €'" class="item-price"></span>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="catalog-buttons">
|
||||
<button @click.prevent="clearBasket()" class="btn btn-grey">
|
||||
<i class="fa fa-trash"></i>
|
||||
{% trans %}Clear{% endtrans %}
|
||||
</button>
|
||||
<button class="btn btn-blue">
|
||||
<i class="fa fa-check"></i>
|
||||
<input type="submit" value="{% trans %}Validate{% endtrans %}"/>
|
||||
@ -158,7 +182,7 @@
|
||||
<button
|
||||
id="{{ p.id }}"
|
||||
class="card product-button clickable shadow"
|
||||
:class="{selected: items.some((i) => i.id === {{ p.id }})}"
|
||||
:class="{selected: basket.some((i) => i.id === {{ p.id }})}"
|
||||
@click='addFromCatalog({{ p.id }}, {{ p.name|tojson }}, {{ p.selling_price }})'
|
||||
>
|
||||
{% if p.icon %}
|
||||
|
@ -82,9 +82,8 @@
|
||||
{% elif basket.total > user.account_balance %}
|
||||
<p>{% trans %}AE account payment disabled because you do not have enough money remaining.{% endtrans %}</p>
|
||||
{% else %}
|
||||
<form method="post" action="{{ url('eboutic:pay_with_sith') }}" name="sith-pay-form">
|
||||
<form method="post" action="{{ url('eboutic:pay_with_sith', basket_id=basket.id) }}" name="sith-pay-form">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="pay_with_sith_account">
|
||||
<input class="btn btn-blue" type="submit" value="{% trans %}Pay with Sith account{% endtrans %}"/>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
@ -28,10 +28,10 @@ from eboutic.converters import PaymentResultConverter
|
||||
from eboutic.views import (
|
||||
BillingInfoFormFragment,
|
||||
EbouticCommand,
|
||||
EbouticCreateBasket,
|
||||
EbouticPayWithSith,
|
||||
EtransactionAutoAnswer,
|
||||
EurokPartnerFragment,
|
||||
eboutic_main,
|
||||
pay_with_sith,
|
||||
payment_result,
|
||||
)
|
||||
|
||||
@ -39,10 +39,12 @@ register_converter(PaymentResultConverter, "res")
|
||||
|
||||
urlpatterns = [
|
||||
# Subscription views
|
||||
path("", eboutic_main, name="main"),
|
||||
path("command/", EbouticCommand.as_view(), name="command"),
|
||||
path("", EbouticCreateBasket.as_view(), name="main"),
|
||||
path("command/<int:basket_id>", EbouticCommand.as_view(), name="command"),
|
||||
path("billing-infos/", BillingInfoFormFragment.as_view(), name="billing_infos"),
|
||||
path("pay/sith/", pay_with_sith, name="pay_with_sith"),
|
||||
path(
|
||||
"pay/sith/<int:basket_id>", EbouticPayWithSith.as_view(), name="pay_with_sith"
|
||||
),
|
||||
path("pay/<res:result>/", payment_result, name="payment_result"),
|
||||
path("eurok/", EurokPartnerFragment.as_view(), name="eurok"),
|
||||
path(
|
||||
|
194
eboutic/views.py
194
eboutic/views.py
@ -35,21 +35,21 @@ from django.contrib.auth.mixins import (
|
||||
from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.db import DatabaseError, transaction
|
||||
from django.db.models.fields import forms
|
||||
from django.db.utils import cached_property
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import redirect, render
|
||||
from django.urls import reverse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.decorators.http import require_GET, require_POST
|
||||
from django.views.generic import TemplateView, UpdateView, View
|
||||
from django.views.decorators.http import require_GET
|
||||
from django.views.generic import DetailView, FormView, TemplateView, UpdateView, View
|
||||
from django.views.generic.edit import SingleObjectMixin
|
||||
from django_countries.fields import Country
|
||||
|
||||
from core.auth.mixins import IsSubscriberMixin
|
||||
from core.auth.mixins import CanViewMixin, IsSubscriberMixin
|
||||
from core.views.mixins import FragmentMixin, UseFragmentsMixin
|
||||
from counter.forms import BillingInfoForm
|
||||
from counter.forms import BaseBasketForm, BillingInfoForm, ProductForm
|
||||
from counter.models import BillingInfo, Counter, Customer, Product
|
||||
from eboutic.forms import BasketForm
|
||||
from eboutic.models import (
|
||||
Basket,
|
||||
BasketItem,
|
||||
@ -58,39 +58,69 @@ from eboutic.models import (
|
||||
InvoiceItem,
|
||||
get_eboutic_products,
|
||||
)
|
||||
from eboutic.schemas import PurchaseItemList, PurchaseItemSchema
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
|
||||
from django.utils.html import SafeString
|
||||
|
||||
|
||||
@login_required
|
||||
@require_GET
|
||||
def eboutic_main(request: HttpRequest) -> HttpResponse:
|
||||
"""Main view of the eboutic application.
|
||||
class BaseEbouticBasketForm(BaseBasketForm):
|
||||
def _check_enough_money(self, *args, **kwargs):
|
||||
# Disable money check
|
||||
...
|
||||
|
||||
Return an Http response whose content is of type text/html.
|
||||
The latter represents the page from which a user can see
|
||||
the catalogue of products that he can buy and fill
|
||||
his shopping cart.
|
||||
|
||||
EbouticBasketForm = forms.formset_factory(
|
||||
ProductForm, formset=BaseEbouticBasketForm, absolute_max=None, min_num=1
|
||||
)
|
||||
|
||||
|
||||
class EbouticCreateBasket(LoginRequiredMixin, FormView):
|
||||
"""Main view of the eboutic application.
|
||||
|
||||
The purchasable products are those of the eboutic which
|
||||
belong to a category of products of a product category
|
||||
(orphan products are inaccessible).
|
||||
|
||||
If the session contains a key-value pair that associates "errors"
|
||||
with a list of strings, this pair is removed from the session
|
||||
and its value displayed to the user when the page is rendered.
|
||||
"""
|
||||
errors = request.session.pop("errors", None)
|
||||
products = get_eboutic_products(request.user)
|
||||
context = {
|
||||
"errors": errors,
|
||||
"products": products,
|
||||
"customer_amount": request.user.account_balance,
|
||||
}
|
||||
return render(request, "eboutic/eboutic_main.jinja", context)
|
||||
|
||||
template_name = "eboutic/eboutic_main.jinja"
|
||||
form_class = EbouticBasketForm
|
||||
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super().get_form_kwargs()
|
||||
kwargs["form_kwargs"] = {
|
||||
"customer": 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 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
|
||||
@ -166,48 +196,15 @@ class BillingInfoFormFragment(
|
||||
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"
|
||||
basket: Basket
|
||||
fragments = {
|
||||
"billing_infos_form": BillingInfoFormFragment,
|
||||
}
|
||||
|
||||
@method_decorator(login_required)
|
||||
def post(self, request, *args, **kwargs):
|
||||
return redirect("eboutic:main")
|
||||
|
||||
def get(self, request: HttpRequest, *args, **kwargs):
|
||||
form = BasketForm(request)
|
||||
if not form.is_valid():
|
||||
request.session["errors"] = form.errors
|
||||
request.session.modified = True
|
||||
res = redirect("eboutic:main")
|
||||
res.set_cookie(
|
||||
"basket_items",
|
||||
PurchaseItemList.dump_json(form.cleaned_data, by_alias=True).decode(),
|
||||
path="/eboutic",
|
||||
)
|
||||
return res
|
||||
basket = Basket.from_session(request.session)
|
||||
if basket is not None:
|
||||
basket.items.all().delete()
|
||||
else:
|
||||
basket = Basket.objects.create(user=request.user)
|
||||
request.session["basket_id"] = basket.id
|
||||
request.session.modified = True
|
||||
|
||||
items: list[PurchaseItemSchema] = form.cleaned_data
|
||||
pks = {item.product_id for item in items}
|
||||
products = {p.pk: p for p in Product.objects.filter(pk__in=pks)}
|
||||
db_items = []
|
||||
for pk in pks:
|
||||
quantity = sum(i.quantity for i in items if i.product_id == pk)
|
||||
db_items.append(BasketItem.from_product(products[pk], quantity, basket))
|
||||
BasketItem.objects.bulk_create(db_items)
|
||||
self.basket = basket
|
||||
return super().get(request)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
kwargs = super().get_context_data(**kwargs)
|
||||
if hasattr(self.request.user, "customer"):
|
||||
@ -215,51 +212,48 @@ class EbouticCommand(LoginRequiredMixin, UseFragmentsMixin, TemplateView):
|
||||
kwargs["customer_amount"] = customer.amount
|
||||
else:
|
||||
kwargs["customer_amount"] = None
|
||||
kwargs["basket"] = self.basket
|
||||
kwargs["billing_infos"] = {}
|
||||
|
||||
with contextlib.suppress(BillingInfo.DoesNotExist):
|
||||
kwargs["billing_infos"] = json.dumps(
|
||||
dict(self.basket.get_e_transaction_data())
|
||||
dict(self.object.get_e_transaction_data())
|
||||
)
|
||||
return kwargs
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def pay_with_sith(request):
|
||||
basket = Basket.from_session(request.session)
|
||||
refilling = settings.SITH_COUNTER_PRODUCTTYPE_REFILLING
|
||||
if basket is None or basket.items.filter(type_id=refilling).exists():
|
||||
return redirect("eboutic:main")
|
||||
c = Customer.objects.filter(user__id=basket.user_id).first()
|
||||
if c is None:
|
||||
return redirect("eboutic:main")
|
||||
if c.amount < basket.total:
|
||||
res = redirect("eboutic:payment_result", "failure")
|
||||
res.delete_cookie("basket_items", "/eboutic")
|
||||
return res
|
||||
eboutic = Counter.objects.get(type="EBOUTIC")
|
||||
sales = basket.generate_sales(eboutic, c.user, "SITH_ACCOUNT")
|
||||
try:
|
||||
with transaction.atomic():
|
||||
# Selling.save has some important business logic in it.
|
||||
# Do not bulk_create this
|
||||
for sale in sales:
|
||||
sale.save()
|
||||
basket.delete()
|
||||
request.session.pop("basket_id", None)
|
||||
res = redirect("eboutic:payment_result", "success")
|
||||
except DatabaseError as e:
|
||||
with sentry_sdk.push_scope() as scope:
|
||||
scope.user = {"username": request.user.username}
|
||||
scope.set_extra("someVariable", e.__repr__())
|
||||
sentry_sdk.capture_message(
|
||||
f"Erreur le {datetime.now()} dans eboutic.pay_with_sith"
|
||||
class EbouticPayWithSith(CanViewMixin, SingleObjectMixin, View):
|
||||
http_method_names = ["post"]
|
||||
model = Basket
|
||||
pk_url_kwarg = "basket_id"
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
basket = self.get_object()
|
||||
refilling = settings.SITH_COUNTER_PRODUCTTYPE_REFILLING
|
||||
if basket.items.filter(type_id=refilling).exists():
|
||||
messages.error(
|
||||
self.request,
|
||||
_("You can't buy a refilling with sith money"),
|
||||
)
|
||||
res = redirect("eboutic:payment_result", "failure")
|
||||
res.delete_cookie("basket_items", "/eboutic")
|
||||
return res
|
||||
return redirect("eboutic:main")
|
||||
|
||||
eboutic = Counter.objects.get(type="EBOUTIC")
|
||||
sales = basket.generate_sales(eboutic, basket.user, "SITH_ACCOUNT")
|
||||
try:
|
||||
with transaction.atomic():
|
||||
# Selling.save has some important business logic in it.
|
||||
# Do not bulk_create this
|
||||
for sale in sales:
|
||||
sale.save()
|
||||
basket.delete()
|
||||
return redirect("eboutic:payment_result", "success")
|
||||
except DatabaseError as e:
|
||||
with sentry_sdk.push_scope() as scope:
|
||||
scope.user = {"username": request.user.username}
|
||||
scope.set_extra("someVariable", e.__repr__())
|
||||
sentry_sdk.capture_message(
|
||||
f"Erreur le {datetime.now()} dans eboutic.pay_with_sith"
|
||||
)
|
||||
return redirect("eboutic:payment_result", "failure")
|
||||
|
||||
|
||||
class EtransactionAutoAnswer(View):
|
||||
|
Loading…
x
Reference in New Issue
Block a user