From 60f18669c8e57fae9185fb1eee9b80739044058a Mon Sep 17 00:00:00 2001 From: Sli Date: Fri, 20 Dec 2024 17:32:37 +0100 Subject: [PATCH 01/32] Make counter click client side first --- counter/models.py | 9 + .../counter-product-select-index.ts | 4 + .../bundled/counter/counter-click-index.ts | 135 +++-- counter/templates/counter/counter_click.jinja | 116 ++--- counter/views/click.py | 491 +++++++----------- package-lock.json | 6 +- 6 files changed, 340 insertions(+), 421 deletions(-) diff --git a/counter/models.py b/counter/models.py index 48bb841f0..b290d0a09 100644 --- a/counter/models.py +++ b/counter/models.py @@ -327,6 +327,8 @@ def is_owned_by(self, user): class Product(models.Model): """A product, with all its related information.""" + QUANTITY_FOR_TRAY_PRICE = 6 + name = models.CharField(_("name"), max_length=64) description = models.TextField(_("description"), default="") product_type = models.ForeignKey( @@ -426,6 +428,13 @@ def can_be_sold_to(self, user: User) -> bool: def profit(self): 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): def annotate_has_barman(self, user: User) -> Self: diff --git a/counter/static/bundled/counter/components/counter-product-select-index.ts b/counter/static/bundled/counter/components/counter-product-select-index.ts index 20aa23a5d..aecdaa03a 100644 --- a/counter/static/bundled/counter/components/counter-product-select-index.ts +++ b/counter/static/bundled/counter/components/counter-product-select-index.ts @@ -15,6 +15,10 @@ export class CounterProductSelect extends AutoCompleteSelectBase { return ["FIN", "ANN"]; } + public getSelectedProduct(): [number, string] { + return parseProduct(this.widget.getValue() as string); + } + protected attachBehaviors(): void { this.allowMultipleProducts(); } diff --git a/counter/static/bundled/counter/counter-click-index.ts b/counter/static/bundled/counter/counter-click-index.ts index ef8ba2db0..bd82f6ca3 100644 --- a/counter/static/bundled/counter/counter-click-index.ts +++ b/counter/static/bundled/counter/counter-click-index.ts @@ -1,24 +1,47 @@ import { exportToHtml } from "#core:utils/globals"; -import type TomSelect from "tom-select"; + +const quantityForTrayPrice = 6; interface CounterConfig { csrfToken: string; clickApiUrl: string; - sessionBasket: Record; customerBalance: number; customerId: number; + products: Record; } -interface BasketItem { - // biome-ignore lint/style/useNamingConvention: talking with python - bonus_qty: number; + +interface Product { + code: string; + name: string; 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) => { document.addEventListener("alpine:init", () => { Alpine.data("counter", () => ({ - basket: config.sessionBasket, + basket: {} as Record, errors: [], customerBalance: config.customerBalance, codeField: undefined, @@ -26,17 +49,58 @@ exportToHtml("loadCounter", (config: CounterConfig) => { init() { this.codeField = this.$refs.codeField; 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() { - if (!this.basket || Object.keys(this.basket).length === 0) { + if (this.getBasketSize() === 0) { return 0; } const total = Object.values(this.basket).reduce( - (acc: number, cur: BasketItem) => acc + cur.qty * cur.price, + (acc: number, cur: BasketItem) => acc + cur.sum(), 0, ) as number; - return total / 100; + return total; }, onRefillingSuccess(event: CustomEvent) { @@ -50,33 +114,36 @@ exportToHtml("loadCounter", (config: CounterConfig) => { this.codeField.widget.focus(); }, - async handleCode(event: SubmitEvent) { - const widget: TomSelect = this.codeField.widget; - 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(); + finish() { + this.$refs.basketForm.submit(); }, - async handleAction(event: SubmitEvent) { - const payload = $(event.target).serialize(); - const request = new Request(config.clickApiUrl, { - method: "POST", - body: payload, - headers: { - // biome-ignore lint/style/useNamingConvention: this goes into http headers - Accept: "application/json", - "X-CSRFToken": config.csrfToken, - }, + cancel() { + this.basket = new Object({}); + // We need to wait for the templated form to be removed before sending + this.$nextTick(() => { + this.finish(); }); - const response = await fetch(request); - const json = await response.json(); - this.basket = json.basket; - this.errors = json.errors; + }, + + handleCode(event: SubmitEvent) { + 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(); }, })); }); diff --git a/counter/templates/counter/counter_click.jinja b/counter/templates/counter/counter_click.jinja index 01b38be19..ced83eecf 100644 --- a/counter/templates/counter/counter_click.jinja +++ b/counter/templates/counter/counter_click.jinja @@ -34,7 +34,12 @@
{% trans %}Customer{% endtrans %}
{{ user_mini_profile(customer.user) }} {{ user_subscription(customer.user) }} -

{% trans %}Amount: {% endtrans %}

+

{% trans %}Amount: {% endtrans %} € + + + € + +

@@ -72,53 +77,37 @@

{% trans %}Basket: {% endtrans %}

- -
    - -
-

- Total: - - -

- -
- {% csrf_token %} - - -
-
+ {% csrf_token %} - - + {{ form.errors }} + {{ form.non_form_errors() }} +
+ {{ form.management_form }} +
+ + +

+ Total: + + +

+ + +
{% if object.type == "BAR" %} @@ -159,23 +148,16 @@ {% for category in categories.keys() -%}
{{ category }}
- {% for p in categories[category] -%} -
- {% csrf_token %} - - - -
+ {% for product in categories[category] -%} + {%- endfor %}
{%- endfor %} @@ -192,15 +174,15 @@ code: "{{ p.code }}", name: "{{ p.name }}", price: {{ p.price }}, + hasTrayPrice: {{ p.tray | tojson }}, }, {%- endfor -%} }; window.addEventListener("DOMContentLoaded", () => { loadCounter({ csrfToken: "{{ csrf_token }}", - clickApiUrl: "{{ url('counter:click', counter_id=counter.id, user_id=customer.user.id) }}", - sessionBasket: {{ request.session["basket"]|tojson }}, - customerBalance: {{ customer.amount }}, + clickApiUrl: "{{ url('counter:click', counter_id=counter.id, user_id=customer.user.id) }}", customerBalance: {{ customer.amount }}, + products: products, customerId: {{ customer.pk }}, }); }); diff --git a/counter/views/click.py b/counter/views/click.py index 9542b467c..98ac8e682 100644 --- a/counter/views/click.py +++ b/counter/views/click.py @@ -12,20 +12,26 @@ # OR WITHIN THE LOCAL FILE "LICENSE" # # -import re -from http import HTTPStatus -from typing import TYPE_CHECKING -from urllib.parse import parse_qs +import math 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.http import Http404, HttpResponseRedirect, JsonResponse -from django.shortcuts import get_object_or_404, redirect +from django.forms import ( + 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.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.views import CanViewMixin from counter.forms import RefillForm @@ -34,11 +40,111 @@ from counter.views.mixins import CounterTabsMixin 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 This is a detail view not to have to worry about loading the counter Everything is made by hand in the post method. @@ -46,37 +152,18 @@ class CounterClick(CounterTabsMixin, CanViewMixin, DetailView): model = Counter queryset = Counter.objects.annotate_is_open() + form_class = BasketForm template_name = "counter/counter_click.jinja" pk_url_kwarg = "counter_id" current_tab = "counter" - def render_to_response(self, *args, **kwargs): - if self.is_ajax(self.request): - response = {"errors": []} - status = HTTPStatus.OK - - if self.request.session["too_young"]: - response["errors"].append(_("Too young for that product")) - 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 get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["form_kwargs"] = { + "customer": self.customer, + "counter": self.object, + } + return kwargs def dispatch(self, request, *args, **kwargs): self.customer = get_object_or_404(Customer, user__id=self.kwargs["user_id"]) @@ -90,301 +177,74 @@ def dispatch(self, request, *args, **kwargs): or request.session["counter_token"] != obj.token or len(obj.barmen_list) == 0 ): - return redirect(obj) + return redirect(obj) # Redirect to counter return super().dispatch(request, *args, **kwargs) - def get(self, request, *args, **kwargs): - """Simple get view.""" - if "basket" not in request.session: # Init the basket session entry - request.session["basket"] = {} - 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 - - def post(self, request, *args, **kwargs): - """Handle the many possibilities of the post request.""" - self.object = self.get_object() - if (self.object.type != "BAR" and not request.user.is_authenticated) or ( - self.object.type == "BAR" and len(self.object.barmen_list) < 1 - ): # Check that at least one barman is logged in - return self.cancel(request) - 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" - ) - if "basket" not in request.session: - request.session["basket"] = {} - 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 - if self.object.type != "BAR": - self.operator = request.user - elif self.object.customer_is_barman(self.customer): - self.operator = self.customer.user - else: - self.operator = self.object.get_random_barman() - action = self.request.POST.get("action", None) - if action is None: - action = parse_qs(request.body.decode()).get("action", [""])[0] - if action == "add_product": - self.add_product(request) - elif action == "del_product": - self.del_product(request) - elif action == "code": - return self.parse_code(request) - elif action == "cancel": - return self.cancel(request) - elif action == "finish": - return self.finish(request) - context = self.get_context_data(object=self.object) - return self.render_to_response(context) - - def get_product(self, pid): - return Product.objects.filter(pk=int(pid)).first() + def form_valid(self, formset): + ret = super().form_valid(formset) - 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) - ) + if len(formset) == 0: + return ret - @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 : - - ``, where the string is the code of the product - - `X`, 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[0-9]+)X)?(?P[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.""" + operator = get_operator(self.object, self.customer) 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) + self.request.session["last_basket"] = [] + + for form in formset: + self.request.session["last_basket"].append( + f"{form.cleaned_data['quantity']} x {form.product.name}" ) - s = Selling( - label=p.name, - product=p, - club=p.club, + + Selling( + label=form.product.name, + product=form.product, + club=form.product.club, counter=self.object, - unit_price=uprice, - quantity=infos["qty"], - seller=self.operator, + unit_price=form.cleaned_data["unit_price"], + quantity=form.cleaned_data["quantity"] + - form.cleaned_data["bonus_quantity"], + seller=operator, customer=self.customer, - ) - s.save() - if infos["bonus_qty"]: - s = Selling( - label=p.name + " (Plateau)", - product=p, - club=p.club, + ).save() + if form.cleaned_data["bonus_quantity"] > 0: + Selling( + label=f"{form.product.name} (Plateau)", + product=form.product, + club=form.product.club, counter=self.object, unit_price=0, - quantity=infos["bonus_qty"], - seller=self.operator, + quantity=form.cleaned_data["bonus_quantity"], + seller=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) - ) + ).save() - 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) - ) + self.customer.recorded_products -= formset.total_recordings + self.customer.save() + + # Add some info for the main counter view to display + self.request.session["last_customer"] = self.customer.user.get_display_name() + 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): + return Product.objects.filter(pk=int(pid)).first() def get_context_data(self, **kwargs): """Add customer to the context.""" kwargs = super().get_context_data(**kwargs) 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): products = products.annotate(price=F("special_selling_price")) else: products = products.annotate(price=F("selling_price")) + kwargs["products"] = products kwargs["categories"] = {} for product in kwargs["products"]: @@ -393,7 +253,6 @@ def get_context_data(self, **kwargs): product ) kwargs["customer"] = self.customer - kwargs["basket_total"] = self.sum_basket(self.request) if self.object.type == "BAR": kwargs["student_card_fragment"] = StudentCardFormView.get_template_data( @@ -404,6 +263,7 @@ def get_context_data(self, **kwargs): kwargs["refilling_fragment"] = RefillingCreateView.get_template_data( self.customer ).render(self.request) + return kwargs @@ -442,10 +302,7 @@ def dispatch(self, request, *args, **kwargs): if not self.counter.can_refill(): raise PermissionDenied - if self.counter.customer_is_barman(self.customer): - self.operator = self.customer.user - else: - self.operator = self.counter.get_random_barman() + self.operator = get_operator(self.counter, self.customer) return super().dispatch(request, *args, **kwargs) diff --git a/package-lock.json b/package-lock.json index c46ef180d..9b49ac0e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4608,9 +4608,9 @@ "dev": true }, "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", "dev": true, "funding": [ { From a383f3e7179446d71bd60a8b9185f09079275eb7 Mon Sep 17 00:00:00 2001 From: Sli Date: Fri, 20 Dec 2024 18:34:27 +0100 Subject: [PATCH 02/32] Don't use codes as a primary key in counter click --- .../bundled/counter/counter-click-index.ts | 16 +++++----------- counter/templates/counter/counter_click.jinja | 14 +++++++------- counter/views/click.py | 13 +++++-------- 3 files changed, 17 insertions(+), 26 deletions(-) diff --git a/counter/static/bundled/counter/counter-click-index.ts b/counter/static/bundled/counter/counter-click-index.ts index bd82f6ca3..a33dfa251 100644 --- a/counter/static/bundled/counter/counter-click-index.ts +++ b/counter/static/bundled/counter/counter-click-index.ts @@ -11,6 +11,7 @@ interface CounterConfig { } interface Product { + id: string; code: string; name: string; price: number; @@ -56,18 +57,11 @@ exportToHtml("loadCounter", (config: CounterConfig) => { .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)]; + removeFromBasket(id: string) { + delete this.basket[id]; }, - addToBasket(code: string, quantity: number): [boolean, string] { - const id = this.getItemIdFromCode(code); + addToBasket(id: string, quantity: number): [boolean, string] { const item: BasketItem = this.basket[id] || new BasketItem(config.products[id], 0); @@ -126,7 +120,7 @@ exportToHtml("loadCounter", (config: CounterConfig) => { }); }, - handleCode(event: SubmitEvent) { + handleCode() { const [quantity, code] = this.codeField.getSelectedProduct() as [ number, string, diff --git a/counter/templates/counter/counter_click.jinja b/counter/templates/counter/counter_click.jinja index ced83eecf..cfc4794f8 100644 --- a/counter/templates/counter/counter_click.jinja +++ b/counter/templates/counter/counter_click.jinja @@ -63,7 +63,7 @@ {% for category in categories.keys() %} {% for product in categories[category] %} - + {% endfor %} {% endfor %} @@ -86,17 +86,17 @@ @@ -149,7 +149,7 @@
{{ category }}
{% for product in categories[category] -%} - - {%- endfor %} +
{{ category }}
+
+ {% for product in categories[category] -%} + + {%- endfor %} +
{%- endfor %} From eea237b813cbcc39859a13caaf57e5493a5bfd95 Mon Sep 17 00:00:00 2001 From: Sli Date: Sun, 22 Dec 2024 01:56:57 +0100 Subject: [PATCH 10/32] Pre-filter allowed products in backend for counter click --- counter/models.py | 7 --- counter/views/click.py | 102 ++++++++++++++++++++++------------------- 2 files changed, 55 insertions(+), 54 deletions(-) diff --git a/counter/models.py b/counter/models.py index b290d0a09..d5f9c9c72 100644 --- a/counter/models.py +++ b/counter/models.py @@ -428,13 +428,6 @@ def can_be_sold_to(self, user: User) -> bool: def profit(self): 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): def annotate_has_barman(self, user: User) -> Self: diff --git a/counter/views/click.py b/counter/views/click.py index 75f0fcd9f..38d4ce57e 100644 --- a/counter/views/click.py +++ b/counter/views/click.py @@ -53,13 +53,15 @@ class ProductForm(Form): def __init__( self, + customer: Customer, + counter: Counter, + allowed_products: list[Product], *args, - customer: Customer | None = None, - counter: Counter | None = None, **kwargs, ): - self.customer = customer - self.counter = counter + self.customer = customer # Used by formset + self.counter = counter # Used by formset + self.allowed_products = allowed_products super().__init__(*args, **kwargs) def clean(self): @@ -67,45 +69,27 @@ def clean(self): if len(self.errors) > 0: return - 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(id=cleaned_data["id"]).first() + self.product = next( + ( + product + for product in self.allowed_products + if product.id == cleaned_data["id"] + ), + None, + ) if self.product is None: raise ValidationError( - _( - "Product %(product)s doesn't exist or isn't available on this counter" - ) - % {"product": cleaned_data["id"]} + _("The selected product isn't available for this user") ) - # 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["total_price"] = self.product.price * ( cleaned_data["quantity"] - cleaned_data["bonus_quantity"] ) @@ -164,27 +148,62 @@ class CounterClick(CounterTabsMixin, CanViewMixin, SingleObjectMixin, FormView): pk_url_kwarg = "counter_id" current_tab = "counter" + def get_products(self) -> list[Product]: + """Get all allowed products for the current customer on the current counter""" + + if hasattr(self, "_products"): + return self._products + + products = self.object.products.select_related("product_type").prefetch_related( + "buying_groups" + ) + + # Only include allowed products + if not self.customer.user.date_of_birth or self.customer.user.is_banned_alcohol: + products = products.filter(limit_age__lt=18) + else: + products = products.filter(limit_age__lte=self.customer.user.get_age()) + + # Compute special price for customer if he is a barmen on that bar + if self.object.customer_is_barman(self.customer): + products = products.annotate(price=F("special_selling_price")) + else: + products = products.annotate(price=F("selling_price")) + + self._products = [ + product + for product in products.all() + if product.can_be_sold_to(self.customer.user) + ] + + return self._products + def get_form_kwargs(self): kwargs = super().get_form_kwargs() kwargs["form_kwargs"] = { "customer": self.customer, "counter": self.object, + "allowed_products": self.get_products(), } return kwargs def dispatch(self, request, *args, **kwargs): self.customer = get_object_or_404(Customer, user__id=self.kwargs["user_id"]) obj: Counter = self.get_object() - if not self.customer.can_buy: - raise Http404 + + if not self.customer.can_buy or self.customer.user.is_banned_counter: + return redirect(obj) # Redirect to counter + if obj.type != "BAR" and not request.user.is_authenticated: raise PermissionDenied + if obj.type == "BAR" and ( "counter_token" not in request.session or request.session["counter_token"] != obj.token or len(obj.barmen_list) == 0 ): return redirect(obj) # Redirect to counter + return super().dispatch(request, *args, **kwargs) def form_valid(self, formset): @@ -207,7 +226,7 @@ def form_valid(self, formset): product=form.product, club=form.product.club, counter=self.object, - unit_price=form.cleaned_data["unit_price"], + unit_price=form.product.price, quantity=form.cleaned_data["quantity"] - form.cleaned_data["bonus_quantity"], seller=operator, @@ -238,21 +257,10 @@ def form_valid(self, formset): def get_success_url(self): return resolve_url(self.object) - def get_product(self, pid): - return Product.objects.filter(pk=int(pid)).first() - def get_context_data(self, **kwargs): """Add customer to the context.""" kwargs = super().get_context_data(**kwargs) - 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): - products = products.annotate(price=F("special_selling_price")) - else: - products = products.annotate(price=F("selling_price")) - - kwargs["products"] = products + kwargs["products"] = self.get_products() kwargs["categories"] = {} for product in kwargs["products"]: if product.product_type: From 7071553c3b940377c5f247e07bd0d6e1a017fb9a Mon Sep 17 00:00:00 2001 From: Sli Date: Sun, 22 Dec 2024 02:14:14 +0100 Subject: [PATCH 11/32] Optimize product id validation on counter click --- counter/views/click.py | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/counter/views/click.py b/counter/views/click.py index 38d4ce57e..a214e6c44 100644 --- a/counter/views/click.py +++ b/counter/views/click.py @@ -55,7 +55,7 @@ def __init__( self, customer: Customer, counter: Counter, - allowed_products: list[Product], + allowed_products: dict[int, Product], *args, **kwargs, ): @@ -64,25 +64,24 @@ def __init__( self.allowed_products = allowed_products super().__init__(*args, **kwargs) - def clean(self): - cleaned_data = super().clean() - if len(self.errors) > 0: - return + def clean_id(self): + data = self.cleaned_data["id"] # We store self.product so we can use it later on the formset validation - self.product = next( - ( - product - for product in self.allowed_products - if product.id == cleaned_data["id"] - ), - None, - ) + # 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: @@ -183,7 +182,9 @@ def get_form_kwargs(self): kwargs["form_kwargs"] = { "customer": self.customer, "counter": self.object, - "allowed_products": self.get_products(), + "allowed_products": { + product.id: product for product in self.get_products() + }, } return kwargs From 372470b44b711d9c0dd84d1e6241359a73d4e8c2 Mon Sep 17 00:00:00 2001 From: Sli Date: Sun, 22 Dec 2024 12:00:42 +0100 Subject: [PATCH 12/32] Improve empty basket and tray price management --- .../static/bundled/counter/counter-click-index.ts | 12 +++++++----- counter/templates/counter/counter_click.jinja | 8 +++++--- counter/views/click.py | 12 +++++------- 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/counter/static/bundled/counter/counter-click-index.ts b/counter/static/bundled/counter/counter-click-index.ts index e05de762a..f7886bee4 100644 --- a/counter/static/bundled/counter/counter-click-index.ts +++ b/counter/static/bundled/counter/counter-click-index.ts @@ -1,10 +1,8 @@ import { exportToHtml } from "#core:utils/globals"; -const quantityForTrayPrice = 6; - interface InitialFormData { /* Used to refill the form when the backend raises an error */ - id?: string; + id?: Pick; quantity?: number; errors?: string[]; } @@ -23,11 +21,13 @@ interface Product { name: string; price: number; hasTrayPrice: boolean; + quantityForTrayPrice: number; } class BasketItem { quantity: number; product: Product; + quantityForTrayPrice: number; errors: string[]; constructor(product: Product, quantity: number) { @@ -40,7 +40,7 @@ class BasketItem { if (!this.product.hasTrayPrice) { return 0; } - return Math.floor(this.quantity / quantityForTrayPrice); + return Math.floor(this.quantity / this.product.quantityForTrayPrice); } sum(): number { @@ -127,7 +127,9 @@ exportToHtml("loadCounter", (config: CounterConfig) => { }, finish() { - this.$refs.basketForm.submit(); + if (this.getBasketSize() > 0) { + this.$refs.basketForm.submit(); + } }, cancel() { diff --git a/counter/templates/counter/counter_click.jinja b/counter/templates/counter/counter_click.jinja index 96a4b0e20..1a5f6d9e5 100644 --- a/counter/templates/counter/counter_click.jinja +++ b/counter/templates/counter/counter_click.jinja @@ -79,11 +79,12 @@ {% endfor %}

