diff --git a/core/templates/core/delete_confirm.jinja b/core/templates/core/delete_confirm.jinja index 6ae8a1b2..70fcd764 100644 --- a/core/templates/core/delete_confirm.jinja +++ b/core/templates/core/delete_confirm.jinja @@ -21,6 +21,8 @@

{% trans %}Delete confirmation{% endtrans %}

{% csrf_token %}

{% trans name=object_name %}Are you sure you want to delete "{{ name }}"?{% endtrans %}

+ {% if help_text %}

{{ help_text }}

{% endif %} +
diff --git a/counter/forms.py b/counter/forms.py index ed47232a..6a1b83e4 100644 --- a/counter/forms.py +++ b/counter/forms.py @@ -5,6 +5,7 @@ from datetime import date, datetime, timezone from dateutil.relativedelta import relativedelta from django import forms +from django.core.validators import MaxValueValidator from django.db.models import Exists, OuterRef, Q from django.forms import BaseModelFormSet from django.utils.timezone import now @@ -34,6 +35,7 @@ from counter.models import ( Eticket, InvoiceCall, Product, + ProductFormula, Refilling, ReturnableProduct, ScheduledProductAction, @@ -316,7 +318,6 @@ class ProductForm(forms.ModelForm): } counters = forms.ModelMultipleChoiceField( - help_text=None, label=_("Counters"), required=False, widget=AutoCompleteSelectMultipleCounter, @@ -327,10 +328,31 @@ class ProductForm(forms.ModelForm): super().__init__(*args, instance=instance, **kwargs) if self.instance.id: self.fields["counters"].initial = self.instance.counters.all() + if hasattr(self.instance, "formula"): + self.formula_init(self.instance.formula) self.action_formset = ScheduledProductActionFormSet( *args, product=self.instance, **kwargs ) + def formula_init(self, formula: ProductFormula): + """Part of the form initialisation specific to formula products.""" + self.fields["selling_price"].help_text = _( + "This product is a formula. " + "Its price cannot be greater than the price " + "of the products constituting it, which is %(price)s €" + ) % {"price": formula.max_selling_price} + self.fields["special_selling_price"].help_text = _( + "This product is a formula. " + "Its special price cannot be greater than the price " + "of the products constituting it, which is %(price)s €" + ) % {"price": formula.max_special_selling_price} + for key, price in ( + ("selling_price", formula.max_selling_price), + ("special_selling_price", formula.max_special_selling_price), + ): + self.fields[key].widget.attrs["max"] = price + self.fields[key].validators.append(MaxValueValidator(price)) + def is_valid(self): return super().is_valid() and self.action_formset.is_valid() @@ -349,13 +371,47 @@ class ProductForm(forms.ModelForm): return product +class ProductFormulaForm(forms.ModelForm): + class Meta: + model = ProductFormula + fields = ["products", "result"] + widgets = { + "products": AutoCompleteSelectMultipleProduct, + "result": AutoCompleteSelectProduct, + } + + def clean(self): + cleaned_data = super().clean() + if cleaned_data["result"] in cleaned_data["products"]: + self.add_error( + None, + _( + "The same product cannot be at the same time " + "the result and a part of the formula." + ), + ) + prices = [p.selling_price for p in cleaned_data["products"]] + special_prices = [p.special_selling_price for p in cleaned_data["products"]] + selling_price = cleaned_data["result"].selling_price + special_selling_price = cleaned_data["result"].special_selling_price + if selling_price > sum(prices) or special_selling_price > sum(special_prices): + self.add_error( + "result", + _( + "The result cannot be more expensive " + "than the total of the other products." + ), + ) + return cleaned_data + + class ReturnableProductForm(forms.ModelForm): class Meta: model = ReturnableProduct fields = ["product", "returned_product", "max_return"] widgets = { - "product": AutoCompleteSelectProduct(), - "returned_product": AutoCompleteSelectProduct(), + "product": AutoCompleteSelectProduct, + "returned_product": AutoCompleteSelectProduct, } def save(self, commit: bool = True) -> ReturnableProduct: # noqa FBT diff --git a/counter/migrations/0037_productformula.py b/counter/migrations/0037_productformula.py new file mode 100644 index 00000000..75fbdd7f --- /dev/null +++ b/counter/migrations/0037_productformula.py @@ -0,0 +1,43 @@ +# Generated by Django 5.2.8 on 2025-11-26 11:34 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [("counter", "0036_product_created_at_product_updated_at")] + + operations = [ + migrations.CreateModel( + name="ProductFormula", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "products", + models.ManyToManyField( + help_text="The products that constitute this formula.", + related_name="formulas", + to="counter.product", + verbose_name="products", + ), + ), + ( + "result", + models.OneToOneField( + help_text="The formula product.", + on_delete=django.db.models.deletion.CASCADE, + to="counter.product", + verbose_name="result product", + ), + ), + ], + ), + ] diff --git a/counter/models.py b/counter/models.py index 44101d42..d53d129c 100644 --- a/counter/models.py +++ b/counter/models.py @@ -456,6 +456,37 @@ class Product(models.Model): return self.selling_price - self.purchase_price +class ProductFormula(models.Model): + products = models.ManyToManyField( + Product, + related_name="formulas", + verbose_name=_("products"), + help_text=_("The products that constitute this formula."), + ) + result = models.OneToOneField( + Product, + related_name="formula", + on_delete=models.CASCADE, + verbose_name=_("result product"), + help_text=_("The product got with the formula."), + ) + + def __str__(self): + return self.result.name + + @cached_property + def max_selling_price(self) -> float: + # iterating over all products is less efficient than doing + # a simple aggregation, but this method is likely to be used in + # coordination with `max_special_selling_price`, + # and Django caches the result of the `all` queryset. + return sum(p.selling_price for p in self.products.all()) + + @cached_property + def max_special_selling_price(self) -> float: + return sum(p.special_selling_price for p in self.products.all()) + + class CounterQuerySet(models.QuerySet): def annotate_has_barman(self, user: User) -> Self: """Annotate the queryset with the `user_is_barman` field. diff --git a/counter/static/bundled/counter/counter-click-index.ts b/counter/static/bundled/counter/counter-click-index.ts index 6d582b20..3e940bf8 100644 --- a/counter/static/bundled/counter/counter-click-index.ts +++ b/counter/static/bundled/counter/counter-click-index.ts @@ -1,6 +1,10 @@ import { AlertMessage } from "#core:utils/alert-message.ts"; import { BasketItem } from "#counter:counter/basket.ts"; -import type { CounterConfig, ErrorMessage } from "#counter:counter/types.ts"; +import type { + CounterConfig, + ErrorMessage, + ProductFormula, +} from "#counter:counter/types.ts"; import type { CounterProductSelect } from "./components/counter-product-select-index.ts"; document.addEventListener("alpine:init", () => { @@ -47,15 +51,43 @@ document.addEventListener("alpine:init", () => { this.basket[id] = item; + this.checkFormulas(); + if (this.sumBasket() > this.customerBalance) { item.quantity = oldQty; if (item.quantity === 0) { delete this.basket[id]; } - return gettext("Not enough money"); + this.alertMessage.display(gettext("Not enough money"), { success: false }); } + }, - return ""; + checkFormulas() { + const products = new Set( + Object.keys(this.basket).map((i: string) => Number.parseInt(i)), + ); + const formula: ProductFormula = config.formulas.find((f: ProductFormula) => { + return f.products.every((p: number) => products.has(p)); + }); + if (formula === undefined) { + return; + } + for (const product of formula.products) { + const key = product.toString(); + this.basket[key].quantity -= 1; + if (this.basket[key].quantity <= 0) { + this.removeFromBasket(key); + } + } + this.alertMessage.display( + interpolate( + gettext("Formula %(formula)s applied"), + { formula: config.products[formula.result.toString()].name }, + true, + ), + { success: true }, + ); + this.addToBasket(formula.result.toString(), 1); }, getBasketSize() { @@ -70,14 +102,7 @@ document.addEventListener("alpine:init", () => { (acc: number, cur: BasketItem) => acc + cur.sum(), 0, ) as number; - return total; - }, - - addToBasketWithMessage(id: string, quantity: number) { - const message = this.addToBasket(id, quantity); - if (message.length > 0) { - this.alertMessage.display(message, { success: false }); - } + return Math.round(total * 100) / 100; }, onRefillingSuccess(event: CustomEvent) { @@ -116,7 +141,7 @@ document.addEventListener("alpine:init", () => { this.finish(); } } else { - this.addToBasketWithMessage(code, quantity); + this.addToBasket(code, quantity); } this.codeField.widget.clear(); this.codeField.widget.focus(); diff --git a/counter/static/bundled/counter/types.d.ts b/counter/static/bundled/counter/types.d.ts index 18fea258..330b6f0e 100644 --- a/counter/static/bundled/counter/types.d.ts +++ b/counter/static/bundled/counter/types.d.ts @@ -7,10 +7,16 @@ export interface InitialFormData { errors?: string[]; } +export interface ProductFormula { + result: number; + products: number[]; +} + export interface CounterConfig { customerBalance: number; customerId: number; products: Record; + formulas: ProductFormula[]; formInitial: InitialFormData[]; cancelUrl: string; } diff --git a/counter/static/counter/css/counter-click.scss b/counter/static/counter/css/counter-click.scss index 478fa32c..6288f902 100644 --- a/counter/static/counter/css/counter-click.scss +++ b/counter/static/counter/css/counter-click.scss @@ -10,12 +10,12 @@ float: right; } -.basket-error-container { +.basket-message-container { position: relative; display: block } -.basket-error { +.basket-message { z-index: 10; // to get on top of tomselect text-align: center; position: absolute; diff --git a/counter/templates/counter/counter_click.jinja b/counter/templates/counter/counter_click.jinja index dc38d8a3..07bfd461 100644 --- a/counter/templates/counter/counter_click.jinja +++ b/counter/templates/counter/counter_click.jinja @@ -32,13 +32,11 @@
-
{% trans %}Customer{% endtrans %}
@@ -88,11 +86,12 @@ -
+
@@ -111,9 +110,9 @@
- + - + : @@ -213,7 +212,7 @@
{{ category }}
{% for product in categories[category] -%} - + {% endif %} + + {%- endfor -%} +
+ +{% endblock %} diff --git a/counter/tests/test_formula.py b/counter/tests/test_formula.py new file mode 100644 index 00000000..766b3870 --- /dev/null +++ b/counter/tests/test_formula.py @@ -0,0 +1,59 @@ +from django.test import TestCase + +from counter.baker_recipes import product_recipe +from counter.forms import ProductFormulaForm + + +class TestFormulaForm(TestCase): + @classmethod + def setUpTestData(cls): + cls.products = product_recipe.make( + selling_price=iter([1.5, 1, 1]), + special_selling_price=iter([1.4, 0.9, 0.9]), + _quantity=3, + _bulk_create=True, + ) + + def test_ok(self): + form = ProductFormulaForm( + data={ + "result": self.products[0].id, + "products": [self.products[1].id, self.products[2].id], + } + ) + assert form.is_valid() + formula = form.save() + assert formula.result == self.products[0] + assert set(formula.products.all()) == set(self.products[1:]) + + def test_price_invalid(self): + self.products[0].selling_price = 2.1 + self.products[0].save() + form = ProductFormulaForm( + data={ + "result": self.products[0].id, + "products": [self.products[1].id, self.products[2].id], + } + ) + assert not form.is_valid() + assert form.errors == { + "result": [ + "Le résultat ne peut pas être plus cher " + "que le total des autres produits." + ] + } + + def test_product_both_in_result_and_products(self): + form = ProductFormulaForm( + data={ + "result": self.products[0].id, + "products": [self.products[0].id, self.products[1].id], + } + ) + assert not form.is_valid() + assert form.errors == { + "__all__": [ + "Un même produit ne peut pas être à la fois " + "le résultat et un élément de la formule." + ] + } diff --git a/counter/tests/test_product.py b/counter/tests/test_product.py index d5d90c4c..a804fa42 100644 --- a/counter/tests/test_product.py +++ b/counter/tests/test_product.py @@ -15,8 +15,9 @@ from pytest_django.asserts import assertNumQueries, assertRedirects from club.models import Club from core.baker_recipes import board_user, subscriber_user from core.models import Group, User +from counter.baker_recipes import product_recipe from counter.forms import ProductForm -from counter.models import Product, ProductType +from counter.models import Product, ProductFormula, ProductType @pytest.mark.django_db @@ -93,6 +94,9 @@ class TestCreateProduct(TestCase): def setUpTestData(cls): cls.product_type = baker.make(ProductType) cls.club = baker.make(Club) + cls.counter_admin = baker.make( + User, groups=[Group.objects.get(id=settings.SITH_GROUP_COUNTER_ADMIN_ID)] + ) cls.data = { "name": "foo", "description": "bar", @@ -116,13 +120,36 @@ class TestCreateProduct(TestCase): assert instance.name == "foo" assert instance.selling_price == 1.0 - def test_view(self): - self.client.force_login( - baker.make( - User, - groups=[Group.objects.get(id=settings.SITH_GROUP_COUNTER_ADMIN_ID)], - ) + def test_form_with_product_from_formula(self): + """Test when the edited product is a result of a formula.""" + self.client.force_login(self.counter_admin) + products = product_recipe.make( + selling_price=iter([1.5, 1, 1]), + special_selling_price=iter([1.4, 0.9, 0.9]), + _quantity=3, + _bulk_create=True, ) + baker.make(ProductFormula, result=products[0], products=products[1:]) + + data = self.data | {"selling_price": 1.7, "special_selling_price": 1.5} + form = ProductForm(data=data, instance=products[0]) + assert form.is_valid() + + # it shouldn't be possible to give a price higher than the formula's products + data = self.data | {"selling_price": 2.1, "special_selling_price": 1.9} + form = ProductForm(data=data, instance=products[0]) + assert not form.is_valid() + assert form.errors == { + "selling_price": [ + "Assurez-vous que cette valeur est inférieure ou égale à 2.00." + ], + "special_selling_price": [ + "Assurez-vous que cette valeur est inférieure ou égale à 1.80." + ], + } + + def test_view(self): + self.client.force_login(self.counter_admin) url = reverse("counter:new_product") response = self.client.get(url) assert response.status_code == 200 diff --git a/counter/urls.py b/counter/urls.py index 67c7d950..9637ecd0 100644 --- a/counter/urls.py +++ b/counter/urls.py @@ -25,6 +25,10 @@ from counter.views.admin import ( CounterStatView, ProductCreateView, ProductEditView, + ProductFormulaCreateView, + ProductFormulaDeleteView, + ProductFormulaEditView, + ProductFormulaListView, ProductListView, ProductTypeCreateView, ProductTypeEditView, @@ -116,6 +120,24 @@ urlpatterns = [ ProductEditView.as_view(), name="product_edit", ), + path( + "admin/formula/", ProductFormulaListView.as_view(), name="product_formula_list" + ), + path( + "admin/formula/new/", + ProductFormulaCreateView.as_view(), + name="product_formula_create", + ), + path( + "admin/formula//edit", + ProductFormulaEditView.as_view(), + name="product_formula_edit", + ), + path( + "admin/formula//delete", + ProductFormulaDeleteView.as_view(), + name="product_formula_delete", + ), path( "admin/product-type/list/", ProductTypeListView.as_view(), diff --git a/counter/views/admin.py b/counter/views/admin.py index 24c112f5..bd87150b 100644 --- a/counter/views/admin.py +++ b/counter/views/admin.py @@ -34,11 +34,13 @@ from counter.forms import ( CloseCustomerAccountForm, CounterEditForm, ProductForm, + ProductFormulaForm, ReturnableProductForm, ) from counter.models import ( Counter, Product, + ProductFormula, ProductType, Refilling, ReturnableProduct, @@ -162,6 +164,62 @@ class ProductEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView): current_tab = "products" +class ProductFormulaListView(CounterAdminTabsMixin, PermissionRequiredMixin, ListView): + model = ProductFormula + queryset = ProductFormula.objects.select_related("result").prefetch_related( + "products" + ) + template_name = "counter/formula_list.jinja" + current_tab = "formulas" + permission_required = "counter.view_productformula" + + +class ProductFormulaCreateView( + CounterAdminTabsMixin, PermissionRequiredMixin, CreateView +): + model = ProductFormula + form_class = ProductFormulaForm + pk_url_kwarg = "formula_id" + template_name = "core/create.jinja" + current_tab = "formulas" + success_url = reverse_lazy("counter:product_formula_list") + permission_required = "counter.add_productformula" + + +class ProductFormulaEditView( + CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView +): + model = ProductFormula + form_class = ProductFormulaForm + pk_url_kwarg = "formula_id" + template_name = "core/edit.jinja" + current_tab = "formulas" + success_url = reverse_lazy("counter:product_formula_list") + permission_required = "counter.change_productformula" + + +class ProductFormulaDeleteView( + CounterAdminTabsMixin, PermissionRequiredMixin, DeleteView +): + model = ProductFormula + pk_url_kwarg = "formula_id" + template_name = "core/delete_confirm.jinja" + current_tab = "formulas" + success_url = reverse_lazy("counter:product_formula_list") + permission_required = "counter.delete_productformula" + + def get_context_data(self, **kwargs): + obj_name = self.object.result.name + return super().get_context_data(**kwargs) | { + "object_name": _("%(formula)s (formula)") % {"formula": obj_name}, + "help_text": _( + "This action will only delete the formula, " + "but not the %(product)s product itself." + ) + % {"product": obj_name}, + } + + class ReturnableProductListView( CounterAdminTabsMixin, PermissionRequiredMixin, ListView ): diff --git a/counter/views/click.py b/counter/views/click.py index 02c0bdaa..29338d6e 100644 --- a/counter/views/click.py +++ b/counter/views/click.py @@ -12,6 +12,7 @@ # OR WITHIN THE LOCAL FILE "LICENSE" # # +from collections import defaultdict from django.core.exceptions import PermissionDenied from django.db import transaction @@ -31,6 +32,7 @@ from counter.forms import BasketForm, RefillForm from counter.models import ( Counter, Customer, + ProductFormula, ReturnableProduct, Selling, ) @@ -206,12 +208,13 @@ class CounterClick( """Add customer to the context.""" kwargs = super().get_context_data(**kwargs) kwargs["products"] = self.products - kwargs["categories"] = {} + kwargs["formulas"] = ProductFormula.objects.filter( + result__in=self.products + ).prefetch_related("products") + kwargs["categories"] = defaultdict(list) for product in kwargs["products"]: if product.product_type: - kwargs["categories"].setdefault(product.product_type, []).append( - product - ) + kwargs["categories"][product.product_type].append(product) kwargs["customer"] = self.customer kwargs["cancel_url"] = self.get_success_url() diff --git a/counter/views/mixins.py b/counter/views/mixins.py index c7fabdd6..2cce25b4 100644 --- a/counter/views/mixins.py +++ b/counter/views/mixins.py @@ -100,6 +100,11 @@ class CounterAdminTabsMixin(TabedViewMixin): "slug": "products", "name": _("Products"), }, + { + "url": reverse_lazy("counter:product_formula_list"), + "slug": "formulas", + "name": _("Formulas"), + }, { "url": reverse_lazy("counter:product_type_list"), "slug": "product_types", diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index cbbf7a53..c594b166 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -2957,6 +2957,38 @@ msgstr "" "Décrivez le produit. Si c'est un click pour un évènement, donnez quelques " "détails dessus, comme la date (en incluant l'année)." +#: counter/forms.py +#, python-format +msgid "" +"This product is a formula. Its price cannot be greater than the price of the " +"products constituting it, which is %(price)s €" +msgstr "" +"Ce produit est une formule. Son prix ne peut pas être supérieur au prix des " +"produits qui la constituent, soit %(price)s €." + +#: counter/forms.py +#, python-format +msgid "" +"This product is a formula. Its special price cannot be greater than the " +"price of the products constituting it, which is %(price)s €" +msgstr "" +"Ce produit est une formule. Son prix spécial ne peut pas être supérieur au " +"prix des produits qui la constituent, soit %(price)s €." + +#: counter/forms.py +msgid "" +"The same product cannot be at the same time the result and a part of the " +"formula." +msgstr "" +"Un même produit ne peut pas être à la fois le résultat et un élément de la " +"formule." + +#: counter/forms.py +msgid "" +"The result cannot be more expensive than the total of the other products." +msgstr "" +"Le résultat ne peut pas être plus cher que le total des autres produits." + #: counter/forms.py msgid "Refound this account" msgstr "Rembourser ce compte" @@ -3121,6 +3153,18 @@ msgstr "produit" msgid "products" msgstr "produits" +#: counter/models.py +msgid "The products that constitute this formula." +msgstr "Les produits qui constituent cette formule." + +#: counter/models.py +msgid "result product" +msgstr "produit résultat" + +#: counter/models.py +msgid "The product got with the formula." +msgstr "Le produit obtenu par la formule." + #: counter/models.py msgid "counter type" msgstr "type de comptoir" @@ -3541,6 +3585,48 @@ msgstr "Nouveau eticket" msgid "There is no eticket in this website." msgstr "Il n'y a pas de eticket sur ce site web." +#: counter/templates/counter/formula_list.jinja +msgid "Product formulas" +msgstr "Formules de produits" + +#: counter/templates/counter/formula_list.jinja +msgid "" +"Formulas allow you to associate a group of products with a result product " +"(the formula itself)." +msgstr "" +"Les formules permettent d'associer un groupe de produits à un produit " +"résultat (la formule en elle-même)." + +#: counter/templates/counter/formula_list.jinja +msgid "" +"If the product of a formula is available on a counter, it will be " +"automatically applied if all the products that make it up are added to the " +"basket." +msgstr "" +"Si le produit d'une formule est disponible sur un comptoir, celle-ci sera " +"automatiquement appliquée si tous les produits qui la constituent sont " +"ajoutés au panier." + +#: counter/templates/counter/formula_list.jinja +msgid "" +"For example, if there is a formula that combines a \"Sandwich Formula\" " +"product with the \"Sandwich\" and \"Soft Drink\" products, then, if a person " +"orders a sandwich and a soft drink, the formula will be applied and the " +"basket will then contain a sandwich formula instead." +msgstr "" +"Par exemple s'il existe une formule associant un produit « Formule " +"sandwich » aux produits « Sandwich » et « Soft », alors, si une personne " +"commande un sandwich et un soft, la formule sera appliquée et le panier " +"contiendra alors une formule sandwich à la place." + +#: counter/templates/counter/formula_list.jinja +msgid "New formula" +msgstr "Nouvelle formule" + +#: counter/templates/counter/formula_list.jinja +msgid "instead of" +msgstr "au lieu de" + #: counter/templates/counter/fragments/create_student_card.jinja msgid "No student card registered." msgstr "Aucune carte étudiante enregistrée." @@ -3803,6 +3889,20 @@ msgstr "Temps" msgid "Top 100 barman %(counter_name)s (all semesters)" msgstr "Top 100 barman %(counter_name)s (tous les semestres)" +#: counter/views/admin.py +#, python-format +msgid "%(formula)s (formula)" +msgstr "%(formula)s (formule)" + +#: counter/views/admin.py +#, python-format +msgid "" +"This action will only delete the formula, but not the %(product)s product " +"itself." +msgstr "" +"Cette action supprimera seulement la formule, mais pas le produit " +"%(product)s en lui-même." + #: counter/views/admin.py #, python-format msgid "returnable product : %(returnable)s -> %(returned)s" @@ -3888,6 +3988,10 @@ msgstr "Dernières opérations" msgid "Counter administration" msgstr "Administration des comptoirs" +#: counter/views/mixins.py +msgid "Formulas" +msgstr "Formules" + #: counter/views/mixins.py msgid "Product types" msgstr "Types de produit" diff --git a/locale/fr/LC_MESSAGES/djangojs.po b/locale/fr/LC_MESSAGES/djangojs.po index 0dc0a145..9b598aee 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: 2025-08-23 15:30+0200\n" +"POT-Creation-Date: 2025-11-26 15:45+0100\n" "PO-Revision-Date: 2024-09-17 11:54+0200\n" "Last-Translator: Sli \n" "Language-Team: AE info \n" @@ -206,6 +206,10 @@ msgstr "capture.%s" msgid "Not enough money" msgstr "Pas assez d'argent" +#: counter/static/bundled/counter/counter-click-index.ts +msgid "Formula %(formula)s applied" +msgstr "Formule %(formula)s appliquée" + #: counter/static/bundled/counter/counter-click-index.ts msgid "You can't send an empty basket." msgstr "Vous ne pouvez pas envoyer un panier vide." @@ -262,3 +266,9 @@ msgstr "Il n'a pas été possible de modérer l'image" #: sas/static/bundled/sas/viewer-index.ts msgid "Couldn't delete picture" msgstr "Il n'a pas été possible de supprimer l'image" + +#: timetable/static/bundled/timetable/generator-index.ts +msgid "" +"Wrong timetable format. Make sure you copied if from your student folder." +msgstr "" +"Mauvais format d'emploi du temps. Assurez-vous que vous l'avez copié depuis votre dossier étudiants."