max amount for counter refills

This commit is contained in:
imperosol
2026-06-07 14:28:21 +02:00
parent f6f31af975
commit 5e553d91a8
6 changed files with 60 additions and 33 deletions
+10 -5
View File
@@ -6,6 +6,7 @@ from datetime import date, datetime, timezone
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django import forms from django import forms
from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db.models import Exists, OuterRef, Q from django.db.models import Exists, OuterRef, Q
from django.forms import BaseModelFormSet from django.forms import BaseModelFormSet
@@ -168,18 +169,19 @@ class RefillForm(forms.ModelForm):
error_css_class = "error" error_css_class = "error"
required_css_class = "required" required_css_class = "required"
amount = forms.FloatField(
min_value=0, widget=forms.NumberInput(attrs={"class": "focus"})
)
class Meta: class Meta:
model = Refilling model = Refilling
fields = ["amount", "payment_method"] fields = ["amount", "payment_method"]
widgets = {"payment_method": forms.RadioSelect} 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) 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 = ( self.fields["payment_method"].choices = (
method method
for method in self.fields["payment_method"].choices 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: if self.fields["payment_method"].initial not in self.allowed_refilling_methods:
self.fields["payment_method"].initial = self.allowed_refilling_methods[0] 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): class CounterEditForm(forms.ModelForm):
+8
View File
@@ -881,6 +881,14 @@ class Refilling(models.Model):
return False return False
return user.is_owner(self.counter) and self.payment_method != "CARD" 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): def delete(self, *args, **kwargs):
self.customer.amount -= self.amount self.customer.amount -= self.amount
self.customer.save() self.customer.save()
@@ -6,7 +6,7 @@ const productParsingRegex = /^(\d+x)?(.*)/i;
const codeParsingRegex = / \((\w+)\)$/; const codeParsingRegex = / \((\w+)\)$/;
function parseProduct(query: string): [number, string] { 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]]; return [Number.parseInt(parsed[1] || "1", 10), parsed[2]];
} }
@@ -3,7 +3,6 @@ import { BasketItem } from "#counter:counter/basket";
import type { import type {
CounterConfig, CounterConfig,
CounterItem, CounterItem,
ErrorMessage,
ProductFormula, ProductFormula,
} from "#counter:counter/types"; } from "#counter:counter/types";
import type { CounterProductSelect } from "./components/counter-product-select-index"; 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.codeField.widget.hook("after", "onOptionSelect", () => {
this.handleCode(); this.handleCode();
}); });
@@ -34,14 +33,14 @@ document.addEventListener("alpine:init", () => {
// of a formset so we dynamically apply it here // of a formset so we dynamically apply it here
this.$refs.basketManagementForm this.$refs.basketManagementForm
.querySelector("#id_form-TOTAL_FORMS") .querySelector("#id_form-TOTAL_FORMS")
.setAttribute(":value", "getBasketSize()"); ?.setAttribute(":value", "getBasketSize()");
}, },
removeFromBasket(id: string) { removeFromBasket(id: string) {
delete this.basket[id]; delete this.basket[id];
}, },
addToBasket(id: string, quantity: number): ErrorMessage { addToBasket(id: string, quantity: number) {
const item: BasketItem = const item: BasketItem =
this.basket[id] || new BasketItem(config.products[id], 0); this.basket[id] || new BasketItem(config.products[id], 0);
@@ -50,7 +49,7 @@ document.addEventListener("alpine:init", () => {
if (item.quantity <= 0) { if (item.quantity <= 0) {
delete this.basket[id]; delete this.basket[id];
return ""; return;
} }
this.basket[id] = item; this.basket[id] = item;
@@ -72,7 +71,7 @@ document.addEventListener("alpine:init", () => {
const products = new Set( const products = new Set(
Object.values(this.basket).map((item: BasketItem) => item.product.productId), 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)); return f.products.every((p: number) => products.has(p));
}); });
if (formula === undefined) { 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 // Now that the formula is found, remove the items composing it from the basket
for (const product of formula.products) { 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, ([_, i]: [string, BasketItem]) => i.product.productId === product,
)[0]; );
if (item === undefined) {
continue;
}
const key = item[0];
this.basket[key].quantity -= 1; this.basket[key].quantity -= 1;
if (this.basket[key].quantity <= 0) { if (this.basket[key].quantity <= 0) {
this.removeFromBasket(key); this.removeFromBasket(key);
@@ -92,7 +95,7 @@ document.addEventListener("alpine:init", () => {
const result = Object.values(config.products) const result = Object.values(config.products)
.filter((item: CounterItem) => item.productId === formula.result) .filter((item: CounterItem) => item.productId === formula.result)
.reduce((acc, curr) => (acc.price.amount < curr.price.amount ? acc : curr)); .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( this.alertMessage.display(
interpolate( interpolate(
gettext("Formula %(formula)s applied"), gettext("Formula %(formula)s applied"),
@@ -119,14 +122,18 @@ document.addEventListener("alpine:init", () => {
}, },
onRefillingSuccess(event: CustomEvent) { 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; return;
} }
this.customerBalance += Number.parseFloat( this.customerBalance += Number.parseFloat(
(event.detail.target.querySelector("#id_amount") as HTMLInputElement).value, (event.detail.target.querySelector("#id_amount") as HTMLInputElement).value,
); );
document.getElementById("selling-accordion").setAttribute("open", ""); document.getElementById("selling-accordion")?.setAttribute("open", "");
this.codeField.widget.focus(); this.codeField?.widget.focus();
}, },
finish() { finish() {
@@ -136,7 +143,7 @@ document.addEventListener("alpine:init", () => {
}); });
return; return;
} }
this.$refs.basketForm.submit(); (this.$refs.basketForm as HTMLFormElement).submit();
}, },
cancel() { cancel() {
@@ -144,6 +151,8 @@ document.addEventListener("alpine:init", () => {
}, },
handleCode() { handleCode() {
if (!this.codeField) throw Error("Unexpected null codeField.");
const [quantity, code] = this.codeField.getSelectedProduct() as [number, string]; const [quantity, code] = this.codeField.getSelectedProduct() as [number, string];
if (this.codeField.getOperationCodes().includes(code.toUpperCase())) { if (this.codeField.getOperationCodes().includes(code.toUpperCase())) {
@@ -182,7 +182,7 @@
{% if refilling_fragment %} {% if refilling_fragment %}
<div <div
class="accordion-content" class="accordion-content"
@htmx:after-request="onRefillingSuccess" @htmx:after-swap="onRefillingSuccess"
> >
{{ refilling_fragment }} {{ refilling_fragment }}
</div> </div>
+18 -13
View File
@@ -24,7 +24,7 @@ from django.shortcuts import get_object_or_404, redirect, resolve_url
from django.urls import reverse from django.urls import reverse
from django.utils.safestring import SafeString from django.utils.safestring import SafeString
from django.utils.translation import gettext as _ 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 django.views.generic.detail import SingleObjectMixin
from ninja.main import HttpRequest from ninja.main import HttpRequest
@@ -32,7 +32,14 @@ from core.auth.mixins import CanViewMixin
from core.models import User from core.models import User
from core.views.mixins import FragmentMixin, UseFragmentsMixin from core.views.mixins import FragmentMixin, UseFragmentsMixin
from counter.forms import BasketForm, RefillForm 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.utils import is_logged_in_counter
from counter.views.mixins import CounterTabsMixin from counter.views.mixins import CounterTabsMixin
from counter.views.student_card import StudentCardFormFragment from counter.views.student_card import StudentCardFormFragment
@@ -219,9 +226,10 @@ class CounterClick(
return kwargs return kwargs
class RefillingCreateView(FragmentMixin, FormView): class RefillingCreateView(FragmentMixin, CreateView):
"""This is a fragment only view which integrates with counter_click.jinja""" """This is a fragment only view which integrates with counter_click.jinja"""
model = Refilling
form_class = RefillForm form_class = RefillForm
template_name = "counter/fragments/create_refill.jinja" template_name = "counter/fragments/create_refill.jinja"
@@ -242,23 +250,20 @@ class RefillingCreateView(FragmentMixin, FormView):
): ):
raise PermissionDenied raise PermissionDenied
self.operator = get_operator(request, self.counter, self.customer)
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
def render_fragment(self, request, **kwargs) -> SafeString: def render_fragment(self, request, **kwargs) -> SafeString:
self.customer = kwargs.pop("customer") self.customer = kwargs.pop("customer")
self.counter = kwargs.pop("counter") self.counter = kwargs.pop("counter")
self.object = None
return super().render_fragment(request, **kwargs) return super().render_fragment(request, **kwargs)
def form_valid(self, form): def get_form_kwargs(self):
res = super().form_valid(form) return super().get_form_kwargs() | {
form.clean() "counter": self.counter,
form.instance.counter = self.counter "operator": get_operator(self.request, self.counter, self.customer),
form.instance.operator = self.operator "customer": self.customer,
form.instance.customer = self.customer }
form.instance.save()
return res
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)