Compare commits

...

7 Commits

Author SHA1 Message Date
imperosol
a43559732d add tests 2025-11-27 14:33:21 +01:00
imperosol
0a65569be3 add translations 2025-11-27 14:33:21 +01:00
imperosol
b0659524ad add checks on ProductForm 2025-11-27 14:33:21 +01:00
imperosol
a354f33ed9 automatically apply formulas on click 2025-11-27 14:16:29 +01:00
imperosol
df8b23a4a1 product formulas management views 2025-11-26 15:50:09 +01:00
imperosol
9fb3f83df5 fix typo in text shown to user 2025-11-26 15:50:09 +01:00
imperosol
d9d5944a6d feat: ProductFormula model 2025-11-26 15:50:09 +01:00
18 changed files with 476 additions and 42 deletions

View File

@@ -35,7 +35,7 @@ TODO : rewrite the pagination used in this template an Alpine one
{% csrf_token %} {% csrf_token %}
{{ form }} {{ form }}
<p><input type="submit" value="{% trans %}Show{% endtrans %}" /></p> <p><input type="submit" value="{% trans %}Show{% endtrans %}" /></p>
<p><input type="submit" value="{% trans %}Download as cvs{% endtrans %}" formaction="{{ url('club:sellings_csv', club_id=object.id) }}"/></p> <p><input type="submit" value="{% trans %}Download as csv{% endtrans %}" formaction="{{ url('club:sellings_csv', club_id=object.id) }}"/></p>
</form> </form>
<p> <p>
{% trans %}Quantity: {% endtrans %}{{ total_quantity }} {% trans %}units{% endtrans %}<br/> {% trans %}Quantity: {% endtrans %}{{ total_quantity }} {% trans %}units{% endtrans %}<br/>

View File

@@ -5,6 +5,7 @@ from datetime import date, datetime, timezone
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django import forms from django import forms
from django.core.validators import MaxValueValidator
from django.db.models import Exists, OuterRef, Q from django.db.models import Exists, OuterRef, Q
from django.forms import BaseModelFormSet from django.forms import BaseModelFormSet
from django.utils.timezone import now from django.utils.timezone import now
@@ -34,6 +35,7 @@ from counter.models import (
Eticket, Eticket,
InvoiceCall, InvoiceCall,
Product, Product,
ProductFormula,
Refilling, Refilling,
ReturnableProduct, ReturnableProduct,
ScheduledProductAction, ScheduledProductAction,
@@ -316,7 +318,6 @@ class ProductForm(forms.ModelForm):
} }
counters = forms.ModelMultipleChoiceField( counters = forms.ModelMultipleChoiceField(
help_text=None,
label=_("Counters"), label=_("Counters"),
required=False, required=False,
widget=AutoCompleteSelectMultipleCounter, widget=AutoCompleteSelectMultipleCounter,
@@ -327,10 +328,31 @@ class ProductForm(forms.ModelForm):
super().__init__(*args, instance=instance, **kwargs) super().__init__(*args, instance=instance, **kwargs)
if self.instance.id: if self.instance.id:
self.fields["counters"].initial = self.instance.counters.all() self.fields["counters"].initial = self.instance.counters.all()
if hasattr(self.instance, "formula"):
self.formula_init(self.instance.formula)
self.action_formset = ScheduledProductActionFormSet( self.action_formset = ScheduledProductActionFormSet(
*args, product=self.instance, **kwargs *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): def is_valid(self):
return super().is_valid() and self.action_formset.is_valid() return super().is_valid() and self.action_formset.is_valid()
@@ -349,13 +371,47 @@ class ProductForm(forms.ModelForm):
return product 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 ReturnableProductForm(forms.ModelForm):
class Meta: class Meta:
model = ReturnableProduct model = ReturnableProduct
fields = ["product", "returned_product", "max_return"] fields = ["product", "returned_product", "max_return"]
widgets = { widgets = {
"product": AutoCompleteSelectProduct(), "product": AutoCompleteSelectProduct,
"returned_product": AutoCompleteSelectProduct(), "returned_product": AutoCompleteSelectProduct,
} }
def save(self, commit: bool = True) -> ReturnableProduct: # noqa FBT def save(self, commit: bool = True) -> ReturnableProduct: # noqa FBT

View File

@@ -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", "0035_remove_selling_is_validated_and_more")]
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",
),
),
],
),
]

