counter: make click page dynamic to avoid repetitive loading

This makes the whole click page load only once for a normal click
workflow. The current basket is now rendered client side with Vue.JS,
and the backend view is able to answer with JSON if asked to.

This should lighten the workflow a lot on the client side, especially
with poor connectivity, and the server should also feel lighter during
big events, due to far less complex Jinja pages to render.
This commit is contained in:
Skia 2021-09-25 20:48:07 +02:00
parent efb70652af
commit 406380e4f1
5 changed files with 209 additions and 114 deletions

File diff suppressed because one or more lines are too long

View File

@ -1125,7 +1125,7 @@ u, .underline {
overflow: auto; overflow: auto;
} }
#bar_ui { #click_form {
float: left; float: left;
min-width: 57%; min-width: 57%;
} }

View File

@ -1,23 +1,6 @@
{% extends "core/base.jinja" %} {% extends "core/base.jinja" %}
{% from "core/macros.jinja" import user_mini_profile, user_subscription %} {% from "core/macros.jinja" import user_mini_profile, user_subscription %}
{% macro add_product(id, content, class="") %}
<form method="post" action="{{ url('counter:click', counter_id=counter.id, user_id=customer.user.id) }}" class="{{ class }}">
{% csrf_token %}
<input type="hidden" name="action" value="add_product">
<button type="submit" name="product_id" value="{{ id }}"> {{ content|safe }} </button>
</form>
{% endmacro %}
{% macro del_product(id, content, class="") %}
<form method="post" action="{{ url('counter:click', counter_id=counter.id, user_id=customer.user.id) }}" class="{{ class }}">
{% csrf_token %}
<input type="hidden" name="action" value="del_product">
<button type="submit" name="product_id" value="{{ id }}"> {{ content }} </button>
</form>
{% endmacro %}
{% block title %} {% block title %}
{{ counter }} {{ counter }}
{% endblock %} {% endblock %}
@ -58,108 +41,177 @@
{% endif %} {% endif %}
</div> </div>
<div id="bar_ui"> <div id="bar_ui">
<h5>{% trans %}Selling{% endtrans %}</h5> <noscript>
<div> <p class="important">Javascript is required for the counter UI.</p>
<div class="important"> </noscript>
{% if request.session['too_young'] %} <div id="click_form">
<p><strong>{% trans %}Too young for that product{% endtrans %}</strong></p> <h5>{% trans %}Selling{% endtrans %}</h5>
{% endif %} <div>
{% if request.session['not_allowed'] %}
<p><strong>{% trans %}Not allowed for that product{% endtrans %}</strong></p> <div class="important">
{% endif %} <p v-for="error in errors"><strong>{{ error }}</strong></p>
{% if request.session['no_age'] %} </div>
<p><strong>{% trans %}No date of birth provided{% endtrans %}</strong></p>
{% endif %} <form method="post" action="{{ url('counter:click', counter_id=counter.id, user_id=customer.user.id) }}" class="code_form" @submit.prevent="handle_code">
{% if request.session['not_enough'] %} {% csrf_token %}
<p><strong>{% trans %}Not enough money{% endtrans %}</strong></p> <input type="hidden" name="action" value="code">
{% endif %} <input type="input" name="code" value="" class="focus" id="code_field"/>
<input type="submit" value="{% trans %}Go{% endtrans %}" />
</form>
<p>{% trans %}Basket: {% endtrans %}</p>
{% raw %}
<ul>
<li v-for="p_info,p_id in basket">
<form method="post" action="" class="inline del_product_form" @submit.prevent="handle_action">
<input type="hidden" name="csrfmiddlewaretoken" v-bind:value="js_csrf_token">
<input type="hidden" name="action" value="del_product">
<input type="hidden" name="product_id" v-bind:value="p_id">
<button type="submit"> - </button>
</form>
{{ p_info["qty"] + p_info["bonus_qty"] }}
<form method="post" action="" class="inline add_product_form" @submit.prevent="handle_action">
<input type="hidden" name="csrfmiddlewaretoken" v-bind:value="js_csrf_token">
<input type="hidden" name="action" value="add_product">
<input type="hidden" name="product_id" v-bind:value="p_id">
<button type="submit"> + </button>
</form>
{{ products[p_id].name }}: {{ (p_info["qty"]*p_info["price"]/100).toLocaleString(undefined, { minimumFractionDigits: 2 }) }} € <span v-if="p_info['bonus_qty'] > 0">P</span>
</li>
</ul>
<p>
<strong>Total: {{ sum_basket().toLocaleString(undefined, { minimumFractionDigits: 2 }) }} €</strong>
</p>
{% endraw %}
<div class="important">
<p v-for="error in errors"><strong>{{ error }}</strong></p>
</div>
<form method="post" action="{{ url('counter:click', counter_id=counter.id, user_id=customer.user.id) }}">
{% csrf_token %}
<input type="hidden" name="action" value="finish">
<input type="submit" value="{% trans %}Finish{% endtrans %}" />
</form>
<form method="post" action="{{ url('counter:click', counter_id=counter.id, user_id=customer.user.id) }}">
{% csrf_token %}
<input type="hidden" name="action" value="cancel">
<input type="submit" value="{% trans %}Cancel{% endtrans %}" />
</form>
</div> </div>
<form method="post" action="{{ url('counter:click', counter_id=counter.id, user_id=customer.user.id) }}"> {% if counter.type == 'BAR' %}
{% csrf_token %} <h5>{% trans %}Refilling{% endtrans %}</h5>
<input type="hidden" name="action" value="code"> <div>
<input type="input" name="code" value="" class="focus" id="code_field"/> <form method="post" action="{{ url('counter:click', counter_id=counter.id, user_id=customer.user.id) }}">
<input type="submit" value="{% trans %}Go{% endtrans %}" /> {% csrf_token %}
</form> {{ refill_form.as_p() }}
<p>{% trans %}Basket: {% endtrans %}</p> <input type="hidden" name="action" value="refill">
<input type="submit" value="{% trans %}Go{% endtrans %}" />
</form>
</div>
{% endif %}
</div>
<div id="products">
<ul> <ul>
{% for id,infos in request.session['basket']|dictsort %} {% for category in categories.keys() -%}
{% set product = counter.products.filter(id=id).first() %} <li><a href="#cat_{{ category|slugify }}">{{ category }}</a></li>
{% set s = infos['qty'] * infos['price'] / 100 %} {%- endfor %}
<li>{{ del_product(id, '-', "inline") }} {{ infos['qty'] + infos['bonus_qty'] }} {{ add_product(id, '+', "inline") }}
{{ product.name }}: {{ "%0.2f"|format(s) }}
{% if infos['bonus_qty'] %}
P
{% endif %}
</li>
{% endfor %}
</ul> </ul>
<p><strong>{% trans %}Total: {% endtrans %}{{ "%0.2f"|format(basket_total) }} €</strong></p>
<div class="important">
{% if request.session['too_young'] %}
<p><strong>{% trans %}Too young for that product{% endtrans %}</strong></p>
{% endif %}
{% if request.session['not_allowed'] %}
<p><strong>{% trans %}Not allowed for that product{% endtrans %}</strong></p>
{% endif %}
{% if request.session['no_age'] %}
<p><strong>{% trans %}No date of birth provided{% endtrans %}</strong></p>
{% endif %}
{% if request.session['not_enough'] %}
<p><strong>{% trans %}Not enough money{% endtrans %}</strong></p>
{% endif %}
</div>
<form method="post" action="{{ url('counter:click', counter_id=counter.id, user_id=customer.user.id) }}">
{% csrf_token %}
<input type="hidden" name="action" value="finish">
<input type="submit" value="{% trans %}Finish{% endtrans %}" />
</form>
<form method="post" action="{{ url('counter:click', counter_id=counter.id, user_id=customer.user.id) }}">
{% csrf_token %}
<input type="hidden" name="action" value="cancel">
<input type="submit" value="{% trans %}Cancel{% endtrans %}" />
</form>
</div>
{% if counter.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>
{% endif %}
</div>
<div id="products">
<ul>
{% for category in categories.keys() -%} {% for category in categories.keys() -%}
<li><a href="#cat_{{ category|slugify }}">{{ category }}</a></li> <div id="cat_{{ category|slugify }}">
{%- endfor %} <h5>{{ category }}</h5>
</ul> {% for p in categories[category] -%}
{% for category in categories.keys() -%} {% set file = None %}
<div id="cat_{{ category|slugify }}"> {% if p.icon %}
<h5>{{ category }}</h5> {% set file = p.icon.url %}
{% for p in categories[category] -%} {% else %}
{% set file = None %} {% set file = static('core/img/na.gif') %}
{% if p.icon %} {% endif %}
{% set file = p.icon.url %} <form method="post" action="{{ url('counter:click', counter_id=counter.id, user_id=customer.user.id) }}" class="form_button add_product_form" @submit.prevent="handle_action">
{% else %} {% csrf_token %}
{% set file = static('core/img/na.gif') %} <input type="hidden" name="action" value="add_product">
{% endif %} <input type="hidden" name="product_id" value="{{ p.id }}">
{% set prod = '<strong>%s</strong><hr><img src="%s" /><span>%s €<br>%s</span>' % (p.name, file, p.selling_price, p.code) %} <button type="submit"><strong>{{ p.name }}</strong><hr><img src="{{ file }}" /><span>{{ p.selling_price }} €<br>{{ p.code }}</span></button>
{{ add_product(p.id, prod, "form_button") }} </form>
{%- endfor %}
</div>
{%- endfor %} {%- endfor %}
</div> </div>
{%- endfor %}
</div> </div>
{% endblock %} {% endblock %}
{% block script %} {% block script %}
{{ super() }} {{ super() }}
<script src="{{ static('core/js/vue.global.prod.js') }}"></script>
<script> <script>
$( function() { $( function() {
/* Vue.JS dynamic form */
const click_form_vue = Vue.createApp({
data() {
return {
js_csrf_token: "{{ csrf_token }}",
products: {
{% for p in products -%}
{{ p.id }}: {
code: "{{ p.code }}",
name: "{{ p.name }}",
selling_price: "{{ p.selling_price }}",
special_selling_price: "{{ p.special_selling_price }}",
},
{%- endfor %}
},
basket: {{ request.session["basket"]|tojson }},
errors: [],
}
},
methods: {
sum_basket() {
var vm = this;
var total = 0;
for(idx in vm.basket) {
var item = vm.basket[idx];
console.log(item);
total += item["qty"] * item["price"];
}
return total / 100;
},
handle_code(event) {
var vm = this;
var code = $(event.target).find("#code_field").val().toUpperCase();
console.log("Code:");
console.log(code);
if(code == "{% trans %}END{% endtrans %}" || code == "{% trans %}CAN{% endtrans %}") {
$(event.target).submit();
} else {
vm.handle_action(event);
}
},
handle_action(event) {
var vm = this;
var payload = $(event.target).serialize();
$.ajax({
type: 'post',
dataType: 'json',
data: payload,
success: function(response) {
vm.basket = response.basket;
vm.errors = [];
},
error: function(error) {
vm.basket = error.responseJSON.basket;
vm.errors = error.responseJSON.errors;
}
});
$('form.code_form #code_field').val("").focus();
}
}
}).mount('#bar_ui');
/* Autocompletion in the code field */ /* Autocompletion in the code field */
var products_autocomplete = [ var products_autocomplete = [
{% for p in products -%} {% for p in products -%}
@ -196,7 +248,7 @@ $( function() {
}); });
/* Accordion UI between basket and refills */ /* Accordion UI between basket and refills */
$("#bar_ui").accordion({ $("#click_form").accordion({
heightStyle: "content", heightStyle: "content",
activate: function(event, ui){ activate: function(event, ui){
$(".focus").focus(); $(".focus").focus();

View File

@ -68,18 +68,31 @@ class CounterTest(TestCase):
location, location,
{ {
"action": "refill", "action": "refill",
"amount": "10", "amount": "5",
"payment_method": "CASH", "payment_method": "CASH",
"bank": "OTHER", "bank": "OTHER",
}, },
) )
response = self.client.post(location, {"action": "code", "code": "BARB"}) response = self.client.post(location, {"action": "code", "code": "BARB"})
response = self.client.post(location, {"action": "add_product", "product_id": "4"})
response = self.client.post(location, {"action": "del_product", "product_id": "4"})
response = self.client.post(location, {"action": "code", "code": "2xdeco"})
response = self.client.post(location, {"action": "code", "code": "1xbarb"})
response = self.client.post(location, {"action": "code", "code": "fin"}) response = self.client.post(location, {"action": "code", "code": "fin"})
response_get = self.client.get(response.get("location")) response_get = self.client.get(response.get("location"))
response_content = response_get.content.decode("utf-8")
self.assertTrue( self.assertTrue(
"<p>Client : Richard Batsbak - Nouveau montant : 8.30" "<li>2 x Barbar"
in str(response_get.content) in str(response_content)
)
self.assertTrue(
"<li>2 x Déconsigne Eco-cup"
in str(response_content)
)
self.assertTrue(
"<p>Client : Richard Batsbak - Nouveau montant : 3.60"
in str(response_content)
) )

View File

@ -38,7 +38,7 @@ from django.views.generic.edit import (
from django.forms.models import modelform_factory from django.forms.models import modelform_factory
from django.forms import CheckboxSelectMultiple from django.forms import CheckboxSelectMultiple
from django.urls import reverse_lazy, reverse from django.urls import reverse_lazy, reverse
from django.http import HttpResponseRedirect, HttpResponse from django.http import HttpResponseRedirect, HttpResponse, JsonResponse
from django.utils import timezone from django.utils import timezone
from django import forms from django import forms
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
@ -48,6 +48,7 @@ from django.db import DataError, transaction, models
import re import re
import pytz import pytz
from datetime import date, timedelta, datetime from datetime import date, timedelta, datetime
from http import HTTPStatus
from ajax_select.fields import AutoCompleteSelectField, AutoCompleteSelectMultipleField from ajax_select.fields import AutoCompleteSelectField, AutoCompleteSelectMultipleField
from ajax_select import make_ajax_field from ajax_select import make_ajax_field
@ -357,6 +358,34 @@ class CounterClick(CounterTabsMixin, CanViewMixin, DetailView):
pk_url_kwarg = "counter_id" pk_url_kwarg = "counter_id"
current_tab = "counter" current_tab = "counter"
def render_to_response(self, *args, **kwargs):
if self.request.is_ajax(): # JSON response for AJAX requests
response = {"errors": []}
status = HTTPStatus.OK
if self.request.session["too_young"]:
response["errors"].append(_("Too young for that product"))
status = HTTPStatus.UNAVAILABLE_FOR_LEGAL_REASONS
if self.request.session["not_allowed"]:
response["errors"].append(_("Not allowed for that product"))
status = HTTPStatus.FORBIDDEN
if self.request.session["no_age"]:
response["errors"].append(_("No date of birth provided"))
status = HTTPStatus.UNAVAILABLE_FOR_LEGAL_REASONS
if self.request.session["not_enough"]:
response["errors"].append(_("Not enough money"))
status = HTTPStatus.PAYMENT_REQUIRED
if len(response["errors"]) > 1:
status = HTTPStatus.BAD_REQUEST
response["basket"] = self.request.session["basket"]
return JsonResponse(response, status=status)
else: # Standard HTML page
return super().render_to_response(*args, **kwargs)
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
self.customer = get_object_or_404(Customer, user__id=self.kwargs["user_id"]) self.customer = get_object_or_404(Customer, user__id=self.kwargs["user_id"])
obj = self.get_object() obj = self.get_object()