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 {};
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

View File

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

View File

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

View File

@@ -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():
i = Invoice.objects.create(user=b.user)
InvoiceItem.objects.bulk_create(
[
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()
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
)