mirror of
https://github.com/ae-utbm/sith.git
synced 2026-02-21 05:18:38 +00:00
Compare commits
15 Commits
dependabot
...
product-fo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9506c8688f | ||
|
|
f3f470ec6c | ||
|
|
ced524587f | ||
|
|
5f01f973de | ||
|
|
6a6a7e949f | ||
|
|
4e73f103d8 | ||
|
|
b03346c733 | ||
|
|
7be1d1cc63 | ||
|
|
71ed7cdf7d | ||
|
|
43768171a1 | ||
|
|
0eccb4a5b5 | ||
|
|
e7584c8c83 | ||
|
|
ac06de4f55 | ||
|
|
e2fca3e6d2 | ||
|
|
2138783bde |
@@ -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/>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from django.utils.timezone import localdate, make_aware, now
|
|||||||
from faker import Faker
|
from faker import Faker
|
||||||
|
|
||||||
from club.models import Club, Membership
|
from club.models import Club, Membership
|
||||||
from core.models import Group, User
|
from core.models import Group, User, UserBan
|
||||||
from counter.models import (
|
from counter.models import (
|
||||||
Counter,
|
Counter,
|
||||||
Customer,
|
Customer,
|
||||||
@@ -40,6 +40,7 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
self.stdout.write("Creating users...")
|
self.stdout.write("Creating users...")
|
||||||
users = self.create_users()
|
users = self.create_users()
|
||||||
|
self.create_bans(random.sample(users, k=len(users) // 200)) # 0.5% of users
|
||||||
subscribers = random.sample(users, k=int(0.8 * len(users)))
|
subscribers = random.sample(users, k=int(0.8 * len(users)))
|
||||||
self.stdout.write("Creating subscriptions...")
|
self.stdout.write("Creating subscriptions...")
|
||||||
self.create_subscriptions(subscribers)
|
self.create_subscriptions(subscribers)
|
||||||
@@ -88,6 +89,8 @@ class Command(BaseCommand):
|
|||||||
self.stdout.write("Done")
|
self.stdout.write("Done")
|
||||||
|
|
||||||
def create_users(self) -> list[User]:
|
def create_users(self) -> list[User]:
|
||||||
|
# Create a single password hash for all users to make it faster.
|
||||||
|
# It's insecure as hell, but it's ok since it's only for dev purposes.
|
||||||
password = make_password("plop")
|
password = make_password("plop")
|
||||||
users = [
|
users = [
|
||||||
User(
|
User(
|
||||||
@@ -114,14 +117,33 @@ class Command(BaseCommand):
|
|||||||
public_group.users.add(*users)
|
public_group.users.add(*users)
|
||||||
return users
|
return users
|
||||||
|
|
||||||
|
def create_bans(self, users: list[User]):
|
||||||
|
ban_groups = [
|
||||||
|
settings.SITH_GROUP_BANNED_COUNTER_ID,
|
||||||
|
settings.SITH_GROUP_BANNED_SUBSCRIPTION_ID,
|
||||||
|
settings.SITH_GROUP_BANNED_ALCOHOL_ID,
|
||||||
|
]
|
||||||
|
UserBan.objects.bulk_create(
|
||||||
|
[
|
||||||
|
UserBan(
|
||||||
|
user=user,
|
||||||
|
ban_group_id=i,
|
||||||
|
reason=self.faker.sentence(),
|
||||||
|
expires_at=make_aware(self.faker.future_datetime("+1y")),
|
||||||
|
)
|
||||||
|
for user in users
|
||||||
|
for i in random.sample(ban_groups, k=random.randint(1, len(ban_groups)))
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
def create_subscriptions(self, users: list[User]):
|
def create_subscriptions(self, users: list[User]):
|
||||||
def prepare_subscription(_user: User, start_date: date) -> Subscription:
|
def prepare_subscription(_user: User, start_date: date) -> Subscription:
|
||||||
payment_method = random.choice(settings.SITH_SUBSCRIPTION_PAYMENT_METHOD)[0]
|
payment_method = random.choice(settings.SITH_SUBSCRIPTION_PAYMENT_METHOD)[0]
|
||||||
duration = random.randint(1, 4)
|
duration = random.randint(1, 4)
|
||||||
sub = Subscription(member=_user, payment_method=payment_method)
|
s = Subscription(member=_user, payment_method=payment_method)
|
||||||
sub.subscription_start = sub.compute_start(d=start_date, duration=duration)
|
s.subscription_start = s.compute_start(d=start_date, duration=duration)
|
||||||
sub.subscription_end = sub.compute_end(duration)
|
s.subscription_end = s.compute_end(duration)
|
||||||
return sub
|
return s
|
||||||
|
|
||||||
subscriptions = []
|
subscriptions = []
|
||||||
customers = []
|
customers = []
|
||||||
|
|||||||
@@ -21,6 +21,8 @@
|
|||||||
<h2>{% trans %}Delete confirmation{% endtrans %}</h2>
|
<h2>{% trans %}Delete confirmation{% endtrans %}</h2>
|
||||||
<form action="" method="post">{% csrf_token %}
|
<form action="" method="post">{% csrf_token %}
|
||||||
<p>{% trans name=object_name %}Are you sure you want to delete "{{ name }}"?{% endtrans %}</p>
|
<p>{% trans name=object_name %}Are you sure you want to delete "{{ name }}"?{% endtrans %}</p>
|
||||||
|
{% if help_text %}<p><em>{{ help_text }}</em></p>{% endif %}
|
||||||
|
<br/>
|
||||||
<input type="submit" value="{% trans %}Confirm{% endtrans %}" />
|
<input type="submit" value="{% trans %}Confirm{% endtrans %}" />
|
||||||
</form>
|
</form>
|
||||||
<form method="GET" action="javascript:history.back();">
|
<form method="GET" action="javascript:history.back();">
|
||||||
|
|||||||
@@ -39,8 +39,9 @@ class ProductAdmin(SearchModelAdmin):
|
|||||||
"code",
|
"code",
|
||||||
"product_type",
|
"product_type",
|
||||||
"selling_price",
|
"selling_price",
|
||||||
"profit",
|
|
||||||
"archived",
|
"archived",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
)
|
)
|
||||||
list_select_related = ("product_type",)
|
list_select_related = ("product_type",)
|
||||||
search_fields = ("name", "code")
|
search_fields = ("name", "code")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2026-02-10 15:40
|
||||||
|
from operator import attrgetter
|
||||||
|
|
||||||
|
import django.utils.timezone
|
||||||
|
from django.db import migrations, models
|
||||||
|
from django.db.migrations.state import StateApps
|
||||||
|
from django.db.models import OuterRef, Subquery
|
||||||
|
|
||||||
|
from counter.models import Selling
|
||||||
|
|
||||||
|
|
||||||
|
def apply_product_history_dates(apps: StateApps, schema_editor):
|
||||||
|
"""Approximate a posteriori the value of created_at and updated_at."""
|
||||||
|
Product = apps.get_model("counter", "Product")
|
||||||
|
sales_subquery = Selling.objects.filter(product=OuterRef("pk")).values("date")
|
||||||
|
|
||||||
|
# for products that have an associated sale, we set the creation date
|
||||||
|
# to the one of the first sale, and the update date to the one of the last sale
|
||||||
|
products = list(
|
||||||
|
Product.objects.exclude(sellings=None)
|
||||||
|
.annotate(
|
||||||
|
new_created_at=Subquery(sales_subquery.order_by("date")[:1]),
|
||||||
|
new_updated_at=Subquery(sales_subquery.order_by("-date")[:1]),
|
||||||
|
)
|
||||||
|
.only("id")
|
||||||
|
)
|
||||||
|
for product in products:
|
||||||
|
product.created_at = product.new_created_at
|
||||||
|
product.updated_at = product.new_updated_at
|
||||||
|
|
||||||
|
# For the remaining products (those without sale),
|
||||||
|
# they are given the creation and update date of the previous product having sales.
|
||||||
|
products_without_sale = list(Product.objects.filter(sellings=None).only("id"))
|
||||||
|
for product in products_without_sale:
|
||||||
|
previous_product = max(
|
||||||
|
(p for p in products if p.id < product.id), key=attrgetter("id")
|
||||||
|
)
|
||||||
|
product.created_at = previous_product.created_at
|
||||||
|
product.updated_at = previous_product.updated_at
|
||||||
|
products.extend(products_without_sale)
|
||||||
|
|
||||||
|
Product.objects.bulk_update(products, fields=["created_at", "updated_at"])
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [("counter", "0035_remove_selling_is_validated_and_more")]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="product",
|
||||||
|
name="created_at",
|
||||||
|
field=models.DateTimeField(
|
||||||
|
auto_now_add=True,
|
||||||
|
default=django.utils.timezone.now,
|
||||||
|
verbose_name="created at",
|
||||||
|
),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="product",
|
||||||
|
name="updated_at",
|
||||||
|
field=models.DateTimeField(auto_now=True, verbose_name="updated at"),
|
||||||
|
),
|
||||||
|
migrations.RunPython(
|
||||||
|
apply_product_history_dates, reverse_code=migrations.RunPython.noop
|
||||||
|
),
|
||||||
|
]
|
||||||
43
counter/migrations/0037_productformula.py
Normal file
43
counter/migrations/0037_productformula.py
Normal 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", "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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -399,6 +399,8 @@ class Product(models.Model):
|
|||||||
Group, related_name="products", verbose_name=_("buying groups"), blank=True
|
Group, related_name="products", verbose_name=_("buying groups"), blank=True
|
||||||
)
|
)
|
||||||
archived = models.BooleanField(_("archived"), default=False)
|
archived = models.BooleanField(_("archived"), default=False)
|
||||||
|
created_at = models.DateTimeField(_("created at"), auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(_("updated at"), auto_now=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("product")
|
verbose_name = _("product")
|
||||||
@@ -454,6 +456,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.
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { AlertMessage } from "#core:utils/alert-message.ts";
|
import { AlertMessage } from "#core:utils/alert-message.ts";
|
||||||
import { BasketItem } from "#counter:counter/basket.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";
|
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();
|
||||||
|
|||||||
6
counter/static/bundled/counter/types.d.ts
vendored
6
counter/static/bundled/counter/types.d.ts
vendored
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 -%}
|
||||||
|
|||||||
78
counter/templates/counter/formula_list.jinja
Normal file
78
counter/templates/counter/formula_list.jinja
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
{% 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>
|
||||||
|
{%- trans trimmed -%}
|
||||||
|
Formulas allow you to associate a group of products
|
||||||
|
with a result product (the formula itself).
|
||||||
|
{%- endtrans -%}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{%- trans trimmed -%}
|
||||||
|
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.
|
||||||
|
{%- endtrans -%}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{%- trans trimmed -%}
|
||||||
|
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.
|
||||||
|
{%- endtrans -%}
|
||||||
|
</p>
|
||||||
|
<p class="margin-bottom">
|
||||||
|
<a href="{{ url('counter:product_formula_create') }}" class="btn btn-blue">
|
||||||
|
{% trans %}New formula{% endtrans %}
|
||||||
|
<i class="fa fa-plus"></i>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<div class="product-group">
|
||||||
|
{%- for formula in object_list -%}
|
||||||
|
<a
|
||||||
|
class="card card-row shadow clickable"
|
||||||
|
href="{{ url('counter:product_formula_edit', formula_id=formula.id) }}"
|
||||||
|
>
|
||||||
|
<div class="card-content">
|
||||||
|
<strong class="card-title">{{ formula.result.name }}</strong>
|
||||||
|
<p>
|
||||||
|
{% for p in formula.products.all() %}
|
||||||
|
<i>{{ p.code }} ({{ p.selling_price }} €)</i>
|
||||||
|
{% if not loop.last %}+{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{{ formula.result.selling_price }} €
|
||||||
|
({% trans %}instead of{% endtrans %} {{ formula.max_selling_price}} €)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% if user.has_perm("counter.delete_productformula") %}
|
||||||
|
<button
|
||||||
|
x-data
|
||||||
|
class="btn btn-red btn-no-text card-top-left"
|
||||||
|
@click.prevent="document.location.href = '{{ url('counter:product_formula_delete', formula_id=formula.id) }}'"
|
||||||
|
>
|
||||||
|
{# The delete link is a button with a JS event listener
|
||||||
|
instead of a proper <a> element,
|
||||||
|
because the enclosing card is already a <a>,
|
||||||
|
and HTML forbids nested <a> #}
|
||||||
|
<i class="fa fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
{%- endfor -%}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
{% if object %}
|
{% if object %}
|
||||||
<h2>{% trans name=object %}Edit product {{ name }}{% endtrans %}</h2>
|
<h2>{% trans name=object %}Edit product {{ name }}{% endtrans %}</h2>
|
||||||
|
<p><i>{% trans %}Creation date{% endtrans %} : {{ object.created_at|date }}</i></p>
|
||||||
|
<p><i>{% trans %}Last update{% endtrans %} : {{ object.updated_at|date }}</i></p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<h2>{% trans %}Product creation{% endtrans %}</h2>
|
<h2>{% trans %}Product creation{% endtrans %}</h2>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
59
counter/tests/test_formula.py
Normal file
59
counter/tests/test_formula.py
Normal 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."
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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,62 @@ class ProductEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
|
|||||||
current_tab = "products"
|
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(
|
class ReturnableProductListView(
|
||||||
CounterAdminTabsMixin, PermissionRequiredMixin, ListView
|
CounterAdminTabsMixin, PermissionRequiredMixin, ListView
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
msgid ""
|
msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-02-08 16:14+0100\n"
|
"POT-Creation-Date: 2026-02-14 15:21+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
|
||||||
@@ -1566,7 +1566,7 @@ msgstr "Visiteur"
|
|||||||
msgid "ban type"
|
msgid "ban type"
|
||||||
msgstr "type de ban"
|
msgstr "type de ban"
|
||||||
|
|
||||||
#: core/models.py
|
#: core/models.py counter/models.py
|
||||||
msgid "created at"
|
msgid "created at"
|
||||||
msgstr "créé le"
|
msgstr "créé le"
|
||||||
|
|
||||||
@@ -2957,6 +2957,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"
|
||||||
@@ -3109,6 +3141,10 @@ msgstr "groupe d'achat"
|
|||||||
msgid "archived"
|
msgid "archived"
|
||||||
msgstr "archivé"
|
msgstr "archivé"
|
||||||
|
|
||||||
|
#: counter/models.py
|
||||||
|
msgid "updated at"
|
||||||
|
msgstr "mis à jour le"
|
||||||
|
|
||||||
#: counter/models.py
|
#: counter/models.py
|
||||||
msgid "product"
|
msgid "product"
|
||||||
msgstr "produit"
|
msgstr "produit"
|
||||||
@@ -3117,6 +3153,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"
|
||||||
@@ -3537,6 +3585,48 @@ 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 ""
|
||||||
|
"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
|
#: 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."
|
||||||
@@ -3664,6 +3754,14 @@ msgstr ""
|
|||||||
msgid "Edit product %(name)s"
|
msgid "Edit product %(name)s"
|
||||||
msgstr "Édition du produit %(name)s"
|
msgstr "Édition du produit %(name)s"
|
||||||
|
|
||||||
|
#: counter/templates/counter/product_form.jinja
|
||||||
|
msgid "Creation date"
|
||||||
|
msgstr "Date de création"
|
||||||
|
|
||||||
|
#: counter/templates/counter/product_form.jinja
|
||||||
|
msgid "Last update"
|
||||||
|
msgstr "Dernière mise à jour"
|
||||||
|
|
||||||
#: counter/templates/counter/product_form.jinja
|
#: counter/templates/counter/product_form.jinja
|
||||||
msgid "Product creation"
|
msgid "Product creation"
|
||||||
msgstr "Création de produit"
|
msgstr "Création de produit"
|
||||||
@@ -3791,6 +3889,20 @@ msgstr "Temps"
|
|||||||
msgid "Top 100 barman %(counter_name)s (all semesters)"
|
msgid "Top 100 barman %(counter_name)s (all semesters)"
|
||||||
msgstr "Top 100 barman %(counter_name)s (tous les semestres)"
|
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
|
#: counter/views/admin.py
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "returnable product : %(returnable)s -> %(returned)s"
|
msgid "returnable product : %(returnable)s -> %(returned)s"
|
||||||
@@ -3876,6 +3988,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"
|
||||||
@@ -3951,8 +4067,8 @@ msgid ""
|
|||||||
"inconvenience."
|
"inconvenience."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Les paiements par carte bancaire sont actuellement désactivés sur l'eboutic. "
|
"Les paiements par carte bancaire sont actuellement désactivés sur l'eboutic. "
|
||||||
"Vous pouvez cependant toujours recharger votre compte dans un des lieux de vie de l'AE. "
|
"Vous pouvez cependant toujours recharger votre compte dans un des lieux de "
|
||||||
"Veuillez nous excuser pour le désagrément."
|
"vie de l'AE. Veuillez nous excuser pour le désagrément."
|
||||||
|
|
||||||
#: eboutic/templates/eboutic/eboutic_checkout.jinja
|
#: eboutic/templates/eboutic/eboutic_checkout.jinja
|
||||||
msgid ""
|
msgid ""
|
||||||
@@ -4121,8 +4237,8 @@ msgstr "Les candidatures sont fermées pour cette élection"
|
|||||||
#: election/templates/election/election_detail.jinja
|
#: election/templates/election/election_detail.jinja
|
||||||
msgid "Candidate pictures won't display for privacy reasons."
|
msgid "Candidate pictures won't display for privacy reasons."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"La photo du candidat ne s'affiche pas pour "
|
"La photo du candidat ne s'affiche pas pour des raisons de respect de la vie "
|
||||||
"des raisons de respect de la vie privée."
|
"privée."
|
||||||
|
|
||||||
#: election/templates/election/election_detail.jinja
|
#: election/templates/election/election_detail.jinja
|
||||||
msgid "Polls close "
|
msgid "Polls close "
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
8
uv.lock
generated
8
uv.lock
generated
@@ -775,11 +775,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "filelock"
|
name = "filelock"
|
||||||
version = "3.20.3"
|
version = "3.20.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485, upload-time = "2026-01-09T17:55:05.421Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/58/46/0028a82567109b5ef6e4d2a1f04a583fb513e6cf9527fcdd09afd817deeb/filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4", size = 18922, upload-time = "2025-10-08T18:03:50.056Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" },
|
{ url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054, upload-time = "2025-10-08T18:03:48.35Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2446,4 +2446,4 @@ dependencies = [
|
|||||||
{ name = "django-haystack" },
|
{ name = "django-haystack" },
|
||||||
{ name = "filelock" },
|
{ name = "filelock" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/a3/db/c6219763f6c4519cdaae812e60fd9471a7805de2b39d912931e45575c8e6/xapian-haystack-3.1.0.tar.gz", hash = "sha256:9f9ab90bf450bf6699d164594d569243aafb6c9f0990a16855f55a1d16bc09c6", size = 37887, upload-time = "2023-03-19T11:58:37.035Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/a3/db/c6219763f6c4519cdaae812e60fd9471a7805de2b39d912931e45575c8e6/xapian-haystack-3.1.0.tar.gz", hash = "sha256:9f9ab90bf450bf6699d164594d569243aafb6c9f0990a16855f55a1d16bc09c6", size = 37887, upload-time = "2023-03-19T11:58:37.035Z" }
|
||||||
Reference in New Issue
Block a user