View File

@@ -455,6 +455,37 @@ class Product(models.Model):
return self.selling_price - self.purchase_price 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): class CounterQuerySet(models.QuerySet):
def annotate_has_barman(self, user: User) -> Self: def annotate_has_barman(self, user: User) -> Self:
"""Annotate the queryset with the `user_is_barman` field. """Annotate the queryset with the `user_is_barman` field.

View File

@@ -1,6 +1,10 @@
import { AlertMessage } from "#core:utils/alert-message"; import { AlertMessage } from "#core:utils/alert-message";
import { BasketItem } from "#counter:counter/basket"; import { BasketItem } from "#counter:counter/basket";
import type { CounterConfig, ErrorMessage } from "#counter:counter/types"; import type {
CounterConfig,
ErrorMessage,
ProductFormula,
} from "#counter:counter/types";
import type { CounterProductSelect } from "./components/counter-product-select-index.ts"; import type { CounterProductSelect } from "./components/counter-product-select-index.ts";
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
@@ -47,15 +51,43 @@ document.addEventListener("alpine:init", () => {
this.basket[id] = item; this.basket[id] = item;
this.checkFormulas();
if (this.sumBasket() > this.customerBalance) { if (this.sumBasket() > this.customerBalance) {
item.quantity = oldQty; item.quantity = oldQty;
if (item.quantity === 0) { if (item.quantity === 0) {
delete this.basket[id]; 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() { getBasketSize() {
@@ -70,14 +102,7 @@ document.addEventListener("alpine:init", () => {
(acc: number, cur: BasketItem) => acc + cur.sum(), (acc: number, cur: BasketItem) => acc + cur.sum(),
0, 0,
) as number; ) as number;
return total; return Math.round(total * 100) / 100;
},
addToBasketWithMessage(id: string, quantity: number) {
const message = this.addToBasket(id, quantity);
if (message.length > 0) {
this.alertMessage.display(message, { success: false });
}
}, },
onRefillingSuccess(event: CustomEvent) { onRefillingSuccess(event: CustomEvent) {
@@ -116,7 +141,7 @@ document.addEventListener("alpine:init", () => {
this.finish(); this.finish();
} }
} else { } else {
this.addToBasketWithMessage(code, quantity); this.addToBasket(code, quantity);
} }
this.codeField.widget.clear(); this.codeField.widget.clear();
this.codeField.widget.focus(); this.codeField.widget.focus();

View File

@@ -7,10 +7,16 @@ export interface InitialFormData {
errors?: string[]; errors?: string[];
} }
export interface ProductFormula {
result: number;
products: number[];
}
export interface CounterConfig { export interface CounterConfig {
customerBalance: number; customerBalance: number;
customerId: number; customerId: number;
products: Record<string, Product>; products: Record<string, Product>;
formulas: ProductFormula[];
formInitial: InitialFormData[]; formInitial: InitialFormData[];
cancelUrl: string; cancelUrl: string;
} }

View File

@@ -10,12 +10,12 @@
float: right; float: right;
} }
.basket-error-container { .basket-message-container {
position: relative; position: relative;
display: block display: block
} }
.basket-error { .basket-message {
z-index: 10; // to get on top of tomselect z-index: 10; // to get on top of tomselect
text-align: center; text-align: center;
position: absolute; position: absolute;

View File

@@ -32,13 +32,11 @@
<div id="bar-ui" x-data="counter({ <div id="bar-ui" x-data="counter({
customerBalance: {{ customer.amount }}, customerBalance: {{ customer.amount }},
products: products, products: products,
formulas: formulas,
customerId: {{ customer.pk }}, customerId: {{ customer.pk }},
formInitial: formInitial, formInitial: formInitial,
cancelUrl: '{{ cancel_url }}', cancelUrl: '{{ cancel_url }}',
})"> })">
<noscript>
<p class="important">Javascript is required for the counter UI.</p>
</noscript>
<div id="user_info"> <div id="user_info">
<h5>{% trans %}Customer{% endtrans %}</h5> <h5>{% trans %}Customer{% endtrans %}</h5>
@@ -88,11 +86,12 @@
<form x-cloak method="post" action="" x-ref="basketForm"> <form x-cloak method="post" action="" x-ref="basketForm">
<div class="basket-error-container"> <div class="basket-message-container">
<div <div
x-cloak x-cloak
class="alert alert-red basket-error" class="alert basket-message"
x-show="alertMessage.show" :class="alertMessage.success ? 'alert-green' : 'alert-red'"
x-show="alertMessage.open"
x-transition.duration.500ms x-transition.duration.500ms
x-text="alertMessage.content" x-text="alertMessage.content"
></div> ></div>
@@ -111,9 +110,9 @@
</div> </div>
</template> </template>
<button @click.prevent="addToBasketWithMessage(item.product.id, -1)">-</button> <button @click.prevent="addToBasket(item.product.id, -1)">-</button>
<span class="quantity" x-text="item.quantity"></span> <span class="quantity" x-text="item.quantity"></span>
<button @click.prevent="addToBasketWithMessage(item.product.id, 1)">+</button> <button @click.prevent="addToBasket(item.product.id, 1)">+</button>
<span x-text="item.product.name"></span> : <span x-text="item.product.name"></span> :
<span x-text="item.sum().toLocaleString(undefined, { minimumFractionDigits: 2 })">€</span> <span x-text="item.sum().toLocaleString(undefined, { minimumFractionDigits: 2 })">€</span>
@@ -213,7 +212,7 @@
<h5 class="margin-bottom">{{ category }}</h5> <h5 class="margin-bottom">{{ category }}</h5>
<div class="row gap-2x"> <div class="row gap-2x">
{% for product in categories[category] -%} {% for product in categories[category] -%}
<button class="card shadow" @click="addToBasketWithMessage('{{ product.id }}', 1)"> <button class="card shadow" @click="addToBasket('{{ product.id }}', 1)">
<img <img
class="card-image" class="card-image"
alt="image de {{ product.name }}" alt="image de {{ product.name }}"
@@ -252,6 +251,18 @@
}, },
{%- endfor -%} {%- endfor -%}
}; };
const formulas = [
{%- for formula in formulas -%}
{
result: {{ formula.result_id }},
products: [
{%- for product in formula.products.all() -%}
{{ product.id }},
{%- endfor -%}
]
},
{%- endfor -%}
];
const formInitial = [ const formInitial = [
{%- for f in form -%} {%- for f in form -%}
{%- if f.cleaned_data -%} {%- if f.cleaned_data -%}

View File

@@ -0,0 +1,35 @@
{% extends "core/base.jinja" %}
{% block title %}
{% trans %}Product formulas{% endtrans %}
{% endblock %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static("core/components/card.scss") }}">
<link rel="stylesheet" href="{{ static("counter/css/admin.scss") }}">
{% endblock %}
{% block content %}
<main>
<h3 class="margin-bottom">{% trans %}Product formulas{% endtrans %}</h3>
<p>
<a href="{{ url('counter:product_formula_create') }}" class="btn btn-blue">
{% trans %}New formula{% endtrans %}
<i class="fa fa-plus"></i>
</a>
</p>
<ul class="product-group">
{%- for formula in object_list -%}
<li>
<a href="{{ url('counter:product_formula_edit', formula_id=formula.id) }}">
{{ formula.result.name }}
</a>
<a href="{{ url('counter:product_formula_delete', formula_id=formula.id) }}">
<i class="fa fa-trash delete-action"></i>
</a>
</li>
{%- endfor -%}
</ul>
</div>
</main>
{% endblock %}

View File

@@ -89,7 +89,7 @@
:disabled="csvLoading" :disabled="csvLoading"
:aria-busy="csvLoading" :aria-busy="csvLoading"
> >
{% trans %}Download as cvs{% endtrans %} <i class="fa fa-file-arrow-down"></i> {% trans %}Download as csv{% endtrans %} <i class="fa fa-file-arrow-down"></i>
</button> </button>
</div> </div>

View File

@@ -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."
]
}

