diff --git a/counter/api.py b/counter/api.py index f3f0f101..d58f0154 100644 --- a/counter/api.py +++ b/counter/api.py @@ -21,11 +21,12 @@ from ninja_extra import ControllerBase, api_controller, paginate, route from ninja_extra.pagination import PageNumberPaginationExtra from ninja_extra.schemas import PaginatedResponseSchema -from core.api_permissions import CanAccessLookup, CanView, IsRoot -from counter.models import Counter, Product +from core.api_permissions import CanAccessLookup, CanView, IsLoggedInCounter, IsRoot +from counter.models import Counter, Customer, Product from counter.schemas import ( CounterFilterSchema, CounterSchema, + CustomerBalance, ProductSchema, SimplifiedCounterSchema, ) @@ -60,6 +61,13 @@ class CounterController(ControllerBase): 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") class ProductController(ControllerBase): @route.get( diff --git a/counter/schemas.py b/counter/schemas.py index ec1a842d..4fbbc712 100644 --- a/counter/schemas.py +++ b/counter/schemas.py @@ -4,7 +4,7 @@ from annotated_types import MinLen from ninja import Field, FilterSchema, ModelSchema from core.schemas import SimpleUserSchema -from counter.models import Counter, Product +from counter.models import Counter, Customer, Product class CounterSchema(ModelSchema): @@ -16,6 +16,12 @@ class CounterSchema(ModelSchema): fields = ["id", "name", "type", "club", "products"] +class CustomerBalance(ModelSchema): + class Meta: + model = Customer + fields = ["amount"] + + class CounterFilterSchema(FilterSchema): search: Annotated[str, MinLen(1)] = Field(None, q="name__icontains") diff --git a/counter/static/counter/js/counter_click.js b/counter/static/counter/js/counter_click.js deleted file mode 100644 index b0ddb42c..00000000 --- a/counter/static/counter/js/counter_click.js +++ /dev/null @@ -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(); -}); diff --git a/counter/static/webpack/counter/counter-click-index.ts b/counter/static/webpack/counter/counter-click-index.ts new file mode 100644 index 00000000..359573b1 --- /dev/null +++ b/counter/static/webpack/counter/counter-click-index.ts @@ -0,0 +1,131 @@ +import { exportToHtml } from "#core:utils/globals"; +import { customerGetBalance } from "#openapi"; + +interface CounterConfig { + csrfToken: string; + clickApiUrl: string; + sessionBasket: Record; + 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(); +}); diff --git a/counter/templates/counter/counter_click.jinja b/counter/templates/counter/counter_click.jinja index 3c153879..3bcb5df2 100644 --- a/counter/templates/counter/counter_click.jinja +++ b/counter/templates/counter/counter_click.jinja @@ -6,7 +6,7 @@ {% endblock %} {% block additional_js %} - + {% endblock %} {% block info_boxes %} @@ -28,7 +28,7 @@
{% trans %}Customer{% endtrans %}
{{ user_mini_profile(customer.user) }} {{ user_subscription(customer.user) }} -

{% trans %}Amount: {% endtrans %}{{ customer.amount }} €

+

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

{% if counter.type == 'BAR' %}
- {% if (counter.type == 'BAR' and barmens_can_refill) %} + {% if is_reflling_allowed %}
{% trans %}Refilling{% endtrans %}
-
-
- {% csrf_token %} - {{ refill_form.as_p() }} - - -
-
+
{% endif %} @@ -158,9 +155,6 @@ {% block script %} {{ super() }} {% endblock script %} \ No newline at end of file diff --git a/counter/templates/counter/refill_fragment.jinja b/counter/templates/counter/refill_fragment.jinja new file mode 100644 index 00000000..9e812f4a --- /dev/null +++ b/counter/templates/counter/refill_fragment.jinja @@ -0,0 +1,9 @@ +
+ {% csrf_token %} + {{ form.as_p() }} + +
diff --git a/counter/tests/test_counter.py b/counter/tests/test_counter.py index 855b43f6..b3362a8c 100644 --- a/counter/tests/test_counter.py +++ b/counter/tests/test_counter.py @@ -67,13 +67,16 @@ class TestCounter(TestCase): {"code": self.richard.customer.account_id, "counter_token": counter_token}, ) 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")) assert ">Richard Batsbak/stats/", CounterStatView.as_view(), name="stats"), path("/login/", counter_login, name="login"), path("/logout/", counter_logout, name="logout"), + path( + "/refill//", + RefillingCreateFragmentView.as_view(), + name="refilling_create_fragment", + ), + path( + "/card/add//", + StudentCardFormFragmentView.as_view(), + name="add_student_card_fragment", + ), path("eticket//pdf/", EticketPDFView.as_view(), name="eticket_pdf"), path( "customer//card/add/", StudentCardFormView.as_view(), name="add_student_card", ), - path( - "customer//card/add/counter//", - StudentCardFormFragmentView.as_view(), - name="add_student_card_fragment", - ), path( "customer//card/delete//", StudentCardDeleteView.as_view(), diff --git a/counter/views.py b/counter/views.py index 4176e3ba..d8a36f80 100644 --- a/counter/views.py +++ b/counter/views.py @@ -294,7 +294,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 @@ -305,7 +304,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 @@ -342,8 +340,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": @@ -577,19 +573,6 @@ 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) @@ -607,11 +590,119 @@ 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["is_reflling_allowed"] = self.object.can_refill() 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 def counter_login(request: HttpRequest, counter_id: int) -> HttpResponseRedirect: """Log a user in a counter. @@ -1511,52 +1602,3 @@ class StudentCardFormView(AllowFragment, FormView): return reverse_lazy( "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, - }, - )