mirror of
https://github.com/ae-utbm/sith.git
synced 2024-12-23 00:01:16 +00:00
Make counter click client side first
This commit is contained in:
parent
eaac0c728f
commit
60f18669c8
@ -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(
|
||||||
@ -426,6 +428,13 @@ class Product(models.Model):
|
|||||||
def profit(self):
|
def profit(self):
|
||||||
return self.selling_price - self.purchase_price
|
return self.selling_price - self.purchase_price
|
||||||
|
|
||||||
|
def get_actual_price(self, counter: Counter, customer: Customer):
|
||||||
|
"""Return the price of the article taking into account if the customer has a special price
|
||||||
|
or not in the counter it's being purchased on"""
|
||||||
|
if counter.customer_is_barman(customer):
|
||||||
|
return self.special_selling_price
|
||||||
|
return self.selling_price
|
||||||
|
|
||||||
|
|
||||||
class CounterQuerySet(models.QuerySet):
|
class CounterQuerySet(models.QuerySet):
|
||||||
def annotate_has_barman(self, user: User) -> Self:
|
def annotate_has_barman(self, user: User) -> Self:
|
||||||
|
@ -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();
|
||||||
}
|
}
|
||||||
|
@ -1,24 +1,47 @@
|
|||||||
import { exportToHtml } from "#core:utils/globals";
|
import { exportToHtml } from "#core:utils/globals";
|
||||||
import type TomSelect from "tom-select";
|
|
||||||
|
const quantityForTrayPrice = 6;
|
||||||
|
|
||||||
interface CounterConfig {
|
interface CounterConfig {
|
||||||
csrfToken: string;
|
csrfToken: string;
|
||||||
clickApiUrl: string;
|
clickApiUrl: string;
|
||||||
sessionBasket: Record<number, BasketItem>;
|
|
||||||
customerBalance: number;
|
customerBalance: number;
|
||||||
customerId: number;
|
customerId: number;
|
||||||
|
products: Record<string, Product>;
|
||||||
}
|
}
|
||||||
interface BasketItem {
|
|
||||||
// biome-ignore lint/style/useNamingConvention: talking with python
|
interface Product {
|
||||||
bonus_qty: number;
|
code: string;
|
||||||
|
name: string;
|
||||||
price: number;
|
price: number;
|
||||||
qty: number;
|
hasTrayPrice: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
class BasketItem {
|
||||||
|
quantity: number;
|
||||||
|
product: Product;
|
||||||
|
|
||||||
|
constructor(product: Product, quantity: number) {
|
||||||
|
this.quantity = quantity;
|
||||||
|
this.product = product;
|
||||||
|
}
|
||||||
|
|
||||||
|
getBonusQuantity(): number {
|
||||||
|
if (!this.product.hasTrayPrice) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return Math.floor(this.quantity / quantityForTrayPrice);
|
||||||
|
}
|
||||||
|
|
||||||
|
sum(): number {
|
||||||
|
return (this.quantity - this.getBonusQuantity()) * this.product.price;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
||||||
@ -26,17 +49,58 @@ exportToHtml("loadCounter", (config: CounterConfig) => {
|
|||||||
init() {
|
init() {
|
||||||
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()");
|
||||||
|
},
|
||||||
|
|
||||||
|
getItemIdFromCode(code: string): string {
|
||||||
|
return Object.keys(config.products).find(
|
||||||
|
(key) => config.products[key].code === code,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
removeFromBasket(code: string) {
|
||||||
|
delete this.basket[this.getItemIdFromCode(code)];
|
||||||
|
},
|
||||||
|
|
||||||
|
addToBasket(code: string, quantity: number): [boolean, string] {
|
||||||
|
const id = this.getItemIdFromCode(code);
|
||||||
|
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 [true, ""];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.sum() > this.customerBalance) {
|
||||||
|
item.quantity = oldQty;
|
||||||
|
return [false, gettext("Not enough money")];
|
||||||
|
}
|
||||||
|
|
||||||
|
this.basket[id] = item;
|
||||||
|
return [true, ""];
|
||||||
|
},
|
||||||
|
|
||||||
|
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;
|
||||||
},
|
},
|
||||||
|
|
||||||
onRefillingSuccess(event: CustomEvent) {
|
onRefillingSuccess(event: CustomEvent) {
|
||||||
@ -50,33 +114,36 @@ exportToHtml("loadCounter", (config: CounterConfig) => {
|
|||||||
this.codeField.widget.focus();
|
this.codeField.widget.focus();
|
||||||
},
|
},
|
||||||
|
|
||||||
async handleCode(event: SubmitEvent) {
|
finish() {
|
||||||
const widget: TomSelect = this.codeField.widget;
|
this.$refs.basketForm.submit();
|
||||||
const code = (widget.getValue() as string).toUpperCase();
|
|
||||||
if (this.codeField.getOperationCodes().includes(code)) {
|
|
||||||
$(event.target).submit();
|
|
||||||
} else {
|
|
||||||
await this.handleAction(event);
|
|
||||||
}
|
|
||||||
widget.clear();
|
|
||||||
widget.focus();
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async handleAction(event: SubmitEvent) {
|
cancel() {
|
||||||
const payload = $(event.target).serialize();
|
this.basket = new Object({});
|
||||||
const request = new Request(config.clickApiUrl, {
|
// We need to wait for the templated form to be removed before sending
|
||||||
method: "POST",
|
this.$nextTick(() => {
|
||||||
body: payload,
|
this.finish();
|
||||||
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;
|
handleCode(event: SubmitEvent) {
|
||||||
this.errors = json.errors;
|
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.addToBasket(code, quantity);
|
||||||
|
}
|
||||||
|
this.codeField.widget.clear();
|
||||||
|
this.codeField.widget.focus();
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
@ -34,7 +34,12 @@
|
|||||||
<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" style="width: 20%;">
|
||||||
@ -72,53 +77,37 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<p>{% trans %}Basket: {% endtrans %}</p>
|
<p>{% trans %}Basket: {% endtrans %}</p>
|
||||||
|
<form method="post" action="" x-ref="basketForm">
|
||||||
<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 %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="action" value="del_product">
|
{{ form.errors }}
|
||||||
<input type="hidden" name="product_id" :value="id">
|
{{ form.non_form_errors() }}
|
||||||
<input type="submit" value="-"/>
|
<div x-ref="basketManagementForm">
|
||||||
</form>
|
{{ form.management_form }}
|
||||||
|
|
||||||
<span x-text="item['qty'] + item['bonus_qty']"></span>
|
|
||||||
|
|
||||||
<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>
|
</div>
|
||||||
</template>
|
<template x-for="(item, index) in Object.values(basket)">
|
||||||
|
<ul>
|
||||||
|
<button @click.prevent="addToBasket(item.product.code, -1)">-</button>
|
||||||
|
<span x-text="item.quantity"></span>
|
||||||
|
<button @click.prevent="addToBasket(item.product.code, 1)">+</button>
|
||||||
|
|
||||||
|
<span x-text="item.product.name"></span> :
|
||||||
|
<span x-text="item.sum().toLocaleString(undefined, { minimumFractionDigits: 2 })">€</span>
|
||||||
|
<span x-cloak x-show="item.getBonusQuantity() > 0" x-text="`${item.getBonusQuantity()} x P`"></span>
|
||||||
|
<button @click.prevent="removeFromBasket(item.product.code)"><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.code" :id="`id_form-${index}-code`" :name="`form-${index}-code`" required readonly>
|
||||||
</ul>
|
</ul>
|
||||||
|
</template>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<strong>Total: </strong>
|
<strong>Total: </strong>
|
||||||
<strong x-text="sumBasket().toLocaleString(undefined, { minimumFractionDigits: 2 })"></strong>
|
<strong x-text="sumBasket().toLocaleString(undefined, { minimumFractionDigits: 2 })"></strong>
|
||||||
<strong> €</strong>
|
<strong> €</strong>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form method="post"
|
<input type="submit" @click.prevent="finish" value="{% trans %}Finish{% endtrans %}"/>
|
||||||
action="{{ url('counter:click', counter_id=counter.id, user_id=customer.user.id) }}">
|
<input type="submit" @click.prevent="cancel" value="{% trans %}Cancel{% endtrans %}"/>
|
||||||
{% csrf_token %}
|
|
||||||
<input type="hidden" name="action" value="finish">
|
|
||||||
<input type="submit" value="{% trans %}Finish{% endtrans %}"/>
|
|
||||||
</form>
|
|
||||||
<form method="post"
|
|
||||||
action="{{ url('counter:click', counter_id=counter.id, user_id=customer.user.id) }}">
|
|
||||||
{% csrf_token %}
|
|
||||||
<input type="hidden" name="action" value="cancel">
|
|
||||||
<input type="submit" value="{% trans %}Cancel{% endtrans %}"/>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% if object.type == "BAR" %}
|
{% if object.type == "BAR" %}
|
||||||
@ -159,23 +148,16 @@
|
|||||||
{% for category in categories.keys() -%}
|
{% for category in categories.keys() -%}
|
||||||
<div id="cat_{{ category|slugify }}">
|
<div id="cat_{{ category|slugify }}">
|
||||||
<h5>{{ category }}</h5>
|
<h5>{{ category }}</h5>
|
||||||
{% for p in categories[category] -%}
|
{% for product in categories[category] -%}
|
||||||
<form method="post"
|
<button @click="addToBasket('{{ product.code }}', 1)">
|
||||||
action="{{ url('counter:click', counter_id=counter.id, user_id=customer.user.id) }}"
|
<strong>{{ product.name }}</strong>
|
||||||
class="form_button add_product_form" @submit.prevent="handleAction">
|
{% if product.icon %}
|
||||||
{% csrf_token %}
|
<img src="{{ product.icon.url }}" alt="image de {{ product.name }}"/>
|
||||||
<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 %}
|
{% else %}
|
||||||
<img src="{{ static('core/img/na.gif') }}" alt="image de {{ p.name }}"/>
|
<img src="{{ static('core/img/na.gif') }}" alt="image de {{ product.name }}"/>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span>{{ p.price }} €<br>{{ p.code }}</span>
|
<span>{{ product.price }} €<br>{{ product.code }}</span>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
|
||||||
{%- endfor %}
|
{%- endfor %}
|
||||||
</div>
|
</div>
|
||||||
{%- endfor %}
|
{%- endfor %}
|
||||||
@ -192,15 +174,15 @@
|
|||||||
code: "{{ p.code }}",
|
code: "{{ p.code }}",
|
||||||
name: "{{ p.name }}",
|
name: "{{ p.name }}",
|
||||||
price: {{ p.price }},
|
price: {{ p.price }},
|
||||||
|
hasTrayPrice: {{ p.tray | tojson }},
|
||||||
},
|
},
|
||||||
{%- endfor -%}
|
{%- endfor -%}
|
||||||
};
|
};
|
||||||
window.addEventListener("DOMContentLoaded", () => {
|
window.addEventListener("DOMContentLoaded", () => {
|
||||||
loadCounter({
|
loadCounter({
|
||||||
csrfToken: "{{ csrf_token }}",
|
csrfToken: "{{ csrf_token }}",
|
||||||
clickApiUrl: "{{ url('counter:click', counter_id=counter.id, user_id=customer.user.id) }}",
|
clickApiUrl: "{{ url('counter:click', counter_id=counter.id, user_id=customer.user.id) }}", customerBalance: {{ customer.amount }},
|
||||||
sessionBasket: {{ request.session["basket"]|tojson }},
|
products: products,
|
||||||
customerBalance: {{ customer.amount }},
|
|
||||||
customerId: {{ customer.pk }},
|
customerId: {{ customer.pk }},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -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.db.models import F
|
||||||
from django.http import Http404, HttpResponseRedirect, JsonResponse
|
from django.forms import (
|
||||||
from django.shortcuts import get_object_or_404, redirect
|
BaseFormSet,
|
||||||
|
IntegerField,
|
||||||
|
ModelForm,
|
||||||
|
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 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,111 @@ 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(counter: Counter, customer: Customer) -> User:
|
||||||
|
if counter.customer_is_barman(customer):
|
||||||
|
return customer.user
|
||||||
|
return counter.get_random_barman()
|
||||||
|
|
||||||
|
|
||||||
class CounterClick(CounterTabsMixin, CanViewMixin, DetailView):
|
class ProductForm(ModelForm):
|
||||||
|
quantity = IntegerField(min_value=1)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Product
|
||||||
|
fields = ["code"]
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*args,
|
||||||
|
customer: Customer | None = None,
|
||||||
|
counter: Counter | None = None,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
self.customer = customer
|
||||||
|
self.counter = counter
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned_data = super().clean()
|
||||||
|
if self.customer is None or self.counter is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"{self} has been initialized without customer or counter"
|
||||||
|
)
|
||||||
|
|
||||||
|
user = self.customer.user
|
||||||
|
|
||||||
|
# We store self.product so we can use it later on the formset validation
|
||||||
|
self.product = self.counter.products.filter(code=cleaned_data["code"]).first()
|
||||||
|
if self.product is None:
|
||||||
|
raise ValidationError(
|
||||||
|
_(
|
||||||
|
"Product %(product)s doesn't exist or isn't available on this counter"
|
||||||
|
)
|
||||||
|
% {"product": cleaned_data["code"]}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test alcohoolic products
|
||||||
|
if self.product.limit_age >= 18:
|
||||||
|
if not user.date_of_birth:
|
||||||
|
raise ValidationError(_("Too young for that product"))
|
||||||
|
if user.is_banned_alcohol:
|
||||||
|
raise ValidationError(_("Not allowed for that product"))
|
||||||
|
if user.date_of_birth and self.customer.user.get_age() < self.product.limit_age:
|
||||||
|
raise ValidationError(_("Too young for that product"))
|
||||||
|
|
||||||
|
if user.is_banned_counter:
|
||||||
|
raise ValidationError(_("Not allowed for that product"))
|
||||||
|
|
||||||
|
# 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["unit_price"] = self.product.get_actual_price(
|
||||||
|
self.counter, self.customer
|
||||||
|
)
|
||||||
|
cleaned_data["total_price"] = cleaned_data["unit_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_recorded_products(self[0].customer)
|
||||||
|
self._check_enough_money(self[0].counter, self[0].customer)
|
||||||
|
|
||||||
|
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,37 +152,18 @@ 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_form_kwargs(self):
|
||||||
if self.is_ajax(self.request):
|
kwargs = super().get_form_kwargs()
|
||||||
response = {"errors": []}
|
kwargs["form_kwargs"] = {
|
||||||
status = HTTPStatus.OK
|
"customer": self.customer,
|
||||||
|
"counter": self.object,
|
||||||
if self.request.session["too_young"]:
|
}
|
||||||
response["errors"].append(_("Too young for that product"))
|
return kwargs
|
||||||
status = HTTPStatus.UNAVAILABLE_FOR_LEGAL_REASONS
|
|
||||||
if self.request.session["not_allowed"]:
|
|
||||||
response["errors"].append(_("Not allowed for that product"))
|
|
||||||
status = HTTPStatus.FORBIDDEN
|
|
||||||
if self.request.session["no_age"]:
|
|
||||||
response["errors"].append(_("No date of birth provided"))
|
|
||||||
status = HTTPStatus.UNAVAILABLE_FOR_LEGAL_REASONS
|
|
||||||
if self.request.session["not_enough"]:
|
|
||||||
response["errors"].append(_("Not enough money"))
|
|
||||||
status = HTTPStatus.PAYMENT_REQUIRED
|
|
||||||
|
|
||||||
if len(response["errors"]) > 1:
|
|
||||||
status = HTTPStatus.BAD_REQUEST
|
|
||||||
|
|
||||||
response["basket"] = self.request.session["basket"]
|
|
||||||
|
|
||||||
return JsonResponse(response, status=status)
|
|
||||||
|
|
||||||
else: # Standard HTML page
|
|
||||||
return super().render_to_response(*args, **kwargs)
|
|
||||||
|
|
||||||
def dispatch(self, request, *args, **kwargs):
|
def dispatch(self, request, *args, **kwargs):
|
||||||
self.customer = get_object_or_404(Customer, user__id=self.kwargs["user_id"])
|
self.customer = get_object_or_404(Customer, user__id=self.kwargs["user_id"])
|
||||||
@ -90,301 +177,74 @@ class CounterClick(CounterTabsMixin, CanViewMixin, DetailView):
|
|||||||
or request.session["counter_token"] != obj.token
|
or request.session["counter_token"] != obj.token
|
||||||
or len(obj.barmen_list) == 0
|
or len(obj.barmen_list) == 0
|
||||||
):
|
):
|
||||||
return redirect(obj)
|
return redirect(obj) # Redirect to counter
|
||||||
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"] = {}
|
if len(formset) == 0:
|
||||||
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
|
return ret
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
operator = get_operator(self.object, self.customer)
|
||||||
"""Handle the many possibilities of the post request."""
|
with transaction.atomic():
|
||||||
self.object = self.get_object()
|
self.request.session["last_basket"] = []
|
||||||
if (self.object.type != "BAR" and not request.user.is_authenticated) or (
|
|
||||||
self.object.type == "BAR" and len(self.object.barmen_list) < 1
|
for form in formset:
|
||||||
): # Check that at least one barman is logged in
|
self.request.session["last_basket"].append(
|
||||||
return self.cancel(request)
|
f"{form.cleaned_data['quantity']} x {form.product.name}"
|
||||||
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"
|
|
||||||
)
|
Selling(
|
||||||
if "basket" not in request.session:
|
label=form.product.name,
|
||||||
request.session["basket"] = {}
|
product=form.product,
|
||||||
request.session["basket_total"] = 0
|
club=form.product.club,
|
||||||
request.session["not_enough"] = False # Reset every variable
|
counter=self.object,
|
||||||
request.session["too_young"] = False
|
unit_price=form.cleaned_data["unit_price"],
|
||||||
request.session["not_allowed"] = False
|
quantity=form.cleaned_data["quantity"]
|
||||||
request.session["no_age"] = False
|
- form.cleaned_data["bonus_quantity"],
|
||||||
if self.object.type != "BAR":
|
seller=operator,
|
||||||
self.operator = request.user
|
customer=self.customer,
|
||||||
elif self.object.customer_is_barman(self.customer):
|
).save()
|
||||||
self.operator = self.customer.user
|
if form.cleaned_data["bonus_quantity"] > 0:
|
||||||
else:
|
Selling(
|
||||||
self.operator = self.object.get_random_barman()
|
label=f"{form.product.name} (Plateau)",
|
||||||
action = self.request.POST.get("action", None)
|
product=form.product,
|
||||||
if action is None:
|
club=form.product.club,
|
||||||
action = parse_qs(request.body.decode()).get("action", [""])[0]
|
counter=self.object,
|
||||||
if action == "add_product":
|
unit_price=0,
|
||||||
self.add_product(request)
|
quantity=form.cleaned_data["bonus_quantity"],
|
||||||
elif action == "del_product":
|
seller=operator,
|
||||||
self.del_product(request)
|
customer=self.customer,
|
||||||
elif action == "code":
|
).save()
|
||||||
return self.parse_code(request)
|
|
||||||
elif action == "cancel":
|
self.customer.recorded_products -= formset.total_recordings
|
||||||
return self.cancel(request)
|
self.customer.save()
|
||||||
elif action == "finish":
|
|
||||||
return self.finish(request)
|
# Add some info for the main counter view to display
|
||||||
context = self.get_context_data(object=self.object)
|
self.request.session["last_customer"] = self.customer.user.get_display_name()
|
||||||
return self.render_to_response(context)
|
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_product(self, pid):
|
def get_product(self, pid):
|
||||||
return Product.objects.filter(pk=int(pid)).first()
|
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():
|
|
||||||
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():
|
|
||||||
# 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)
|
|
||||||
)
|
|
||||||
s = Selling(
|
|
||||||
label=p.name,
|
|
||||||
product=p,
|
|
||||||
club=p.club,
|
|
||||||
counter=self.object,
|
|
||||||
unit_price=uprice,
|
|
||||||
quantity=infos["qty"],
|
|
||||||
seller=self.operator,
|
|
||||||
customer=self.customer,
|
|
||||||
)
|
|
||||||
s.save()
|
|
||||||
if infos["bonus_qty"]:
|
|
||||||
s = Selling(
|
|
||||||
label=p.name + " (Plateau)",
|
|
||||||
product=p,
|
|
||||||
club=p.club,
|
|
||||||
counter=self.object,
|
|
||||||
unit_price=0,
|
|
||||||
quantity=infos["bonus_qty"],
|
|
||||||
seller=self.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)
|
|
||||||
)
|
|
||||||
|
|
||||||
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)
|
|
||||||
)
|
|
||||||
|
|
||||||
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")
|
products = self.object.products.select_related("product_type")
|
||||||
|
|
||||||
|
# Optimisation to bulk edit prices instead of `calling get_actual_price` on everything
|
||||||
if self.object.customer_is_barman(self.customer):
|
if self.object.customer_is_barman(self.customer):
|
||||||
products = products.annotate(price=F("special_selling_price"))
|
products = products.annotate(price=F("special_selling_price"))
|
||||||
else:
|
else:
|
||||||
products = products.annotate(price=F("selling_price"))
|
products = products.annotate(price=F("selling_price"))
|
||||||
|
|
||||||
kwargs["products"] = products
|
kwargs["products"] = products
|
||||||
kwargs["categories"] = {}
|
kwargs["categories"] = {}
|
||||||
for product in kwargs["products"]:
|
for product in kwargs["products"]:
|
||||||
@ -393,7 +253,6 @@ class CounterClick(CounterTabsMixin, CanViewMixin, DetailView):
|
|||||||
product
|
product
|
||||||
)
|
)
|
||||||
kwargs["customer"] = self.customer
|
kwargs["customer"] = self.customer
|
||||||
kwargs["basket_total"] = self.sum_basket(self.request)
|
|
||||||
|
|
||||||
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(
|
||||||
@ -404,6 +263,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 +302,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(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)
|
||||||
|
|
||||||
|
6
package-lock.json
generated
6
package-lock.json
generated
@ -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": [
|
||||||
{
|
{
|
||||||
|
Loading…
Reference in New Issue
Block a user