Convert customer refill to a fragment view

This commit is contained in:
Antoine Bartuccio 2024-11-15 02:23:27 +01:00
parent 55fb89a63e
commit db5d8a8611
9 changed files with 312 additions and 185 deletions

View File

@ -21,11 +21,12 @@ from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.pagination import PageNumberPaginationExtra from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.schemas import PaginatedResponseSchema from ninja_extra.schemas import PaginatedResponseSchema
from core.api_permissions import CanAccessLookup, CanView, IsRoot from core.api_permissions import CanAccessLookup, CanView, IsLoggedInCounter, IsRoot
from counter.models import Counter, Product from counter.models import Counter, Customer, Product
from counter.schemas import ( from counter.schemas import (
CounterFilterSchema, CounterFilterSchema,
CounterSchema, CounterSchema,
CustomerBalance,
ProductSchema, ProductSchema,
SimplifiedCounterSchema, SimplifiedCounterSchema,
) )
@ -60,6 +61,13 @@ class CounterController(ControllerBase):
return filters.filter(Counter.objects.all()) return filters.filter(Counter.objects.all())
@api_controller("/customer")
class CustomerController(ControllerBase):
@route.get("/balance", response=CustomerBalance, permissions=[IsLoggedInCounter])
def get_balance(self, customer_id: int):
return self.get_object_or_exception(Customer, pk=customer_id)
@api_controller("/product") @api_controller("/product")
class ProductController(ControllerBase): class ProductController(ControllerBase):
@route.get( @route.get(

View File

@ -4,7 +4,7 @@ from annotated_types import MinLen
from ninja import Field, FilterSchema, ModelSchema from ninja import Field, FilterSchema, ModelSchema
from core.schemas import SimpleUserSchema from core.schemas import SimpleUserSchema
from counter.models import Counter, Product from counter.models import Counter, Customer, Product
class CounterSchema(ModelSchema): class CounterSchema(ModelSchema):
@ -16,6 +16,12 @@ class CounterSchema(ModelSchema):
fields = ["id", "name", "type", "club", "products"] fields = ["id", "name", "type", "club", "products"]
class CustomerBalance(ModelSchema):
class Meta:
model = Customer
fields = ["amount"]
class CounterFilterSchema(FilterSchema): class CounterFilterSchema(FilterSchema):
search: Annotated[str, MinLen(1)] = Field(None, q="name__icontains") search: Annotated[str, MinLen(1)] = Field(None, q="name__icontains")

View File

@ -1,86 +0,0 @@
document.addEventListener("alpine:init", () => {
Alpine.data("counter", () => ({
// biome-ignore lint/correctness/noUndeclaredVariables: defined in counter_click.jinja
basket: sessionBasket,
errors: [],
sumBasket() {
if (!this.basket || Object.keys(this.basket).length === 0) {
return 0;
}
const total = Object.values(this.basket).reduce(
(acc, cur) => acc + cur.qty * cur.price,
0,
);
return total / 100;
},
async handleCode(event) {
const code = $(event.target).find("#code_field").val().toUpperCase();
if (["FIN", "ANN"].includes(code)) {
$(event.target).submit();
} else {
await this.handleAction(event);
}
},
async handleAction(event) {
const payload = $(event.target).serialize();
// biome-ignore lint/correctness/noUndeclaredVariables: defined in counter_click.jinja
const request = new Request(clickApiUrl, {
method: "POST",
body: payload,
headers: {
// biome-ignore lint/style/useNamingConvention: this goes into http headers
Accept: "application/json",
// biome-ignore lint/correctness/noUndeclaredVariables: defined in counter_click.jinja
"X-CSRFToken": csrfToken,
},
});
const response = await fetch(request);
const json = await response.json();
this.basket = json.basket;
this.errors = json.errors;
$("form.code_form #code_field").val("").focus();
},
}));
});
$(() => {
/* Autocompletion in the code field */
const codeField = $("#code_field");
let quantity = "";
codeField.autocomplete({
select: (event, ui) => {
event.preventDefault();
codeField.val(quantity + ui.item.value);
},
focus: (event, ui) => {
event.preventDefault();
codeField.val(quantity + ui.item.value);
},
source: (request, response) => {
// biome-ignore lint/performance/useTopLevelRegex: performance impact is minimal
const res = /^(\d+x)?(.*)/i.exec(request.term);
quantity = res[1] || "";
const search = res[2];
const matcher = new RegExp($.ui.autocomplete.escapeRegex(search), "i");
response(
// biome-ignore lint/correctness/noUndeclaredVariables: defined in counter_click.jinja
$.grep(productsAutocomplete, (value) => {
return matcher.test(value.tags);
}),
);
},
});
/* Accordion UI between basket and refills */
$("#click_form").accordion({
heightStyle: "content",
activate: () => $(".focus").focus(),
});
$("#products").tabs();
codeField.focus();
});

View File

@ -0,0 +1,131 @@
import { exportToHtml } from "#core:utils/globals";
import { customerGetBalance } from "#openapi";
interface CounterConfig {
csrfToken: string;
clickApiUrl: string;
sessionBasket: Record<number, BasketItem>;
customerBalance: number;
customerId: number;
}
interface BasketItem {
// biome-ignore lint/style/useNamingConvention: talking with python
bonus_qty: number;
price: number;
qty: number;
}
exportToHtml("loadCounter", (config: CounterConfig) => {
document.addEventListener("alpine:init", () => {
Alpine.data("counter", () => ({
basket: config.sessionBasket,
errors: [],
customerBalance: config.customerBalance,
sumBasket() {
if (!this.basket || Object.keys(this.basket).length === 0) {
return 0;
}
const total = Object.values(this.basket).reduce(
(acc: number, cur: BasketItem) => acc + cur.qty * cur.price,
0,
) as number;
return total / 100;
},
async updateBalance() {
this.customerBalance = (
await customerGetBalance({
query: {
// biome-ignore lint/style/useNamingConvention: api is in snake_case
customer_id: config.customerId,
},
})
).data.amount;
},
async handleCode(event: SubmitEvent) {
const code = (
$(event.target).find("#code_field").val() as string
).toUpperCase();
if (["FIN", "ANN"].includes(code)) {
$(event.target).submit();
} else {
await this.handleAction(event);
}
},
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,
},
});
const response = await fetch(request);
const json = await response.json();
this.basket = json.basket;
this.errors = json.errors;
$("form.code_form #code_field").val("").focus();
},
}));
});
});
interface Product {
value: string;
label: string;
tags: string;
}
declare global {
const productsAutocomplete: Product[];
}
$(() => {
/* Autocompletion in the code field */
// biome-ignore lint/suspicious/noExplicitAny: dealing with legacy jquery
const codeField: any = $("#code_field");
let quantity = "";
codeField.autocomplete({
// biome-ignore lint/suspicious/noExplicitAny: dealing with legacy jquery
select: (event: any, ui: any) => {
event.preventDefault();
codeField.val(quantity + ui.item.value);
},
// biome-ignore lint/suspicious/noExplicitAny: dealing with legacy jquery
focus: (event: any, ui: any) => {
event.preventDefault();
codeField.val(quantity + ui.item.value);
},
// biome-ignore lint/suspicious/noExplicitAny: dealing with legacy jquery
source: (request: any, response: any) => {
// biome-ignore lint/performance/useTopLevelRegex: performance impact is minimal
const res = /^(\d+x)?(.*)/i.exec(request.term);
quantity = res[1] || "";
const search = res[2];
// biome-ignore lint/suspicious/noExplicitAny: dealing with legacy jquery
const matcher = new RegExp(($ as any).ui.autocomplete.escapeRegex(search), "i");
response(
$.grep(productsAutocomplete, (value: Product) => {
return matcher.test(value.tags);
}),
);
},
});
/* Accordion UI between basket and refills */
// biome-ignore lint/suspicious/noExplicitAny: dealing with legacy jquery
($("#click_form") as any).accordion({
heightStyle: "content",
activate: () => $(".focus").focus(),
});
// biome-ignore lint/suspicious/noExplicitAny: dealing with legacy jquery
($("#products") as any).tabs();
codeField.focus();
});

