Make counter click client side first

This commit is contained in:
Antoine Bartuccio 2024-12-20 17:32:37 +01:00
parent 4f233538e0
commit e464809865
6 changed files with 342 additions and 423 deletions

View File

@ -327,6 +327,8 @@ class ProductType(OrderedModel):
class Product(models.Model): class Product(models.Model):
"""A product, with all its related information.""" """A product, with all its related information."""
QUANTITY_FOR_TRAY_PRICE = 6
name = models.CharField(_("name"), max_length=64) name = models.CharField(_("name"), max_length=64)
description = models.TextField(_("description"), default="") description = models.TextField(_("description"), default="")
product_type = models.ForeignKey( product_type = models.ForeignKey(
@ -426,6 +428,13 @@ class Product(models.Model):
def profit(self): def profit(self):
return self.selling_price - self.purchase_price return self.selling_price - self.purchase_price
def get_actual_price(self, counter: Counter, customer: Customer):
"""Return the price of the article taking into account if the customer has a special price
or not in the counter it's being purchased on"""
if counter.customer_is_barman(customer):
return self.special_selling_price
return self.selling_price
class CounterQuerySet(models.QuerySet): class CounterQuerySet(models.QuerySet):
def annotate_has_barman(self, user: User) -> Self: def annotate_has_barman(self, user: User) -> Self:

View File

@ -15,6 +15,10 @@ export class CounterProductSelect extends AutoCompleteSelectBase {
return ["FIN", "ANN"]; return ["FIN", "ANN"];
} }
public getSelectedProduct(): [number, string] {
return parseProduct(this.widget.getValue() as string);
}
protected attachBehaviors(): void { protected attachBehaviors(): void {
this.allowMultipleProducts(); this.allowMultipleProducts();
} }

View File

@ -1,24 +1,47 @@
import { exportToHtml } from "#core:utils/globals"; import { exportToHtml } from "#core:utils/globals";
import type TomSelect from "tom-select";
const quantityForTrayPrice = 6;
interface CounterConfig { interface CounterConfig {
csrfToken: string; csrfToken: string;
clickApiUrl: string; clickApiUrl: string;
sessionBasket: Record<number, BasketItem>;
customerBalance: number; customerBalance: number;
customerId: number; customerId: number;
products: Record<string, Product>;
} }
interface BasketItem {
// biome-ignore lint/style/useNamingConvention: talking with python interface Product {
bonus_qty: number; code: string;
name: string;
price: number; price: number;
qty: number; hasTrayPrice: boolean;
}
class BasketItem {
quantity: number;
product: Product;
constructor(product: Product, quantity: number) {
this.quantity = quantity;
this.product = product;
}
getBonusQuantity(): number {
if (!this.product.hasTrayPrice) {
return 0;
}
return Math.floor(this.quantity / quantityForTrayPrice);
}
sum(): number {
return (this.quantity - this.getBonusQuantity()) * this.product.price;
}
} }
exportToHtml("loadCounter", (config: CounterConfig) => { exportToHtml("loadCounter", (config: CounterConfig) => {
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
Alpine.data("counter", () => ({ Alpine.data("counter", () => ({
basket: config.sessionBasket, basket: {} as Record<string, BasketItem>,
errors: [], errors: [],
customerBalance: config.customerBalance, customerBalance: config.customerBalance,
codeField: undefined, codeField: undefined,
@ -26,17 +49,58 @@ exportToHtml("loadCounter", (config: CounterConfig) => {
init() { init() {
this.codeField = this.$refs.codeField; this.codeField = this.$refs.codeField;
this.codeField.widget.focus(); this.codeField.widget.focus();
// 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", "getBasketSize()");
},
getItemIdFromCode(code: string): string {
return Object.keys(config.products).find(
(key) => config.products[key].code === code,
);
},
removeFromBasket(code: string) {
delete this.basket[this.getItemIdFromCode(code)];
},
addToBasket(code: string, quantity: number): [boolean, string] {
const id = this.getItemIdFromCode(code);
const item: BasketItem =
this.basket[id] || new BasketItem(config.products[id], 0);
const oldQty = item.quantity;
item.quantity += quantity;
if (item.quantity <= 0) {
delete this.basket[id];
return [true, ""];
}
if (item.sum() > this.customerBalance) {
item.quantity = oldQty;
return [false, gettext("Not enough money")];
}
this.basket[id] = item;
return [true, ""];
},
getBasketSize() {
return Object.keys(this.basket).length;
}, },
sumBasket() { sumBasket() {
if (!this.basket || Object.keys(this.basket).length === 0) { if (this.getBasketSize() === 0) {
return 0; return 0;
} }
const total = Object.values(this.basket).reduce( const total = Object.values(this.basket).reduce(
(acc: number, cur: BasketItem) => acc + cur.qty * cur.price, (acc: number, cur: BasketItem) => acc + cur.sum(),
0, 0,
) as number; ) as number;
return total / 100; return total;
}, },
onRefillingSuccess(event: CustomEvent) { onRefillingSuccess(event: CustomEvent) {
@ -50,33 +114,36 @@ exportToHtml("loadCounter", (config: CounterConfig) => {
this.codeField.widget.focus(); this.codeField.widget.focus();
}, },
async handleCode(event: SubmitEvent) { finish() {
const widget: TomSelect = this.codeField.widget; this.$refs.basketForm.submit();
const code = (widget.getValue() as string).toUpperCase();
if (this.codeField.getOperationCodes().includes(code)) {
$(event.target).submit();
} else {
await this.handleAction(event);
}
widget.clear();
widget.focus();
}, },
async handleAction(event: SubmitEvent) { cancel() {
const payload = $(event.target).serialize(); this.basket = new Object({});
const request = new Request(config.clickApiUrl, { // We need to wait for the templated form to be removed before sending
method: "POST", this.$nextTick(() => {
body: payload, this.finish();
headers: {
// biome-ignore lint/style/useNamingConvention: this goes into http headers
Accept: "application/json",
"X-CSRFToken": config.csrfToken,
},
}); });
const response = await fetch(request); },
const json = await response.json();
this.basket = json.basket; handleCode(event: SubmitEvent) {
this.errors = json.errors; const [quantity, code] = this.codeField.getSelectedProduct() as [
number,
string,
];
if (this.codeField.getOperationCodes().includes(code.toUpperCase())) {
if (code === "ANN") {
this.cancel();
}
if (code === "FIN") {
this.finish();
}
} else {
this.addToBasket(code, quantity);
}
this.codeField.widget.clear();
this.codeField.widget.focus();
}, },
})); }));
}); });

View File

@ -34,7 +34,12 @@
<h5>{% trans %}Customer{% endtrans %}</h5> <h5>{% trans %}Customer{% endtrans %}</h5>
{{ user_mini_profile(customer.user) }} {{ user_mini_profile(customer.user) }}
{{ user_subscription(customer.user) }} {{ user_subscription(customer.user) }}
<p>{% trans %}Amount: {% endtrans %}<span x-text="customerBalance"></span> €</p> <p>{% trans %}Amount: {% endtrans %}<span x-text="customerBalance"></span> €
<span x-cloak x-show="getBasketSize() > 0">
<i class="fa-solid fa-arrow-right"></i>
<span x-text="(customerBalance - sumBasket()).toLocaleString(undefined, { minimumFractionDigits: 2 })"></span> €
</span>
</p>
</div> </div>
<div id="click_form" style="width: 20%;"> <div id="click_form" style="width: 20%;">
@ -72,53 +77,37 @@
</div> </div>
</template> </template>
<p>{% trans %}Basket: {% endtrans %}</p> <p>{% trans %}Basket: {% endtrans %}</p>
<form method="post" action="" x-ref="basketForm">
<ul>
<template x-for="[id, item] in Object.entries(basket)" :key="id">
<div>
<form method="post" action="" class="inline del_product_form"
@submit.prevent="handleAction">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="action" value="del_product"> {{ form.errors }}
<input type="hidden" name="product_id" :value="id"> {{ form.non_form_errors() }}
<input type="submit" value="-"/> <div x-ref="basketManagementForm">
</form> {{ form.management_form }}
<span x-text="item['qty'] + item['bonus_qty']"></span>
<form method="post" action="" class="inline add_product_form"
@submit.prevent="handleAction">
{% csrf_token %}
<input type="hidden" name="action" value="add_product">
<input type="hidden" name="product_id" :value="id">
<input type="submit" value="+">
</form>
<span x-text="products[id].name"></span> :
<span x-text="(item['qty'] * item['price'] / 100)
.toLocaleString(undefined, { minimumFractionDigits: 2 })">
</span> €
<template x-if="item['bonus_qty'] > 0">P</template>
</div> </div>
</template> <template x-for="(item, index) in Object.values(basket)">
<ul>
<button @click.prevent="addToBasket(item.product.code, -1)">-</button>
<span x-text="item.quantity"></span>
<button @click.prevent="addToBasket(item.product.code, 1)">+</button>
<span x-text="item.product.name"></span> :
<span x-text="item.sum().toLocaleString(undefined, { minimumFractionDigits: 2 })">€</span>
<span x-cloak x-show="item.getBonusQuantity() > 0" x-text="`${item.getBonusQuantity()} x P`"></span>
<button @click.prevent="removeFromBasket(item.product.code)"><i class="fa fa-trash-can delete-action"></i></button>
<input type="hidden" :value="item.quantity" :id="`id_form-${index}-quantity`" :name="`form-${index}-quantity`" required readonly>
<input type="hidden" :value="item.product.code" :id="`id_form-${index}-code`" :name="`form-${index}-code`" required readonly>
</ul> </ul>
</template>
<p> <p>
<strong>Total: </strong> <strong>Total: </strong>
<strong x-text="sumBasket().toLocaleString(undefined, { minimumFractionDigits: 2 })"></strong> <strong x-text="sumBasket().toLocaleString(undefined, { minimumFractionDigits: 2 })"></strong>
<strong> €</strong> <strong> €</strong>
</p> </p>
<form method="post" <input type="submit" @click.prevent="finish" value="{% trans %}Finish{% endtrans %}"/>
action="{{ url('counter:click', counter_id=counter.id, user_id=customer.user.id) }}"> <input type="submit" @click.prevent="cancel" value="{% trans %}Cancel{% endtrans %}"/>
{% csrf_token %}
<input type="hidden" name="action" value="finish">
<input type="submit" value="{% trans %}Finish{% endtrans %}"/>
</form>
<form method="post"
action="{{ url('counter:click', counter_id=counter.id, user_id=customer.user.id) }}">
{% csrf_token %}
<input type="hidden" name="action" value="cancel">
<input type="submit" value="{% trans %}Cancel{% endtrans %}"/>
</form> </form>
</div> </div>
{% if object.type == "BAR" %} {% if object.type == "BAR" %}
@ -159,23 +148,16 @@
{% for category in categories.keys() -%} {% for category in categories.keys() -%}
<div id="cat_{{ category|slugify }}"> <div id="cat_{{ category|slugify }}">
<h5>{{ category }}</h5> <h5>{{ category }}</h5>
{% for p in categories[category] -%} {% for product in categories[category] -%}
<form method="post" <button @click="addToBasket('{{ product.code }}', 1)">
action="{{ url('counter:click', counter_id=counter.id, user_id=customer.user.id) }}" <strong>{{ product.name }}</strong>
class="form_button add_product_form" @submit.prevent="handleAction"> {% if product.icon %}
{% csrf_token %} <img src="{{ product.icon.url }}" alt="image de {{ product.name }}"/>
<input type="hidden" name="action" value="add_product">
<input type="hidden" name="product_id" value="{{ p.id }}">
<button type="submit">
<strong>{{ p.name }}</strong>
{% if p.icon %}
<img src="{{ p.icon.url }}" alt="image de {{ p.name }}"/>
{% else %} {% else %}
<img src="{{ static('core/img/na.gif') }}" alt="image de {{ p.name }}"/> <img src="{{ static('core/img/na.gif') }}" alt="image de {{ product.name }}"/>
{% endif %} {% endif %}
<span>{{ p.price }} €<br>{{ p.code }}</span> <span>{{ product.price }} €<br>{{ product.code }}</span>
</button> </button>
</form>
{%- endfor %} {%- endfor %}
</div> </div>
{%- endfor %} {%- endfor %}
@ -192,15 +174,15 @@
code: "{{ p.code }}", code: "{{ p.code }}",
name: "{{ p.name }}", name: "{{ p.name }}",
price: {{ p.price }}, price: {{ p.price }},
hasTrayPrice: {{ p.tray | tojson }},
}, },
{%- endfor -%} {%- endfor -%}
}; };
window.addEventListener("DOMContentLoaded", () => { window.addEventListener("DOMContentLoaded", () => {
loadCounter({ loadCounter({
csrfToken: "{{ csrf_token }}", csrfToken: "{{ csrf_token }}",
clickApiUrl: "{{ url('counter:click', counter_id=counter.id, user_id=customer.user.id) }}", clickApiUrl: "{{ url('counter:click', counter_id=counter.id, user_id=customer.user.id) }}", customerBalance: {{ customer.amount }},
sessionBasket: {{ request.session["basket"]|tojson }}, products: products,
customerBalance: {{ customer.amount }},
customerId: {{ customer.pk }}, customerId: {{ customer.pk }},
}); });
}); });