{% trans %}Basket: {% endtrans %}

- + {% csrf_token %}
{{ form.management_form }}
+
    {% trans %}This basket is empty{% endtrans %}
- + : - + + From 138e1662c72a4a9f897163f4c2511c307194623d Mon Sep 17 00:00:00 2001 From: Sli Date: Tue, 24 Dec 2024 00:29:23 +0100 Subject: [PATCH 29/32] Add popup css class and display basket error messages with it on counter click --- core/static/core/style.scss | 21 ++++++++++++++++++- counter/templates/counter/counter_click.jinja | 2 +- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/core/static/core/style.scss b/core/static/core/style.scss index 06feea50e..2f12d3800 100644 --- a/core/static/core/style.scss +++ b/core/static/core/style.scss @@ -337,12 +337,31 @@ body { margin-left: -125px; box-sizing: border-box; position: fixed; - z-index: 1; + z-index: 10; + /* to get on top of tomselect */ left: 50%; top: 60px; text-align: center; } + .popup { + z-index: 10; + /* to get on top of tomselect */ + display: inline-block; + text-align: center; + overflow: auto; + margin: auto; + position: fixed; + top: 0; + left: 0; + bottom: 0; + right: 0; + height: 20vh; + width: 20vw; + align-content: center; + } + + .tabs { border-radius: 5px; diff --git a/counter/templates/counter/counter_click.jinja b/counter/templates/counter/counter_click.jinja index 37356ea01..b4257b76f 100644 --- a/counter/templates/counter/counter_click.jinja +++ b/counter/templates/counter/counter_click.jinja @@ -33,7 +33,7 @@

Date: Wed, 25 Dec 2024 20:44:52 +0100 Subject: [PATCH 30/32] Put error popup inside the basket --- core/static/core/style.scss | 31 ++++++++----------- counter/templates/counter/counter_click.jinja | 20 ++++++------ 2 files changed, 24 insertions(+), 27 deletions(-) diff --git a/core/static/core/style.scss b/core/static/core/style.scss index 2f12d3800..486964bca 100644 --- a/core/static/core/style.scss +++ b/core/static/core/style.scss @@ -344,24 +344,6 @@ body { text-align: center; } - .popup { - z-index: 10; - /* to get on top of tomselect */ - display: inline-block; - text-align: center; - overflow: auto; - margin: auto; - position: fixed; - top: 0; - left: 0; - bottom: 0; - right: 0; - height: 20vh; - width: 20vw; - align-content: center; - } - - .tabs { border-radius: 5px; @@ -1283,6 +1265,18 @@ u, float: right; } + .basket-error-container { + position: relative; + display: block + } + + .basket-error { + z-index: 10; // to get on top of tomselect + text-align: center; + position: absolute; + } + + #products { flex-basis: 100%; margin: 0.2em; @@ -1292,6 +1286,7 @@ u, #click_form { flex: auto; margin: 0.2em; + width: 20%; } #user_info { diff --git a/counter/templates/counter/counter_click.jinja b/counter/templates/counter/counter_click.jinja index b4257b76f..e902a3c87 100644 --- a/counter/templates/counter/counter_click.jinja +++ b/counter/templates/counter/counter_click.jinja @@ -31,14 +31,6 @@

Javascript is required for the counter UI.

- -
{% trans %}Customer{% endtrans %}
{{ user_mini_profile(customer.user) }} @@ -51,7 +43,7 @@

-
+
{% trans %}Selling{% endtrans %}
{% set counter_click_url = url('counter:click', counter_id=counter.id, user_id=customer.user_id) %} @@ -86,6 +78,16 @@ +
+
+
+ {% csrf_token %}
{{ form.management_form }} From 43768f16919608780c3079933efe98ab777b952f Mon Sep 17 00:00:00 2001 From: Sli Date: Thu, 26 Dec 2024 11:52:30 +0100 Subject: [PATCH 31/32] Refactor counter-click css --- core/static/core/style.scss | 57 -------------- .../bundled/counter/counter-click-index.ts | 2 +- counter/static/counter/css/counter-click.scss | 62 ++++++++++++++++ counter/templates/counter/counter_click.jinja | 74 +++++++++++++------ 4 files changed, 115 insertions(+), 80 deletions(-) create mode 100644 counter/static/counter/css/counter-click.scss diff --git a/core/static/core/style.scss b/core/static/core/style.scss index 486964bca..cf1cedc6f 100644 --- a/core/static/core/style.scss +++ b/core/static/core/style.scss @@ -1249,63 +1249,6 @@ u, text-decoration: underline; } -#bar-ui { - padding: 0.4em; - display: flex; - flex-wrap: wrap; - flex-direction: row-reverse; - - .quantity { - display: inline-block; - min-width: 1.2em; - text-align: center; - } - - .remove-item { - float: right; - } - - .basket-error-container { - position: relative; - display: block - } - - .basket-error { - z-index: 10; // to get on top of tomselect - text-align: center; - position: absolute; - } - - - #products { - flex-basis: 100%; - margin: 0.2em; - overflow: auto; - } - - #click_form { - flex: auto; - margin: 0.2em; - width: 20%; - } - - #user_info { - flex: auto; - padding: 0.5em; - margin: 0.2em; - height: 100%; - background: $secondary-neutral-light-color; - - img { - max-width: 70%; - } - - input { - background: white; - } - } -} - /*-----------------------------USER PROFILE----------------------------*/ .user_mini_profile { diff --git a/counter/static/bundled/counter/counter-click-index.ts b/counter/static/bundled/counter/counter-click-index.ts index 23a8105d4..7ab8de1db 100644 --- a/counter/static/bundled/counter/counter-click-index.ts +++ b/counter/static/bundled/counter/counter-click-index.ts @@ -146,7 +146,7 @@ exportToHtml("loadCounter", (config: CounterConfig) => { $(() => { /* Accordion UI between basket and refills */ // biome-ignore lint/suspicious/noExplicitAny: dealing with legacy jquery - ($("#click_form") as any).accordion({ + ($("#click-form") as any).accordion({ heightStyle: "content", activate: () => $(".focus").focus(), }); diff --git a/counter/static/counter/css/counter-click.scss b/counter/static/counter/css/counter-click.scss new file mode 100644 index 000000000..c715867af --- /dev/null +++ b/counter/static/counter/css/counter-click.scss @@ -0,0 +1,62 @@ +@import "core/static/core/colors"; + +.quantity { + display: inline-block; + min-width: 1.2em; + text-align: center; +} + +.remove-item { + float: right; +} + +.basket-error-container { + position: relative; + display: block +} + +.basket-error { + z-index: 10; // to get on top of tomselect + text-align: center; + position: absolute; +} + + +#bar-ui { + padding: 0.4em; + display: flex; + flex-wrap: wrap; + flex-direction: row-reverse; +} + +#products { + flex-basis: 100%; + margin: 0.2em; + overflow: auto; +} + +#click-form { + flex: auto; + margin: 0.2em; + width: 20%; + + ul { + list-style-type: none; + } +} + +#user_info { + flex: auto; + padding: 0.5em; + margin: 0.2em; + height: 100%; + background: $secondary-neutral-light-color; + + img { + max-width: 70%; + } + + input { + background: white; + } +} \ No newline at end of file diff --git a/counter/templates/counter/counter_click.jinja b/counter/templates/counter/counter_click.jinja index e902a3c87..e2ad85625 100644 --- a/counter/templates/counter/counter_click.jinja +++ b/counter/templates/counter/counter_click.jinja @@ -6,6 +6,7 @@ {% endblock %} {% block additional_css %} + @@ -24,7 +25,7 @@ {% endblock %} {% block content %} -

{{ counter }}

+

{{ counter }}

-
+
{% trans %}Selling{% endtrans %}
{% set counter_click_url = url('counter:click', counter_id=counter.id, user_id=customer.user_id) %} @@ -92,28 +93,47 @@
{{ form.management_form }}
-
    {% trans %}This basket is empty{% endtrans %}
- +

Total: @@ -122,8 +142,18 @@

- - + +
From 43f47e20872a12ae7a7c0ef4a57c5c887949ad74 Mon Sep 17 00:00:00 2001 From: Sli Date: Fri, 27 Dec 2024 01:59:54 +0100 Subject: [PATCH 32/32] Improve product card display on counter click --- counter/templates/counter/counter_click.jinja | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/counter/templates/counter/counter_click.jinja b/counter/templates/counter/counter_click.jinja index e2ad85625..82707e2e0 100644 --- a/counter/templates/counter/counter_click.jinja +++ b/counter/templates/counter/counter_click.jinja @@ -203,7 +203,6 @@
{% for product in categories[category] -%} {%- endfor %}