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 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(
self,
godfathers_depth: NonNegativeInt = 4,

View File

@ -208,6 +208,7 @@ body {
a.btn {
display: inline-block;
}
.btn {
font-size: 15px;
font-weight: normal;
@ -336,7 +337,8 @@ body {
margin-left: -125px;
box-sizing: border-box;
position: fixed;
z-index: 1;
z-index: 10;
/* to get on top of tomselect */
left: 50%;
top: 60px;
text-align: center;
@ -431,12 +433,17 @@ body {
flex-wrap: wrap;
$col-gap: 1rem;
$row-gap: 0.5rem;
&.gap {
column-gap: var($col-gap);
row-gap: var($row-gap);
}
@for $i from 2 through 5 {
&.gap-#{$i}x {
column-gap: $i * $col-gap;
row-gap: $i * $row-gap;
}
}
}
@ -1242,40 +1249,6 @@ u,
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_mini_profile {

View File

@ -60,7 +60,7 @@
{% endif %}
{% if user.date_of_birth %}
<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>
{% endif %}
</div>

View File

@ -87,7 +87,7 @@ class GetUserForm(forms.Form):
def clean(self):
cleaned_data = super().clean()
cus = None
customer = None
if cleaned_data["code"] != "":
if len(cleaned_data["code"]) == StudentCard.UID_SIZE:
card = (
@ -96,17 +96,18 @@ class GetUserForm(forms.Form):
.first()
)
if card is not None:
cus = card.customer
if cus is None:
cus = Customer.objects.filter(
customer = card.customer
if customer is None:
customer = Customer.objects.filter(
account_id__iexact=cleaned_data["code"]
).first()
elif cleaned_data["id"] is not None:
cus = Customer.objects.filter(user=cleaned_data["id"]).first()
if cus is None or not cus.can_buy:
elif cleaned_data["id"]:
customer = Customer.objects.filter(user=cleaned_data["id"]).first()
if customer is None or not customer.can_buy:
raise forms.ValidationError(_("User not found"))
cleaned_data["user_id"] = cus.user.id
cleaned_data["user"] = cus.user
cleaned_data["user_id"] = customer.user.id
cleaned_data["user"] = customer.user
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 timezone as tz
from decimal import Decimal
from typing import Self, Tuple
from typing import Self
from dict2xml import dict2xml
from django.conf import settings
@ -138,7 +138,7 @@ class Customer(models.Model):
return (date.today() - subscription.subscription_end) < timedelta(days=90)
@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,
but with the default field replaced by some under the hood.
@ -327,6 +327,8 @@ class ProductType(OrderedModel):
class Product(models.Model):
"""A product, with all its related information."""
QUANTITY_FOR_TRAY_PRICE = 6
name = models.CharField(_("name"), max_length=64)
description = models.TextField(_("description"), default="")
product_type = models.ForeignKey(
@ -525,7 +527,7 @@ class Counter(models.Model):
if user.is_anonymous:
return False
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 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
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):
def annotate_total(self) -> Self:
@ -761,7 +791,8 @@ class SellingQuerySet(models.QuerySet):
class Selling(models.Model):
"""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,
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"];
}
public getSelectedProduct(): [number, string] {
return parseProduct(this.widget.getValue() as string);
}
protected attachBehaviors(): void {
this.allowMultipleProducts();
}

View File

@ -1,42 +1,100 @@
import { exportToHtml } from "#core:utils/globals";
import type TomSelect from "tom-select";
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;
}
import { BasketItem } from "#counter:counter/basket";
import type { CounterConfig, ErrorMessage } from "#counter:counter/types";
exportToHtml("loadCounter", (config: CounterConfig) => {
document.addEventListener("alpine:init", () => {
Alpine.data("counter", () => ({
basket: config.sessionBasket,
basket: {} as Record<string, BasketItem>,
errors: [],
customerBalance: config.customerBalance,
codeField: undefined,
alertMessage: {
content: "",
show: false,
timeout: null,
},
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.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() {
if (!this.basket || Object.keys(this.basket).length === 0) {
if (this.getBasketSize() === 0) {
return 0;
}
const total = Object.values(this.basket).reduce(
(acc: number, cur: BasketItem) => acc + cur.qty * cur.price,
(acc: number, cur: BasketItem) => acc + cur.sum(),
0,
) 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) {
@ -50,33 +108,36 @@ exportToHtml("loadCounter", (config: CounterConfig) => {
this.codeField.widget.focus();
},
async handleCode(event: SubmitEvent) {
const widget: TomSelect = this.codeField.widget;
const code = (widget.getValue() as string).toUpperCase();
if (this.codeField.getOperationCodes().includes(code)) {
$(event.target).submit();
} else {
await this.handleAction(event);
finish() {
if (this.getBasketSize() === 0) {
this.showAlertMessage(gettext("You can't send an empty basket."));
return;
}
widget.clear();
widget.focus();
this.$refs.basketForm.submit();
},
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;
cancel() {
location.href = config.cancelUrl;
},
handleCode() {
const [quantity, code] = this.codeField.getSelectedProduct() as [
number,
string,
];
if (this.codeField.getOperationCodes().includes(code.toUpperCase())) {
if (code === "ANN") {
this.cancel();
}
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 */
// biome-ignore lint/suspicious/noExplicitAny: dealing with legacy jquery
($("#click_form") as any).accordion({
($("#click-form") as any).accordion({
heightStyle: "content",
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 %}
{% 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('core/components/ajax-select.scss') }}" defer></link>
<link rel="stylesheet" href="{{ static("core/components/card.scss") }}">
{% endblock %}
{% block additional_js %}
@ -23,7 +25,7 @@
{% endblock %}
{% block content %}
<h4 id="click_interface">{{ counter }}</h4>
<h4>{{ counter }}</h4>
<div id="bar-ui" x-data="counter">
<noscript>
@ -34,20 +36,21 @@
<h5>{% trans %}Customer{% endtrans %}</h5>
{{ user_mini_profile(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 id="click_form" style="width: 20%;">
<div id="click-form">
<h5 id="selling-accordion">{% trans %}Selling{% endtrans %}</h5>
<div>
{% set counter_click_url = url('counter:click', counter_id=counter.id, user_id=customer.user_id) %}
{# Formulaire pour rechercher un produit en tapant son code dans une barre de recherche #}
<form method="post" action=""
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 %}">
<option value=""></option>
@ -58,7 +61,7 @@
{% for category in categories.keys() %}
<optgroup label="{{ category }}">
{% for product in categories[category] %}
<option value="{{ product.code }}">{{ product }}</option>
<option value="{{ product.id }}">{{ product }}</option>
{% endfor %}
</optgroup>
{% endfor %}
@ -67,58 +70,91 @@
<input type="submit" value="{% trans %}Go{% endtrans %}"/>
</form>
<template x-for="error in errors">
<div class="alert alert-red" x-text="error">
{% for error in form.non_form_errors() %}
<div class="alert alert-red">
{{ error }}
</div>
</template>
{% endfor %}
<p>{% trans %}Basket: {% endtrans %}</p>
<ul>
<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>
<form x-cloak method="post" action="" x-ref="basketForm">
<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 %}
<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 %}"/>
<div x-ref="basketManagementForm">
{{ form.management_form }}
</div>
<ul>
<li x-show="getBasketSize() === 0">{% trans %}This basket is empty{% endtrans %}</li>
<template x-for="(item, index) in Object.values(basket)">
<li>
<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>
</div>
{% if object.type == "BAR" %}
@ -151,34 +187,41 @@
</div>
<div id="products">
<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>{{ 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 %}
{% if not products %}
<div class="alert alert-red">
{% trans %}No products available on this counter for this user{% endtrans %}
</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>
{% endblock content %}
@ -187,21 +230,38 @@
{{ super() }}
<script>
const products = {
{%- for p in products -%}
{{ p.id }}: {
code: "{{ p.code }}",
name: "{{ p.name }}",
price: {{ p.price }},
{%- for product in products -%}
{{ product.id }}: {
id: "{{ product.id }}",
name: "{{ product.name }}",
price: {{ product.price }},
hasTrayPrice: {{ product.tray | tojson }},
quantityForTrayPrice: {{ product.QUANTITY_FOR_TRAY_PRICE }},
},
{%- 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", () => {
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 }},
products: products,
customerId: {{ customer.pk }},
formInitial: formInitial,
cancelUrl: "{{ cancel_url }}",
});
});
</script>

View File

@ -59,5 +59,26 @@
{% endif %}
{% 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"
#
#
import re
from dataclasses import asdict, dataclass
from datetime import timedelta
from decimal import Decimal
import pytest
from django.conf import settings
from django.contrib.auth.models import make_password
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.utils import timezone
from django.utils.timezone import now
from django.utils.timezone import localdate, now
from freezegun import freeze_time
from model_bakery import baker
from club.models import Club, Membership
from core.baker_recipes import subscriber_user
from core.models import User
from core.baker_recipes import board_user, subscriber_user, very_old_subscriber_user
from core.models import Group, User
from counter.baker_recipes import product_recipe
from counter.models import (
Counter,
Customer,
Permanency,
Product,
Refilling,
Selling,
)
class TestCounter(TestCase):
class TestFullClickBase(TestCase):
@classmethod
def setUpTestData(cls):
cls.skia = User.objects.filter(username="skia").first()
cls.sli = User.objects.filter(username="sli").first()
cls.krophil = User.objects.filter(username="krophil").first()
cls.richard = User.objects.filter(username="rbatsbak").first()
cls.mde = Counter.objects.filter(name="MDE").first()
cls.foyer = Counter.objects.get(id=2)
cls.customer = subscriber_user.make()
cls.barmen = subscriber_user.make(password=make_password("plop"))
cls.board_admin = board_user.make(password=make_password("plop"))
cls.club_admin = subscriber_user.make()
cls.root = baker.make(User, is_superuser=True)
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(
reverse("counter:login", kwargs={"counter_id": self.mde.id}),
{"username": self.skia.username, "password": "plop"},
)
response = self.client.get(
reverse("counter:details", kwargs={"counter_id": self.mde.id})
reverse("counter:login", args=[self.counter.id]),
{"username": used_barman.username, "password": "plop"},
)
assert 'class="link-button">S&#39; Kia</button>' in str(response.content)
counter_token = re.search(
r'name="counter_token" value="([^"]*)"', str(response.content)
).group(1)
response = self.client.post(
reverse("counter:details", kwargs={"counter_id": self.mde.id}),
{"code": self.richard.customer.account_id, "counter_token": counter_token},
)
counter_url = response.get("location")
refill_url = reverse(
"counter:refilling_create",
kwargs={"customer_id": self.richard.customer.pk},
)
response = self.client.get(counter_url)
assert ">Richard Batsbak</" in str(response.content)
self.client.post(
refill_url,
def refill_user(
self,
user: User | Customer,
counter: Counter,
amount: int,
client: Client | None = None,
) -> HttpResponse:
used_client = client if client is not None else self.client
return used_client.post(
reverse(
"counter:refilling_create",
kwargs={"customer_id": user.pk},
),
{
"amount": "5",
"amount": str(amount),
"payment_method": "CASH",
"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(
counter_url, "action=add_product&product_id=4", content_type="text/xml"
)
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"
reverse("counter:login", args=[self.counter.id]),
{"username": used_barman.username, "password": "plop"},
)
response_get = self.client.get(response.get("location"))
response_content = response_get.content.decode("utf-8")
assert "2 x Barbar" in str(response_content)
assert "2 x Déconsigne Eco-cup" in str(response_content)
assert "<p>Client : Richard Batsbak - Nouveau montant : 3.60" in str(
response_content
@classmethod
def set_age(cls, user: User, age: int):
user.date_of_birth = localdate().replace(year=localdate().year - age)
user.save()
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(
reverse("counter:login", kwargs={"counter_id": self.mde.id}),
{"username": self.sli.username, "password": "plop"},
def refill_user(self, user: User, amount: Decimal | int):
baker.make(Refilling, amount=amount, customer=user.customer, is_validated=False)
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(
refill_url,
{
"amount": "5",
"payment_method": "CASH",
"bank": "OTHER",
},
HTTP_REFERER=counter_url,
)
assert response.status_code == 302
def test_click_office_success(self):
self.refill_user(self.customer, 10)
self.client.force_login(self.club_admin)
self.client.post(
reverse("counter:login", kwargs={"counter_id": self.foyer.id}),
{"username": self.krophil.username, "password": "plop"},
assert (
self.submit_basket(
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(
reverse("counter:details", kwargs={"counter_id": self.foyer.id})
assert self.updated_amount(self.club_admin) == Decimal("8.5")
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(
r'name="counter_token" value="([^"]*)"', str(response.content)
).group(1)
assert self.updated_amount(self.customer) == Decimal("5.5")
response = self.client.post(
reverse("counter:details", kwargs={"counter_id": self.foyer.id}),
{"code": self.richard.customer.account_id, "counter_token": counter_token},
)
counter_url = response.get("location")
refill_url = reverse(
"counter:refilling_create",
kwargs={
"customer_id": self.richard.customer.pk,
},
# Test barmen special price
self.refill_user(self.barmen, 10)
assert (
self.submit_basket(self.barmen, [BasketItem(self.beer.id, 1)])
).status_code == 302
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(
refill_url,
{
"amount": "5",
"payment_method": "CASH",
"bank": "OTHER",
},
HTTP_REFERER=counter_url,
assert self.updated_amount(self.customer) == Decimal("17")
# Applying tray price
assert (
self.submit_basket(
self.customer,
[
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):
"""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.sli)
counters = Counter.objects.annotate_has_barman(self.barmen)
for counter in counters:
if counter.name in ("Foyer", "MDE"):
if counter in (self.counter, self.other_counter):
assert counter.has_annotated_barman
else:
assert not counter.has_annotated_barman
@ -436,4 +859,4 @@ class TestClubCounterClickAccess(TestCase):
self.counter.sellers.add(self.user)
self.client.force_login(self.user)
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"
#
#
import re
from http import HTTPStatus
from typing import TYPE_CHECKING
from urllib.parse import parse_qs
import math
from django.core.exceptions import PermissionDenied
from django.db import DataError, transaction
from django.db.models import F
from django.http import Http404, HttpResponseRedirect, JsonResponse
from django.shortcuts import get_object_or_404, redirect
from django.db import transaction
from django.forms import (
BaseFormSet,
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.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.views import CanViewMixin
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.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
This is a detail view not to have to worry about loading the counter
Everything is made by hand in the post method.
@ -46,346 +143,102 @@ class CounterClick(CounterTabsMixin, CanViewMixin, DetailView):
model = Counter
queryset = Counter.objects.annotate_is_open()
form_class = BasketForm
template_name = "counter/counter_click.jinja"
pk_url_kwarg = "counter_id"
current_tab = "counter"
def render_to_response(self, *args, **kwargs):
if self.is_ajax(self.request):
response = {"errors": []}
status = HTTPStatus.OK
def get_queryset(self):
return super().get_queryset().exclude(type="EBOUTIC").annotate_is_open()
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 get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs["form_kwargs"] = {
"customer": self.customer,
"counter": self.object,
"allowed_products": {product.id: product for product in self.products},
}
return kwargs
def dispatch(self, request, *args, **kwargs):
self.customer = get_object_or_404(Customer, user__id=self.kwargs["user_id"])
obj: Counter = self.get_object()
if not self.customer.can_buy:
raise Http404
if obj.type != "BAR" and not request.user.is_authenticated:
raise PermissionDenied
if obj.type == "BAR" and (
"counter_token" not in request.session
or request.session["counter_token"] != obj.token
or len(obj.barmen_list) == 0
if not self.customer.can_buy or self.customer.user.is_banned_counter:
return redirect(obj) # Redirect to counter
if obj.type == "OFFICE" and (
obj.sellers.filter(pk=request.user.pk).exists()
or not obj.club.has_rights_in_club(request.user)
):
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)
def get(self, request, *args, **kwargs):
"""Simple get view."""
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 form_valid(self, formset):
ret = super().form_valid(formset)
def post(self, request, *args, **kwargs):
"""Handle the many possibilities of the post request."""
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)
if len(formset) == 0:
return ret
def get_product(self, pid):
return Product.objects.filter(pk=int(pid)).first()
def get_price(self, pid):
p = self.get_product(pid)
if self.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."""
operator = get_operator(self.request, self.object, self.customer)
with transaction.atomic():
request.session["last_basket"] = []
if self.sum_basket(request) > self.customer.amount:
raise DataError(_("You have not enough money to buy all the basket"))
self.request.session["last_basket"] = []
for pid, infos in request.session["basket"].items():
# This duplicates code for DB optimization (prevent to load many times the same object)
p = Product.objects.filter(pk=pid).first()
if self.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)
for form in formset:
self.request.session["last_basket"].append(
f"{form.cleaned_data['quantity']} x {form.product.name}"
)
s = Selling(
label=p.name,
product=p,
club=p.club,
Selling(
label=form.product.name,
product=form.product,
club=form.product.club,
counter=self.object,
unit_price=uprice,
quantity=infos["qty"],
seller=self.operator,
unit_price=form.product.price,
quantity=form.cleaned_data["quantity"]
- form.cleaned_data["bonus_quantity"],
seller=operator,
customer=self.customer,
)
s.save()
if infos["bonus_qty"]:
s = Selling(
label=p.name + " (Plateau)",
product=p,
club=p.club,
).save()
if form.cleaned_data["bonus_quantity"] > 0:
Selling(
label=f"{form.product.name} (Plateau)",
product=form.product,
club=form.product.club,
counter=self.object,
unit_price=0,
quantity=infos["bonus_qty"],
seller=self.operator,
quantity=form.cleaned_data["bonus_quantity"],
seller=operator,
customer=self.customer,
)
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)
)
).save()
def cancel(self, request):
"""Cancel the click session."""
kwargs = {"counter_id": self.object.id}
request.session.pop("basket", None)
return HttpResponseRedirect(
reverse_lazy("counter:details", args=self.args, kwargs=kwargs)
)
self.customer.recorded_products -= formset.total_recordings
self.customer.save()
# Add some info for the main counter view to display
self.request.session["last_customer"] = self.customer.user.get_display_name()
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):
"""Add customer to the context."""
kwargs = super().get_context_data(**kwargs)
products = self.object.products.select_related("product_type")
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["products"] = self.products
kwargs["categories"] = {}
for product in kwargs["products"]:
if product.product_type:
@ -393,8 +246,12 @@ class CounterClick(CounterTabsMixin, CanViewMixin, DetailView):
product
)
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":
kwargs["student_card_fragment"] = StudentCardFormView.get_template_data(
self.customer
@ -404,6 +261,7 @@ class CounterClick(CounterTabsMixin, CanViewMixin, DetailView):
kwargs["refilling_fragment"] = RefillingCreateView.get_template_data(
self.customer
).render(self.request)
return kwargs
@ -442,10 +300,7 @@ class RefillingCreateView(FormView):
if not self.counter.can_refill():
raise PermissionDenied
if self.counter.customer_is_barman(self.customer):
self.operator = self.customer.user
else:
self.operator = self.counter.get_random_barman()
self.operator = get_operator(request, self.counter, self.customer)
return super().dispatch(request, *args, **kwargs)

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -7,7 +7,7 @@
msgid ""
msgstr ""
"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"
"Last-Translator: Sli <antoine@bartuccio.fr>\n"
"Language-Team: AE info <ae.info@utbm.fr>\n"
@ -122,35 +122,43 @@ msgstr "photos.%(extension)s"
msgid "captured.%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"
msgstr "nom"
#: counter/static/bundled/counter/product-list-index.ts:42
#: counter/static/bundled/counter/product-list-index.ts:43
msgid "product type"
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"
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"
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"
msgstr "prix de vente"
#: counter/static/bundled/counter/product-list-index.ts:47
#: counter/static/bundled/counter/product-list-index.ts:48
msgid "archived"
msgstr "archivé"
#: counter/static/bundled/counter/product-list-index.ts:116
#: counter/static/bundled/counter/product-list-index.ts:125
msgid "Uncategorized"
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"
msgstr "produits.csv"

6
package-lock.json generated
View File

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

View File

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

View File

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