diff --git a/core/models.py b/core/models.py index 347e9bd4..c8375727 100644 --- a/core/models.py +++ b/core/models.py @@ -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, diff --git a/core/static/core/style.scss b/core/static/core/style.scss index 61eb71e0..cf1cedc6 100644 --- a/core/static/core/style.scss +++ b/core/static/core/style.scss @@ -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 { diff --git a/core/templates/core/macros.jinja b/core/templates/core/macros.jinja index e624e87a..84c5b05a 100644 --- a/core/templates/core/macros.jinja +++ b/core/templates/core/macros.jinja @@ -60,7 +60,7 @@ {% endif %} {% if user.date_of_birth %}
- {{ user.date_of_birth|date("d/m/Y") }} ({{ user.get_age() }}) + {{ user.date_of_birth|date("d/m/Y") }} ({{ user.age }})
{% endif %} diff --git a/counter/forms.py b/counter/forms.py index 80a0e0ad..cfd1b46d 100644 --- a/counter/forms.py +++ b/counter/forms.py @@ -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 diff --git a/counter/migrations/0029_alter_selling_label.py b/counter/migrations/0029_alter_selling_label.py new file mode 100644 index 00000000..f239efd4 --- /dev/null +++ b/counter/migrations/0029_alter_selling_label.py @@ -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"), + ), + ] diff --git a/counter/models.py b/counter/models.py index 48bb841f..6668e520 100644 --- a/counter/models.py +++ b/counter/models.py @@ -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", diff --git a/counter/static/bundled/counter/basket.ts b/counter/static/bundled/counter/basket.ts new file mode 100644 index 00000000..34d244e1 --- /dev/null +++ b/counter/static/bundled/counter/basket.ts @@ -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; + } +} diff --git a/counter/static/bundled/counter/components/counter-product-select-index.ts b/counter/static/bundled/counter/components/counter-product-select-index.ts index 20aa23a5..aecdaa03 100644 --- a/counter/static/bundled/counter/components/counter-product-select-index.ts +++ b/counter/static/bundled/counter/components/counter-product-select-index.ts @@ -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(); } diff --git a/counter/static/bundled/counter/counter-click-index.ts b/counter/static/bundled/counter/counter-click-index.ts index ef8ba2db..7ab8de1d 100644 --- a/counter/static/bundled/counter/counter-click-index.ts +++ b/counter/static/bundled/counter/counter-click-index.ts @@ -1,42 +1,100 @@ import { exportToHtml } from "#core:utils/globals"; -import type TomSelect from "tom-select"; - -interface CounterConfig { - csrfToken: string; - clickApiUrl: string; - sessionBasket: Record; - customerBalance: number; - customerId: number; -} -interface BasketItem { - // biome-ignore lint/style/useNamingConvention: talking with python - bonus_qty: number; - price: number; - qty: number; -} +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, 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(), }); diff --git a/counter/static/bundled/counter/types.d.ts b/counter/static/bundled/counter/types.d.ts new file mode 100644 index 00000000..4a22a916 --- /dev/null +++ b/counter/static/bundled/counter/types.d.ts @@ -0,0 +1,25 @@ +type ErrorMessage = string; + +export interface InitialFormData { + /* Used to refill the form when the backend raises an error */ + id?: keyof Record; + quantity?: number; + errors?: string[]; +} + +export interface CounterConfig { + customerBalance: number; + customerId: number; + products: Record; + formInitial: InitialFormData[]; + cancelUrl: string; +} + +export interface Product { + id: string; + code: string; + name: string; + price: number; + hasTrayPrice: boolean; + quantityForTrayPrice: number; +} diff --git a/counter/static/counter/css/counter-click.scss b/counter/static/counter/css/counter-click.scss new file mode 100644 index 00000000..c715867a --- /dev/null +++ b/counter/static/counter/css/counter-click.scss @@ -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; + } +} \ No newline at end of file diff --git a/counter/templates/counter/counter_click.jinja b/counter/templates/counter/counter_click.jinja index 01b38be1..82707e2e 100644 --- a/counter/templates/counter/counter_click.jinja +++ b/counter/templates/counter/counter_click.jinja @@ -6,8 +6,10 @@ {% endblock %} {% block additional_css %} + + {% endblock %} {% block additional_js %} @@ -23,7 +25,7 @@ {% endblock %} {% block content %} -

{{ counter }}

+

{{ counter }}

-
+
{% trans %}Selling{% endtrans %}
{% 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 #}
- {% csrf_token %} - - @@ -58,7 +61,7 @@ {% for category in categories.keys() %} {% for product in categories[category] %} - + {% endfor %} {% endfor %} @@ -67,58 +70,91 @@ - + {% endfor %}

{% trans %}Basket: {% endtrans %}

-
    - -
-

- Total: - - -

- -
{% csrf_token %} - - -
-
- {% csrf_token %} - - +
+ {{ form.management_form }} +
+
    +
  • {% trans %}This basket is empty{% endtrans %}
  • + +
+ +

+ Total: + + +

+ +
+ + +
{% if object.type == "BAR" %} @@ -151,34 +187,41 @@
-
    - {% for category in categories.keys() -%} -
  • {{ category }}
  • - {%- endfor %} -
- {% for category in categories.keys() -%} -
-
{{ category }}
- {% for p in categories[category] -%} -
- {% csrf_token %} - - - -
- {%- endfor %} + {% if not products %} +
+ {% trans %}No products available on this counter for this user{% endtrans %}
- {%- endfor %} + {% else %} +
    + {% for category in categories.keys() -%} +
  • {{ category }}
  • + {%- endfor %} +
+ {% for category in categories.keys() -%} +
+
{{ category }}
+
+ {% for product in categories[category] -%} + + {%- endfor %} +
+
+ {%- endfor %} + {% endif %}
{% endblock content %} @@ -187,21 +230,38 @@ {{ super() }} diff --git a/counter/templates/counter/counter_main.jinja b/counter/templates/counter/counter_main.jinja index fcf1bb43..b418f263 100644 --- a/counter/templates/counter/counter_main.jinja +++ b/counter/templates/counter/counter_main.jinja @@ -59,5 +59,26 @@ {% endif %} {% endblock %} +{% block script %} + {{ super() }} + +{% endblock %} + diff --git a/counter/tests/test_counter.py b/counter/tests/test_counter.py index 154578c6..1b378d5b 100644 --- a/counter/tests/test_counter.py +++ b/counter/tests/test_counter.py @@ -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' Kia' 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 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 "

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 diff --git a/counter/views/click.py b/counter/views/click.py index 9542b467..f324a9fc 100644 --- a/counter/views/click.py +++ b/counter/views/click.py @@ -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 : - - ``, where the string is the code of the product - - `X`, 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[0-9]+)X)?(?P[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) diff --git a/counter/views/home.py b/counter/views/home.py index 60cc5a5a..d66b0969 100644 --- a/counter/views/home.py +++ b/counter/views/home.py @@ -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 ( diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 3e15ddf0..d6c2e813 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-12-21 02:15+0100\n" +"POT-Creation-Date: 2024-12-23 02:37+0100\n" "PO-Revision-Date: 2016-07-18\n" "Last-Translator: Maréchal \n" @@ -18,8 +18,8 @@ msgstr "" #: accounting/models.py:62 accounting/models.py:101 accounting/models.py:132 #: accounting/models.py:190 club/models.py:55 com/models.py:287 -#: com/models.py:306 counter/models.py:299 counter/models.py:330 -#: counter/models.py:481 forum/models.py:60 launderette/models.py:29 +#: com/models.py:306 counter/models.py:299 counter/models.py:332 +#: counter/models.py:483 forum/models.py:60 launderette/models.py:29 #: launderette/models.py:80 launderette/models.py:116 msgid "name" msgstr "nom" @@ -65,8 +65,8 @@ msgid "account number" msgstr "numéro de compte" #: accounting/models.py:107 accounting/models.py:136 club/models.py:345 -#: com/models.py:87 com/models.py:272 com/models.py:312 counter/models.py:359 -#: counter/models.py:483 trombi/models.py:209 +#: com/models.py:87 com/models.py:272 com/models.py:312 counter/models.py:361 +#: counter/models.py:485 trombi/models.py:209 msgid "club" msgstr "club" @@ -87,12 +87,12 @@ msgstr "Compte club" msgid "%(club_account)s on %(bank_account)s" msgstr "%(club_account)s sur %(bank_account)s" -#: accounting/models.py:188 club/models.py:351 counter/models.py:966 +#: accounting/models.py:188 club/models.py:351 counter/models.py:997 #: election/models.py:16 launderette/models.py:165 msgid "start date" msgstr "date de début" -#: accounting/models.py:189 club/models.py:352 counter/models.py:967 +#: accounting/models.py:189 club/models.py:352 counter/models.py:998 #: election/models.py:17 msgid "end date" msgstr "date de fin" @@ -106,7 +106,7 @@ msgid "club account" msgstr "compte club" #: accounting/models.py:199 accounting/models.py:255 counter/models.py:93 -#: counter/models.py:684 +#: counter/models.py:714 msgid "amount" msgstr "montant" @@ -126,20 +126,20 @@ msgstr "numéro" msgid "journal" msgstr "classeur" -#: accounting/models.py:256 core/models.py:913 core/models.py:1422 -#: core/models.py:1467 core/models.py:1496 core/models.py:1520 -#: counter/models.py:694 counter/models.py:798 counter/models.py:1002 +#: accounting/models.py:256 core/models.py:905 core/models.py:1414 +#: core/models.py:1459 core/models.py:1488 core/models.py:1512 +#: counter/models.py:724 counter/models.py:829 counter/models.py:1033 #: eboutic/models.py:57 eboutic/models.py:193 forum/models.py:312 #: forum/models.py:413 msgid "date" msgstr "date" -#: accounting/models.py:257 counter/models.py:302 counter/models.py:1003 +#: accounting/models.py:257 counter/models.py:302 counter/models.py:1034 #: pedagogy/models.py:208 msgid "comment" msgstr "commentaire" -#: accounting/models.py:259 counter/models.py:696 counter/models.py:800 +#: accounting/models.py:259 counter/models.py:726 counter/models.py:831 #: subscription/models.py:56 msgid "payment method" msgstr "méthode de paiement" @@ -165,8 +165,8 @@ msgid "accounting type" msgstr "type comptable" #: accounting/models.py:294 accounting/models.py:429 accounting/models.py:460 -#: accounting/models.py:492 core/models.py:1495 core/models.py:1521 -#: counter/models.py:764 +#: accounting/models.py:492 core/models.py:1487 core/models.py:1513 +#: counter/models.py:795 msgid "label" msgstr "étiquette" @@ -218,7 +218,7 @@ msgstr "Compte" msgid "Company" msgstr "Entreprise" -#: accounting/models.py:307 core/models.py:303 sith/settings.py:423 +#: accounting/models.py:307 core/models.py:303 sith/settings.py:424 msgid "Other" msgstr "Autre" @@ -264,7 +264,7 @@ msgstr "" "Vous devez fournir soit un type comptable simplifié ou un type comptable " "standard" -#: accounting/models.py:421 counter/models.py:340 pedagogy/models.py:41 +#: accounting/models.py:421 counter/models.py:342 pedagogy/models.py:41 msgid "code" msgstr "code" @@ -517,7 +517,7 @@ msgid "Effective amount" msgstr "Montant effectif" #: accounting/templates/accounting/club_account_details.jinja:36 -#: sith/settings.py:461 +#: sith/settings.py:462 msgid "Closed" msgstr "Fermé" @@ -586,7 +586,7 @@ msgstr "Classeur : " #: accounting/templates/accounting/journal_statement_accounting.jinja:30 #: core/templates/core/user_account.jinja:39 #: core/templates/core/user_account_detail.jinja:9 -#: counter/templates/counter/counter_click.jinja:37 +#: counter/templates/counter/counter_click.jinja:46 msgid "Amount: " msgstr "Montant : " @@ -950,11 +950,11 @@ msgstr "Une action est requise" msgid "You must specify at least an user or an email address" msgstr "vous devez spécifier au moins un utilisateur ou une adresse email" -#: club/forms.py:149 counter/forms.py:206 +#: club/forms.py:149 counter/forms.py:207 msgid "Begin date" msgstr "Date de début" -#: club/forms.py:152 com/views.py:84 com/views.py:202 counter/forms.py:209 +#: club/forms.py:152 com/views.py:84 com/views.py:202 counter/forms.py:210 #: election/views.py:170 subscription/forms.py:21 msgid "End date" msgstr "Date de fin" @@ -1041,7 +1041,7 @@ msgstr "Vous ne pouvez pas faire de boucles dans les clubs" msgid "A club with that unix_name already exists" msgstr "Un club avec ce nom UNIX existe déjà." -#: club/models.py:337 counter/models.py:957 counter/models.py:993 +#: club/models.py:337 counter/models.py:988 counter/models.py:1024 #: eboutic/models.py:53 eboutic/models.py:189 election/models.py:183 #: launderette/models.py:130 launderette/models.py:184 sas/models.py:273 #: trombi/models.py:205 @@ -1054,7 +1054,7 @@ msgid "role" msgstr "rôle" #: club/models.py:359 core/models.py:84 counter/models.py:300 -#: counter/models.py:331 election/models.py:13 election/models.py:115 +#: counter/models.py:333 election/models.py:13 election/models.py:115 #: election/models.py:188 forum/models.py:61 forum/models.py:245 msgid "description" msgstr "description" @@ -1068,7 +1068,7 @@ msgid "Enter a valid address. Only the root of the address is needed." msgstr "" "Entrez une adresse valide. Seule la racine de l'adresse est nécessaire." -#: club/models.py:427 com/models.py:97 com/models.py:322 core/models.py:914 +#: club/models.py:427 com/models.py:97 com/models.py:322 core/models.py:906 msgid "is moderated" msgstr "est modéré" @@ -1217,7 +1217,7 @@ msgid "Barman" msgstr "Barman" #: club/templates/club/club_sellings.jinja:51 -#: counter/templates/counter/counter_click.jinja:34 +#: counter/templates/counter/counter_click.jinja:43 #: counter/templates/counter/last_ops.jinja:22 #: counter/templates/counter/last_ops.jinja:47 #: counter/templates/counter/refilling_list.jinja:12 @@ -1454,7 +1454,7 @@ msgstr "contenu" msgid "A more detailed and exhaustive description of the event." msgstr "Une description plus détaillée et exhaustive de l'évènement." -#: com/models.py:82 core/models.py:1465 launderette/models.py:88 +#: com/models.py:82 core/models.py:1457 launderette/models.py:88 #: launderette/models.py:124 launderette/models.py:167 msgid "type" msgstr "type" @@ -1508,7 +1508,7 @@ msgstr "weekmail" msgid "rank" msgstr "rang" -#: com/models.py:308 core/models.py:879 core/models.py:929 +#: com/models.py:308 core/models.py:871 core/models.py:921 msgid "file" msgstr "fichier" @@ -1762,12 +1762,12 @@ msgstr "Agenda" msgid "Birthdays" msgstr "Anniversaires" -#: com/templates/com/news_list.jinja:145 +#: com/templates/com/news_list.jinja:143 #, python-format msgid "%(age)s year old" msgstr "%(age)s ans" -#: com/templates/com/news_list.jinja:156 com/tests.py:101 com/tests.py:111 +#: com/templates/com/news_list.jinja:153 com/tests.py:101 com/tests.py:111 msgid "You need an up to date subscription to access this content" msgstr "Votre cotisation doit être à jour pour accéder à cette section" @@ -1977,15 +1977,15 @@ msgstr "Ce champ est obligatoire." msgid "An event cannot end before its beginning." msgstr "Un évènement ne peut pas se finir avant d'avoir commencé." -#: com/views.py:445 +#: com/views.py:446 msgid "Delete and save to regenerate" msgstr "Supprimer et sauver pour régénérer" -#: com/views.py:460 +#: com/views.py:461 msgid "Weekmail of the " msgstr "Weekmail du " -#: com/views.py:564 +#: com/views.py:565 msgid "" "You must be a board member of the selected club to post in the Weekmail." msgstr "" @@ -2181,7 +2181,7 @@ msgstr "profil visible par les cotisants" msgid "A user with that username already exists" msgstr "Un utilisateur de ce nom d'utilisateur existe déjà" -#: core/models.py:718 core/templates/core/macros.jinja:80 +#: core/models.py:710 core/templates/core/macros.jinja:80 #: core/templates/core/macros.jinja:84 core/templates/core/macros.jinja:85 #: core/templates/core/user_detail.jinja:100 #: core/templates/core/user_detail.jinja:101 @@ -2201,101 +2201,101 @@ msgstr "Un utilisateur de ce nom d'utilisateur existe déjà" msgid "Profile" msgstr "Profil" -#: core/models.py:829 +#: core/models.py:821 msgid "Visitor" msgstr "Visiteur" -#: core/models.py:836 +#: core/models.py:828 msgid "receive the Weekmail" msgstr "recevoir le Weekmail" -#: core/models.py:837 +#: core/models.py:829 msgid "show your stats to others" msgstr "montrez vos statistiques aux autres" -#: core/models.py:839 +#: core/models.py:831 msgid "get a notification for every click" msgstr "avoir une notification pour chaque click" -#: core/models.py:842 +#: core/models.py:834 msgid "get a notification for every refilling" msgstr "avoir une notification pour chaque rechargement" -#: core/models.py:868 sas/forms.py:81 +#: core/models.py:860 sas/forms.py:81 msgid "file name" msgstr "nom du fichier" -#: core/models.py:872 core/models.py:1223 +#: core/models.py:864 core/models.py:1215 msgid "parent" msgstr "parent" -#: core/models.py:886 +#: core/models.py:878 msgid "compressed file" msgstr "version allégée" -#: core/models.py:893 +#: core/models.py:885 msgid "thumbnail" msgstr "miniature" -#: core/models.py:901 core/models.py:918 +#: core/models.py:893 core/models.py:910 msgid "owner" msgstr "propriétaire" -#: core/models.py:905 core/models.py:1240 +#: core/models.py:897 core/models.py:1232 msgid "edit group" msgstr "groupe d'édition" -#: core/models.py:908 core/models.py:1243 +#: core/models.py:900 core/models.py:1235 msgid "view group" msgstr "groupe de vue" -#: core/models.py:910 +#: core/models.py:902 msgid "is folder" msgstr "est un dossier" -#: core/models.py:911 +#: core/models.py:903 msgid "mime type" msgstr "type mime" -#: core/models.py:912 +#: core/models.py:904 msgid "size" msgstr "taille" -#: core/models.py:923 +#: core/models.py:915 msgid "asked for removal" msgstr "retrait demandé" -#: core/models.py:925 +#: core/models.py:917 msgid "is in the SAS" msgstr "est dans le SAS" -#: core/models.py:992 +#: core/models.py:984 msgid "Character '/' not authorized in name" msgstr "Le caractère '/' n'est pas autorisé dans les noms de fichier" -#: core/models.py:994 core/models.py:998 +#: core/models.py:986 core/models.py:990 msgid "Loop in folder tree" msgstr "Boucle dans l'arborescence des dossiers" -#: core/models.py:1001 +#: core/models.py:993 msgid "You can not make a file be a children of a non folder file" msgstr "" "Vous ne pouvez pas mettre un fichier enfant de quelque chose qui n'est pas " "un dossier" -#: core/models.py:1012 +#: core/models.py:1004 msgid "Duplicate file" msgstr "Un fichier de ce nom existe déjà" -#: core/models.py:1029 +#: core/models.py:1021 msgid "You must provide a file" msgstr "Vous devez fournir un fichier" -#: core/models.py:1206 +#: core/models.py:1198 msgid "page unix name" msgstr "nom unix de la page" -#: core/models.py:1212 +#: core/models.py:1204 msgid "" "Enter a valid page name. This value may contain only unaccented letters, " "numbers and ./+/-/_ characters." @@ -2303,55 +2303,55 @@ msgstr "" "Entrez un nom de page correct. Uniquement des lettres non accentuées, " "numéros, et ./+/-/_" -#: core/models.py:1230 +#: core/models.py:1222 msgid "page name" msgstr "nom de la page" -#: core/models.py:1235 +#: core/models.py:1227 msgid "owner group" msgstr "groupe propriétaire" -#: core/models.py:1248 +#: core/models.py:1240 msgid "lock user" msgstr "utilisateur bloquant" -#: core/models.py:1255 +#: core/models.py:1247 msgid "lock_timeout" msgstr "décompte du déblocage" -#: core/models.py:1305 +#: core/models.py:1297 msgid "Duplicate page" msgstr "Une page de ce nom existe déjà" -#: core/models.py:1308 +#: core/models.py:1300 msgid "Loop in page tree" msgstr "Boucle dans l'arborescence des pages" -#: core/models.py:1419 +#: core/models.py:1411 msgid "revision" msgstr "révision" -#: core/models.py:1420 +#: core/models.py:1412 msgid "page title" msgstr "titre de la page" -#: core/models.py:1421 +#: core/models.py:1413 msgid "page content" msgstr "contenu de la page" -#: core/models.py:1462 +#: core/models.py:1454 msgid "url" msgstr "url" -#: core/models.py:1463 +#: core/models.py:1455 msgid "param" msgstr "param" -#: core/models.py:1468 +#: core/models.py:1460 msgid "viewed" msgstr "vue" -#: core/models.py:1526 +#: core/models.py:1518 msgid "operation type" msgstr "type d'opération" @@ -2476,13 +2476,13 @@ msgstr "Forum" msgid "Gallery" msgstr "Photos" -#: core/templates/core/base/navbar.jinja:22 counter/models.py:491 +#: core/templates/core/base/navbar.jinja:22 counter/models.py:493 #: counter/templates/counter/counter_list.jinja:11 #: eboutic/templates/eboutic/eboutic_main.jinja:4 #: eboutic/templates/eboutic/eboutic_main.jinja:23 #: eboutic/templates/eboutic/eboutic_makecommand.jinja:16 #: eboutic/templates/eboutic/eboutic_payment_result.jinja:4 -#: sith/settings.py:422 sith/settings.py:430 +#: sith/settings.py:423 sith/settings.py:431 msgid "Eboutic" msgstr "Eboutic" @@ -2562,7 +2562,7 @@ msgstr "Confirmation" #: core/templates/core/delete_confirm.jinja:20 #: core/templates/core/file_delete_confirm.jinja:46 -#: counter/templates/counter/counter_click.jinja:121 +#: counter/templates/counter/counter_click.jinja:125 #: counter/templates/counter/fragments/delete_student_card.jinja:12 #: sas/templates/sas/ask_picture_removal.jinja:20 msgid "Cancel" @@ -3276,7 +3276,7 @@ msgid "Go to my Trombi tools" msgstr "Allez à mes outils de Trombi" #: core/templates/core/user_preferences.jinja:39 -#: counter/templates/counter/counter_click.jinja:144 +#: counter/templates/counter/counter_click.jinja:149 msgid "Student card" msgstr "Carte étudiante" @@ -3355,7 +3355,7 @@ msgstr "Cotisations" msgid "Subscription stats" msgstr "Statistiques de cotisation" -#: core/templates/core/user_tools.jinja:48 counter/forms.py:179 +#: core/templates/core/user_tools.jinja:48 counter/forms.py:180 #: counter/views/mixins.py:89 msgid "Counters" msgstr "Comptoirs" @@ -3575,21 +3575,21 @@ msgstr "Photos" msgid "Galaxy" msgstr "Galaxie" -#: counter/apps.py:28 sith/settings.py:412 sith/settings.py:419 +#: counter/apps.py:28 sith/settings.py:413 sith/settings.py:420 msgid "Check" msgstr "Chèque" -#: counter/apps.py:29 sith/settings.py:413 sith/settings.py:421 +#: counter/apps.py:29 sith/settings.py:414 sith/settings.py:422 msgid "Cash" msgstr "Espèces" -#: counter/apps.py:30 counter/models.py:802 sith/settings.py:415 -#: sith/settings.py:420 +#: counter/apps.py:30 counter/models.py:833 sith/settings.py:416 +#: sith/settings.py:421 msgid "Credit card" msgstr "Carte bancaire" -#: counter/apps.py:36 counter/models.py:507 counter/models.py:963 -#: counter/models.py:999 launderette/models.py:32 +#: counter/apps.py:36 counter/models.py:509 counter/models.py:994 +#: counter/models.py:1030 launderette/models.py:32 msgid "counter" msgstr "comptoir" @@ -3597,7 +3597,7 @@ msgstr "comptoir" msgid "This UID is invalid" msgstr "Cet UID est invalide" -#: counter/forms.py:107 +#: counter/forms.py:108 msgid "User not found" msgstr "Utilisateur non trouvé" @@ -3625,7 +3625,7 @@ msgstr "client" msgid "customers" msgstr "clients" -#: counter/models.py:112 counter/views/click.py:68 +#: counter/models.py:112 counter/views/click.py:117 msgid "Not enough money" msgstr "Solde insuffisant" @@ -3677,116 +3677,116 @@ msgstr "L'opération qui a vidé le compte." msgid "A text that will be shown on the eboutic." msgstr "Un texte qui sera affiché sur l'eboutic." -#: counter/models.py:311 counter/models.py:335 +#: counter/models.py:311 counter/models.py:337 msgid "product type" msgstr "type du produit" -#: counter/models.py:342 +#: counter/models.py:344 msgid "purchase price" msgstr "prix d'achat" -#: counter/models.py:343 +#: counter/models.py:345 msgid "Initial cost of purchasing the product" msgstr "Coût initial d'achat du produit" -#: counter/models.py:345 +#: counter/models.py:347 msgid "selling price" msgstr "prix de vente" -#: counter/models.py:347 +#: counter/models.py:349 msgid "special selling price" msgstr "prix de vente spécial" -#: counter/models.py:348 +#: counter/models.py:350 msgid "Price for barmen during their permanence" msgstr "Prix pour les barmen durant leur permanence" -#: counter/models.py:356 +#: counter/models.py:358 msgid "icon" msgstr "icône" -#: counter/models.py:361 +#: counter/models.py:363 msgid "limit age" msgstr "âge limite" -#: counter/models.py:362 +#: counter/models.py:364 msgid "tray price" msgstr "prix plateau" -#: counter/models.py:364 +#: counter/models.py:366 msgid "buying groups" msgstr "groupe d'achat" -#: counter/models.py:366 election/models.py:50 +#: counter/models.py:368 election/models.py:50 msgid "archived" msgstr "archivé" -#: counter/models.py:369 counter/models.py:1097 +#: counter/models.py:371 counter/models.py:1128 msgid "product" msgstr "produit" -#: counter/models.py:486 +#: counter/models.py:488 msgid "products" msgstr "produits" -#: counter/models.py:489 +#: counter/models.py:491 msgid "counter type" msgstr "type de comptoir" -#: counter/models.py:491 +#: counter/models.py:493 msgid "Bar" msgstr "Bar" -#: counter/models.py:491 +#: counter/models.py:493 msgid "Office" msgstr "Bureau" -#: counter/models.py:494 +#: counter/models.py:496 msgid "sellers" msgstr "vendeurs" -#: counter/models.py:502 launderette/models.py:178 +#: counter/models.py:504 launderette/models.py:178 msgid "token" msgstr "jeton" -#: counter/models.py:702 +#: counter/models.py:732 msgid "bank" msgstr "banque" -#: counter/models.py:704 counter/models.py:805 +#: counter/models.py:734 counter/models.py:836 msgid "is validated" msgstr "est validé" -#: counter/models.py:709 +#: counter/models.py:739 msgid "refilling" msgstr "rechargement" -#: counter/models.py:782 eboutic/models.py:249 +#: counter/models.py:813 eboutic/models.py:249 msgid "unit price" msgstr "prix unitaire" -#: counter/models.py:783 counter/models.py:1077 eboutic/models.py:250 +#: counter/models.py:814 counter/models.py:1108 eboutic/models.py:250 msgid "quantity" msgstr "quantité" -#: counter/models.py:802 +#: counter/models.py:833 msgid "Sith account" msgstr "Compte utilisateur" -#: counter/models.py:810 +#: counter/models.py:841 msgid "selling" msgstr "vente" -#: counter/models.py:914 +#: counter/models.py:945 msgid "Unknown event" msgstr "Événement inconnu" -#: counter/models.py:915 +#: counter/models.py:946 #, python-format msgid "Eticket bought for the event %(event)s" msgstr "Eticket acheté pour l'événement %(event)s" -#: counter/models.py:917 counter/models.py:930 +#: counter/models.py:948 counter/models.py:961 #, python-format msgid "" "You bought an eticket for the event %(event)s.\n" @@ -3798,67 +3798,67 @@ msgstr "" "Vous pouvez également retrouver tous vos e-tickets sur votre page de compte " "%(url)s." -#: counter/models.py:968 +#: counter/models.py:999 msgid "last activity date" msgstr "dernière activité" -#: counter/models.py:971 +#: counter/models.py:1002 msgid "permanency" msgstr "permanence" -#: counter/models.py:1004 +#: counter/models.py:1035 msgid "emptied" msgstr "coffre vidée" -#: counter/models.py:1007 +#: counter/models.py:1038 msgid "cash register summary" msgstr "relevé de caisse" -#: counter/models.py:1073 +#: counter/models.py:1104 msgid "cash summary" msgstr "relevé" -#: counter/models.py:1076 +#: counter/models.py:1107 msgid "value" msgstr "valeur" -#: counter/models.py:1079 +#: counter/models.py:1110 msgid "check" msgstr "chèque" -#: counter/models.py:1081 +#: counter/models.py:1112 msgid "True if this is a bank check, else False" msgstr "Vrai si c'est un chèque, sinon Faux." -#: counter/models.py:1085 +#: counter/models.py:1116 msgid "cash register summary item" msgstr "élément de relevé de caisse" -#: counter/models.py:1101 +#: counter/models.py:1132 msgid "banner" msgstr "bannière" -#: counter/models.py:1103 +#: counter/models.py:1134 msgid "event date" msgstr "date de l'événement" -#: counter/models.py:1105 +#: counter/models.py:1136 msgid "event title" msgstr "titre de l'événement" -#: counter/models.py:1107 +#: counter/models.py:1138 msgid "secret" msgstr "secret" -#: counter/models.py:1146 +#: counter/models.py:1177 msgid "uid" msgstr "uid" -#: counter/models.py:1151 counter/models.py:1156 +#: counter/models.py:1182 counter/models.py:1187 msgid "student card" msgstr "carte étudiante" -#: counter/models.py:1157 +#: counter/models.py:1188 msgid "student cards" msgstr "cartes étudiantes" @@ -3918,28 +3918,28 @@ msgstr "oui" msgid "There is no cash register summary in this website." msgstr "Il n'y a pas de relevé de caisse dans ce site web." -#: counter/templates/counter/counter_click.jinja:41 +#: counter/templates/counter/counter_click.jinja:55 #: launderette/templates/launderette/launderette_admin.jinja:8 msgid "Selling" msgstr "Vente" -#: counter/templates/counter/counter_click.jinja:52 +#: counter/templates/counter/counter_click.jinja:66 msgid "Select a product..." msgstr "Sélectionnez un produit…" -#: counter/templates/counter/counter_click.jinja:54 +#: counter/templates/counter/counter_click.jinja:68 msgid "Operations" msgstr "Opérations" -#: counter/templates/counter/counter_click.jinja:55 +#: counter/templates/counter/counter_click.jinja:69 msgid "Confirm (FIN)" msgstr "Confirmer (FIN)" -#: counter/templates/counter/counter_click.jinja:56 +#: counter/templates/counter/counter_click.jinja:70 msgid "Cancel (ANN)" msgstr "Annuler (ANN)" -#: counter/templates/counter/counter_click.jinja:67 +#: counter/templates/counter/counter_click.jinja:81 #: counter/templates/counter/fragments/create_refill.jinja:8 #: counter/templates/counter/fragments/create_student_card.jinja:10 #: counter/templates/counter/invoices_call.jinja:16 @@ -3950,21 +3950,25 @@ msgstr "Annuler (ANN)" msgid "Go" msgstr "Valider" -#: counter/templates/counter/counter_click.jinja:74 +#: counter/templates/counter/counter_click.jinja:89 #: eboutic/templates/eboutic/eboutic_makecommand.jinja:19 msgid "Basket: " msgstr "Panier : " -#: counter/templates/counter/counter_click.jinja:115 +#: counter/templates/counter/counter_click.jinja:95 +msgid "This basket is empty" +msgstr "Votre panier est vide" + +#: counter/templates/counter/counter_click.jinja:124 msgid "Finish" msgstr "Terminer" -#: counter/templates/counter/counter_click.jinja:125 +#: counter/templates/counter/counter_click.jinja:130 #: counter/templates/counter/refilling_list.jinja:9 msgid "Refilling" msgstr "Rechargement" -#: counter/templates/counter/counter_click.jinja:135 +#: counter/templates/counter/counter_click.jinja:140 msgid "" "As a barman, you are not able to refill any account on your own. An admin " "should be connected on this counter for that. The customer can refill by " @@ -3974,6 +3978,10 @@ msgstr "" "vous même. Un admin doit être connecté sur ce comptoir pour cela. Le client " "peut recharger son compte en utilisant l'eboutic" +#: counter/templates/counter/counter_click.jinja:161 +msgid "No products available on this counter for this user" +msgstr "Pas de produits disponnibles dans ce comptoir pour cet utilisateur" + #: counter/templates/counter/counter_list.jinja:4 #: counter/templates/counter/counter_list.jinja:10 msgid "Counter admin list" @@ -4317,35 +4325,31 @@ msgstr "Montant du chèque" msgid "Check quantity" msgstr "Nombre de chèque" -#: counter/views/click.py:59 -msgid "Too young for that product" -msgstr "Trop jeune pour ce produit" +#: counter/views/click.py:77 +msgid "The selected product isn't available for this user" +msgstr "Le produit sélectionné n'est pas disponnible pour cet utilisateur" -#: counter/views/click.py:62 -msgid "Not allowed for that product" -msgstr "Non autorisé pour ce produit" +#: counter/views/click.py:112 +msgid "Submmited basket is invalid" +msgstr "Le panier envoyé est invalide" -#: counter/views/click.py:65 -msgid "No date of birth provided" -msgstr "Pas de date de naissance renseignée" - -#: counter/views/click.py:325 -msgid "You have not enough money to buy all the basket" -msgstr "Vous n'avez pas assez d'argent pour acheter le panier" +#: counter/views/click.py:130 +msgid "This user have reached his recording limit" +msgstr "Cet utilisateur a atteint sa limite de déconsigne" #: counter/views/eticket.py:120 msgid "people(s)" msgstr "personne(s)" -#: counter/views/home.py:74 +#: counter/views/home.py:77 msgid "Bad credentials" msgstr "Mauvais identifiants" -#: counter/views/home.py:76 +#: counter/views/home.py:79 msgid "User is not barman" msgstr "L'utilisateur n'est pas barman." -#: counter/views/home.py:81 +#: counter/views/home.py:84 msgid "Bad location, someone is already logged in somewhere else" msgstr "Mauvais comptoir, quelqu'un est déjà connecté ailleurs" @@ -4947,12 +4951,12 @@ msgid "Washing and drying" msgstr "Lavage et séchage" #: launderette/templates/launderette/launderette_book.jinja:27 -#: sith/settings.py:650 +#: sith/settings.py:651 msgid "Washing" msgstr "Lavage" #: launderette/templates/launderette/launderette_book.jinja:31 -#: sith/settings.py:650 +#: sith/settings.py:651 msgid "Drying" msgstr "Séchage" @@ -5467,372 +5471,372 @@ msgstr "Personne(s)" msgid "Identify users on pictures" msgstr "Identifiez les utilisateurs sur les photos" -#: sith/settings.py:253 sith/settings.py:469 +#: sith/settings.py:253 sith/settings.py:470 msgid "English" msgstr "Anglais" -#: sith/settings.py:253 sith/settings.py:468 +#: sith/settings.py:253 sith/settings.py:469 msgid "French" msgstr "Français" -#: sith/settings.py:396 +#: sith/settings.py:397 msgid "TC" msgstr "TC" -#: sith/settings.py:397 +#: sith/settings.py:398 msgid "IMSI" msgstr "IMSI" -#: sith/settings.py:398 +#: sith/settings.py:399 msgid "IMAP" msgstr "IMAP" -#: sith/settings.py:399 +#: sith/settings.py:400 msgid "INFO" msgstr "INFO" -#: sith/settings.py:400 +#: sith/settings.py:401 msgid "GI" msgstr "GI" -#: sith/settings.py:401 sith/settings.py:479 +#: sith/settings.py:402 sith/settings.py:480 msgid "E" msgstr "E" -#: sith/settings.py:402 +#: sith/settings.py:403 msgid "EE" msgstr "EE" -#: sith/settings.py:403 +#: sith/settings.py:404 msgid "GESC" msgstr "GESC" -#: sith/settings.py:404 +#: sith/settings.py:405 msgid "GMC" msgstr "GMC" -#: sith/settings.py:405 +#: sith/settings.py:406 msgid "MC" msgstr "MC" -#: sith/settings.py:406 +#: sith/settings.py:407 msgid "EDIM" msgstr "EDIM" -#: sith/settings.py:407 +#: sith/settings.py:408 msgid "Humanities" msgstr "Humanités" -#: sith/settings.py:408 +#: sith/settings.py:409 msgid "N/A" msgstr "N/A" -#: sith/settings.py:414 +#: sith/settings.py:415 msgid "Transfert" msgstr "Virement" -#: sith/settings.py:427 +#: sith/settings.py:428 msgid "Belfort" msgstr "Belfort" -#: sith/settings.py:428 +#: sith/settings.py:429 msgid "Sevenans" msgstr "Sevenans" -#: sith/settings.py:429 +#: sith/settings.py:430 msgid "Montbéliard" msgstr "Montbéliard" -#: sith/settings.py:449 +#: sith/settings.py:450 msgid "Free" msgstr "Libre" -#: sith/settings.py:450 +#: sith/settings.py:451 msgid "CS" msgstr "CS" -#: sith/settings.py:451 +#: sith/settings.py:452 msgid "TM" msgstr "TM" -#: sith/settings.py:452 +#: sith/settings.py:453 msgid "OM" msgstr "OM" -#: sith/settings.py:453 +#: sith/settings.py:454 msgid "QC" msgstr "QC" -#: sith/settings.py:454 +#: sith/settings.py:455 msgid "EC" msgstr "EC" -#: sith/settings.py:455 +#: sith/settings.py:456 msgid "RN" msgstr "RN" -#: sith/settings.py:456 +#: sith/settings.py:457 msgid "ST" msgstr "ST" -#: sith/settings.py:457 +#: sith/settings.py:458 msgid "EXT" msgstr "EXT" -#: sith/settings.py:462 +#: sith/settings.py:463 msgid "Autumn" msgstr "Automne" -#: sith/settings.py:463 +#: sith/settings.py:464 msgid "Spring" msgstr "Printemps" -#: sith/settings.py:464 +#: sith/settings.py:465 msgid "Autumn and spring" msgstr "Automne et printemps" -#: sith/settings.py:470 +#: sith/settings.py:471 msgid "German" msgstr "Allemand" -#: sith/settings.py:471 +#: sith/settings.py:472 msgid "Spanish" msgstr "Espagnol" -#: sith/settings.py:475 +#: sith/settings.py:476 msgid "A" msgstr "A" -#: sith/settings.py:476 +#: sith/settings.py:477 msgid "B" msgstr "B" -#: sith/settings.py:477 +#: sith/settings.py:478 msgid "C" msgstr "C" -#: sith/settings.py:478 +#: sith/settings.py:479 msgid "D" msgstr "D" -#: sith/settings.py:480 +#: sith/settings.py:481 msgid "FX" msgstr "FX" -#: sith/settings.py:481 +#: sith/settings.py:482 msgid "F" msgstr "F" -#: sith/settings.py:482 +#: sith/settings.py:483 msgid "Abs" msgstr "Abs" -#: sith/settings.py:486 +#: sith/settings.py:487 msgid "Selling deletion" msgstr "Suppression de vente" -#: sith/settings.py:487 +#: sith/settings.py:488 msgid "Refilling deletion" msgstr "Suppression de rechargement" -#: sith/settings.py:531 +#: sith/settings.py:532 msgid "One semester" msgstr "Un semestre, 20 €" -#: sith/settings.py:532 +#: sith/settings.py:533 msgid "Two semesters" msgstr "Deux semestres, 35 €" -#: sith/settings.py:534 +#: sith/settings.py:535 msgid "Common core cursus" msgstr "Cursus tronc commun, 60 €" -#: sith/settings.py:538 +#: sith/settings.py:539 msgid "Branch cursus" msgstr "Cursus branche, 60 €" -#: sith/settings.py:539 +#: sith/settings.py:540 msgid "Alternating cursus" msgstr "Cursus alternant, 30 €" -#: sith/settings.py:540 +#: sith/settings.py:541 msgid "Honorary member" msgstr "Membre honoraire, 0 €" -#: sith/settings.py:541 +#: sith/settings.py:542 msgid "Assidu member" msgstr "Membre d'Assidu, 0 €" -#: sith/settings.py:542 +#: sith/settings.py:543 msgid "Amicale/DOCEO member" msgstr "Membre de l'Amicale/DOCEO, 0 €" -#: sith/settings.py:543 +#: sith/settings.py:544 msgid "UT network member" msgstr "Cotisant du réseau UT, 0 €" -#: sith/settings.py:544 +#: sith/settings.py:545 msgid "CROUS member" msgstr "Membres du CROUS, 0 €" -#: sith/settings.py:545 +#: sith/settings.py:546 msgid "Sbarro/ESTA member" msgstr "Membre de Sbarro ou de l'ESTA, 20 €" -#: sith/settings.py:547 +#: sith/settings.py:548 msgid "One semester Welcome Week" msgstr "Un semestre Welcome Week" -#: sith/settings.py:551 +#: sith/settings.py:552 msgid "One month for free" msgstr "Un mois gratuit" -#: sith/settings.py:552 +#: sith/settings.py:553 msgid "Two months for free" msgstr "Deux mois gratuits" -#: sith/settings.py:553 +#: sith/settings.py:554 msgid "Eurok's volunteer" msgstr "Bénévole Eurockéennes" -#: sith/settings.py:555 +#: sith/settings.py:556 msgid "Six weeks for free" msgstr "6 semaines gratuites" -#: sith/settings.py:559 +#: sith/settings.py:560 msgid "One day" msgstr "Un jour" -#: sith/settings.py:560 +#: sith/settings.py:561 msgid "GA staff member" msgstr "Membre staff GA (2 semaines), 1 €" -#: sith/settings.py:563 +#: sith/settings.py:564 msgid "One semester (-20%)" msgstr "Un semestre (-20%), 12 €" -#: sith/settings.py:568 +#: sith/settings.py:569 msgid "Two semesters (-20%)" msgstr "Deux semestres (-20%), 22 €" -#: sith/settings.py:573 +#: sith/settings.py:574 msgid "Common core cursus (-20%)" msgstr "Cursus tronc commun (-20%), 36 €" -#: sith/settings.py:578 +#: sith/settings.py:579 msgid "Branch cursus (-20%)" msgstr "Cursus branche (-20%), 36 €" -#: sith/settings.py:583 +#: sith/settings.py:584 msgid "Alternating cursus (-20%)" msgstr "Cursus alternant (-20%), 24 €" -#: sith/settings.py:589 +#: sith/settings.py:590 msgid "One year for free(CA offer)" msgstr "Une année offerte (Offre CA)" -#: sith/settings.py:609 +#: sith/settings.py:610 msgid "President" msgstr "Président⸱e" -#: sith/settings.py:610 +#: sith/settings.py:611 msgid "Vice-President" msgstr "Vice-Président⸱e" -#: sith/settings.py:611 +#: sith/settings.py:612 msgid "Treasurer" msgstr "Trésorier⸱e" -#: sith/settings.py:612 +#: sith/settings.py:613 msgid "Communication supervisor" msgstr "Responsable communication" -#: sith/settings.py:613 +#: sith/settings.py:614 msgid "Secretary" msgstr "Secrétaire" -#: sith/settings.py:614 +#: sith/settings.py:615 msgid "IT supervisor" msgstr "Responsable info" -#: sith/settings.py:615 +#: sith/settings.py:616 msgid "Board member" msgstr "Membre du bureau" -#: sith/settings.py:616 +#: sith/settings.py:617 msgid "Active member" msgstr "Membre actif⸱ve" -#: sith/settings.py:617 +#: sith/settings.py:618 msgid "Curious" msgstr "Curieux⸱euse" -#: sith/settings.py:654 +#: sith/settings.py:655 msgid "A new poster needs to be moderated" msgstr "Une nouvelle affiche a besoin d'être modérée" -#: sith/settings.py:655 +#: sith/settings.py:656 msgid "A new mailing list needs to be moderated" msgstr "Une nouvelle mailing list a besoin d'être modérée" -#: sith/settings.py:658 +#: sith/settings.py:659 msgid "A new pedagogy comment has been signaled for moderation" msgstr "" "Un nouveau commentaire de la pédagogie a été signalé pour la modération" -#: sith/settings.py:660 +#: sith/settings.py:661 #, python-format msgid "There are %s fresh news to be moderated" msgstr "Il y a %s nouvelles toutes fraîches à modérer" -#: sith/settings.py:661 +#: sith/settings.py:662 msgid "New files to be moderated" msgstr "Nouveaux fichiers à modérer" -#: sith/settings.py:662 +#: sith/settings.py:663 #, python-format msgid "There are %s pictures to be moderated in the SAS" msgstr "Il y a %s photos à modérer dans le SAS" -#: sith/settings.py:663 +#: sith/settings.py:664 msgid "You've been identified on some pictures" msgstr "Vous avez été identifié sur des photos" -#: sith/settings.py:664 +#: sith/settings.py:665 #, python-format msgid "You just refilled of %s €" msgstr "Vous avez rechargé votre compte de %s€" -#: sith/settings.py:665 +#: sith/settings.py:666 #, python-format msgid "You just bought %s" msgstr "Vous avez acheté %s" -#: sith/settings.py:666 +#: sith/settings.py:667 msgid "You have a notification" msgstr "Vous avez une notification" -#: sith/settings.py:678 +#: sith/settings.py:679 msgid "Success!" msgstr "Succès !" -#: sith/settings.py:679 +#: sith/settings.py:680 msgid "Fail!" msgstr "Échec !" -#: sith/settings.py:680 +#: sith/settings.py:681 msgid "You successfully posted an article in the Weekmail" msgstr "Article posté avec succès dans le Weekmail" -#: sith/settings.py:681 +#: sith/settings.py:682 msgid "You successfully edited an article in the Weekmail" msgstr "Article édité avec succès dans le Weekmail" -#: sith/settings.py:682 +#: sith/settings.py:683 msgid "You successfully sent the Weekmail" msgstr "Weekmail envoyé avec succès" -#: sith/settings.py:690 +#: sith/settings.py:691 msgid "AE tee-shirt" msgstr "Tee-shirt AE" @@ -6205,3 +6209,15 @@ msgstr "Vous ne pouvez plus écrire de commentaires, la date est passée." #, python-format msgid "Maximum characters: %(max_length)s" msgstr "Nombre de caractères max: %(max_length)s" + +#~ msgid "Too young for that product" +#~ msgstr "Trop jeune pour ce produit" + +#~ msgid "Not allowed for that product" +#~ msgstr "Non autorisé pour ce produit" + +#~ msgid "No date of birth provided" +#~ msgstr "Pas de date de naissance renseignée" + +#~ msgid "You have not enough money to buy all the basket" +#~ msgstr "Vous n'avez pas assez d'argent pour acheter le panier" diff --git a/locale/fr/LC_MESSAGES/djangojs.po b/locale/fr/LC_MESSAGES/djangojs.po index 26ff64c3..7412eac5 100644 --- a/locale/fr/LC_MESSAGES/djangojs.po +++ b/locale/fr/LC_MESSAGES/djangojs.po @@ -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 \n" "Language-Team: AE info \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" diff --git a/package-lock.json b/package-lock.json index c46ef180..9b49ac0e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": [ { diff --git a/package.json b/package.json index 77572a6f..9721eea4 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/tsconfig.json b/tsconfig.json index 6edae1f7..7b3be5fc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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/*"] } } }