diff --git a/eboutic/static/bundled/eboutic/eboutic-index.ts b/eboutic/static/bundled/eboutic/eboutic-index.ts index 0cd689fd..e345d936 100644 --- a/eboutic/static/bundled/eboutic/eboutic-index.ts +++ b/eboutic/static/bundled/eboutic/eboutic-index.ts @@ -1,13 +1,15 @@ export {}; interface BasketItem { - id: number; + priceId: number; name: string; quantity: number; - // biome-ignore lint/style/useNamingConvention: the python code is snake_case - unit_price: number; + unitPrice: number; } +// increment the key number if the data schema of the cached basket changes +const BASKET_CACHE_KEY = "basket1"; + document.addEventListener("alpine:init", () => { Alpine.data("basket", (lastPurchaseTime?: number) => ({ basket: [] as BasketItem[], @@ -30,24 +32,24 @@ document.addEventListener("alpine:init", () => { // 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") + .getElementById("#id_form-TOTAL_FORMS") .setAttribute(":value", "basket.length"); }, loadBasket(): BasketItem[] { - if (localStorage.basket === undefined) { + if (localStorage.getItem(BASKET_CACHE_KEY) === null) { return []; } try { - return JSON.parse(localStorage.basket); + return JSON.parse(localStorage.getItem(BASKET_CACHE_KEY)); } catch (_err) { return []; } }, saveBasket() { - localStorage.basket = JSON.stringify(this.basket); - localStorage.basketTimestamp = Date.now(); + localStorage.setItem(BASKET_CACHE_KEY, JSON.stringify(this.basket)); + localStorage.setItem("basketTimestamp", Date.now().toString()); }, /** @@ -56,7 +58,7 @@ document.addEventListener("alpine:init", () => { */ getTotal() { return this.basket.reduce( - (acc: number, item: BasketItem) => acc + item.quantity * item.unit_price, + (acc: number, item: BasketItem) => acc + item.quantity * item.unitPrice, 0, ); }, @@ -74,7 +76,7 @@ document.addEventListener("alpine:init", () => { * @param itemId the id of the item to remove */ remove(itemId: number) { - const index = this.basket.findIndex((e: BasketItem) => e.id === itemId); + const index = this.basket.findIndex((e: BasketItem) => e.priceId === itemId); if (index < 0) { return; @@ -83,7 +85,7 @@ document.addEventListener("alpine:init", () => { if (this.basket[index].quantity === 0) { this.basket = this.basket.filter( - (e: BasketItem) => e.id !== this.basket[index].id, + (e: BasketItem) => e.priceId !== this.basket[index].id, ); } }, @@ -104,11 +106,10 @@ document.addEventListener("alpine:init", () => { */ createItem(id: number, name: string, price: number): BasketItem { const newItem = { - id, + priceId: id, name, quantity: 0, - // biome-ignore lint/style/useNamingConvention: the python code is snake_case - unit_price: price, + unitPrice: price, } as BasketItem; this.basket.push(newItem); @@ -125,7 +126,7 @@ document.addEventListener("alpine:init", () => { * @param price The unit price of the product */ addFromCatalog(id: number, name: string, price: number) { - let item = this.basket.find((e: BasketItem) => e.id === id); + let item = this.basket.find((e: BasketItem) => e.priceId === 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_checkout.jinja b/eboutic/templates/eboutic/eboutic_checkout.jinja index 5bf89f98..369c5d44 100644 --- a/eboutic/templates/eboutic/eboutic_checkout.jinja +++ b/eboutic/templates/eboutic/eboutic_checkout.jinja @@ -32,9 +32,9 @@
{% for item in basket.items.all() %}{{ items[0].category_comment }}
- {% endif %} -{% trans %}There are no items available for sale{% endtrans %}
{% endfor %} diff --git a/eboutic/views.py b/eboutic/views.py index f48c86c1..efea3f89 100644 --- a/eboutic/views.py +++ b/eboutic/views.py @@ -17,6 +17,7 @@ from __future__ import annotations import base64 import contextlib +import itertools import json from typing import TYPE_CHECKING @@ -48,11 +49,11 @@ from django_countries.fields import Country from core.auth.mixins import CanViewMixin from core.views.mixins import FragmentMixin, UseFragmentsMixin -from counter.forms import BaseBasketForm, BasketProductForm, BillingInfoForm +from counter.forms import BaseBasketForm, BasketItemForm, BillingInfoForm from counter.models import ( BillingInfo, Customer, - Product, + Price, Refilling, Selling, get_eboutic, @@ -63,7 +64,7 @@ from eboutic.models import ( BillingInfoState, Invoice, InvoiceItem, - get_eboutic_products, + get_eboutic_prices, ) if TYPE_CHECKING: @@ -78,7 +79,7 @@ class BaseEbouticBasketForm(BaseBasketForm): EbouticBasketForm = forms.formset_factory( - BasketProductForm, formset=BaseEbouticBasketForm, absolute_max=None, min_num=1 + BasketItemForm, formset=BaseEbouticBasketForm, absolute_max=None, min_num=1 ) @@ -88,7 +89,6 @@ class EbouticMainView(LoginRequiredMixin, FormView): The purchasable products are those of the eboutic which belong to a category of products of a product category (orphan products are inaccessible). - """ template_name = "eboutic/eboutic_main.jinja" @@ -99,7 +99,7 @@ class EbouticMainView(LoginRequiredMixin, FormView): kwargs["form_kwargs"] = { "customer": self.customer, "counter": get_eboutic(), - "allowed_products": {product.id: product for product in self.products}, + "allowed_prices": {price.id: price for price in self.prices}, } return kwargs @@ -110,19 +110,22 @@ class EbouticMainView(LoginRequiredMixin, FormView): 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() + BasketItem.objects.bulk_create( + [ + BasketItem.from_price( + form.price, form.cleaned_data["quantity"], self.basket + ) + for form in formset + ] + ) 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) + def prices(self) -> list[Price]: + return get_eboutic_prices(self.request.user) @cached_property def customer(self) -> Customer: @@ -130,7 +133,12 @@ class EbouticMainView(LoginRequiredMixin, FormView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - context["products"] = self.products + context["categories"] = [ + list(i[1]) + for i in itertools.groupby( + self.prices, key=lambda p: p.product.product_type_id + ) + ] context["customer_amount"] = self.request.user.account_balance purchases = ( @@ -267,11 +275,8 @@ class EbouticPayWithSith(CanViewMixin, SingleObjectMixin, View): 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"), - ) + if basket.items.filter(product__product_type_id=refilling).exists(): + messages.error(self.request, _("You can't buy a refilling with sith money")) return redirect("eboutic:payment_result", "failure") eboutic = get_eboutic() @@ -326,22 +331,23 @@ class EtransactionAutoAnswer(View): raise SuspiciousOperation( "Basket total and amount do not match" ) - i = Invoice() - i.user = b.user - i.payment_method = "CARD" - i.save() - for it in b.items.all(): - InvoiceItem( - invoice=i, - product_id=it.product_id, - product_name=it.product_name, - type_id=it.type_id, - product_unit_price=it.product_unit_price, - quantity=it.quantity, - ).save() + i = Invoice.objects.create(user=b.user) + InvoiceItem.objects.bulk_create( + [ + InvoiceItem( + invoice=i, + product_id=item.product_id, + label=item.label, + unit_price=item.unit_price, + quantity=item.quantity, + ) + for item in b.items.all() + ] + ) i.validate() b.delete() except Exception as e: + sentry_sdk.capture_exception(e) return HttpResponse( "Basket processing failed with error: " + repr(e), status=500 )