View File

@ -12,20 +12,26 @@
# OR WITHIN THE LOCAL FILE "LICENSE" # OR WITHIN THE LOCAL FILE "LICENSE"
# #
# #
import re import math
from http import HTTPStatus
from typing import TYPE_CHECKING
from urllib.parse import parse_qs
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.db import DataError, transaction from django.db import transaction
from django.db.models import F from django.db.models import F
from django.http import Http404, HttpResponseRedirect, JsonResponse from django.forms import (
from django.shortcuts import get_object_or_404, redirect BaseFormSet,
IntegerField,
ModelForm,
ValidationError,
formset_factory,
)
from django.http import Http404
from django.shortcuts import get_object_or_404, redirect, resolve_url
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, FormView from django.views.generic import FormView
from django.views.generic.detail import SingleObjectMixin
from core.models import User
from core.utils import FormFragmentTemplateData from core.utils import FormFragmentTemplateData
from core.views import CanViewMixin from core.views import CanViewMixin
from counter.forms import RefillForm from counter.forms import RefillForm
@ -34,11 +40,111 @@ from counter.utils import is_logged_in_counter
from counter.views.mixins import CounterTabsMixin from counter.views.mixins import CounterTabsMixin
from counter.views.student_card import StudentCardFormView from counter.views.student_card import StudentCardFormView
if TYPE_CHECKING:
from core.models import User def get_operator(counter: Counter, customer: Customer) -> User:
if counter.customer_is_barman(customer):
return customer.user
return counter.get_random_barman()
class CounterClick(CounterTabsMixin, CanViewMixin, DetailView): class ProductForm(ModelForm):
quantity = IntegerField(min_value=1)
class Meta:
model = Product
fields = ["code"]
def __init__(
self,
*args,
customer: Customer | None = None,
counter: Counter | None = None,
**kwargs,
):
self.customer = customer
self.counter = counter
super().__init__(*args, **kwargs)
def clean(self):
cleaned_data = super().clean()
if self.customer is None or self.counter is None:
raise RuntimeError(
f"{self} has been initialized without customer or counter"
)
user = self.customer.user
# We store self.product so we can use it later on the formset validation
self.product = self.counter.products.filter(code=cleaned_data["code"]).first()
if self.product is None:
raise ValidationError(
_(
"Product %(product)s doesn't exist or isn't available on this counter"
)
% {"product": cleaned_data["code"]}
)
# Test alcohoolic products
if self.product.limit_age >= 18:
if not user.date_of_birth:
raise ValidationError(_("Too young for that product"))
if user.is_banned_alcohol:
raise ValidationError(_("Not allowed for that product"))
if user.date_of_birth and self.customer.user.get_age() < self.product.limit_age:
raise ValidationError(_("Too young for that product"))
if user.is_banned_counter:
raise ValidationError(_("Not allowed for that product"))
# 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["unit_price"] = self.product.get_actual_price(
self.counter, self.customer
)
cleaned_data["total_price"] = cleaned_data["unit_price"] * (
cleaned_data["quantity"] - cleaned_data["bonus_quantity"]
)
return cleaned_data
class BaseBasketForm(BaseFormSet):
def clean(self):
super().clean()
if len(self) == 0:
return
self._check_recorded_products(self[0].customer)
self._check_enough_money(self[0].counter, self[0].customer)
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"""
self.total_recordings = 0
for form in self:
# form.product is stored by the clean step of each formset form
if form.product.is_record_product:
self.total_recordings -= form.cleaned_data["quantity"]
if form.product.is_unrecord_product:
self.total_recordings += form.cleaned_data["quantity"]
if not customer.can_record_more(self.total_recordings):
raise ValidationError(_("This user have reached his recording limit"))
BasketForm = formset_factory(
ProductForm, formset=BaseBasketForm, absolute_max=None, min_num=1
)
class CounterClick(CounterTabsMixin, CanViewMixin, SingleObjectMixin, FormView):
"""The click view """The click view
This is a detail view not to have to worry about loading the counter This is a detail view not to have to worry about loading the counter
Everything is made by hand in the post method. Everything is made by hand in the post method.
@ -46,37 +152,18 @@ class CounterClick(CounterTabsMixin, CanViewMixin, DetailView):
model = Counter model = Counter
queryset = Counter.objects.annotate_is_open() queryset = Counter.objects.annotate_is_open()
form_class = BasketForm
template_name = "counter/counter_click.jinja" template_name = "counter/counter_click.jinja"
pk_url_kwarg = "counter_id" pk_url_kwarg = "counter_id"
current_tab = "counter" current_tab = "counter"
def render_to_response(self, *args, **kwargs): def get_form_kwargs(self):
if self.is_ajax(self.request): kwargs = super().get_form_kwargs()
response = {"errors": []} kwargs["form_kwargs"] = {
status = HTTPStatus.OK "customer": self.customer,
"counter": self.object,
if self.request.session["too_young"]: }
response["errors"].append(_("Too young for that product")) return kwargs
status = HTTPStatus.UNAVAILABLE_FOR_LEGAL_REASONS
if self.request.session["not_allowed"]:
response["errors"].append(_("Not allowed for that product"))
status = HTTPStatus.FORBIDDEN
if self.request.session["no_age"]:
response["errors"].append(_("No date of birth provided"))
status = HTTPStatus.UNAVAILABLE_FOR_LEGAL_REASONS
if self.request.session["not_enough"]:
response["errors"].append(_("Not enough money"))
status = HTTPStatus.PAYMENT_REQUIRED
if len(response["errors"]) > 1:
status = HTTPStatus.BAD_REQUEST
response["basket"] = self.request.session["basket"]
return JsonResponse(response, status=status)
else: # Standard HTML page
return super().render_to_response(*args, **kwargs)
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
self.customer = get_object_or_404(Customer, user__id=self.kwargs["user_id"]) self.customer = get_object_or_404(Customer, user__id=self.kwargs["user_id"])
@ -90,301 +177,74 @@ class CounterClick(CounterTabsMixin, CanViewMixin, DetailView):
or request.session["counter_token"] != obj.token or request.session["counter_token"] != obj.token
or len(obj.barmen_list) == 0 or len(obj.barmen_list) == 0
): ):
return redirect(obj) return redirect(obj) # Redirect to counter
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
def get(self, request, *args, **kwargs): def form_valid(self, formset):
"""Simple get view.""" ret = super().form_valid(formset)
if "basket" not in request.session: # Init the basket session entry
request.session["basket"] = {} if len(formset) == 0:
request.session["basket_total"] = 0
request.session["not_enough"] = False # Reset every variable
request.session["too_young"] = False
request.session["not_allowed"] = False
request.session["no_age"] = False
ret = super().get(request, *args, **kwargs)
if (self.object.type != "BAR" and not request.user.is_authenticated) or (
self.object.type == "BAR" and len(self.object.barmen_list) == 0
): # Check that at least one barman is logged in
ret = self.cancel(request) # Otherwise, go to main view
return ret return ret
def post(self, request, *args, **kwargs): operator = get_operator(self.object, self.customer)
"""Handle the many possibilities of the post request.""" with transaction.atomic():
self.object = self.get_object() self.request.session["last_basket"] = []
if (self.object.type != "BAR" and not request.user.is_authenticated) or (
self.object.type == "BAR" and len(self.object.barmen_list) < 1 for form in formset:
): # Check that at least one barman is logged in self.request.session["last_basket"].append(
return self.cancel(request) f"{form.cleaned_data['quantity']} x {form.product.name}"
if self.object.type == "BAR" and not (
"counter_token" in self.request.session
and self.request.session["counter_token"] == self.object.token
): # Also check the token to avoid the bar to be stolen
return HttpResponseRedirect(
reverse_lazy(
"counter:details",
args=self.args,
kwargs={"counter_id": self.object.id},
) )
+ "?bad_location"
) Selling(
if "basket" not in request.session: label=form.product.name,
request.session["basket"] = {} product=form.product,
request.session["basket_total"] = 0 club=form.product.club,
request.session["not_enough"] = False # Reset every variable counter=self.object,
request.session["too_young"] = False unit_price=form.cleaned_data["unit_price"],
request.session["not_allowed"] = False quantity=form.cleaned_data["quantity"]
request.session["no_age"] = False - form.cleaned_data["bonus_quantity"],
if self.object.type != "BAR": seller=operator,
self.operator = request.user customer=self.customer,
elif self.object.customer_is_barman(self.customer): ).save()
self.operator = self.customer.user if form.cleaned_data["bonus_quantity"] > 0:
else: Selling(
self.operator = self.object.get_random_barman() label=f"{form.product.name} (Plateau)",
action = self.request.POST.get("action", None) product=form.product,
if action is None: club=form.product.club,
action = parse_qs(request.body.decode()).get("action", [""])[0] counter=self.object,
if action == "add_product": unit_price=0,
self.add_product(request) quantity=form.cleaned_data["bonus_quantity"],
elif action == "del_product": seller=operator,
self.del_product(request) customer=self.customer,
elif action == "code": ).save()
return self.parse_code(request)
elif action == "cancel": self.customer.recorded_products -= formset.total_recordings
return self.cancel(request) self.customer.save()
elif action == "finish":
return self.finish(request) # Add some info for the main counter view to display
context = self.get_context_data(object=self.object) self.request.session["last_customer"] = self.customer.user.get_display_name()
return self.render_to_response(context) self.request.session["last_total"] = f"{formset.total_price:0.2f}"
self.request.session["new_customer_amount"] = str(self.customer.amount)
return ret
def get_success_url(self):
return resolve_url(self.object)
def get_product(self, pid): def get_product(self, pid):
return Product.objects.filter(pk=int(pid)).first() return Product.objects.filter(pk=int(pid)).first()
def get_price(self, pid):
p = self.get_product(pid)
if self.object.customer_is_barman(self.customer):
price = p.special_selling_price
else:
price = p.selling_price
return price
def sum_basket(self, request):
total = 0
for infos in request.session["basket"].values():
total += infos["price"] * infos["qty"]
return total / 100
def get_total_quantity_for_pid(self, request, pid):
pid = str(pid)
if pid not in request.session["basket"]:
return 0
return (
request.session["basket"][pid]["qty"]
+ request.session["basket"][pid]["bonus_qty"]
)
def compute_record_product(self, request, product=None):
recorded = 0
basket = request.session["basket"]
if product:
if product.is_record_product:
recorded -= 1
elif product.is_unrecord_product:
recorded += 1
for p in basket:
bproduct = self.get_product(str(p))
if bproduct.is_record_product:
recorded -= basket[p]["qty"]
elif bproduct.is_unrecord_product:
recorded += basket[p]["qty"]
return recorded
def is_record_product_ok(self, request, product):
return self.customer.can_record_more(
self.compute_record_product(request, product)
)
@staticmethod
def is_ajax(request):
# when using the fetch API, the django request.POST dict is empty
# this is but a wretched contrivance which strive to replace
# the deprecated django is_ajax() method
# and which must be replaced as soon as possible
# by a proper separation between the api endpoints of the counter
return len(request.POST) == 0 and len(request.body) != 0
def add_product(self, request, q=1, p=None):
"""Add a product to the basket
q is the quantity passed as integer
p is the product id, passed as an integer.
"""
pid = p or parse_qs(request.body.decode())["product_id"][0]
pid = str(pid)
price = self.get_price(pid)
total = self.sum_basket(request)
product: Product = self.get_product(pid)
user: User = self.customer.user
buying_groups = list(product.buying_groups.values_list("pk", flat=True))
can_buy = len(buying_groups) == 0 or any(
user.is_in_group(pk=group_id) for group_id in buying_groups
)
if not can_buy:
request.session["not_allowed"] = True
return False
bq = 0 # Bonus quantity, for trays
if (
product.tray
): # Handle the tray to adjust the quantity q to add and the bonus quantity bq
total_qty_mod_6 = self.get_total_quantity_for_pid(request, pid) % 6
bq = int((total_qty_mod_6 + q) / 6) # Integer division
q -= bq
if self.customer.amount < (
total + round(q * float(price), 2)
): # Check for enough money
request.session["not_enough"] = True
return False
if product.is_unrecord_product and not self.is_record_product_ok(
request, product
):
request.session["not_allowed"] = True
return False
if product.limit_age >= 18 and not user.date_of_birth:
request.session["no_age"] = True
return False
if product.limit_age >= 18 and user.is_banned_alcohol:
request.session["not_allowed"] = True
return False
if user.is_banned_counter:
request.session["not_allowed"] = True
return False
if (
user.date_of_birth and self.customer.user.get_age() < product.limit_age
): # Check if affordable
request.session["too_young"] = True
return False
if pid in request.session["basket"]: # Add if already in basket
request.session["basket"][pid]["qty"] += q
request.session["basket"][pid]["bonus_qty"] += bq
else: # or create if not
request.session["basket"][pid] = {
"qty": q,
"price": int(price * 100),
"bonus_qty": bq,
}
request.session.modified = True
return True
def del_product(self, request):
"""Delete a product from the basket."""
pid = parse_qs(request.body.decode())["product_id"][0]
product = self.get_product(pid)
if pid in request.session["basket"]:
if (
product.tray
and (self.get_total_quantity_for_pid(request, pid) % 6 == 0)
and request.session["basket"][pid]["bonus_qty"]
):
request.session["basket"][pid]["bonus_qty"] -= 1
else:
request.session["basket"][pid]["qty"] -= 1
if request.session["basket"][pid]["qty"] <= 0:
del request.session["basket"][pid]
request.session.modified = True
def parse_code(self, request):
"""Parse the string entered by the barman.
This can be of two forms :
- `<str>`, where the string is the code of the product
- `<int>X<str>`, where the integer is the quantity and str the code.
"""
string = parse_qs(request.body.decode()).get("code", [""])[0].upper()
if string == "FIN":
return self.finish(request)
elif string == "ANN":
return self.cancel(request)
regex = re.compile(r"^((?P<nb>[0-9]+)X)?(?P<code>[A-Z0-9]+)$")
m = regex.match(string)
if m is not None:
nb = m.group("nb")
code = m.group("code")
nb = int(nb) if nb is not None else 1
p = self.object.products.filter(code=code).first()
if p is not None:
self.add_product(request, nb, p.id)
context = self.get_context_data(object=self.object)
return self.render_to_response(context)
def finish(self, request):
"""Finish the click session, and validate the basket."""
with transaction.atomic():
request.session["last_basket"] = []
if self.sum_basket(request) > self.customer.amount:
raise DataError(_("You have not enough money to buy all the basket"))
for pid, infos in request.session["basket"].items():
# This duplicates code for DB optimization (prevent to load many times the same object)
p = Product.objects.filter(pk=pid).first()
if self.object.customer_is_barman(self.customer):
uprice = p.special_selling_price
else:
uprice = p.selling_price
request.session["last_basket"].append(
"%d x %s" % (infos["qty"] + infos["bonus_qty"], p.name)
)
s = Selling(
label=p.name,
product=p,
club=p.club,
counter=self.object,
unit_price=uprice,
quantity=infos["qty"],
seller=self.operator,
customer=self.customer,
)
s.save()
if infos["bonus_qty"]:
s = Selling(
label=p.name + " (Plateau)",
product=p,
club=p.club,
counter=self.object,
unit_price=0,
quantity=infos["bonus_qty"],
seller=self.operator,
customer=self.customer,
)
s.save()
self.customer.recorded_products -= self.compute_record_product(request)
self.customer.save()
request.session["last_customer"] = self.customer.user.get_display_name()
request.session["last_total"] = "%0.2f" % self.sum_basket(request)
request.session["new_customer_amount"] = str(self.customer.amount)
del request.session["basket"]
request.session.modified = True
kwargs = {"counter_id": self.object.id}
return HttpResponseRedirect(
reverse_lazy("counter:details", args=self.args, kwargs=kwargs)
)
def cancel(self, request):
"""Cancel the click session."""
kwargs = {"counter_id": self.object.id}
request.session.pop("basket", None)
return HttpResponseRedirect(
reverse_lazy("counter:details", args=self.args, kwargs=kwargs)
)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
"""Add customer to the context.""" """Add customer to the context."""
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)
products = self.object.products.select_related("product_type") products = self.object.products.select_related("product_type")
# Optimisation to bulk edit prices instead of `calling get_actual_price` on everything
if self.object.customer_is_barman(self.customer): if self.object.customer_is_barman(self.customer):
products = products.annotate(price=F("special_selling_price")) products = products.annotate(price=F("special_selling_price"))
else: else:
products = products.annotate(price=F("selling_price")) products = products.annotate(price=F("selling_price"))
kwargs["products"] = products kwargs["products"] = products
kwargs["categories"] = {} kwargs["categories"] = {}
for product in kwargs["products"]: for product in kwargs["products"]:
@ -393,7 +253,6 @@ class CounterClick(CounterTabsMixin, CanViewMixin, DetailView):
product product
) )
kwargs["customer"] = self.customer kwargs["customer"] = self.customer
kwargs["basket_total"] = self.sum_basket(self.request)
if self.object.type == "BAR": if self.object.type == "BAR":
kwargs["student_card_fragment"] = StudentCardFormView.get_template_data( kwargs["student_card_fragment"] = StudentCardFormView.get_template_data(
@ -404,6 +263,7 @@ class CounterClick(CounterTabsMixin, CanViewMixin, DetailView):
kwargs["refilling_fragment"] = RefillingCreateView.get_template_data( kwargs["refilling_fragment"] = RefillingCreateView.get_template_data(
self.customer self.customer
).render(self.request) ).render(self.request)
return kwargs return kwargs
@ -442,10 +302,7 @@ class RefillingCreateView(FormView):
if not self.counter.can_refill(): if not self.counter.can_refill():
raise PermissionDenied raise PermissionDenied
if self.counter.customer_is_barman(self.customer): self.operator = get_operator(self.counter, self.customer)
self.operator = self.customer.user
else:
self.operator = self.counter.get_random_barman()
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)

6
package-lock.json generated
View File

@ -4608,9 +4608,9 @@
"dev": true "dev": true
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.7", "version": "3.3.8",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz",
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {