Merge pull request #923 from ae-utbm/counter-click-step-2

Casser counter click step 2 : separate refilling from counter clicks with fragments
This commit is contained in:
Bartuccio Antoine 2024-12-17 10:58:34 +01:00 committed by GitHub
commit 6416de237f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 552 additions and 366 deletions

View File

@ -24,6 +24,12 @@
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
PAYMENT_METHOD = [
("CHECK", _("Check")),
("CASH", _("Cash")),
("CARD", _("Credit card")),
]
class CounterConfig(AppConfig):
name = "counter"

View File

@ -111,6 +111,8 @@ class GetUserForm(forms.Form):
class RefillForm(forms.ModelForm):
allowed_refilling_methods = ["CASH", "CARD"]
error_css_class = "error"
required_css_class = "required"
amount = forms.FloatField(
@ -120,6 +122,21 @@ class RefillForm(forms.ModelForm):
class Meta:
model = Refilling
fields = ["amount", "payment_method", "bank"]
widgets = {"payment_method": forms.RadioSelect}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["payment_method"].choices = (
method
for method in self.fields["payment_method"].choices
if method[0] 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]
if "CHECK" not in self.allowed_refilling_methods:
del self.fields["bank"]
class CounterEditForm(forms.ModelForm):

View File

@ -0,0 +1,22 @@
# Generated by Django 4.2.17 on 2024-12-15 22:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("counter", "0026_alter_studentcard_customer"),
]
operations = [
migrations.AlterField(
model_name="refilling",
name="payment_method",
field=models.CharField(
choices=[("CHECK", "Check"), ("CASH", "Cash"), ("CARD", "Credit card")],
default="CARD",
max_length=255,
verbose_name="payment method",
),
),
]

View File

@ -42,7 +42,8 @@ from club.models import Club
from core.fields import ResizedImageField
from core.models import Group, Notification, User
from core.utils import get_start_of_semester
from sith.settings import SITH_COUNTER_OFFICES, SITH_MAIN_CLUB
from counter.apps import PAYMENT_METHOD
from sith.settings import SITH_MAIN_CLUB
from subscription.models import Subscription
@ -558,9 +559,6 @@ class Counter(models.Model):
"""Show if the counter authorize the refilling with physic money."""
if self.type != "BAR":
return False
if self.id in SITH_COUNTER_OFFICES:
# If the counter is either 'AE' or 'BdF', refills are authorized
return True
# at least one of the barmen is in the AE board
ae = Club.objects.get(unix_name=SITH_MAIN_CLUB["unix_name"])
return any(ae.get_membership_for(barman) for barman in self.barmen_list)
@ -650,6 +648,14 @@ class Counter(models.Model):
)
)["total"]
def customer_is_barman(self, customer: Customer | User) -> bool:
"""Check if this counter is a `bar` and if the customer is currently logged in.
This is useful to compute special prices."""
# Customer and User are two different tables,
# but they share the same primary key
return self.type == "BAR" and any(b.pk == customer.pk for b in self.barmen_list)
class RefillingQuerySet(models.QuerySet):
def annotate_total(self) -> Self:
@ -688,8 +694,8 @@ class Refilling(models.Model):
payment_method = models.CharField(
_("payment method"),
max_length=255,
choices=settings.SITH_COUNTER_PAYMENT_METHOD,
default="CASH",
choices=PAYMENT_METHOD,
default="CARD",
)
bank = models.CharField(
_("bank"), max_length=255, choices=settings.SITH_COUNTER_BANK, default="OTHER"

View File

@ -0,0 +1,129 @@
import { exportToHtml } from "#core:utils/globals";
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;
},
onRefillingSuccess(event: CustomEvent) {
if (event.type !== "htmx:after-request" || event.detail.failed) {
return;
}
this.customerBalance += Number.parseFloat(
(event.detail.target.querySelector("#id_amount") as HTMLInputElement).value,
);
document.getElementById("selling-accordion").click();
},
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

@ -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

