diff --git a/counter/forms.py b/counter/forms.py index 6641a80e..18efa724 100644 --- a/counter/forms.py +++ b/counter/forms.py @@ -6,6 +6,7 @@ from datetime import date, datetime, timezone from dateutil.relativedelta import relativedelta from django import forms +from django.conf import settings from django.core.exceptions import ValidationError from django.db.models import Exists, OuterRef, Q from django.forms import BaseModelFormSet @@ -168,18 +169,19 @@ class RefillForm(forms.ModelForm): error_css_class = "error" required_css_class = "required" - amount = forms.FloatField( - min_value=0, widget=forms.NumberInput(attrs={"class": "focus"}) - ) class Meta: model = Refilling fields = ["amount", "payment_method"] widgets = {"payment_method": forms.RadioSelect} - def __init__(self, *args, **kwargs): + def __init__( + self, *args, counter: Counter, operator: User, customer: Customer, **kwargs + ): super().__init__(*args, **kwargs) - + max_value = settings.SITH_ACCOUNT_MAX_MONEY - customer.amount + # server-side max_value validation is done by Refilling.clean + self.fields["amount"].widget.attrs["max"] = max_value self.fields["payment_method"].choices = ( method for method in self.fields["payment_method"].choices @@ -187,6 +189,9 @@ class RefillForm(forms.ModelForm): ) if self.fields["payment_method"].initial not in self.allowed_refilling_methods: self.fields["payment_method"].initial = self.allowed_refilling_methods[0] + self.instance.counter = counter + self.instance.operator = operator + self.instance.customer = customer class CounterEditForm(forms.ModelForm): diff --git a/counter/models.py b/counter/models.py index a34f20b1..fb180191 100644 --- a/counter/models.py +++ b/counter/models.py @@ -881,6 +881,14 @@ class Refilling(models.Model): return False return user.is_owner(self.counter) and self.payment_method != "CARD" + def clean(self): + super().clean() + if self.amount + self.customer.amount > settings.SITH_ACCOUNT_MAX_MONEY: + raise ValidationError( + _("There cannot be more than %(money)d€ on an AE account") + % {"money": settings.SITH_ACCOUNT_MAX_MONEY} + ) + def delete(self, *args, **kwargs): self.customer.amount -= self.amount self.customer.save() 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 d4e96a81..433ae9f9 100644 --- a/counter/static/bundled/counter/components/counter-product-select-index.ts +++ b/counter/static/bundled/counter/components/counter-product-select-index.ts @@ -6,7 +6,7 @@ const productParsingRegex = /^(\d+x)?(.*)/i; const codeParsingRegex = / \((\w+)\)$/; function parseProduct(query: string): [number, string] { - const parsed = productParsingRegex.exec(query); + const parsed = productParsingRegex.exec(query) as RegExpExecArray; return [Number.parseInt(parsed[1] || "1", 10), parsed[2]]; } diff --git a/counter/static/bundled/counter/counter-click-index.ts b/counter/static/bundled/counter/counter-click-index.ts index 7228f067..5504cd12 100644 --- a/counter/static/bundled/counter/counter-click-index.ts +++ b/counter/static/bundled/counter/counter-click-index.ts @@ -3,7 +3,6 @@ import { BasketItem } from "#counter:counter/basket"; import type { CounterConfig, CounterItem, - ErrorMessage, ProductFormula, } from "#counter:counter/types"; import type { CounterProductSelect } from "./components/counter-product-select-index"; @@ -24,7 +23,7 @@ document.addEventListener("alpine:init", () => { } } - this.codeField = this.$refs.codeField; + this.codeField = this.$refs.codeField as CounterProductSelect; this.codeField.widget.hook("after", "onOptionSelect", () => { this.handleCode(); }); @@ -34,14 +33,14 @@ document.addEventListener("alpine:init", () => { // of a formset so we dynamically apply it here this.$refs.basketManagementForm .querySelector("#id_form-TOTAL_FORMS") - .setAttribute(":value", "getBasketSize()"); + ?.setAttribute(":value", "getBasketSize()"); }, removeFromBasket(id: string) { delete this.basket[id]; }, - addToBasket(id: string, quantity: number): ErrorMessage { + addToBasket(id: string, quantity: number) { const item: BasketItem = this.basket[id] || new BasketItem(config.products[id], 0); @@ -50,7 +49,7 @@ document.addEventListener("alpine:init", () => { if (item.quantity <= 0) { delete this.basket[id]; - return ""; + return; } this.basket[id] = item; @@ -72,7 +71,7 @@ document.addEventListener("alpine:init", () => { const products = new Set( Object.values(this.basket).map((item: BasketItem) => item.product.productId), ); - const formula: ProductFormula = config.formulas.find((f: ProductFormula) => { + const formula = config.formulas.find((f: ProductFormula) => { return f.products.every((p: number) => products.has(p)); }); if (formula === undefined) { @@ -80,9 +79,13 @@ document.addEventListener("alpine:init", () => { } // Now that the formula is found, remove the items composing it from the basket for (const product of formula.products) { - const key = Object.entries(this.basket).find( + const item = Object.entries(this.basket).find( ([_, i]: [string, BasketItem]) => i.product.productId === product, - )[0]; + ); + if (item === undefined) { + continue; + } + const key = item[0]; this.basket[key].quantity -= 1; if (this.basket[key].quantity <= 0) { this.removeFromBasket(key); @@ -92,7 +95,7 @@ document.addEventListener("alpine:init", () => { const result = Object.values(config.products) .filter((item: CounterItem) => item.productId === formula.result) .reduce((acc, curr) => (acc.price.amount < curr.price.amount ? acc : curr)); - this.addToBasket(result.price.id, 1); + this.addToBasket(result.price.id.toString(), 1); this.alertMessage.display( interpolate( gettext("Formula %(formula)s applied"), @@ -119,14 +122,18 @@ document.addEventListener("alpine:init", () => { }, onRefillingSuccess(event: CustomEvent) { - if (event.type !== "htmx:after-request" || event.detail.failed) { + if ( + event.type !== "htmx:after-swap" || + event.detail.failed || + event.detail.elt.querySelector(".errorlist") + ) { return; } this.customerBalance += Number.parseFloat( (event.detail.target.querySelector("#id_amount") as HTMLInputElement).value, ); - document.getElementById("selling-accordion").setAttribute("open", ""); - this.codeField.widget.focus(); + document.getElementById("selling-accordion")?.setAttribute("open", ""); + this.codeField?.widget.focus(); }, finish() { @@ -136,7 +143,7 @@ document.addEventListener("alpine:init", () => { }); return; } - this.$refs.basketForm.submit(); + (this.$refs.basketForm as HTMLFormElement).submit(); }, cancel() { @@ -144,6 +151,8 @@ document.addEventListener("alpine:init", () => { }, handleCode() { + if (!this.codeField) throw Error("Unexpected null codeField."); + const [quantity, code] = this.codeField.getSelectedProduct() as [number, string]; if (this.codeField.getOperationCodes().includes(code.toUpperCase())) { diff --git a/counter/templates/counter/counter_click.jinja b/counter/templates/counter/counter_click.jinja index e93b1110..bf07f1b4 100644 --- a/counter/templates/counter/counter_click.jinja +++ b/counter/templates/counter/counter_click.jinja @@ -182,7 +182,7 @@ {% if refilling_fragment %}
{{ refilling_fragment }}
diff --git a/counter/views/click.py b/counter/views/click.py index 949031c8..7633327d 100644 --- a/counter/views/click.py +++ b/counter/views/click.py @@ -24,7 +24,7 @@ from django.shortcuts import get_object_or_404, redirect, resolve_url from django.urls import reverse from django.utils.safestring import SafeString from django.utils.translation import gettext as _ -from django.views.generic import FormView +from django.views.generic import CreateView, FormView from django.views.generic.detail import SingleObjectMixin from ninja.main import HttpRequest @@ -32,7 +32,14 @@ from core.auth.mixins import CanViewMixin from core.models import User from core.views.mixins import FragmentMixin, UseFragmentsMixin from counter.forms import BasketForm, RefillForm -from counter.models import Counter, Customer, ProductFormula, ReturnableProduct, Selling +from counter.models import ( + Counter, + Customer, + ProductFormula, + Refilling, + ReturnableProduct, + Selling, +) from counter.utils import is_logged_in_counter from counter.views.mixins import CounterTabsMixin from counter.views.student_card import StudentCardFormFragment @@ -219,9 +226,10 @@ class CounterClick( return kwargs -class RefillingCreateView(FragmentMixin, FormView): +class RefillingCreateView(FragmentMixin, CreateView): """This is a fragment only view which integrates with counter_click.jinja""" + model = Refilling form_class = RefillForm template_name = "counter/fragments/create_refill.jinja" @@ -242,23 +250,20 @@ class RefillingCreateView(FragmentMixin, FormView): ): raise PermissionDenied - self.operator = get_operator(request, self.counter, self.customer) - return super().dispatch(request, *args, **kwargs) def render_fragment(self, request, **kwargs) -> SafeString: self.customer = kwargs.pop("customer") self.counter = kwargs.pop("counter") + self.object = None return super().render_fragment(request, **kwargs) - def form_valid(self, form): - res = super().form_valid(form) - form.clean() - form.instance.counter = self.counter - form.instance.operator = self.operator - form.instance.customer = self.customer - form.instance.save() - return res + def get_form_kwargs(self): + return super().get_form_kwargs() | { + "counter": self.counter, + "operator": get_operator(self.request, self.counter, self.customer), + "customer": self.customer, + } def get_context_data(self, **kwargs): kwargs = super().get_context_data(**kwargs)