View File

@ -6,7 +6,7 @@
{% endblock %} {% endblock %}
{% block additional_js %} {% block additional_js %}
<script src="{{ static('counter/js/counter_click.js') }}" defer></script> <script src="{{ static('webpack/counter/counter-click-index.ts') }}" defer></script>
{% endblock %} {% endblock %}
{% block info_boxes %} {% block info_boxes %}
@ -28,7 +28,7 @@
<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 %}{{ customer.amount }} €</p> <p>{% trans %}Amount: {% endtrans %}<span x-text="customerBalance"></span> €</p>
{% if counter.type == 'BAR' %} {% if counter.type == 'BAR' %}
<div <div
@ -108,17 +108,14 @@
<input type="submit" value="{% trans %}Cancel{% endtrans %}"/> <input type="submit" value="{% trans %}Cancel{% endtrans %}"/>
</form> </form>
</div> </div>
{% if (counter.type == 'BAR' and barmens_can_refill) %} {% if is_reflling_allowed %}
<h5>{% trans %}Refilling{% endtrans %}</h5> <h5>{% trans %}Refilling{% endtrans %}</h5>
<div> <div
<form method="post" @htmx:after-request="updateBalance()"
action="{{ url('counter:click', counter_id=counter.id, user_id=customer.user.id) }}"> hx-get="{{ url('counter:refilling_create_fragment', counter_id=counter.id, customer_id=customer.pk) }}"
{% csrf_token %} hx-trigger="load"
{{ refill_form.as_p() }} hx-swap="innerHTML"
<input type="hidden" name="action" value="refill"> ></div>
<input type="submit" value="{% trans %}Go{% endtrans %}"/>
</form>
</div>
{% endif %} {% endif %}
</div> </div>
@ -158,9 +155,6 @@
{% block script %} {% block script %}
{{ super() }} {{ super() }}
<script> <script>
const csrfToken = "{{ csrf_token }}";
const clickApiUrl = "{{ url('counter:click', counter_id=counter.id, user_id=customer.user.id) }}";
const sessionBasket = {{ request.session["basket"]|tojson }};
const products = { const products = {
{%- for p in products -%} {%- for p in products -%}
{{ p.id }}: { {{ p.id }}: {
@ -179,5 +173,14 @@
}, },
{%- endfor %} {%- 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 }},
customerId: {{ customer.pk }},
});
});
</script> </script>
{% endblock script %} {% endblock script %}

