use new price system in the eboutic

This commit is contained in:
imperosol
2026-03-02 15:47:42 +01:00
parent 85f1a0b9cb
commit a019707d4a
4 changed files with 97 additions and 95 deletions

View File

@@ -1,13 +1,15 @@
export {}; export {};
interface BasketItem { interface BasketItem {
id: number; priceId: number;
name: string; name: string;
quantity: number; quantity: number;
// biome-ignore lint/style/useNamingConvention: the python code is snake_case unitPrice: number;
unit_price: number;
} }
// increment the key number if the data schema of the cached basket changes
const BASKET_CACHE_KEY = "basket1";
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
Alpine.data("basket", (lastPurchaseTime?: number) => ({ Alpine.data("basket", (lastPurchaseTime?: number) => ({
basket: [] as BasketItem[], basket: [] as BasketItem[],
@@ -30,24 +32,24 @@ document.addEventListener("alpine:init", () => {
// It's quite tricky to manually apply attributes to the management part // It's quite tricky to manually apply attributes to the management part
// of a formset so we dynamically apply it here // of a formset so we dynamically apply it here
this.$refs.basketManagementForm this.$refs.basketManagementForm
.querySelector("#id_form-TOTAL_FORMS") .getElementById("#id_form-TOTAL_FORMS")
.setAttribute(":value", "basket.length"); .setAttribute(":value", "basket.length");
}, },
loadBasket(): BasketItem[] { loadBasket(): BasketItem[] {
if (localStorage.basket === undefined) { if (localStorage.getItem(BASKET_CACHE_KEY) === null) {
return []; return [];
} }
try { try {
return JSON.parse(localStorage.basket); return JSON.parse(localStorage.getItem(BASKET_CACHE_KEY));
} catch (_err) { } catch (_err) {
return []; return [];
} }
}, },
saveBasket() { saveBasket() {
localStorage.basket = JSON.stringify(this.basket); localStorage.setItem(BASKET_CACHE_KEY, JSON.stringify(this.basket));
localStorage.basketTimestamp = Date.now(); localStorage.setItem("basketTimestamp", Date.now().toString());
}, },
/** /**
@@ -56,7 +58,7 @@ document.addEventListener("alpine:init", () => {
*/ */
getTotal() { getTotal() {
return this.basket.reduce( return this.basket.reduce(
(acc: number, item: BasketItem) => acc + item.quantity * item.unit_price, (acc: number, item: BasketItem) => acc + item.quantity * item.unitPrice,
0, 0,
); );
}, },
@@ -74,7 +76,7 @@ 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.basket.findIndex((e: BasketItem) => e.id === itemId); const index = this.basket.findIndex((e: BasketItem) => e.priceId === itemId);
if (index < 0) { if (index < 0) {
return; return;
@@ -83,7 +85,7 @@ document.addEventListener("alpine:init", () => {
if (this.basket[index].quantity === 0) { if (this.basket[index].quantity === 0) {
this.basket = this.basket.filter( 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 { createItem(id: number, name: string, price: number): BasketItem {
const newItem = { const newItem = {
id, priceId: id,
name, name,
quantity: 0, quantity: 0,
// biome-ignore lint/style/useNamingConvention: the python code is snake_case unitPrice: price,
unit_price: price,
} as BasketItem; } as BasketItem;
this.basket.push(newItem); this.basket.push(newItem);
@@ -125,7 +126,7 @@ document.addEventListener("alpine:init", () => {
* @param price The unit price of the product * @param price The unit price of the product
*/ */
addFromCatalog(id: number, name: string, price: number) { addFromCatalog(id: number, name: string, price: number) {
let item = this.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 // 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

@@ -32,9 +32,9 @@
<tbody> <tbody>
{% for item in basket.items.all() %} {% for item in basket.items.all() %}
<tr> <tr>
<td>{{ item.product_name }}</td> <td>{{ item.label }}</td>
<td>{{ item.quantity }}</td> <td>{{ item.quantity }}</td>
<td>{{ item.product_unit_price }} €</td> <td>{{ item.unit_price }} €</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

View File

@@ -41,7 +41,7 @@
</div> </div>
{% endif %} {% endif %}
<ul class="item-list"> <ul class="item-list">
{# Starting money #} {# Starting money #}
<li> <li>
<span class="item-name"> <span class="item-name">
<strong>{% trans %}Current account amount: {% endtrans %}</strong> <strong>{% trans %}Current account amount: {% endtrans %}</strong>
@@ -51,15 +51,15 @@
</span> </span>
</li> </li>
<template x-for="(item, index) in Object.values(basket)" :key="item.id"> <template x-for="(item, index) in Object.values(basket)" :key="item.priceId">
<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.priceId)"></i>
<span x-text="item.quantity"></span> <span x-text="item.quantity"></span>
<i class="fa fa-plus" @click="add(item)"></i> <i class="fa fa-plus" @click="add(item)"></i>
</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.unitPrice * item.quantity).toFixed(2) + ' €'"></span>
<input <input
type="hidden" type="hidden"
@@ -71,16 +71,16 @@
> >
<input <input
type="hidden" type="hidden"
:value="item.id" :value="item.priceId"
:id="`id_form-${index}-id`" :id="`id_form-${index}-price_id`"
:name="`form-${index}-id`" :name="`form-${index}-price_id`"
required required
readonly readonly
> >
</li> </li>
</template> </template>
{# Total price #} {# Total price #}
<li style="margin-top: 20px"> <li style="margin-top: 20px">
<span class="item-name"><strong>{% trans %}Basket amount: {% endtrans %}</strong></span> <span class="item-name"><strong>{% trans %}Basket amount: {% endtrans %}</strong></span>
<span x-text="getTotal().toFixed(2) + ' €'" class="item-price"></span> <span x-text="getTotal().toFixed(2) + ' €'" class="item-price"></span>
@@ -116,45 +116,40 @@
</span> </span>
</div> </div>
{% endif %} {% endif %}
{% for priority_groups in products|groupby('order') %} {% for prices in categories %}
{% for category, items in priority_groups.list|groupby('category') %} {% set category = prices[0].product.product_type %}
{% if items|count > 0 %} <section>
<section> <div class="category-header">
{# I would have wholeheartedly directly used the header element instead <h3>{{ category.name }}</h3>
but it has already been made messy in core/style.scss #} {% if category.comment %}
<div class="category-header"> <p><i>{{ category.comment }}</i></p>
<h3>{{ category }}</h3> {% endif %}
{% if items[0].category_comment %} </div>
<p><i>{{ items[0].category_comment }}</i></p> <div class="product-group">
{% endif %} {% for price in prices %}
</div> <button
<div class="product-group"> id="{{ price.id }}"
{% for p in items %} class="card product-button clickable shadow"
<button :class="{selected: basket.some((i) => i.priceId === {{ price.id }})}"
id="{{ p.id }}" @click='addFromCatalog({{ price.id }}, {{ price.full_label|tojson }}, {{ price.amount }})'
class="card product-button clickable shadow" >
:class="{selected: basket.some((i) => i.id === {{ p.id }})}" {% if price.product.icon %}
@click='addFromCatalog({{ p.id }}, {{ p.name|tojson }}, {{ p.selling_price }})' <img
class="card-image"
src="{{ price.product.icon.url }}"
alt="image de {{ price.full_label }}"
> >
{% if p.icon %} {% else %}
<img <i class="fa-regular fa-image fa-2x card-image"></i>
class="card-image" {% endif %}
src="{{ p.icon.url }}" <div class="card-content">
alt="image de {{ p.name }}" <h4 class="card-title">{{ price.full_label }}</h4>
> <p>{{ price.amount }} €</p>
{% else %} </div>
<i class="fa-regular fa-image fa-2x card-image"></i> </button>
{% endif %} {% endfor %}
<div class="card-content"> </div>
<h4 class="card-title">{{ p.name }}</h4> </section>
<p>{{ p.selling_price }} €</p>
</div>
</button>
{% endfor %}
</div>
</section>
{% endif %}
{% endfor %}
{% else %} {% else %}
<p>{% trans %}There are no items available for sale{% endtrans %}</p> <p>{% trans %}There are no items available for sale{% endtrans %}</p>
{% endfor %} {% endfor %}

View File

@@ -17,6 +17,7 @@ from __future__ import annotations
import base64 import base64
import contextlib import contextlib
import itertools
import json import json
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@@ -48,11 +49,11 @@ from django_countries.fields import Country
from core.auth.mixins import CanViewMixin from core.auth.mixins import CanViewMixin
from core.views.mixins import FragmentMixin, UseFragmentsMixin 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 ( from counter.models import (
BillingInfo, BillingInfo,
Customer, Customer,
Product, Price,
Refilling, Refilling,
Selling, Selling,
get_eboutic, get_eboutic,
@@ -63,7 +64,7 @@ from eboutic.models import (
BillingInfoState, BillingInfoState,
Invoice, Invoice,
InvoiceItem, InvoiceItem,
get_eboutic_products, get_eboutic_prices,
) )
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -78,7 +79,7 @@ class BaseEbouticBasketForm(BaseBasketForm):
EbouticBasketForm = forms.formset_factory( 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 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).
""" """
template_name = "eboutic/eboutic_main.jinja" template_name = "eboutic/eboutic_main.jinja"
@@ -99,7 +99,7 @@ class EbouticMainView(LoginRequiredMixin, FormView):
kwargs["form_kwargs"] = { kwargs["form_kwargs"] = {
"customer": self.customer, "customer": self.customer,
"counter": get_eboutic(), "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 return kwargs
@@ -110,19 +110,22 @@ class EbouticMainView(LoginRequiredMixin, FormView):
with transaction.atomic(): with transaction.atomic():
self.basket = Basket.objects.create(user=self.request.user) self.basket = Basket.objects.create(user=self.request.user)
for form in formset: BasketItem.objects.bulk_create(
BasketItem.from_product( [
form.product, form.cleaned_data["quantity"], self.basket BasketItem.from_price(
).save() form.price, form.cleaned_data["quantity"], self.basket
self.basket.save() )
for form in formset
]
)
return super().form_valid(formset) return super().form_valid(formset)
def get_success_url(self): def get_success_url(self):
return reverse("eboutic:checkout", kwargs={"basket_id": self.basket.id}) return reverse("eboutic:checkout", kwargs={"basket_id": self.basket.id})
@cached_property @cached_property
def products(self) -> list[Product]: def prices(self) -> list[Price]:
return get_eboutic_products(self.request.user) return get_eboutic_prices(self.request.user)
@cached_property @cached_property
def customer(self) -> Customer: def customer(self) -> Customer:
@@ -130,7 +133,12 @@ class EbouticMainView(LoginRequiredMixin, FormView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**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 context["customer_amount"] = self.request.user.account_balance
purchases = ( purchases = (
@@ -267,11 +275,8 @@ class EbouticPayWithSith(CanViewMixin, SingleObjectMixin, View):
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
basket = self.get_object() basket = self.get_object()
refilling = settings.SITH_COUNTER_PRODUCTTYPE_REFILLING refilling = settings.SITH_COUNTER_PRODUCTTYPE_REFILLING
if basket.items.filter(type_id=refilling).exists(): if basket.items.filter(product__product_type_id=refilling).exists():
messages.error( messages.error(self.request, _("You can't buy a refilling with sith money"))
self.request,
_("You can't buy a refilling with sith money"),
)
return redirect("eboutic:payment_result", "failure") return redirect("eboutic:payment_result", "failure")
eboutic = get_eboutic() eboutic = get_eboutic()
@@ -326,22 +331,23 @@ class EtransactionAutoAnswer(View):
raise SuspiciousOperation( raise SuspiciousOperation(
"Basket total and amount do not match" "Basket total and amount do not match"
) )
i = Invoice() i = Invoice.objects.create(user=b.user)
i.user = b.user InvoiceItem.objects.bulk_create(
i.payment_method = "CARD" [
i.save() InvoiceItem(
for it in b.items.all(): invoice=i,
InvoiceItem( product_id=item.product_id,
invoice=i, label=item.label,
product_id=it.product_id, unit_price=item.unit_price,
product_name=it.product_name, quantity=item.quantity,
type_id=it.type_id, )
product_unit_price=it.product_unit_price, for item in b.items.all()
quantity=it.quantity, ]
).save() )
i.validate() i.validate()
b.delete() b.delete()
except Exception as e: except Exception as e:
sentry_sdk.capture_exception(e)
return HttpResponse( return HttpResponse(
"Basket processing failed with error: " + repr(e), status=500 "Basket processing failed with error: " + repr(e), status=500
) )