View File

@@ -15,8 +15,9 @@ from pytest_django.asserts import assertNumQueries, assertRedirects
from club.models import Club from club.models import Club
from core.baker_recipes import board_user, subscriber_user from core.baker_recipes import board_user, subscriber_user
from core.models import Group, User from core.models import Group, User
from counter.baker_recipes import product_recipe
from counter.forms import ProductForm from counter.forms import ProductForm
from counter.models import Product, ProductType from counter.models import Product, ProductFormula, ProductType
@pytest.mark.django_db @pytest.mark.django_db
@@ -93,6 +94,9 @@ class TestCreateProduct(TestCase):
def setUpTestData(cls): def setUpTestData(cls):
cls.product_type = baker.make(ProductType) cls.product_type = baker.make(ProductType)
cls.club = baker.make(Club) cls.club = baker.make(Club)
cls.counter_admin = baker.make(
User, groups=[Group.objects.get(id=settings.SITH_GROUP_COUNTER_ADMIN_ID)]
)
cls.data = { cls.data = {
"name": "foo", "name": "foo",
"description": "bar", "description": "bar",
@@ -116,13 +120,36 @@ class TestCreateProduct(TestCase):
assert instance.name == "foo" assert instance.name == "foo"
assert instance.selling_price == 1.0 assert instance.selling_price == 1.0
def test_view(self): def test_form_with_product_from_formula(self):
self.client.force_login( """Test when the edited product is a result of a formula."""
baker.make( self.client.force_login(self.counter_admin)
User, products = product_recipe.make(
groups=[Group.objects.get(id=settings.SITH_GROUP_COUNTER_ADMIN_ID)], 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") url = reverse("counter:new_product")
response = self.client.get(url) response = self.client.get(url)
assert response.status_code == 200 assert response.status_code == 200

View File

@@ -25,6 +25,10 @@ from counter.views.admin import (
CounterStatView, CounterStatView,
ProductCreateView, ProductCreateView,
ProductEditView, ProductEditView,
ProductFormulaCreateView,
ProductFormulaDeleteView,
ProductFormulaEditView,
ProductFormulaListView,
ProductListView, ProductListView,
ProductTypeCreateView, ProductTypeCreateView,
ProductTypeEditView, ProductTypeEditView,
@@ -116,6 +120,24 @@ urlpatterns = [
ProductEditView.as_view(), ProductEditView.as_view(),
name="product_edit", 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/<int:formula_id>/edit",
ProductFormulaEditView.as_view(),
name="product_formula_edit",
),
path(
"admin/formula/<int:formula_id>/delete",
ProductFormulaDeleteView.as_view(),
name="product_formula_delete",
),
path( path(
"admin/product-type/list/", "admin/product-type/list/",
ProductTypeListView.as_view(), ProductTypeListView.as_view(),

View File

@@ -34,11 +34,13 @@ from counter.forms import (
CloseCustomerAccountForm, CloseCustomerAccountForm,
CounterEditForm, CounterEditForm,
ProductForm, ProductForm,
ProductFormulaForm,
ReturnableProductForm, ReturnableProductForm,
) )
from counter.models import ( from counter.models import (
Counter, Counter,
Product, Product,
ProductFormula,
ProductType, ProductType,
Refilling, Refilling,
ReturnableProduct, ReturnableProduct,
@@ -162,6 +164,49 @@ class ProductEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
current_tab = "products" current_tab = "products"
class ProductFormulaListView(CounterAdminTabsMixin, PermissionRequiredMixin, ListView):
model = ProductFormula
queryset = ProductFormula.objects.select_related("result")
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"
class ReturnableProductListView( class ReturnableProductListView(
CounterAdminTabsMixin, PermissionRequiredMixin, ListView CounterAdminTabsMixin, PermissionRequiredMixin, ListView
): ):

View File

@@ -12,6 +12,7 @@
# OR WITHIN THE LOCAL FILE "LICENSE" # OR WITHIN THE LOCAL FILE "LICENSE"
# #
# #
from collections import defaultdict
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.db import transaction from django.db import transaction
@@ -31,6 +32,7 @@ from counter.forms import BasketForm, RefillForm
from counter.models import ( from counter.models import (
Counter, Counter,
Customer, Customer,
ProductFormula,
ReturnableProduct, ReturnableProduct,
Selling, Selling,
) )
@@ -206,12 +208,13 @@ class CounterClick(
"""Add customer to the context.""" """Add customer to the context."""
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)
kwargs["products"] = self.products 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"]: for product in kwargs["products"]:
if product.product_type: if product.product_type:
kwargs["categories"].setdefault(product.product_type, []).append( kwargs["categories"][product.product_type].append(product)
product
)
kwargs["customer"] = self.customer kwargs["customer"] = self.customer
kwargs["cancel_url"] = self.get_success_url() kwargs["cancel_url"] = self.get_success_url()

View File

@@ -100,6 +100,11 @@ class CounterAdminTabsMixin(TabedViewMixin):
"slug": "products", "slug": "products",
"name": _("Products"), "name": _("Products"),
}, },
{
"url": reverse_lazy("counter:product_formula_list"),
"slug": "formulas",
"name": _("Formulas"),
},
{ {
"url": reverse_lazy("counter:product_type_list"), "url": reverse_lazy("counter:product_type_list"),
"slug": "product_types", "slug": "product_types",

View File

@@ -6,7 +6,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-24 11:05+0100\n" "POT-Creation-Date: 2025-11-27 14:22+0100\n"
"PO-Revision-Date: 2016-07-18\n" "PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n" "Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -388,7 +388,7 @@ msgstr "Montrer"
#: club/templates/club/club_sellings.jinja #: club/templates/club/club_sellings.jinja
#: counter/templates/counter/product_list.jinja #: counter/templates/counter/product_list.jinja
msgid "Download as cvs" msgid "Download as csv"
msgstr "Télécharger en CSV" msgstr "Télécharger en CSV"
#: club/templates/club/club_sellings.jinja #: club/templates/club/club_sellings.jinja
@@ -2960,6 +2960,38 @@ msgstr ""
"Décrivez le produit. Si c'est un click pour un évènement, donnez quelques " "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)." "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 #: counter/forms.py
msgid "Refound this account" msgid "Refound this account"
msgstr "Rembourser ce compte" msgstr "Rembourser ce compte"
@@ -3120,6 +3152,18 @@ msgstr "produit"
msgid "products" msgid "products"
msgstr "produits" 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 #: counter/models.py
msgid "counter type" msgid "counter type"
msgstr "type de comptoir" msgstr "type de comptoir"
@@ -3540,6 +3584,14 @@ msgstr "Nouveau eticket"
msgid "There is no eticket in this website." msgid "There is no eticket in this website."
msgstr "Il n'y a pas de eticket sur ce site web." 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 "New formula"
msgstr "Nouvelle formule"
#: counter/templates/counter/fragments/create_student_card.jinja #: counter/templates/counter/fragments/create_student_card.jinja
msgid "No student card registered." msgid "No student card registered."
msgstr "Aucune carte étudiante enregistrée." msgstr "Aucune carte étudiante enregistrée."
@@ -3879,6 +3931,10 @@ msgstr "Dernières opérations"
msgid "Counter administration" msgid "Counter administration"
msgstr "Administration des comptoirs" msgstr "Administration des comptoirs"
#: counter/views/mixins.py
msgid "Formulas"
msgstr "Formules"
#: counter/views/mixins.py #: counter/views/mixins.py
msgid "Product types" msgid "Product types"
msgstr "Types de produit" msgstr "Types de produit"

View File

@@ -7,7 +7,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "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" "PO-Revision-Date: 2024-09-17 11:54+0200\n"
"Last-Translator: Sli <antoine@bartuccio.fr>\n" "Last-Translator: Sli <antoine@bartuccio.fr>\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -206,6 +206,10 @@ msgstr "capture.%s"
msgid "Not enough money" msgid "Not enough money"
msgstr "Pas assez d'argent" 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 #: counter/static/bundled/counter/counter-click-index.ts
msgid "You can't send an empty basket." msgid "You can't send an empty basket."
msgstr "Vous ne pouvez pas envoyer un panier vide." 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 #: sas/static/bundled/sas/viewer-index.ts
msgid "Couldn't delete picture" msgid "Couldn't delete picture"
msgstr "Il n'a pas été possible de supprimer l'image" 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."