Merge pull request #959 from ae-utbm/counter-click-step-4

Make counter click client side first
This commit is contained in:
Bartuccio Antoine 2024-12-27 22:06:35 +01:00 committed by GitHub
commit 11702d3d7c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1464 additions and 885 deletions

View File

@ -578,14 +578,6 @@ class User(AbstractUser):
return "%s (%s)" % (self.get_full_name(), self.nick_name) return "%s (%s)" % (self.get_full_name(), self.nick_name)
return self.get_full_name() return self.get_full_name()
def get_age(self):
"""Returns the age."""
today = timezone.now()
born = self.date_of_birth
return (
today.year - born.year - ((today.month, today.day) < (born.month, born.day))
)
def get_family( def get_family(
self, self,
godfathers_depth: NonNegativeInt = 4, godfathers_depth: NonNegativeInt = 4,

View File

@ -208,6 +208,7 @@ body {
a.btn { a.btn {
display: inline-block; display: inline-block;
} }
.btn { .btn {
font-size: 15px; font-size: 15px;
font-weight: normal; font-weight: normal;
@ -336,7 +337,8 @@ body {
margin-left: -125px; margin-left: -125px;
box-sizing: border-box; box-sizing: border-box;
position: fixed; position: fixed;
z-index: 1; z-index: 10;
/* to get on top of tomselect */
left: 50%; left: 50%;
top: 60px; top: 60px;
text-align: center; text-align: center;
@ -431,12 +433,17 @@ body {
flex-wrap: wrap; flex-wrap: wrap;
$col-gap: 1rem; $col-gap: 1rem;
$row-gap: 0.5rem;
&.gap { &.gap {
column-gap: var($col-gap); column-gap: var($col-gap);
row-gap: var($row-gap);
} }
@for $i from 2 through 5 { @for $i from 2 through 5 {
&.gap-#{$i}x { &.gap-#{$i}x {
column-gap: $i * $col-gap; column-gap: $i * $col-gap;
row-gap: $i * $row-gap;
} }
} }
} }
@ -1242,40 +1249,6 @@ u,
text-decoration: underline; text-decoration: underline;
} }
#bar-ui {
padding: 0.4em;
display: flex;
flex-wrap: wrap;
flex-direction: row-reverse;
#products {
flex-basis: 100%;
margin: 0.2em;
overflow: auto;
}
#click_form {
flex: auto;
margin: 0.2em;
}
#user_info {
flex: auto;
padding: 0.5em;
margin: 0.2em;
height: 100%;
background: $secondary-neutral-light-color;
img {
max-width: 70%;
}
input {
background: white;
}
}
}
/*-----------------------------USER PROFILE----------------------------*/ /*-----------------------------USER PROFILE----------------------------*/
.user_mini_profile { .user_mini_profile {

View File

@ -60,7 +60,7 @@
{% endif %} {% endif %}
{% if user.date_of_birth %} {% if user.date_of_birth %}
<div class="user_mini_profile_dob"> <div class="user_mini_profile_dob">
{{ user.date_of_birth|date("d/m/Y") }} ({{ user.get_age() }}) {{ user.date_of_birth|date("d/m/Y") }} ({{ user.age }})
</div> </div>
{% endif %} {% endif %}
</div> </div>

View File

@ -87,7 +87,7 @@ class GetUserForm(forms.Form):
def clean(self): def clean(self):
cleaned_data = super().clean() cleaned_data = super().clean()
cus = None customer = None
if cleaned_data["code"] != "": if cleaned_data["code"] != "":
if len(cleaned_data["code"]) == StudentCard.UID_SIZE: if len(cleaned_data["code"]) == StudentCard.UID_SIZE:
card = ( card = (
@ -96,17 +96,18 @@ class GetUserForm(forms.Form):
.first() .first()
) )
if card is not None: if card is not None:
cus = card.customer customer = card.customer
if cus is None: if customer is None:
cus = Customer.objects.filter( customer = Customer.objects.filter(
account_id__iexact=cleaned_data["code"] account_id__iexact=cleaned_data["code"]
).first() ).first()
elif cleaned_data["id"] is not None: elif cleaned_data["id"]:
cus = Customer.objects.filter(user=cleaned_data["id"]).first() customer = Customer.objects.filter(user=cleaned_data["id"]).first()
if cus is None or not cus.can_buy:
if customer is None or not customer.can_buy:
raise forms.ValidationError(_("User not found")) raise forms.ValidationError(_("User not found"))
cleaned_data["user_id"] = cus.user.id cleaned_data["user_id"] = customer.user.id
cleaned_data["user"] = cus.user cleaned_data["user"] = customer.user
return cleaned_data return cleaned_data

View File

@ -0,0 +1,17 @@
# Generated by Django 4.2.17 on 2024-12-22 22:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("counter", "0028_alter_producttype_comment_and_more"),
]
operations = [
migrations.AlterField(
model_name="selling",
name="label",
field=models.CharField(max_length=128, verbose_name="label"),
),
]

View File

@ -21,7 +21,7 @@ import string
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from datetime import timezone as tz from datetime import timezone as tz
from decimal import Decimal from decimal import Decimal
from typing import Self, Tuple from typing import Self
from dict2xml import dict2xml from dict2xml import dict2xml
from django.conf import settings from django.conf import settings
@ -138,7 +138,7 @@ class Customer(models.Model):
return (date.today() - subscription.subscription_end) < timedelta(days=90) return (date.today() - subscription.subscription_end) < timedelta(days=90)
@classmethod @classmethod
def get_or_create(cls, user: User) -> Tuple[Customer, bool]: def get_or_create(cls, user: User) -> tuple[Customer, bool]:
"""Work in pretty much the same way as the usual get_or_create method, """Work in pretty much the same way as the usual get_or_create method,
but with the default field replaced by some under the hood. but with the default field replaced by some under the hood.
@ -327,6 +327,8 @@ class ProductType(OrderedModel):
class Product(models.Model): class Product(models.Model):
"""A product, with all its related information.""" """A product, with all its related information."""
QUANTITY_FOR_TRAY_PRICE = 6
name = models.CharField(_("name"), max_length=64) name = models.CharField(_("name"), max_length=64)
description = models.TextField(_("description"), default="") description = models.TextField(_("description"), default="")
product_type = models.ForeignKey( product_type = models.ForeignKey(
@ -525,7 +527,7 @@ class Counter(models.Model):
if user.is_anonymous: if user.is_anonymous:
return False return False
mem = self.club.get_membership_for(user) mem = self.club.get_membership_for(user)
if mem and mem.role >= 7: if mem and mem.role >= settings.SITH_CLUB_ROLES_ID["Treasurer"]:
return True return True
return user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID) return user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)
@ -657,6 +659,34 @@ class Counter(models.Model):
# but they share the same primary key # but they share the same primary key
return self.type == "BAR" and any(b.pk == customer.pk for b in self.barmen_list) return self.type == "BAR" and any(b.pk == customer.pk for b in self.barmen_list)
def get_products_for(self, customer: Customer) -> list[Product]:
"""
Get all allowed products for the provided customer on this counter
Prices will be annotated
"""
products = self.products.select_related("product_type").prefetch_related(
"buying_groups"
)
# Only include age appropriate products
age = customer.user.age
if customer.user.is_banned_alcohol:
age = min(age, 17)
products = products.filter(limit_age__lte=age)
# Compute special price for customer if he is a barmen on that bar
if self.customer_is_barman(customer):
products = products.annotate(price=F("special_selling_price"))
else:
products = products.annotate(price=F("selling_price"))
return [
product
for product in products.all()
if product.can_be_sold_to(customer.user)
]
class RefillingQuerySet(models.QuerySet): class RefillingQuerySet(models.QuerySet):
def annotate_total(self) -> Self: def annotate_total(self) -> Self:
@ -761,7 +791,8 @@ class SellingQuerySet(models.QuerySet):
class Selling(models.Model): class Selling(models.Model):
"""Handle the sellings.""" """Handle the sellings."""
label = models.CharField(_("label"), max_length=64) # We make sure that sellings have a way begger label than any product name is allowed to
label = models.CharField(_("label"), max_length=128)
product = models.ForeignKey( product = models.ForeignKey(
Product, Product,
related_name="sellings", related_name="sellings",

View File

@ -0,0 +1,25 @@
import type { Product } from "#counter:counter/types";
export class BasketItem {
quantity: number;
product: Product;
quantityForTrayPrice: number;
errors: string[];
constructor(product: Product, quantity: number) {
this.quantity = quantity;
this.product = product;
this.errors = [];
}
getBonusQuantity(): number {
if (!this.product.hasTrayPrice) {
return 0;
}
return Math.floor(this.quantity / this.product.quantityForTrayPrice);
}
sum(): number {
return (this.quantity - this.getBonusQuantity()) * this.product.price;
}
}

View File

@ -15,6 +15,10 @@ export class CounterProductSelect extends AutoCompleteSelectBase {
return ["FIN", "ANN"]; return ["FIN", "ANN"];
} }
public getSelectedProduct(): [number, string] {
return parseProduct(this.widget.getValue() as string);
}
protected attachBehaviors(): void { protected attachBehaviors(): void {
this.allowMultipleProducts(); this.allowMultipleProducts();
} }

View File

@ -1,42 +1,100 @@
import { exportToHtml } from "#core:utils/globals"; import { exportToHtml } from "#core:utils/globals";
import type TomSelect from "tom-select"; import { BasketItem } from "#counter:counter/basket";
import type { CounterConfig, ErrorMessage } from "#counter:counter/types";
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) => { exportToHtml("loadCounter", (config: CounterConfig) => {
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
Alpine.data("counter", () => ({ Alpine.data("counter", () => ({
basket: config.sessionBasket, basket: {} as Record<string, BasketItem>,
errors: [], errors: [],
customerBalance: config.customerBalance, customerBalance: config.customerBalance,
codeField: undefined, codeField: undefined,
alertMessage: {
content: "",
show: false,
timeout: null,
},
init() { init() {
// Fill the basket with the initial data
for (const entry of config.formInitial) {
if (entry.id !== undefined && entry.quantity !== undefined) {
this.addToBasket(entry.id, entry.quantity);
this.basket[entry.id].errors = entry.errors ?? [];
}
}
this.codeField = this.$refs.codeField; this.codeField = this.$refs.codeField;
this.codeField.widget.focus(); this.codeField.widget.focus();
// It's quite tricky to manually apply attributes to the management part
// of a formset so we dynamically apply it here
this.$refs.basketManagementForm
.querySelector("#id_form-TOTAL_FORMS")
.setAttribute(":value", "getBasketSize()");
},
removeFromBasket(id: string) {
delete this.basket[id];
},
addToBasket(id: string, quantity: number): ErrorMessage {
const item: BasketItem =
this.basket[id] || new BasketItem(config.products[id], 0);
const oldQty = item.quantity;
item.quantity += quantity;
if (item.quantity <= 0) {
delete this.basket[id];
return "";
}
this.basket[id] = item;
if (this.sumBasket() > this.customerBalance) {
item.quantity = oldQty;
if (item.quantity === 0) {
delete this.basket[id];
}
return gettext("Not enough money");
}
return "";
},
getBasketSize() {
return Object.keys(this.basket).length;
}, },
sumBasket() { sumBasket() {
if (!this.basket || Object.keys(this.basket).length === 0) { if (this.getBasketSize() === 0) {
return 0; return 0;
} }
const total = Object.values(this.basket).reduce( const total = Object.values(this.basket).reduce(
(acc: number, cur: BasketItem) => acc + cur.qty * cur.price, (acc: number, cur: BasketItem) => acc + cur.sum(),
0, 0,
) as number; ) as number;
return total / 100; return total;
},
showAlertMessage(message: string) {
if (this.alertMessage.timeout !== null) {
clearTimeout(this.alertMessage.timeout);
}
this.alertMessage.content = message;
this.alertMessage.show = true;
this.alertMessage.timeout = setTimeout(() => {
this.alertMessage.show = false;
this.alertMessage.timeout = null;
}, 2000);
},
addToBasketWithMessage(id: string, quantity: number) {
const message = this.addToBasket(id, quantity);
if (message.length > 0) {
this.showAlertMessage(message);
}
}, },
onRefillingSuccess(event: CustomEvent) { onRefillingSuccess(event: CustomEvent) {
@ -50,33 +108,36 @@ exportToHtml("loadCounter", (config: CounterConfig) => {
this.codeField.widget.focus(); this.codeField.widget.focus();
}, },
async handleCode(event: SubmitEvent) { finish() {
const widget: TomSelect = this.codeField.widget; if (this.getBasketSize() === 0) {
const code = (widget.getValue() as string).toUpperCase(); this.showAlertMessage(gettext("You can't send an empty basket."));
if (this.codeField.getOperationCodes().includes(code)) { return;
$(event.target).submit();
} else {
await this.handleAction(event);
} }
widget.clear(); this.$refs.basketForm.submit();
widget.focus();
}, },
async handleAction(event: SubmitEvent) { cancel() {
const payload = $(event.target).serialize(); location.href = config.cancelUrl;
const request = new Request(config.clickApiUrl, { },
method: "POST",
body: payload, handleCode() {
headers: { const [quantity, code] = this.codeField.getSelectedProduct() as [
// biome-ignore lint/style/useNamingConvention: this goes into http headers number,
Accept: "application/json", string,
"X-CSRFToken": config.csrfToken, ];
},
}); if (this.codeField.getOperationCodes().includes(code.toUpperCase())) {
const response = await fetch(request); if (code === "ANN") {
const json = await response.json(); this.cancel();
this.basket = json.basket; }
this.errors = json.errors; if (code === "FIN") {
this.finish();
}
} else {
this.addToBasketWithMessage(code, quantity);
}
this.codeField.widget.clear();
this.codeField.widget.focus();
}, },
})); }));
}); });
@ -85,7 +146,7 @@ exportToHtml("loadCounter", (config: CounterConfig) => {
$(() => { $(() => {
/* Accordion UI between basket and refills */ /* Accordion UI between basket and refills */
// biome-ignore lint/suspicious/noExplicitAny: dealing with legacy jquery // biome-ignore lint/suspicious/noExplicitAny: dealing with legacy jquery
($("#click_form") as any).accordion({ ($("#click-form") as any).accordion({
heightStyle: "content", heightStyle: "content",
activate: () => $(".focus").focus(), activate: () => $(".focus").focus(),
}); });

View File

@ -0,0 +1,25 @@
type ErrorMessage = string;
export interface InitialFormData {
/* Used to refill the form when the backend raises an error */
id?: keyof Record<string, Product>;
quantity?: number;
errors?: string[];
}
export interface CounterConfig {
customerBalance: number;
customerId: number;
products: Record<string, Product>;
formInitial: InitialFormData[];
cancelUrl: string;
}
export interface Product {
id: string;
code: string;
name: string;
price: number;
hasTrayPrice: boolean;
quantityForTrayPrice: number;
}

View File

@ -0,0 +1,62 @@
@import "core/static/core/colors";
.quantity {
display: inline-block;
min-width: 1.2em;
text-align: center;
}
.remove-item {
float: right;
}
.basket-error-container {
position: relative;
display: block
}
.basket-error {
z-index: 10; // to get on top of tomselect
text-align: center;
position: absolute;
}
#bar-ui {
padding: 0.4em;
display: flex;
flex-wrap: wrap;
flex-direction: row-reverse;
}
#products {
flex-basis: 100%;
margin: 0.2em;
overflow: auto;
}
#click-form {
flex: auto;
margin: 0.2em;
width: 20%;
ul {
list-style-type: none;
}
}
#user_info {
flex: auto;
padding: 0.5em;
margin: 0.2em;
height: 100%;
background: $secondary-neutral-light-color;
img {
max-width: 70%;
}
input {
background: white;
}
}

View File

@ -6,8 +6,10 @@
{% endblock %} {% endblock %}
{% block additional_css %} {% block additional_css %}
<link rel="stylesheet" type="text/css" href="{{ static('counter/css/counter-click.scss') }}" defer></link>
<link rel="stylesheet" type="text/css" href="{{ static('bundled/core/components/ajax-select-index.css') }}" defer></link> <link rel="stylesheet" type="text/css" href="{{ static('bundled/core/components/ajax-select-index.css') }}" defer></link>
<link rel="stylesheet" type="text/css" href="{{ static('core/components/ajax-select.scss') }}" defer></link> <link rel="stylesheet" type="text/css" href="{{ static('core/components/ajax-select.scss') }}" defer></link>
<link rel="stylesheet" href="{{ static("core/components/card.scss") }}">
{% endblock %} {% endblock %}
{% block additional_js %} {% block additional_js %}
@ -23,7 +25,7 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<h4 id="click_interface">{{ counter }}</h4> <h4>{{ counter }}</h4>
<div id="bar-ui" x-data="counter"> <div id="bar-ui" x-data="counter">
<noscript> <noscript>
@ -34,20 +36,21 @@
<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 %}<span x-text="customerBalance"></span> €</p> <p>{% trans %}Amount: {% endtrans %}<span x-text="customerBalance"></span> €
<span x-cloak x-show="getBasketSize() > 0">
<i class="fa-solid fa-arrow-right"></i>
<span x-text="(customerBalance - sumBasket()).toLocaleString(undefined, { minimumFractionDigits: 2 })"></span> €
</span>
</p>
</div> </div>
<div id="click_form" style="width: 20%;"> <div id="click-form">
<h5 id="selling-accordion">{% trans %}Selling{% endtrans %}</h5> <h5 id="selling-accordion">{% trans %}Selling{% endtrans %}</h5>
<div> <div>
{% set counter_click_url = url('counter:click', counter_id=counter.id, user_id=customer.user_id) %} {% set counter_click_url = url('counter:click', counter_id=counter.id, user_id=customer.user_id) %}
{# Formulaire pour rechercher un produit en tapant son code dans une barre de recherche #}
<form method="post" action="" <form method="post" action=""
class="code_form" @submit.prevent="handleCode"> class="code_form" @submit.prevent="handleCode">
{% csrf_token %}
<input type="hidden" name="action" value="code">
<counter-product-select name="code" x-ref="codeField" autofocus required placeholder="{% trans %}Select a product...{% endtrans %}"> <counter-product-select name="code" x-ref="codeField" autofocus required placeholder="{% trans %}Select a product...{% endtrans %}">
<option value=""></option> <option value=""></option>
@ -58,7 +61,7 @@
{% for category in categories.keys() %} {% for category in categories.keys() %}
<optgroup label="{{ category }}"> <optgroup label="{{ category }}">
{% for product in categories[category] %} {% for product in categories[category] %}
<option value="{{ product.code }}">{{ product }}</option> <option value="{{ product.id }}">{{ product }}</option>
{% endfor %} {% endfor %}
</optgroup> </optgroup>
{% endfor %} {% endfor %}
@ -67,58 +70,91 @@
<input type="submit" value="{% trans %}Go{% endtrans %}"/> <input type="submit" value="{% trans %}Go{% endtrans %}"/>
</form> </form>
<template x-for="error in errors"> {% for error in form.non_form_errors() %}
<div class="alert alert-red" x-text="error"> <div class="alert alert-red">
{{ error }}
</div> </div>
</template> {% endfor %}
<p>{% trans %}Basket: {% endtrans %}</p> <p>{% trans %}Basket: {% endtrans %}</p>
<ul> <form x-cloak method="post" action="" x-ref="basketForm">
<template x-for="[id, item] in Object.entries(basket)" :key="id">
<div>
<form method="post" action="" class="inline del_product_form"
@submit.prevent="handleAction">
{% csrf_token %}
<input type="hidden" name="action" value="del_product">
<input type="hidden" name="product_id" :value="id">
<input type="submit" value="-"/>
</form>
<span x-text="item['qty'] + item['bonus_qty']"></span> <div class="basket-error-container">
<div
x-cloak
class="alert alert-red basket-error"
x-show="alertMessage.show"
x-transition.duration.500ms
x-text="alertMessage.content"
></div>
</div>
<form method="post" action="" class="inline add_product_form"
@submit.prevent="handleAction">
{% csrf_token %}
<input type="hidden" name="action" value="add_product">
<input type="hidden" name="product_id" :value="id">
<input type="submit" value="+">
</form>
<span x-text="products[id].name"></span> :
<span x-text="(item['qty'] * item['price'] / 100)
.toLocaleString(undefined, { minimumFractionDigits: 2 })">
</span> €
<template x-if="item['bonus_qty'] > 0">P</template>
</div>
</template>
</ul>
<p>
<strong>Total: </strong>
<strong x-text="sumBasket().toLocaleString(undefined, { minimumFractionDigits: 2 })"></strong>
<strong> €</strong>
</p>
<form method="post"
action="{{ url('counter:click', counter_id=counter.id, user_id=customer.user.id) }}">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="action" value="finish"> <div x-ref="basketManagementForm">
<input type="submit" value="{% trans %}Finish{% endtrans %}"/> {{ form.management_form }}
</form> </div>
<form method="post" <ul>
action="{{ url('counter:click', counter_id=counter.id, user_id=customer.user.id) }}"> <li x-show="getBasketSize() === 0">{% trans %}This basket is empty{% endtrans %}</li>
{% csrf_token %} <template x-for="(item, index) in Object.values(basket)">
<input type="hidden" name="action" value="cancel"> <li>
<input type="submit" value="{% trans %}Cancel{% endtrans %}"/> <template x-for="error in item.errors">
<div class="alert alert-red" x-text="error">
</div>
</template>
<button @click.prevent="addToBasketWithMessage(item.product.id, -1)">-</button>
<span class="quantity" x-text="item.quantity"></span>
<button @click.prevent="addToBasketWithMessage(item.product.id, 1)">+</button>
<span x-text="item.product.name"></span> :
<span x-text="item.sum().toLocaleString(undefined, { minimumFractionDigits: 2 })">€</span>
<span x-show="item.getBonusQuantity() > 0" x-text="`${item.getBonusQuantity()} x P`"></span>
<button
class="remove-item"
@click.prevent="removeFromBasket(item.product.id)"
><i class="fa fa-trash-can delete-action"></i></button>
<input
type="hidden"
:value="item.quantity"
:id="`id_form-${index}-quantity`"
:name="`form-${index}-quantity`"
required
readonly
>
<input
type="hidden"
:value="item.product.id"
:id="`id_form-${index}-id`"
:name="`form-${index}-id`"
required
readonly
>
</li>
</template>
</ul>
<p class="margin-bottom">
<strong>Total: </strong>
<strong x-text="sumBasket().toLocaleString(undefined, { minimumFractionDigits: 2 })"></strong>
<strong> €</strong>
</p>
<div class="row">
<input
class="btn btn-blue"
type="submit"
@click.prevent="finish"
:disabled="getBasketSize() === 0"
value="{% trans %}Finish{% endtrans %}"
/>
<input
class="btn btn-grey"
type="submit" @click.prevent="cancel"
value="{% trans %}Cancel{% endtrans %}"
/>
</div>
</form> </form>
</div> </div>
{% if object.type == "BAR" %} {% if object.type == "BAR" %}
@ -151,34 +187,41 @@
</div> </div>
<div id="products"> <div id="products">
<ul> {% if not products %}
{% for category in categories.keys() -%} <div class="alert alert-red">
<li><a href="#cat_{{ category|slugify }}">{{ category }}</a></li> {% trans %}No products available on this counter for this user{% endtrans %}
{%- endfor %}
</ul>
{% for category in categories.keys() -%}
<div id="cat_{{ category|slugify }}">
<h5>{{ category }}</h5>
{% for p in categories[category] -%}
<form method="post"
action="{{ url('counter:click', counter_id=counter.id, user_id=customer.user.id) }}"
class="form_button add_product_form" @submit.prevent="handleAction">
{% csrf_token %}
<input type="hidden" name="action" value="add_product">
<input type="hidden" name="product_id" value="{{ p.id }}">
<button type="submit">
<strong>{{ p.name }}</strong>
{% if p.icon %}
<img src="{{ p.icon.url }}" alt="image de {{ p.name }}"/>
{% else %}
<img src="{{ static('core/img/na.gif') }}" alt="image de {{ p.name }}"/>
{% endif %}
<span>{{ p.price }} €<br>{{ p.code }}</span>
</button>
</form>
{%- endfor %}
</div> </div>
{%- endfor %} {% else %}
<ul>
{% for category in categories.keys() -%}
<li><a href="#cat_{{ category|slugify }}">{{ category }}</a></li>
{%- endfor %}
</ul>
{% for category in categories.keys() -%}
<div id="cat_{{ category|slugify }}">
<h5 class="margin-bottom">{{ category }}</h5>
<div class="row gap-2x">
{% for product in categories[category] -%}
<button class="card shadow" @click="addToBasketWithMessage('{{ product.id }}', 1)">
<img
class="card-image"
alt="image de {{ product.name }}"
{% if product.icon %}
src="{{ product.icon.url }}"
{% else %}
src="{{ static('core/img/na.gif') }}"
{% endif %}
/>
<span class="card-content">
<strong class="card-title">{{ product.name }}</strong>
<p>{{ product.price }} €<br>{{ product.code }}</p>
</span>
</button>
{%- endfor %}
</div>
</div>
{%- endfor %}
{% endif %}
</div> </div>
</div> </div>
{% endblock content %} {% endblock content %}
@ -187,21 +230,38 @@
{{ super() }} {{ super() }}
<script> <script>
const products = { const products = {
{%- for p in products -%} {%- for product in products -%}
{{ p.id }}: { {{ product.id }}: {
code: "{{ p.code }}", id: "{{ product.id }}",
name: "{{ p.name }}", name: "{{ product.name }}",
price: {{ p.price }}, price: {{ product.price }},
hasTrayPrice: {{ product.tray | tojson }},
quantityForTrayPrice: {{ product.QUANTITY_FOR_TRAY_PRICE }},
}, },
{%- endfor -%} {%- endfor -%}
}; };
const formInitial = [
{%- for f in form -%}
{%- if f.cleaned_data -%}
{
{%- if f.cleaned_data["id"] -%}
id: '{{ f.cleaned_data["id"] | tojson }}',
{%- endif -%}
{%- if f.cleaned_data["quantity"] -%}
quantity: {{ f.cleaned_data["quantity"] | tojson }},
{%- endif -%}
errors: {{ form_errors[loop.index0] | tojson }},
},
{%- endif -%}
{%- endfor -%}
];
window.addEventListener("DOMContentLoaded", () => { window.addEventListener("DOMContentLoaded", () => {
loadCounter({ 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 }}, customerBalance: {{ customer.amount }},
products: products,
customerId: {{ customer.pk }}, customerId: {{ customer.pk }},
formInitial: formInitial,
cancelUrl: "{{ cancel_url }}",
}); });
}); });
</script> </script>

