From 262ed7eb4c66f318f391e13dbd6ebd6e0cc3ffc0 Mon Sep 17 00:00:00 2001 From: Sli Date: Tue, 15 Apr 2025 00:07:07 +0200 Subject: [PATCH 01/10] Don't use cookies for processing eboutic baskets --- counter/forms.py | 112 ++++++++++ counter/views/click.py | 119 +---------- eboutic/forms.py | 128 ------------ eboutic/models.py | 6 +- .../static/bundled/eboutic/eboutic-index.ts | 104 ++++------ eboutic/templates/eboutic/eboutic_main.jinja | 104 ++++++---- .../eboutic/eboutic_makecommand.jinja | 3 +- eboutic/urls.py | 12 +- eboutic/views.py | 194 +++++++++--------- 9 files changed, 323 insertions(+), 459 deletions(-) delete mode 100644 eboutic/forms.py diff --git a/counter/forms.py b/counter/forms.py index 27dc74d7..c7df1d9d 100644 --- a/counter/forms.py +++ b/counter/forms.py @@ -1,4 +1,7 @@ +import math + from django import forms +from django.db.models import Q from django.utils.translation import gettext_lazy as _ from phonenumber_field.widgets import RegionalPhoneNumberWidget @@ -261,3 +264,112 @@ class CloseCustomerAccountForm(forms.Form): widget=AutoCompleteSelectUser, queryset=User.objects.all(), ) + + +class ProductForm(forms.Form): + quantity = forms.IntegerField(min_value=1, required=True) + id = forms.IntegerField(min_value=0, required=True) + + def __init__( + self, + customer: Customer, + counter: Counter, + allowed_products: dict[int, Product], + *args, + **kwargs, + ): + self.customer = customer # Used by formset + self.counter = counter # Used by formset + self.allowed_products = allowed_products + super().__init__(*args, **kwargs) + + def clean_id(self): + data = self.cleaned_data["id"] + + # We store self.product so we can use it later on the formset validation + # And also in the global clean + self.product = self.allowed_products.get(data, None) + if self.product is None: + raise forms.ValidationError( + _("The selected product isn't available for this user") + ) + + return data + + def clean(self): + cleaned_data = super().clean() + if len(self.errors) > 0: + return + + # Compute prices + cleaned_data["bonus_quantity"] = 0 + if self.product.tray: + cleaned_data["bonus_quantity"] = math.floor( + cleaned_data["quantity"] / Product.QUANTITY_FOR_TRAY_PRICE + ) + cleaned_data["total_price"] = self.product.price * ( + cleaned_data["quantity"] - cleaned_data["bonus_quantity"] + ) + + return cleaned_data + + +class BaseBasketForm(forms.BaseFormSet): + def clean(self): + self.forms = [form for form in self.forms if form.cleaned_data != {}] + + if len(self.forms) == 0: + return + + self._check_forms_have_errors() + self._check_product_are_unique() + self._check_recorded_products(self[0].customer) + self._check_enough_money(self[0].counter, self[0].customer) + + def _check_forms_have_errors(self): + if any(len(form.errors) > 0 for form in self): + raise forms.ValidationError(_("Submitted basket is invalid")) + + def _check_product_are_unique(self): + product_ids = {form.cleaned_data["id"] for form in self.forms} + if len(product_ids) != len(self.forms): + raise forms.ValidationError(_("Duplicated product entries.")) + + def _check_enough_money(self, counter: Counter, customer: Customer): + self.total_price = sum([data["total_price"] for data in self.cleaned_data]) + if self.total_price > customer.amount: + raise forms.ValidationError(_("Not enough money")) + + def _check_recorded_products(self, customer: Customer): + """Check for, among other things, ecocups and pitchers""" + items = { + form.cleaned_data["id"]: form.cleaned_data["quantity"] + for form in self.forms + } + ids = list(items.keys()) + returnables = list( + ReturnableProduct.objects.filter( + Q(product_id__in=ids) | Q(returned_product_id__in=ids) + ).annotate_balance_for(customer) + ) + limit_reached = [] + for returnable in returnables: + returnable.balance += items.get(returnable.product_id, 0) + for returnable in returnables: + dcons = items.get(returnable.returned_product_id, 0) + returnable.balance -= dcons + if dcons and returnable.balance < -returnable.max_return: + limit_reached.append(returnable.returned_product) + if limit_reached: + raise forms.ValidationError( + _( + "This user have reached his recording limit " + "for the following products : %s" + ) + % ", ".join([str(p) for p in limit_reached]) + ) + + +BasketForm = forms.formset_factory( + ProductForm, formset=BaseBasketForm, absolute_max=None, min_num=1 +) diff --git a/counter/views/click.py b/counter/views/click.py index 4da38643..02c0bdaa 100644 --- a/counter/views/click.py +++ b/counter/views/click.py @@ -12,23 +12,14 @@ # OR WITHIN THE LOCAL FILE "LICENSE" # # -import math from django.core.exceptions import PermissionDenied from django.db import transaction from django.db.models import Q -from django.forms import ( - BaseFormSet, - Form, - IntegerField, - ValidationError, - formset_factory, -) from django.http import Http404 from django.shortcuts import get_object_or_404, redirect, resolve_url from django.urls import reverse from django.utils.safestring import SafeString -from django.utils.translation import gettext_lazy as _ from django.views.generic import FormView from django.views.generic.detail import SingleObjectMixin from ninja.main import HttpRequest @@ -36,11 +27,10 @@ from ninja.main import HttpRequest from core.auth.mixins import CanViewMixin from core.models import User from core.views.mixins import FragmentMixin, UseFragmentsMixin -from counter.forms import RefillForm +from counter.forms import BasketForm, RefillForm from counter.models import ( Counter, Customer, - Product, ReturnableProduct, Selling, ) @@ -57,113 +47,6 @@ def get_operator(request: HttpRequest, counter: Counter, customer: Customer) -> return counter.get_random_barman() -class ProductForm(Form): - quantity = IntegerField(min_value=1) - id = IntegerField(min_value=0) - - def __init__( - self, - customer: Customer, - counter: Counter, - allowed_products: dict[int, Product], - *args, - **kwargs, - ): - self.customer = customer # Used by formset - self.counter = counter # Used by formset - self.allowed_products = allowed_products - super().__init__(*args, **kwargs) - - def clean_id(self): - data = self.cleaned_data["id"] - - # We store self.product so we can use it later on the formset validation - # And also in the global clean - self.product = self.allowed_products.get(data, None) - if self.product is None: - raise ValidationError( - _("The selected product isn't available for this user") - ) - - return data - - def clean(self): - cleaned_data = super().clean() - if len(self.errors) > 0: - return - - # Compute prices - cleaned_data["bonus_quantity"] = 0 - if self.product.tray: - cleaned_data["bonus_quantity"] = math.floor( - cleaned_data["quantity"] / Product.QUANTITY_FOR_TRAY_PRICE - ) - cleaned_data["total_price"] = self.product.price * ( - cleaned_data["quantity"] - cleaned_data["bonus_quantity"] - ) - - return cleaned_data - - -class BaseBasketForm(BaseFormSet): - def clean(self): - if len(self.forms) == 0: - return - - self._check_forms_have_errors() - self._check_product_are_unique() - self._check_recorded_products(self[0].customer) - self._check_enough_money(self[0].counter, self[0].customer) - - def _check_forms_have_errors(self): - if any(len(form.errors) > 0 for form in self): - raise ValidationError(_("Submitted basket is invalid")) - - def _check_product_are_unique(self): - product_ids = {form.cleaned_data["id"] for form in self.forms} - if len(product_ids) != len(self.forms): - raise ValidationError(_("Duplicated product entries.")) - - def _check_enough_money(self, counter: Counter, customer: Customer): - self.total_price = sum([data["total_price"] for data in self.cleaned_data]) - if self.total_price > customer.amount: - raise ValidationError(_("Not enough money")) - - def _check_recorded_products(self, customer: Customer): - """Check for, among other things, ecocups and pitchers""" - items = { - form.cleaned_data["id"]: form.cleaned_data["quantity"] - for form in self.forms - } - ids = list(items.keys()) - returnables = list( - ReturnableProduct.objects.filter( - Q(product_id__in=ids) | Q(returned_product_id__in=ids) - ).annotate_balance_for(customer) - ) - limit_reached = [] - for returnable in returnables: - returnable.balance += items.get(returnable.product_id, 0) - for returnable in returnables: - dcons = items.get(returnable.returned_product_id, 0) - returnable.balance -= dcons - if dcons and returnable.balance < -returnable.max_return: - limit_reached.append(returnable.returned_product) - if limit_reached: - raise ValidationError( - _( - "This user have reached his recording limit " - "for the following products : %s" - ) - % ", ".join([str(p) for p in limit_reached]) - ) - - -BasketForm = formset_factory( - ProductForm, formset=BaseBasketForm, absolute_max=None, min_num=1 -) - - class CounterClick( CounterTabsMixin, UseFragmentsMixin, CanViewMixin, SingleObjectMixin, FormView ): diff --git a/eboutic/forms.py b/eboutic/forms.py deleted file mode 100644 index e15714cf..00000000 --- a/eboutic/forms.py +++ /dev/null @@ -1,128 +0,0 @@ -# -# Copyright 2022 -# - Maréchal None: - """Perform all the checks, but return nothing. - To know if the form is valid, the `is_valid()` method must be used. - - The form shall be considered as valid if it meets all the following conditions : - - it contains a "basket_items" key in the cookies of the request given in the constructor - - this cookie is a list of objects formatted this way : `[{'id': , 'quantity': , - 'name': , 'unit_price': }, ...]`. The order of the fields in each object does not matter - - all the ids are positive integers - - all the ids refer to products available in the EBOUTIC - - all the ids refer to products the user is allowed to buy - - all the quantities are positive integers - """ - try: - basket = PurchaseItemList.validate_json( - unquote(self.cookies.get("basket_items", "[]")) - ) - except ValidationError: - self.error_messages.add(_("The request was badly formatted.")) - return - if len(basket) == 0: - self.error_messages.add(_("Your basket is empty.")) - return - existing_ids = {product.id for product in get_eboutic_products(self.user)} - for item in basket: - # check a product with this id does exist - if item.product_id in existing_ids: - self.correct_items.append(item) - else: - self.error_messages.add( - _( - "%(name)s : this product does not exist or may no longer be available." - ) - % {"name": item.name} - ) - continue - # this function does not return anything. - # instead, it fills a set containing the collected error messages - # an empty set means that no error was seen thus everything is ok - # and the form is valid. - # a non-empty set means there was at least one error thus - # the form is invalid - - def is_valid(self) -> bool: - """Return True if the form is correct else False. - - If the `clean()` method has not been called beforehand, call it. - """ - if not self.error_messages and not self.correct_items: - self.clean() - return not self.error_messages - - @cached_property - def errors(self) -> list[str]: - return list(self.error_messages) - - @cached_property - def cleaned_data(self) -> list[PurchaseItemSchema]: - return self.correct_items diff --git a/eboutic/models.py b/eboutic/models.py index b6daaf34..1d12e182 100644 --- a/eboutic/models.py +++ b/eboutic/models.py @@ -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, diff --git a/eboutic/static/bundled/eboutic/eboutic-index.ts b/eboutic/static/bundled/eboutic/eboutic-index.ts index 1ff9a2da..f31e5088 100644 --- a/eboutic/static/bundled/eboutic/eboutic-index.ts +++ b/eboutic/static/bundled/eboutic/eboutic-index.ts @@ -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 diff --git a/eboutic/templates/eboutic/eboutic_main.jinja b/eboutic/templates/eboutic/eboutic_main.jinja index 71757436..9fb217cf 100644 --- a/eboutic/templates/eboutic/eboutic_main.jinja +++ b/eboutic/templates/eboutic/eboutic_main.jinja @@ -24,48 +24,72 @@

Panier

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

{{ error }}

- {% endfor %} -
+
+ {% csrf_token %} +
+ {{ form.management_form }}
- {% endif %} -
    - {# Starting money #} -
  • - - {% trans %}Current account amount: {% endtrans %} - - - {{ "%0.2f"|format(customer_amount) }} € - -
  • - - {# Total price #} -
  • - {% trans %}Basket amount: {% endtrans %} - -
  • -
-
- - + + + {# Total price #} +
  • + {% trans %}Basket amount: {% endtrans %} + +
  • + +
    +
    - {% if form.non_form_errors() %} + {% if form.non_form_errors() or form.errors %}
    - {% for error in form.non_form_errors() %} + {% for error in form.non_form_errors() + form.errors %}

    {{ error }}

    {% endfor %}
    @@ -50,7 +50,7 @@ -