@ -6,7 +6,7 @@
{% endblock %}
{% block additional_js %}
<script src="{{ static('counter/js/counter_click.js') }}" defer></script>
<script type="module" src="{{ static('bundled/counter/counter-click-index.ts') }}"></script>
{% endblock %}
{% block info_boxes %}
@ -28,16 +28,11 @@
<h5>{% trans %}Customer{% endtrans %}</h5>
{{ user_mini_profile(customer.user) }}
{{ user_subscription(customer.user) }}
<p>{% trans %}Amount: {% endtrans %}{{ customer.amount }} €</p>
{% if counter.type == 'BAR' %}
<h5>{% trans %}Student card{% endtrans %}</h3>
{{ student_card_fragment }}
{% endif %}
<p>{% trans %}Amount: {% endtrans %}<span x-text="customerBalance"></span> €</p>
</div>
<div id="click_form">
<h5>{% trans %}Selling{% endtrans %}</h5>
<div id="click_form" style="width: 20%;">
<h5 id="selling-accordion">{% trans %}Selling{% endtrans %}</h5>
<div>
{% set counter_click_url = url('counter:click', counter_id=counter.id, user_id=customer.user_id) %}
@ -105,17 +100,32 @@
<input type="submit" value="{% trans %}Cancel{% endtrans %}"/>
</form>
</div>
{% if (counter.type == 'BAR' and barmens_can_refill) %}
{% if object.type == "BAR" %}
<h5>{% trans %}Refilling{% endtrans %}</h5>
<div>
<form method="post"
action="{{ url('counter:click', counter_id=counter.id, user_id=customer.user.id) }}">
{% csrf_token %}
{{ refill_form.as_p() }}
<input type="hidden" name="action" value="refill">
<input type="submit" value="{% trans %}Go{% endtrans %}"/>
</form>
</div>
{% if refilling_fragment %}
<div
@htmx:after-request="onRefillingSuccess"
>
{{ refilling_fragment }}
</div>
{% else %}
<div>
<p class="alert alert-yellow">
{% trans trimmed %}
As a barman, you are not able to refill any account on your own.
An admin should be connected on this counter for that.
The customer can refill by using the eboutic.
{% endtrans %}
</p>
</div>
{% endif %}
{% if student_card_fragment %}
<h5>{% trans %}Student card{% endtrans %}</h3>
<div>
{{ student_card_fragment }}
</div>
{% endif %}
{% endif %}
</div>
@ -155,9 +165,6 @@
{% block script %}
{{ super() }}
<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 = {
{%- for p in products -%}
{{ p.id }}: {
@ -176,5 +183,14 @@
},
{%- 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>
{% endblock script %}

View File

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

View File

@ -67,17 +67,22 @@ class TestCounter(TestCase):
{"code": self.richard.customer.account_id, "counter_token": counter_token},
)
counter_url = response.get("location")
response = self.client.get(response.get("location"))
refill_url = reverse(
"counter:refilling_create",
kwargs={"customer_id": self.richard.customer.pk},
)
response = self.client.get(counter_url)
assert ">Richard Batsbak</" in str(response.content)
self.client.post(
counter_url,
refill_url,
{
"action": "refill",
"amount": "5",
"payment_method": "CASH",
"bank": "OTHER",
},
HTTP_REFERER=counter_url,
)
self.client.post(counter_url, "action=code&code=BARB", content_type="text/xml")
self.client.post(
@ -110,15 +115,15 @@ class TestCounter(TestCase):
)
response = self.client.post(
counter_url,
refill_url,
{
"action": "refill",
"amount": "5",
"payment_method": "CASH",
"bank": "OTHER",
},
HTTP_REFERER=counter_url,
)
assert response.status_code == 200
assert response.status_code == 302
self.client.post(
reverse("counter:login", kwargs={"counter_id": self.foyer.id}),
@ -138,17 +143,23 @@ class TestCounter(TestCase):
{"code": self.richard.customer.account_id, "counter_token": counter_token},
)
counter_url = response.get("location")
refill_url = reverse(
"counter:refilling_create",
kwargs={
"customer_id": self.richard.customer.pk,
},
)
response = self.client.post(
counter_url,
refill_url,
{
"action": "refill",
"amount": "5",
"payment_method": "CASH",
"bank": "OTHER",
},
HTTP_REFERER=counter_url,
)
assert response.status_code == 200
assert response.status_code == 403 # Krophil is not board admin
def test_annotate_has_barman_queryset(self):
"""Test if the custom queryset method `annotate_has_barman` works as intended."""

View File

@ -39,7 +39,7 @@ from counter.views.cash import (
CashSummaryListView,
CounterCashSummaryView,
)
from counter.views.click import CounterClick
from counter.views.click import CounterClick, RefillingCreateView
from counter.views.eticket import (
EticketCreateView,
EticketEditView,
@ -60,6 +60,11 @@ from counter.views.student_card import (
urlpatterns = [
path("<int:counter_id>/", CounterMain.as_view(), name="details"),
path("<int:counter_id>/click/<int:user_id>/", CounterClick.as_view(), name="click"),
path(
"refill/<int:customer_id>/",
RefillingCreateView.as_view(),
name="refilling_create",
),
path(
"<int:counter_id>/last_ops/",
CounterLastOperationsView.as_view(),

View File

@ -24,11 +24,13 @@ from django.http import Http404, HttpResponseRedirect, JsonResponse
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView
from django.views.generic import DetailView, FormView
from core.utils import FormFragmentTemplateData
from core.views import CanViewMixin
from counter.forms import RefillForm
from counter.models import Counter, Customer, Product, Selling
from counter.utils import is_logged_in_counter
from counter.views.mixins import CounterTabsMixin
from counter.views.student_card import StudentCardFormView
@ -100,7 +102,6 @@ class CounterClick(CounterTabsMixin, CanViewMixin, DetailView):
request.session["too_young"] = False
request.session["not_allowed"] = False
request.session["no_age"] = False
self.refill_form = None
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
@ -111,7 +112,6 @@ class CounterClick(CounterTabsMixin, CanViewMixin, DetailView):
def post(self, request, *args, **kwargs):
"""Handle the many possibilities of the post request."""
self.object = self.get_object()
self.refill_form = None
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
@ -137,7 +137,7 @@ class CounterClick(CounterTabsMixin, CanViewMixin, DetailView):
request.session["no_age"] = False
if self.object.type != "BAR":
self.operator = request.user
elif self.customer_is_barman():
elif self.object.customer_is_barman(self.customer):
self.operator = self.customer.user
else:
self.operator = self.object.get_random_barman()
@ -148,8 +148,6 @@ class CounterClick(CounterTabsMixin, CanViewMixin, DetailView):
self.add_product(request)
elif action == "del_product":
self.del_product(request)
elif action == "refill":
self.refill(request)
elif action == "code":
return self.parse_code(request)
elif action == "cancel":
@ -159,16 +157,12 @@ class CounterClick(CounterTabsMixin, CanViewMixin, DetailView):
context = self.get_context_data(object=self.object)
return self.render_to_response(context)
def customer_is_barman(self) -> bool:
barmen = self.object.barmen_list
return self.object.type == "BAR" and self.customer.user in barmen
def get_product(self, pid):
return Product.objects.filter(pk=int(pid)).first()
def get_price(self, pid):
p = self.get_product(pid)
if self.customer_is_barman():
if self.object.customer_is_barman(self.customer):
price = p.special_selling_price
else:
price = p.selling_price
@ -333,7 +327,7 @@ class CounterClick(CounterTabsMixin, CanViewMixin, DetailView):
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.customer_is_barman():
if self.object.customer_is_barman(self.customer):
uprice = p.special_selling_price
else:
uprice = p.selling_price
@ -383,24 +377,11 @@ class CounterClick(CounterTabsMixin, CanViewMixin, DetailView):
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):
"""Add customer to the context."""
kwargs = super().get_context_data(**kwargs)
products = self.object.products.select_related("product_type")
if self.customer_is_barman():
if self.object.customer_is_barman(self.customer):
products = products.annotate(price=F("special_selling_price"))
else:
products = products.annotate(price=F("selling_price"))
@ -413,9 +394,75 @@ class CounterClick(CounterTabsMixin, CanViewMixin, DetailView):
)
kwargs["customer"] = self.customer
kwargs["basket_total"] = self.sum_basket(self.request)
kwargs["refill_form"] = self.refill_form or RefillForm()
kwargs["barmens_can_refill"] = self.object.can_refill()
kwargs["student_card_fragment"] = StudentCardFormView.get_template_data(
self.customer
).render(self.request)
if self.object.type == "BAR":
kwargs["student_card_fragment"] = StudentCardFormView.get_template_data(
self.customer
).render(self.request)
if self.object.can_refill():
kwargs["refilling_fragment"] = RefillingCreateView.get_template_data(
self.customer
).render(self.request)
return kwargs
class RefillingCreateView(FormView):
"""This is a fragment only view which integrates with counter_click.jinja"""
form_class = RefillForm
template_name = "counter/fragments/create_refill.jinja"
@classmethod
def get_template_data(
cls, customer: Customer, *, form_instance: form_class | None = None
) -> FormFragmentTemplateData[form_class]:
return FormFragmentTemplateData(
form=form_instance if form_instance else cls.form_class(),
template=cls.template_name,
context={
"action": reverse_lazy(
"counter:refilling_create", kwargs={"customer_id": customer.pk}
),
},
)
def dispatch(self, request, *args, **kwargs):
self.customer: Customer = get_object_or_404(Customer, pk=kwargs["customer_id"])
if not self.customer.can_buy:
raise Http404
if not is_logged_in_counter(request):
raise PermissionDenied
self.counter: Counter = get_object_or_404(
Counter, token=request.session["counter_token"]
)
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()
return super().dispatch(request, *args, **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_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
data = self.get_template_data(self.customer, form_instance=context["form"])
context.update(data.context)
return context
def get_success_url(self, **kwargs):
return self.request.path

View File

@ -70,11 +70,11 @@ class StudentCardFormView(FormView):
@classmethod
def get_template_data(
cls, customer: Customer
) -> FormFragmentTemplateData[StudentCardForm]:
cls, customer: Customer, *, form_instance: form_class | None = None
) -> FormFragmentTemplateData[form_class]:
"""Get necessary data to pre-render the fragment"""
return FormFragmentTemplateData(
form=cls.form_class(),
form=form_instance if form_instance else cls.form_class(),
template=cls.template_name,
context={
"action": reverse(
@ -105,7 +105,7 @@ class StudentCardFormView(FormView):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
data = self.get_template_data(self.customer)
data = self.get_template_data(self.customer, form_instance=context["form"])
context.update(data.context)
return context

File diff suppressed because it is too large Load Diff

View File

@ -432,14 +432,6 @@ SITH_SUBSCRIPTION_LOCATIONS = [
SITH_COUNTER_BARS = [(1, "MDE"), (2, "Foyer"), (35, "La Gommette")]
SITH_COUNTER_OFFICES = {2: "PdF", 1: "AE"}
SITH_COUNTER_PAYMENT_METHOD = [
("CHECK", _("Check")),
("CASH", _("Cash")),
("CARD", _("Credit card")),
]
SITH_COUNTER_BANK = [
("OTHER", "Autre"),
("SOCIETE-GENERALE", "Société générale"),

View File

@ -21,6 +21,7 @@ from django.utils.timezone import localdate
from django.views.generic import CreateView, DetailView, TemplateView
from django.views.generic.edit import FormView
from counter.apps import PAYMENT_METHOD
from subscription.forms import (
SelectionDateForm,
SubscriptionExistingUserForm,
@ -108,6 +109,6 @@ class SubscriptionsStatsView(FormView):
subscription_end__gte=self.end_date, subscription_start__lte=self.start_date
)
kwargs["subscriptions_types"] = settings.SITH_SUBSCRIPTIONS
kwargs["payment_types"] = settings.SITH_COUNTER_PAYMENT_METHOD
kwargs["payment_types"] = PAYMENT_METHOD
kwargs["locations"] = settings.SITH_SUBSCRIPTION_LOCATIONS
return kwargs