View File

@ -59,5 +59,26 @@
{% endif %} {% endif %}
{% endblock %} {% endblock %}
{% block script %}
{{ super() }}
<script type="text/javascript">
window.addEventListener("DOMContentLoaded", () => {
// The login form annoyingly takes priority over the code form
// This is due to the loading time of the web component
// We can't rely on DOMContentLoaded to know if the component is there so we
// periodically run a script until the field is there
const autofocus = () => {
const field = document.querySelector("input[id='id_code']");
if (field === null){
setTimeout(autofocus, 0.5);
return;
}
field.focus();
}
autofocus()
})
</script>
{% endblock %}

View File

@ -12,161 +12,584 @@
# OR WITHIN THE LOCAL FILE "LICENSE" # OR WITHIN THE LOCAL FILE "LICENSE"
# #
# #
import re from dataclasses import asdict, dataclass
from datetime import timedelta from datetime import timedelta
from decimal import Decimal
import pytest import pytest
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import make_password
from django.core.cache import cache from django.core.cache import cache
from django.test import TestCase from django.http import HttpResponse
from django.shortcuts import resolve_url
from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.timezone import now from django.utils.timezone import localdate, now
from freezegun import freeze_time from freezegun import freeze_time
from model_bakery import baker from model_bakery import baker
from club.models import Club, Membership from club.models import Club, Membership
from core.baker_recipes import subscriber_user from core.baker_recipes import board_user, subscriber_user, very_old_subscriber_user
from core.models import User from core.models import Group, User
from counter.baker_recipes import product_recipe
from counter.models import ( from counter.models import (
Counter, Counter,
Customer, Customer,
Permanency, Permanency,
Product, Product,
Refilling,
Selling, Selling,
) )
class TestCounter(TestCase): class TestFullClickBase(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
cls.skia = User.objects.filter(username="skia").first() cls.customer = subscriber_user.make()
cls.sli = User.objects.filter(username="sli").first() cls.barmen = subscriber_user.make(password=make_password("plop"))
cls.krophil = User.objects.filter(username="krophil").first() cls.board_admin = board_user.make(password=make_password("plop"))
cls.richard = User.objects.filter(username="rbatsbak").first() cls.club_admin = subscriber_user.make()
cls.mde = Counter.objects.filter(name="MDE").first() cls.root = baker.make(User, is_superuser=True)
cls.foyer = Counter.objects.get(id=2) cls.subscriber = subscriber_user.make()
def test_full_click(self): cls.counter = baker.make(Counter, type="BAR")
cls.counter.sellers.add(cls.barmen, cls.board_admin)
cls.other_counter = baker.make(Counter, type="BAR")
cls.other_counter.sellers.add(cls.barmen)
cls.yet_another_counter = baker.make(Counter, type="BAR")
cls.customer_old_can_buy = subscriber_user.make()
sub = cls.customer_old_can_buy.subscriptions.first()
sub.subscription_end = localdate() - timedelta(days=89)
sub.save()
cls.customer_old_can_not_buy = very_old_subscriber_user.make()
cls.customer_can_not_buy = baker.make(User)
cls.club_counter = baker.make(Counter, type="OFFICE")
baker.make(
Membership,
start_date=now() - timedelta(days=30),
club=cls.club_counter.club,
role=settings.SITH_CLUB_ROLES_ID["Board member"],
user=cls.club_admin,
)
def updated_amount(self, user: User) -> Decimal:
user.refresh_from_db()
user.customer.refresh_from_db()
return user.customer.amount
class TestRefilling(TestFullClickBase):
def login_in_bar(self, barmen: User | None = None):
used_barman = barmen if barmen is not None else self.board_admin
self.client.post( self.client.post(
reverse("counter:login", kwargs={"counter_id": self.mde.id}), reverse("counter:login", args=[self.counter.id]),
{"username": self.skia.username, "password": "plop"}, {"username": used_barman.username, "password": "plop"},
)
response = self.client.get(
reverse("counter:details", kwargs={"counter_id": self.mde.id})
) )
assert 'class="link-button">S&#39; Kia</button>' in str(response.content) def refill_user(
self,
counter_token = re.search( user: User | Customer,
r'name="counter_token" value="([^"]*)"', str(response.content) counter: Counter,
).group(1) amount: int,
client: Client | None = None,
response = self.client.post( ) -> HttpResponse:
reverse("counter:details", kwargs={"counter_id": self.mde.id}), used_client = client if client is not None else self.client
{"code": self.richard.customer.account_id, "counter_token": counter_token}, return used_client.post(
) reverse(
counter_url = response.get("location") "counter:refilling_create",
refill_url = reverse( kwargs={"customer_id": user.pk},
"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(
refill_url,
{ {
"amount": "5", "amount": str(amount),
"payment_method": "CASH", "payment_method": "CASH",
"bank": "OTHER", "bank": "OTHER",
}, },
HTTP_REFERER=counter_url, HTTP_REFERER=reverse(
"counter:click",
kwargs={"counter_id": counter.id, "user_id": user.pk},
),
) )
self.client.post(counter_url, "action=code&code=BARB", content_type="text/xml")
def test_refilling_office_fail(self):
self.client.force_login(self.club_admin)
assert self.refill_user(self.customer, self.club_counter, 10).status_code == 403
self.client.force_login(self.root)
assert self.refill_user(self.customer, self.club_counter, 10).status_code == 403
self.client.force_login(self.subscriber)
assert self.refill_user(self.customer, self.club_counter, 10).status_code == 403
assert self.updated_amount(self.customer) == 0
def test_refilling_no_refer_fail(self):
def refill():
return self.client.post(
reverse(
"counter:refilling_create",
kwargs={"customer_id": self.customer.pk},
),
{
"amount": "10",
"payment_method": "CASH",
"bank": "OTHER",
},
)
self.client.force_login(self.club_admin)
assert refill()
self.client.force_login(self.root)
assert refill()
self.client.force_login(self.subscriber)
assert refill()
assert self.updated_amount(self.customer) == 0
def test_refilling_not_connected_fail(self):
assert self.refill_user(self.customer, self.counter, 10).status_code == 403
assert self.updated_amount(self.customer) == 0
def test_refilling_counter_open_but_not_connected_fail(self):
self.login_in_bar()
client = Client()
assert (
self.refill_user(self.customer, self.counter, 10, client=client).status_code
== 403
)
assert self.updated_amount(self.customer) == 0
def test_refilling_counter_no_board_member(self):
self.login_in_bar(barmen=self.barmen)
assert self.refill_user(self.customer, self.counter, 10).status_code == 403
assert self.updated_amount(self.customer) == 0
def test_refilling_user_can_not_buy(self):
self.login_in_bar(barmen=self.barmen)
assert (
self.refill_user(self.customer_can_not_buy, self.counter, 10).status_code
== 404
)
assert (
self.refill_user(
self.customer_old_can_not_buy, self.counter, 10
).status_code
== 404
)
def test_refilling_counter_success(self):
self.login_in_bar()
assert self.refill_user(self.customer, self.counter, 30).status_code == 302
assert self.updated_amount(self.customer) == 30
assert self.refill_user(self.customer, self.counter, 10.1).status_code == 302
assert self.updated_amount(self.customer) == Decimal("40.1")
assert (
self.refill_user(self.customer_old_can_buy, self.counter, 1).status_code
== 302
)
assert self.updated_amount(self.customer_old_can_buy) == 1
@dataclass
class BasketItem:
id: int | None = None
quantity: int | None = None
def to_form(self, index: int) -> dict[str, str]:
return {
f"form-{index}-{key}": str(value)
for key, value in asdict(self).items()
if value is not None
}
class TestCounterClick(TestFullClickBase):
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.underage_customer = subscriber_user.make()
cls.banned_counter_customer = subscriber_user.make()
cls.banned_alcohol_customer = subscriber_user.make()
cls.set_age(cls.customer, 20)
cls.set_age(cls.barmen, 20)
cls.set_age(cls.club_admin, 20)
cls.set_age(cls.banned_alcohol_customer, 20)
cls.set_age(cls.underage_customer, 17)
cls.banned_alcohol_customer.groups.add(
Group.objects.get(pk=settings.SITH_GROUP_BANNED_ALCOHOL_ID)
)
cls.banned_counter_customer.groups.add(
Group.objects.get(pk=settings.SITH_GROUP_BANNED_COUNTER_ID)
)
cls.beer = product_recipe.make(
limit_age=18, selling_price="1.5", special_selling_price="1"
)
cls.beer_tap = product_recipe.make(
limit_age=18,
tray=True,
selling_price="1.5",
special_selling_price="1",
)
cls.snack = product_recipe.make(
limit_age=0, selling_price="1.5", special_selling_price="1"
)
cls.stamps = product_recipe.make(
limit_age=0, selling_price="1.5", special_selling_price="1"
)
cls.counter.products.add(cls.beer, cls.beer_tap, cls.snack)
cls.other_counter.products.add(cls.snack)
cls.club_counter.products.add(cls.stamps)
def login_in_bar(self, barmen: User | None = None):
used_barman = barmen if barmen is not None else self.barmen
self.client.post( self.client.post(
counter_url, "action=add_product&product_id=4", content_type="text/xml" reverse("counter:login", args=[self.counter.id]),
) {"username": used_barman.username, "password": "plop"},
self.client.post(
counter_url, "action=del_product&product_id=4", content_type="text/xml"
)
self.client.post(
counter_url, "action=code&code=2xdeco", content_type="text/xml"
)
self.client.post(
counter_url, "action=code&code=1xbarb", content_type="text/xml"
)
response = self.client.post(
counter_url, "action=code&code=fin", content_type="text/xml"
) )
response_get = self.client.get(response.get("location")) @classmethod
response_content = response_get.content.decode("utf-8") def set_age(cls, user: User, age: int):
assert "2 x Barbar" in str(response_content) user.date_of_birth = localdate().replace(year=localdate().year - age)
assert "2 x Déconsigne Eco-cup" in str(response_content) user.save()
assert "<p>Client : Richard Batsbak - Nouveau montant : 3.60" in str(
response_content def submit_basket(
self,
user: User,
basket: list[BasketItem],
counter: Counter | None = None,
client: Client | None = None,
) -> HttpResponse:
used_counter = counter if counter is not None else self.counter
used_client = client if client is not None else self.client
data = {
"form-TOTAL_FORMS": str(len(basket)),
"form-INITIAL_FORMS": "0",
}
for index, item in enumerate(basket):
data.update(item.to_form(index))
return used_client.post(
reverse(
"counter:click",
kwargs={"counter_id": used_counter.id, "user_id": user.id},
),
data,
) )
self.client.post( def refill_user(self, user: User, amount: Decimal | int):
reverse("counter:login", kwargs={"counter_id": self.mde.id}), baker.make(Refilling, amount=amount, customer=user.customer, is_validated=False)
{"username": self.sli.username, "password": "plop"},
def test_click_eboutic_failure(self):
eboutic = baker.make(Counter, type="EBOUTIC")
self.client.force_login(self.club_admin)
assert (
self.submit_basket(
self.customer,
[BasketItem(self.stamps.id, 5)],
counter=eboutic,
).status_code
== 404
) )
response = self.client.post( def test_click_office_success(self):
refill_url, self.refill_user(self.customer, 10)
{ self.client.force_login(self.club_admin)
"amount": "5",
"payment_method": "CASH",
"bank": "OTHER",
},
HTTP_REFERER=counter_url,
)
assert response.status_code == 302
self.client.post( assert (
reverse("counter:login", kwargs={"counter_id": self.foyer.id}), self.submit_basket(
{"username": self.krophil.username, "password": "plop"}, self.customer,
[BasketItem(self.stamps.id, 5)],
counter=self.club_counter,
).status_code
== 302
)
assert self.updated_amount(self.customer) == Decimal("2.5")
# Test no special price on office counter
self.refill_user(self.club_admin, 10)
assert (
self.submit_basket(
self.club_admin,
[BasketItem(self.stamps.id, 1)],
counter=self.club_counter,
).status_code
== 302
) )
response = self.client.get( assert self.updated_amount(self.club_admin) == Decimal("8.5")
reverse("counter:details", kwargs={"counter_id": self.foyer.id})
def test_click_bar_success(self):
self.refill_user(self.customer, 10)
self.login_in_bar(self.barmen)
assert (
self.submit_basket(
self.customer,
[
BasketItem(self.beer.id, 2),
BasketItem(self.snack.id, 1),
],
).status_code
== 302
) )
counter_token = re.search( assert self.updated_amount(self.customer) == Decimal("5.5")
r'name="counter_token" value="([^"]*)"', str(response.content)
).group(1)
response = self.client.post( # Test barmen special price
reverse("counter:details", kwargs={"counter_id": self.foyer.id}),
{"code": self.richard.customer.account_id, "counter_token": counter_token}, self.refill_user(self.barmen, 10)
)
counter_url = response.get("location") assert (
refill_url = reverse( self.submit_basket(self.barmen, [BasketItem(self.beer.id, 1)])
"counter:refilling_create", ).status_code == 302
kwargs={
"customer_id": self.richard.customer.pk, assert self.updated_amount(self.barmen) == Decimal("9")
},
def test_click_tray_price(self):
self.refill_user(self.customer, 20)
self.login_in_bar(self.barmen)
# Not applying tray price
assert (
self.submit_basket(
self.customer,
[
BasketItem(self.beer_tap.id, 2),
],
).status_code
== 302
) )
response = self.client.post( assert self.updated_amount(self.customer) == Decimal("17")
refill_url,
{ # Applying tray price
"amount": "5", assert (
"payment_method": "CASH", self.submit_basket(
"bank": "OTHER", self.customer,
}, [
HTTP_REFERER=counter_url, BasketItem(self.beer_tap.id, 7),
],
).status_code
== 302
) )
assert response.status_code == 403 # Krophil is not board admin
assert self.updated_amount(self.customer) == Decimal("8")
def test_click_alcool_unauthorized(self):
self.login_in_bar()
for user in [self.underage_customer, self.banned_alcohol_customer]:
self.refill_user(user, 10)
# Buy product without age limit
assert (
self.submit_basket(
user,
[
BasketItem(self.snack.id, 2),
],
).status_code
== 302
)
assert self.updated_amount(user) == Decimal("7")
# Buy product without age limit
assert (
self.submit_basket(
user,
[
BasketItem(self.beer.id, 2),
],
).status_code
== 200
)
assert self.updated_amount(user) == Decimal("7")
def test_click_unauthorized_customer(self):
self.login_in_bar()
for user in [
self.banned_counter_customer,
self.customer_old_can_not_buy,
]:
self.refill_user(user, 10)
resp = self.submit_basket(
user,
[
BasketItem(self.snack.id, 2),
],
)
assert resp.status_code == 302
assert resp.url == resolve_url(self.counter)
assert self.updated_amount(user) == Decimal("10")
def test_click_user_without_customer(self):
self.login_in_bar()
assert (
self.submit_basket(
self.customer_can_not_buy,
[
BasketItem(self.snack.id, 2),
],
).status_code
== 404
)
def test_click_allowed_old_subscriber(self):
self.login_in_bar()
self.refill_user(self.customer_old_can_buy, 10)
assert (
self.submit_basket(
self.customer_old_can_buy,
[
BasketItem(self.snack.id, 2),
],
).status_code
== 302
)
assert self.updated_amount(self.customer_old_can_buy) == Decimal("7")
def test_click_wrong_counter(self):
self.login_in_bar()
self.refill_user(self.customer, 10)
assert (
self.submit_basket(
self.customer,
[
BasketItem(self.snack.id, 2),
],
counter=self.other_counter,
).status_code
== 302 # Redirect to counter main
)
# We want to test sending requests from another counter while
# we are currently registered to another counter
# so we connect to a counter and
# we create a new client, in order to check
# that using a client not logged to a counter
# where another client is logged still isn't authorized.
client = Client()
assert (
self.submit_basket(
self.customer,
[
BasketItem(self.snack.id, 2),
],
counter=self.counter,
client=client,
).status_code
== 302 # Redirect to counter main
)
assert self.updated_amount(self.customer) == Decimal("10")
def test_click_not_connected(self):
self.refill_user(self.customer, 10)
assert (
self.submit_basket(
self.customer,
[
BasketItem(self.snack.id, 2),
],
).status_code
== 302 # Redirect to counter main
)
assert (
self.submit_basket(
self.customer,
[
BasketItem(self.snack.id, 2),
],
counter=self.club_counter,
).status_code
== 403
)
assert self.updated_amount(self.customer) == Decimal("10")
def test_click_product_not_in_counter(self):
self.refill_user(self.customer, 10)
self.login_in_bar()
assert (
self.submit_basket(
self.customer,
[
BasketItem(self.stamps.id, 2),
],
).status_code
== 200
)
assert self.updated_amount(self.customer) == Decimal("10")
def test_click_product_invalid(self):
self.refill_user(self.customer, 10)
self.login_in_bar()
for item in [
BasketItem("-1", 2),
BasketItem(self.beer.id, -1),
BasketItem(None, 1),
BasketItem(self.beer.id, None),
BasketItem(None, None),
]:
assert (
self.submit_basket(
self.customer,
[item],
).status_code
== 200
)
assert self.updated_amount(self.customer) == Decimal("10")
def test_click_not_enough_money(self):
self.refill_user(self.customer, 10)
self.login_in_bar()
assert (
self.submit_basket(
self.customer,
[
BasketItem(self.beer_tap.id, 5),
BasketItem(self.beer.id, 10),
],
).status_code
== 200
)
assert self.updated_amount(self.customer) == Decimal("10")
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."""
self.sli.counters.set([self.foyer, self.mde]) counters = Counter.objects.annotate_has_barman(self.barmen)
counters = Counter.objects.annotate_has_barman(self.sli)
for counter in counters: for counter in counters:
if counter.name in ("Foyer", "MDE"): if counter in (self.counter, self.other_counter):
assert counter.has_annotated_barman assert counter.has_annotated_barman
else: else:
assert not counter.has_annotated_barman assert not counter.has_annotated_barman
@ -436,4 +859,4 @@ class TestClubCounterClickAccess(TestCase):
self.counter.sellers.add(self.user) self.counter.sellers.add(self.user)
self.client.force_login(self.user) self.client.force_login(self.user)
res = self.client.get(self.click_url) res = self.client.get(self.click_url)
assert res.status_code == 200 assert res.status_code == 403

View File

@ -12,20 +12,26 @@
# OR WITHIN THE LOCAL FILE "LICENSE" # OR WITHIN THE LOCAL FILE "LICENSE"
# #
# #
import re import math
from http import HTTPStatus
from typing import TYPE_CHECKING
from urllib.parse import parse_qs
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.db import DataError, transaction from django.db import transaction
from django.db.models import F from django.forms import (
from django.http import Http404, HttpResponseRedirect, JsonResponse BaseFormSet,
from django.shortcuts import get_object_or_404, redirect Form,
IntegerField,
ValidationError,
formset_factory,
)
from django.http import Http404
from django.shortcuts import get_object_or_404, redirect, resolve_url
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, FormView from django.views.generic import FormView
from django.views.generic.detail import SingleObjectMixin
from ninja.main import HttpRequest
from core.models import User
from core.utils import FormFragmentTemplateData from core.utils import FormFragmentTemplateData
from core.views import CanViewMixin from core.views import CanViewMixin
from counter.forms import RefillForm from counter.forms import RefillForm
@ -34,11 +40,102 @@ 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 StudentCardFormView from counter.views.student_card import StudentCardFormView
if TYPE_CHECKING:
from core.models import User def get_operator(request: HttpRequest, counter: Counter, customer: Customer) -> User:
if counter.type != "BAR":
return request.user
if counter.customer_is_barman(customer):
return customer.user
return counter.get_random_barman()
class CounterClick(CounterTabsMixin, CanViewMixin, DetailView): class ProductForm(Form):
quantity = IntegerField(min_value=1)
id = IntegerField(min_value=0)
def __init__(
self,
customer: Customer,
counter: Counter,
allowed_products: dict[int, Product],
*args,
**kwargs,
):
self.customer = customer # Used by formset
self.counter = counter # Used by formset
self.allowed_products = allowed_products
super().__init__(*args, **kwargs)
def clean_id(self):
data = self.cleaned_data["id"]
# We store self.product so we can use it later on the formset validation
# And also in the global clean
self.product = self.allowed_products.get(data, None)
if self.product is None:
raise ValidationError(
_("The selected product isn't available for this user")
)
return data
def clean(self):
cleaned_data = super().clean()
if len(self.errors) > 0:
return
# Compute prices
cleaned_data["bonus_quantity"] = 0
if self.product.tray:
cleaned_data["bonus_quantity"] = math.floor(
cleaned_data["quantity"] / Product.QUANTITY_FOR_TRAY_PRICE
)
cleaned_data["total_price"] = self.product.price * (
cleaned_data["quantity"] - cleaned_data["bonus_quantity"]
)
return cleaned_data
class BaseBasketForm(BaseFormSet):
def clean(self):
super().clean()
if len(self) == 0:
return
self._check_forms_have_errors()
self._check_recorded_products(self[0].customer)
self._check_enough_money(self[0].counter, self[0].customer)
def _check_forms_have_errors(self):
if any(len(form.errors) > 0 for form in self):
raise ValidationError(_("Submmited basket is invalid"))
def _check_enough_money(self, counter: Counter, customer: Customer):
self.total_price = sum([data["total_price"] for data in self.cleaned_data])
if self.total_price > customer.amount:
raise ValidationError(_("Not enough money"))
def _check_recorded_products(self, customer: Customer):
"""Check for, among other things, ecocups and pitchers"""
self.total_recordings = 0
for form in self:
# form.product is stored by the clean step of each formset form
if form.product.is_record_product:
self.total_recordings -= form.cleaned_data["quantity"]
if form.product.is_unrecord_product:
self.total_recordings += form.cleaned_data["quantity"]
if not customer.can_record_more(self.total_recordings):
raise ValidationError(_("This user have reached his recording limit"))
BasketForm = formset_factory(
ProductForm, formset=BaseBasketForm, absolute_max=None, min_num=1
)
class CounterClick(CounterTabsMixin, CanViewMixin, SingleObjectMixin, FormView):
"""The click view """The click view
This is a detail view not to have to worry about loading the counter This is a detail view not to have to worry about loading the counter
Everything is made by hand in the post method. Everything is made by hand in the post method.
@ -46,346 +143,102 @@ class CounterClick(CounterTabsMixin, CanViewMixin, DetailView):
model = Counter model = Counter
queryset = Counter.objects.annotate_is_open() queryset = Counter.objects.annotate_is_open()
form_class = BasketForm
template_name = "counter/counter_click.jinja" template_name = "counter/counter_click.jinja"
pk_url_kwarg = "counter_id" pk_url_kwarg = "counter_id"
current_tab = "counter" current_tab = "counter"
def render_to_response(self, *args, **kwargs): def get_queryset(self):
if self.is_ajax(self.request): return super().get_queryset().exclude(type="EBOUTIC").annotate_is_open()
response = {"errors": []}
status = HTTPStatus.OK
if self.request.session["too_young"]: def get_form_kwargs(self):
response["errors"].append(_("Too young for that product")) kwargs = super().get_form_kwargs()
status = HTTPStatus.UNAVAILABLE_FOR_LEGAL_REASONS kwargs["form_kwargs"] = {
if self.request.session["not_allowed"]: "customer": self.customer,
response["errors"].append(_("Not allowed for that product")) "counter": self.object,
status = HTTPStatus.FORBIDDEN "allowed_products": {product.id: product for product in self.products},
if self.request.session["no_age"]: }
response["errors"].append(_("No date of birth provided")) return kwargs
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: Counter = self.get_object() obj: Counter = self.get_object()
if not self.customer.can_buy:
raise Http404 if not self.customer.can_buy or self.customer.user.is_banned_counter:
if obj.type != "BAR" and not request.user.is_authenticated: return redirect(obj) # Redirect to counter
raise PermissionDenied
if obj.type == "BAR" and ( if obj.type == "OFFICE" and (
"counter_token" not in request.session obj.sellers.filter(pk=request.user.pk).exists()
or request.session["counter_token"] != obj.token or not obj.club.has_rights_in_club(request.user)
or len(obj.barmen_list) == 0
): ):
return redirect(obj) raise PermissionDenied
if obj.type == "BAR" and (
not obj.is_open
or "counter_token" not in request.session
or request.session["counter_token"] != obj.token
):
return redirect(obj) # Redirect to counter
self.products = obj.get_products_for(self.customer)
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
def get(self, request, *args, **kwargs): def form_valid(self, formset):
"""Simple get view.""" ret = super().form_valid(formset)
if "basket" not in request.session: # Init the basket session entry
request.session["basket"] = {}
request.session["basket_total"] = 0
request.session["not_enough"] = False # Reset every variable
request.session["too_young"] = False
request.session["not_allowed"] = False
request.session["no_age"] = False
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
): # Check that at least one barman is logged in
ret = self.cancel(request) # Otherwise, go to main view
return ret
def post(self, request, *args, **kwargs): if len(formset) == 0:
"""Handle the many possibilities of the post request.""" return ret
self.object = self.get_object()
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
return self.cancel(request)
if self.object.type == "BAR" and not (
"counter_token" in self.request.session
and self.request.session["counter_token"] == self.object.token
): # Also check the token to avoid the bar to be stolen
return HttpResponseRedirect(
reverse_lazy(
"counter:details",
args=self.args,
kwargs={"counter_id": self.object.id},
)
+ "?bad_location"
)
if "basket" not in request.session:
request.session["basket"] = {}
request.session["basket_total"] = 0
request.session["not_enough"] = False # Reset every variable
request.session["too_young"] = False
request.session["not_allowed"] = False
request.session["no_age"] = False
if self.object.type != "BAR":
self.operator = request.user
elif self.object.customer_is_barman(self.customer):
self.operator = self.customer.user
else:
self.operator = self.object.get_random_barman()
action = self.request.POST.get("action", None)
if action is None:
action = parse_qs(request.body.decode()).get("action", [""])[0]
if action == "add_product":
self.add_product(request)
elif action == "del_product":
self.del_product(request)
elif action == "code":
return self.parse_code(request)
elif action == "cancel":
return self.cancel(request)
elif action == "finish":
return self.finish(request)
context = self.get_context_data(object=self.object)
return self.render_to_response(context)
def get_product(self, pid): operator = get_operator(self.request, self.object, self.customer)
return Product.objects.filter(pk=int(pid)).first()
def get_price(self, pid):
p = self.get_product(pid)
if self.object.customer_is_barman(self.customer):
price = p.special_selling_price
else:
price = p.selling_price
return price
def sum_basket(self, request):
total = 0
for infos in request.session["basket"].values():
total += infos["price"] * infos["qty"]
return total / 100
def get_total_quantity_for_pid(self, request, pid):
pid = str(pid)
if pid not in request.session["basket"]:
return 0
return (
request.session["basket"][pid]["qty"]
+ request.session["basket"][pid]["bonus_qty"]
)
def compute_record_product(self, request, product=None):
recorded = 0
basket = request.session["basket"]
if product:
if product.is_record_product:
recorded -= 1
elif product.is_unrecord_product:
recorded += 1
for p in basket:
bproduct = self.get_product(str(p))
if bproduct.is_record_product:
recorded -= basket[p]["qty"]
elif bproduct.is_unrecord_product:
recorded += basket[p]["qty"]
return recorded
def is_record_product_ok(self, request, product):
return self.customer.can_record_more(
self.compute_record_product(request, product)
)
@staticmethod
def is_ajax(request):
# when using the fetch API, the django request.POST dict is empty
# this is but a wretched contrivance which strive to replace
# the deprecated django is_ajax() method
# and which must be replaced as soon as possible
# by a proper separation between the api endpoints of the counter
return len(request.POST) == 0 and len(request.body) != 0
def add_product(self, request, q=1, p=None):
"""Add a product to the basket
q is the quantity passed as integer
p is the product id, passed as an integer.
"""
pid = p or parse_qs(request.body.decode())["product_id"][0]
pid = str(pid)
price = self.get_price(pid)
total = self.sum_basket(request)
product: Product = self.get_product(pid)
user: User = self.customer.user
buying_groups = list(product.buying_groups.values_list("pk", flat=True))
can_buy = len(buying_groups) == 0 or any(
user.is_in_group(pk=group_id) for group_id in buying_groups
)
if not can_buy:
request.session["not_allowed"] = True
return False
bq = 0 # Bonus quantity, for trays
if (
product.tray
): # Handle the tray to adjust the quantity q to add and the bonus quantity bq
total_qty_mod_6 = self.get_total_quantity_for_pid(request, pid) % 6
bq = int((total_qty_mod_6 + q) / 6) # Integer division
q -= bq
if self.customer.amount < (
total + round(q * float(price), 2)
): # Check for enough money
request.session["not_enough"] = True
return False
if product.is_unrecord_product and not self.is_record_product_ok(
request, product
):
request.session["not_allowed"] = True
return False
if product.limit_age >= 18 and not user.date_of_birth:
request.session["no_age"] = True
return False
if product.limit_age >= 18 and user.is_banned_alcohol:
request.session["not_allowed"] = True
return False
if user.is_banned_counter:
request.session["not_allowed"] = True
return False
if (
user.date_of_birth and self.customer.user.get_age() < product.limit_age
): # Check if affordable
request.session["too_young"] = True
return False
if pid in request.session["basket"]: # Add if already in basket
request.session["basket"][pid]["qty"] += q
request.session["basket"][pid]["bonus_qty"] += bq
else: # or create if not
request.session["basket"][pid] = {
"qty": q,
"price": int(price * 100),
"bonus_qty": bq,
}
request.session.modified = True
return True
def del_product(self, request):
"""Delete a product from the basket."""
pid = parse_qs(request.body.decode())["product_id"][0]
product = self.get_product(pid)
if pid in request.session["basket"]:
if (
product.tray
and (self.get_total_quantity_for_pid(request, pid) % 6 == 0)
and request.session["basket"][pid]["bonus_qty"]
):
request.session["basket"][pid]["bonus_qty"] -= 1
else:
request.session["basket"][pid]["qty"] -= 1
if request.session["basket"][pid]["qty"] <= 0:
del request.session["basket"][pid]
request.session.modified = True
def parse_code(self, request):
"""Parse the string entered by the barman.
This can be of two forms :
- `<str>`, where the string is the code of the product
- `<int>X<str>`, where the integer is the quantity and str the code.
"""
string = parse_qs(request.body.decode()).get("code", [""])[0].upper()
if string == "FIN":
return self.finish(request)
elif string == "ANN":
return self.cancel(request)
regex = re.compile(r"^((?P<nb>[0-9]+)X)?(?P<code>[A-Z0-9]+)$")
m = regex.match(string)
if m is not None:
nb = m.group("nb")
code = m.group("code")
nb = int(nb) if nb is not None else 1
p = self.object.products.filter(code=code).first()
if p is not None:
self.add_product(request, nb, p.id)
context = self.get_context_data(object=self.object)
return self.render_to_response(context)
def finish(self, request):
"""Finish the click session, and validate the basket."""
with transaction.atomic(): with transaction.atomic():
request.session["last_basket"] = [] self.request.session["last_basket"] = []
if self.sum_basket(request) > self.customer.amount:
raise DataError(_("You have not enough money to buy all the basket"))
for pid, infos in request.session["basket"].items(): for form in formset:
# This duplicates code for DB optimization (prevent to load many times the same object) self.request.session["last_basket"].append(
p = Product.objects.filter(pk=pid).first() f"{form.cleaned_data['quantity']} x {form.product.name}"
if self.object.customer_is_barman(self.customer):
uprice = p.special_selling_price
else:
uprice = p.selling_price
request.session["last_basket"].append(
"%d x %s" % (infos["qty"] + infos["bonus_qty"], p.name)
) )
s = Selling(
label=p.name, Selling(
product=p, label=form.product.name,
club=p.club, product=form.product,
club=form.product.club,
counter=self.object, counter=self.object,
unit_price=uprice, unit_price=form.product.price,
quantity=infos["qty"], quantity=form.cleaned_data["quantity"]
seller=self.operator, - form.cleaned_data["bonus_quantity"],
seller=operator,
customer=self.customer, customer=self.customer,
) ).save()
s.save() if form.cleaned_data["bonus_quantity"] > 0:
if infos["bonus_qty"]: Selling(
s = Selling( label=f"{form.product.name} (Plateau)",
label=p.name + " (Plateau)", product=form.product,
product=p, club=form.product.club,
club=p.club,
counter=self.object, counter=self.object,
unit_price=0, unit_price=0,
quantity=infos["bonus_qty"], quantity=form.cleaned_data["bonus_quantity"],
seller=self.operator, seller=operator,
customer=self.customer, customer=self.customer,
) ).save()
s.save()
self.customer.recorded_products -= self.compute_record_product(request)
self.customer.save()
request.session["last_customer"] = self.customer.user.get_display_name()
request.session["last_total"] = "%0.2f" % self.sum_basket(request)
request.session["new_customer_amount"] = str(self.customer.amount)
del request.session["basket"]
request.session.modified = True
kwargs = {"counter_id": self.object.id}
return HttpResponseRedirect(
reverse_lazy("counter:details", args=self.args, kwargs=kwargs)
)
def cancel(self, request): self.customer.recorded_products -= formset.total_recordings
"""Cancel the click session.""" self.customer.save()
kwargs = {"counter_id": self.object.id}
request.session.pop("basket", None) # Add some info for the main counter view to display
return HttpResponseRedirect( self.request.session["last_customer"] = self.customer.user.get_display_name()
reverse_lazy("counter:details", args=self.args, kwargs=kwargs) self.request.session["last_total"] = f"{formset.total_price:0.2f}"
) self.request.session["new_customer_amount"] = str(self.customer.amount)
return ret
def get_success_url(self):
return resolve_url(self.object)
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)
products = self.object.products.select_related("product_type") kwargs["products"] = self.products
if self.object.customer_is_barman(self.customer):
products = products.annotate(price=F("special_selling_price"))
else:
products = products.annotate(price=F("selling_price"))
kwargs["products"] = products
kwargs["categories"] = {} kwargs["categories"] = {}
for product in kwargs["products"]: for product in kwargs["products"]:
if product.product_type: if product.product_type:
@ -393,8 +246,12 @@ class CounterClick(CounterTabsMixin, CanViewMixin, DetailView):
product product
) )
kwargs["customer"] = self.customer kwargs["customer"] = self.customer
kwargs["basket_total"] = self.sum_basket(self.request) kwargs["cancel_url"] = self.get_success_url()
# To get all forms errors to the javascript, we create a list of error list
kwargs["form_errors"] = [
list(field_error.values()) for field_error in kwargs["form"].errors
]
if self.object.type == "BAR": if self.object.type == "BAR":
kwargs["student_card_fragment"] = StudentCardFormView.get_template_data( kwargs["student_card_fragment"] = StudentCardFormView.get_template_data(
self.customer self.customer
@ -404,6 +261,7 @@ class CounterClick(CounterTabsMixin, CanViewMixin, DetailView):
kwargs["refilling_fragment"] = RefillingCreateView.get_template_data( kwargs["refilling_fragment"] = RefillingCreateView.get_template_data(
self.customer self.customer
).render(self.request) ).render(self.request)
return kwargs return kwargs
@ -442,10 +300,7 @@ class RefillingCreateView(FormView):
if not self.counter.can_refill(): if not self.counter.can_refill():
raise PermissionDenied raise PermissionDenied
if self.counter.customer_is_barman(self.customer): self.operator = get_operator(request, self.counter, self.customer)
self.operator = self.customer.user
else:
self.operator = self.counter.get_random_barman()
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)

View File

@ -43,6 +43,9 @@ class CounterMain(
) )
current_tab = "counter" current_tab = "counter"
def get_queryset(self):
return super().get_queryset().exclude(type="EBOUTIC")
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
self.object = self.get_object() self.object = self.get_object()
if self.object.type == "BAR" and not ( if self.object.type == "BAR" and not (

File diff suppressed because it is too large Load Diff

View File

@ -7,7 +7,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-12-18 16:26+0100\n" "POT-Creation-Date: 2024-12-23 02:38+0100\n"
"PO-Revision-Date: 2024-09-17 11:54+0200\n" "PO-Revision-Date: 2024-09-17 11:54+0200\n"
"Last-Translator: Sli <antoine@bartuccio.fr>\n" "Last-Translator: Sli <antoine@bartuccio.fr>\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@ -122,35 +122,43 @@ msgstr "photos.%(extension)s"
msgid "captured.%s" msgid "captured.%s"
msgstr "capture.%s" msgstr "capture.%s"
#: counter/static/bundled/counter/product-list-index.ts:39 #: counter/static/bundled/counter/counter-click-index.ts:60
msgid "Not enough money"
msgstr "Pas assez d'argent"
#: counter/static/bundled/counter/counter-click-index.ts:113
msgid "You can't send an empty basket."
msgstr "Vous ne pouvez pas envoyer un panier vide."
#: counter/static/bundled/counter/product-list-index.ts:40
msgid "name" msgid "name"
msgstr "nom" msgstr "nom"
#: counter/static/bundled/counter/product-list-index.ts:42 #: counter/static/bundled/counter/product-list-index.ts:43
msgid "product type" msgid "product type"
msgstr "type de produit" msgstr "type de produit"
#: counter/static/bundled/counter/product-list-index.ts:44 #: counter/static/bundled/counter/product-list-index.ts:45
msgid "limit age" msgid "limit age"
msgstr "limite d'âge" msgstr "limite d'âge"
#: counter/static/bundled/counter/product-list-index.ts:45 #: counter/static/bundled/counter/product-list-index.ts:46
msgid "purchase price" msgid "purchase price"
msgstr "prix d'achat" msgstr "prix d'achat"
#: counter/static/bundled/counter/product-list-index.ts:46 #: counter/static/bundled/counter/product-list-index.ts:47
msgid "selling price" msgid "selling price"
msgstr "prix de vente" msgstr "prix de vente"
#: counter/static/bundled/counter/product-list-index.ts:47 #: counter/static/bundled/counter/product-list-index.ts:48
msgid "archived" msgid "archived"
msgstr "archivé" msgstr "archivé"
#: counter/static/bundled/counter/product-list-index.ts:116 #: counter/static/bundled/counter/product-list-index.ts:125
msgid "Uncategorized" msgid "Uncategorized"
msgstr "Sans catégorie" msgstr "Sans catégorie"
#: counter/static/bundled/counter/product-list-index.ts:134 #: counter/static/bundled/counter/product-list-index.ts:143
msgid "products.csv" msgid "products.csv"
msgstr "produits.csv" msgstr "produits.csv"

6
package-lock.json generated
View File

@ -4608,9 +4608,9 @@
"dev": true "dev": true
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.7", "version": "3.3.8",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz",
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {

View File

@ -18,7 +18,8 @@
"imports": { "imports": {
"#openapi": "./staticfiles/generated/openapi/index.ts", "#openapi": "./staticfiles/generated/openapi/index.ts",
"#core:*": "./core/static/bundled/*", "#core:*": "./core/static/bundled/*",
"#pedagogy:*": "./pedagogy/static/bundled/*" "#pedagogy:*": "./pedagogy/static/bundled/*",
"#counter:*": "./counter/static/bundled/*"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.25.2", "@babel/core": "^7.25.2",

View File

@ -15,7 +15,8 @@
"paths": { "paths": {
"#openapi": ["./staticfiles/generated/openapi/index.ts"], "#openapi": ["./staticfiles/generated/openapi/index.ts"],
"#core:*": ["./core/static/bundled/*"], "#core:*": ["./core/static/bundled/*"],
"#pedagogy:*": ["./pedagogy/static/bundled/*"] "#pedagogy:*": ["./pedagogy/static/bundled/*"],
"#counter:*": ["./counter/static/bundled/*"]
} }
} }
} }