View File

@ -0,0 +1,9 @@
<form
hx-trigger="submit"
hx-post="{{ action }}"
hx-swap="outerHTML"
>
{% csrf_token %}
{{ form.as_p() }}
<button>{% trans %}Go{% endtrans %}</button>
</form>

View File

@ -67,13 +67,16 @@ class TestCounter(TestCase):
{"code": self.richard.customer.account_id, "counter_token": counter_token}, {"code": self.richard.customer.account_id, "counter_token": counter_token},
) )
counter_url = response.get("location") counter_url = response.get("location")
refill_url = reverse(
"counter:refilling_create_fragment",
kwargs={"counter_id": self.mde.id, "customer_id": self.richard.customer.pk},
)
response = self.client.get(response.get("location")) response = self.client.get(response.get("location"))
assert ">Richard Batsbak</" in str(response.content) assert ">Richard Batsbak</" in str(response.content)
self.client.post( self.client.post(
counter_url, refill_url,
{ {
"action": "refill",
"amount": "5", "amount": "5",
"payment_method": "CASH", "payment_method": "CASH",
"bank": "OTHER", "bank": "OTHER",
@ -110,15 +113,14 @@ class TestCounter(TestCase):
) )
response = self.client.post( response = self.client.post(
counter_url, refill_url,
{ {
"action": "refill",
"amount": "5", "amount": "5",
"payment_method": "CASH", "payment_method": "CASH",
"bank": "OTHER", "bank": "OTHER",
}, },
) )
assert response.status_code == 200 assert response.status_code == 302
self.client.post( self.client.post(
reverse("counter:login", kwargs={"counter_id": self.foyer.id}), reverse("counter:login", kwargs={"counter_id": self.foyer.id}),
@ -138,17 +140,23 @@ class TestCounter(TestCase):
{"code": self.richard.customer.account_id, "counter_token": counter_token}, {"code": self.richard.customer.account_id, "counter_token": counter_token},
) )
counter_url = response.get("location") counter_url = response.get("location")
refill_url = reverse(
"counter:refilling_create_fragment",
kwargs={
"counter_id": self.foyer.id,
"customer_id": self.richard.customer.pk,
},
)
response = self.client.post( response = self.client.post(
counter_url, refill_url,
{ {
"action": "refill",
"amount": "5", "amount": "5",
"payment_method": "CASH", "payment_method": "CASH",
"bank": "OTHER", "bank": "OTHER",
}, },
) )
assert response.status_code == 200 assert response.status_code == 302
def test_annotate_has_barman_queryset(self): def test_annotate_has_barman_queryset(self):
"""Test if the custom queryset method `annotate_has_barman` works as intended.""" """Test if the custom queryset method `annotate_has_barman` works as intended."""

View File

@ -42,6 +42,7 @@ from counter.views import (
ProductTypeCreateView, ProductTypeCreateView,
ProductTypeEditView, ProductTypeEditView,
ProductTypeListView, ProductTypeListView,
RefillingCreateFragmentView,
RefillingDeleteView, RefillingDeleteView,
SellingDeleteView, SellingDeleteView,
StudentCardDeleteView, StudentCardDeleteView,
@ -68,17 +69,22 @@ urlpatterns = [
path("<int:counter_id>/stats/", CounterStatView.as_view(), name="stats"), path("<int:counter_id>/stats/", CounterStatView.as_view(), name="stats"),
path("<int:counter_id>/login/", counter_login, name="login"), path("<int:counter_id>/login/", counter_login, name="login"),
path("<int:counter_id>/logout/", counter_logout, name="logout"), path("<int:counter_id>/logout/", counter_logout, name="logout"),
path(
"<int:counter_id>/refill/<int:customer_id>/",
RefillingCreateFragmentView.as_view(),
name="refilling_create_fragment",
),
path(
"<int:counter_id>/card/add/<int:customer_id>/",
StudentCardFormFragmentView.as_view(),
name="add_student_card_fragment",
),
path("eticket/<int:selling_id>/pdf/", EticketPDFView.as_view(), name="eticket_pdf"), path("eticket/<int:selling_id>/pdf/", EticketPDFView.as_view(), name="eticket_pdf"),
path( path(
"customer/<int:customer_id>/card/add/", "customer/<int:customer_id>/card/add/",
StudentCardFormView.as_view(), StudentCardFormView.as_view(),
name="add_student_card", name="add_student_card",
), ),
path(
"customer/<int:customer_id>/card/add/counter/<int:counter_id>/",
StudentCardFormFragmentView.as_view(),
name="add_student_card_fragment",
),
path( path(
"customer/<int:customer_id>/card/delete/<int:card_id>/", "customer/<int:customer_id>/card/delete/<int:card_id>/",
StudentCardDeleteView.as_view(), StudentCardDeleteView.as_view(),

View File

@ -294,7 +294,6 @@ class CounterClick(CounterTabsMixin, CanViewMixin, DetailView):
request.session["too_young"] = False request.session["too_young"] = False
request.session["not_allowed"] = False request.session["not_allowed"] = False
request.session["no_age"] = False request.session["no_age"] = False
self.refill_form = None
ret = super().get(request, *args, **kwargs) ret = super().get(request, *args, **kwargs)
if (self.object.type != "BAR" and not request.user.is_authenticated) or ( if (self.object.type != "BAR" and not request.user.is_authenticated) or (
self.object.type == "BAR" and len(self.object.barmen_list) == 0 self.object.type == "BAR" and len(self.object.barmen_list) == 0
@ -305,7 +304,6 @@ class CounterClick(CounterTabsMixin, CanViewMixin, DetailView):
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
"""Handle the many possibilities of the post request.""" """Handle the many possibilities of the post request."""
self.object = self.get_object() self.object = self.get_object()
self.refill_form = None
if (self.object.type != "BAR" and not request.user.is_authenticated) or ( if (self.object.type != "BAR" and not request.user.is_authenticated) or (
self.object.type == "BAR" and len(self.object.barmen_list) < 1 self.object.type == "BAR" and len(self.object.barmen_list) < 1
): # Check that at least one barman is logged in ): # Check that at least one barman is logged in
@ -342,8 +340,6 @@ class CounterClick(CounterTabsMixin, CanViewMixin, DetailView):
self.add_product(request) self.add_product(request)
elif action == "del_product": elif action == "del_product":
self.del_product(request) self.del_product(request)
elif action == "refill":
self.refill(request)
elif action == "code": elif action == "code":
return self.parse_code(request) return self.parse_code(request)
elif action == "cancel": elif action == "cancel":
@ -577,19 +573,6 @@ class CounterClick(CounterTabsMixin, CanViewMixin, DetailView):
reverse_lazy("counter:details", args=self.args, kwargs=kwargs) reverse_lazy("counter:details", args=self.args, kwargs=kwargs)
) )
def refill(self, request):
"""Refill the customer's account."""
if not self.object.can_refill():
raise PermissionDenied
form = RefillForm(request.POST)
if form.is_valid():
form.instance.counter = self.object
form.instance.operator = self.operator
form.instance.customer = self.customer
form.instance.save()
else:
self.refill_form = form
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)
@ -607,11 +590,119 @@ class CounterClick(CounterTabsMixin, CanViewMixin, DetailView):
) )
kwargs["customer"] = self.customer kwargs["customer"] = self.customer
kwargs["basket_total"] = self.sum_basket(self.request) kwargs["basket_total"] = self.sum_basket(self.request)
kwargs["refill_form"] = self.refill_form or RefillForm() kwargs["is_reflling_allowed"] = self.object.can_refill()
kwargs["barmens_can_refill"] = self.object.can_refill()
return kwargs return kwargs
class RefillingCreateFragmentView(FormView):
"""This is a fragment only view which integrates with counter_click.jinja"""
form_class = RefillForm
template_name = "counter/refill_fragment.jinja"
def dispatch(self, request, *args, **kwargs):
self.counter = get_object_or_404(
Counter.objects.annotate_is_open(), pk=kwargs["counter_id"]
)
self.customer = get_object_or_404(Customer, pk=kwargs["customer_id"])
if not self.customer.can_buy:
raise Http404
if not (
self.counter.can_refill()
and "counter_token" in request.session
and request.session["counter_token"] == self.counter.token
and self.counter.is_open
):
raise PermissionDenied
if self.customer_is_barman():
self.operator = self.customer.user
else:
self.operator = self.counter.get_random_barman()
return super().dispatch(request, *args, **kwargs)
def customer_is_barman(self) -> bool:
barmen = self.counter.barmen_list
return self.counter.type == "BAR" and self.customer.user in barmen
def form_valid(self, form):
form.clean()
res = super(FormView, self).form_valid(form)
form.instance.counter = self.counter
form.instance.operator = self.operator
form.instance.customer = self.customer
form.instance.save()
return res
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["counter"] = self.counter
context["customer"] = self.customer
context["action"] = self.request.path
context["student_cards"] = self.customer.student_cards.all()
return context
def get_success_url(self, **kwargs):
return reverse_lazy(
"counter:refilling_create_fragment",
kwargs={
"customer_id": self.customer.pk,
"counter_id": self.counter.id,
},
)
class StudentCardFormFragmentView(FormView):
"""
Add a new student card from a counter
This is a fragment only view which integrates with counter_click.jinja
"""
form_class = StudentCardForm
template_name = "counter/add_student_card_fragment.jinja"
def dispatch(self, request, *args, **kwargs):
self.counter = get_object_or_404(
Counter.objects.annotate_is_open(), pk=kwargs["counter_id"]
)
self.customer = get_object_or_404(
Customer.objects.prefetch_related("student_cards"), pk=kwargs["customer_id"]
)
if not (
self.counter.type == "BAR"
and "counter_token" in request.session
and request.session["counter_token"] == self.counter.token
and self.counter.is_open
):
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
def form_valid(self, form):
data = form.clean()
res = super(FormView, self).form_valid(form)
StudentCard(customer=self.customer, uid=data["uid"]).save()
return res
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["counter"] = self.counter
context["customer"] = self.customer
context["action"] = self.request.path
context["student_cards"] = self.customer.student_cards.all()
return context
def get_success_url(self, **kwargs):
return reverse_lazy(
"counter:add_student_card_fragment",
kwargs={
"customer_id": self.customer.pk,
"counter_id": self.counter.id,
},
)
@require_POST @require_POST
def counter_login(request: HttpRequest, counter_id: int) -> HttpResponseRedirect: def counter_login(request: HttpRequest, counter_id: int) -> HttpResponseRedirect:
"""Log a user in a counter. """Log a user in a counter.
@ -1511,52 +1602,3 @@ class StudentCardFormView(AllowFragment, FormView):
return reverse_lazy( return reverse_lazy(
"core:user_prefs", kwargs={"user_id": self.customer.user.pk} "core:user_prefs", kwargs={"user_id": self.customer.user.pk}
) )
class StudentCardFormFragmentView(FormView):
"""
Add a new student card from a counter
This is a fragment only view which integrates with counter_click.jinja
"""
form_class = StudentCardForm
template_name = "counter/add_student_card_fragment.jinja"
def dispatch(self, request, *args, **kwargs):
self.counter = get_object_or_404(
Counter.objects.annotate_is_open(), pk=kwargs["counter_id"]
)
self.customer = get_object_or_404(
Customer.objects.prefetch_related("student_cards"), pk=kwargs["customer_id"]
)
if not (
self.counter.type == "BAR"
and "counter_token" in request.session
and request.session["counter_token"] == self.counter.token
and self.counter.is_open
):
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
def form_valid(self, form):
data = form.clean()
res = super(FormView, self).form_valid(form)
StudentCard(customer=self.customer, uid=data["uid"]).save()
return res
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["counter"] = self.counter
context["customer"] = self.customer
context["action"] = self.request.path
context["student_cards"] = self.customer.student_cards.all()
return context
def get_success_url(self, **kwargs):
return reverse_lazy(
"counter:add_student_card_fragment",
kwargs={
"customer_id": self.customer.pk,
"counter_id": self.counter.id,
},
)