diff --git a/core/api.py b/core/api.py index 2f2c0fb1..08aefa6f 100644 --- a/core/api.py +++ b/core/api.py @@ -123,7 +123,7 @@ class GroupController(ControllerBase): ) @paginate(PageNumberPaginationExtra, page_size=50) def search_group(self, search: Annotated[str, MinLen(1)]): - return Group.objects.filter(name__icontains=search).values() + return Group.objects.filter(name__icontains=search).order_by("name").values() DepthValue = Annotated[int, Ge(0), Le(10)] diff --git a/core/management/commands/populate.py b/core/management/commands/populate.py index 3d50e90f..a326b8ed 100644 --- a/core/management/commands/populate.py +++ b/core/management/commands/populate.py @@ -41,7 +41,14 @@ from com.ics_calendar import IcsCalendar from com.models import News, NewsDate, Sith, Weekmail from core.models import BanGroup, Group, Page, PageRev, SithFile, User from core.utils import resize_image -from counter.models import Counter, Product, ProductType, ReturnableProduct, StudentCard +from counter.models import ( + Counter, + Price, + Product, + ProductType, + ReturnableProduct, + StudentCard, +) from election.models import Candidature, Election, ElectionList, Role from forum.models import Forum from pedagogy.models import UE @@ -374,125 +381,15 @@ class Command(BaseCommand): end_date=localdate() - timedelta(days=100), ) - p = ProductType.objects.create(name="Bières bouteilles") - c = ProductType.objects.create(name="Cotisations") - r = ProductType.objects.create(name="Rechargements") - verre = ProductType.objects.create(name="Verre") - cotis = Product.objects.create( - name="Cotis 1 semestre", - code="1SCOTIZ", - product_type=c, - purchase_price="15", - selling_price="15", - special_selling_price="15", - club=main_club, - ) - cotis2 = Product.objects.create( - name="Cotis 2 semestres", - code="2SCOTIZ", - product_type=c, - purchase_price="28", - selling_price="28", - special_selling_price="28", - club=main_club, - ) - refill = Product.objects.create( - name="Rechargement 15 €", - code="15REFILL", - product_type=r, - purchase_price="15", - selling_price="15", - special_selling_price="15", - club=main_club, - ) - barb = Product.objects.create( - name="Barbar", - code="BARB", - product_type=p, - purchase_price="1.50", - selling_price="1.7", - special_selling_price="1.6", - club=main_club, - limit_age=18, - ) - cble = Product.objects.create( - name="Chimay Bleue", - code="CBLE", - product_type=p, - purchase_price="1.50", - selling_price="1.7", - special_selling_price="1.6", - club=main_club, - limit_age=18, - ) - cons = Product.objects.create( - name="Consigne Eco-cup", - code="CONS", - product_type=verre, - purchase_price="1", - selling_price="1", - special_selling_price="1", - club=main_club, - ) - dcons = Product.objects.create( - name="Déconsigne Eco-cup", - code="DECO", - product_type=verre, - purchase_price="-1", - selling_price="-1", - special_selling_price="-1", - club=main_club, - ) - cors = Product.objects.create( - name="Corsendonk", - code="CORS", - product_type=p, - purchase_price="1.50", - selling_price="1.7", - special_selling_price="1.6", - club=main_club, - limit_age=18, - ) - carolus = Product.objects.create( - name="Carolus", - code="CARO", - product_type=p, - purchase_price="1.50", - selling_price="1.7", - special_selling_price="1.6", - club=main_club, - limit_age=18, - ) - Product.objects.create( - name="remboursement", - code="REMBOURS", - purchase_price="0", - selling_price="0", - special_selling_price="0", - club=refound, - ) - groups.subscribers.products.add( - cotis, cotis2, refill, barb, cble, cors, carolus - ) - groups.old_subscribers.products.add(cotis, cotis2) - - mde = Counter.objects.get(name="MDE") - mde.products.add(barb, cble, cons, dcons) - - eboutic = Counter.objects.get(name="Eboutic") - eboutic.products.add(barb, cotis, cotis2, refill) + self._create_products(groups, main_club, refound) Counter.objects.create(name="Carte AE", club=refound, type="OFFICE") - ReturnableProduct.objects.create( - product=cons, returned_product=dcons, max_return=3 - ) - # Add barman to counter Counter.sellers.through.objects.bulk_create( [ - Counter.sellers.through(counter_id=2, user=krophil), - Counter.sellers.through(counter=mde, user=skia), + Counter.sellers.through(counter_id=1, user=skia), # MDE + Counter.sellers.through(counter_id=2, user=krophil), # Foyer ] ) @@ -748,6 +645,131 @@ class Command(BaseCommand): ] ) + def _create_products( + self, groups: PopulatedGroups, main_club: Club, refound_club: Club + ): + beers_type, cotis_type, refill_type, verre_type = ( + ProductType.objects.bulk_create( + [ + ProductType(name="Bières bouteilles"), + ProductType(name="Cotisations"), + ProductType(name="Rechargements"), + ProductType(name="Verre"), + ] + ) + ) + cotis = Product.objects.create( + name="Cotis 1 semestre", + code="1SCOTIZ", + product_type=cotis_type, + purchase_price=15, + club=main_club, + ) + cotis2 = Product.objects.create( + name="Cotis 2 semestres", + code="2SCOTIZ", + product_type=cotis_type, + purchase_price="28", + club=main_club, + ) + refill = Product.objects.create( + name="Rechargement 15 €", + code="15REFILL", + product_type=refill_type, + purchase_price=15, + club=main_club, + ) + barb = Product.objects.create( + name="Barbar", + code="BARB", + product_type=beers_type, + purchase_price="1.50", + club=main_club, + limit_age=18, + ) + cble = Product.objects.create( + name="Chimay Bleue", + code="CBLE", + product_type=beers_type, + purchase_price="1.50", + club=main_club, + limit_age=18, + ) + cons = Product.objects.create( + name="Consigne Eco-cup", + code="CONS", + product_type=verre_type, + purchase_price="1", + club=main_club, + ) + dcons = Product.objects.create( + name="Déconsigne Eco-cup", + code="DECO", + product_type=verre_type, + purchase_price="-1", + club=main_club, + ) + cors = Product.objects.create( + name="Corsendonk", + code="CORS", + product_type=beers_type, + purchase_price="1.50", + club=main_club, + limit_age=18, + ) + carolus = Product.objects.create( + name="Carolus", + code="CARO", + product_type=beers_type, + purchase_price="1.50", + club=main_club, + limit_age=18, + ) + Product.objects.create( + name="remboursement", + code="REMBOURS", + purchase_price=0, + club=refound_club, + ) + ReturnableProduct.objects.create( + product=cons, returned_product=dcons, max_return=3 + ) + mde = Counter.objects.get(name="MDE") + mde.products.add(barb, cble, cons, dcons) + eboutic = Counter.objects.get(name="Eboutic") + eboutic.products.add(barb, cotis, cotis2, refill) + + cotis, cotis2, refill, barb, cble, cors, carolus, cons, dcons = ( + Price.objects.bulk_create( + [ + Price(product=cotis, amount=15), + Price(product=cotis2, amount=28), + Price(product=refill, amount=15), + Price(product=barb, amount=1.7), + Price(product=cble, amount=1.7), + Price(product=cors, amount=1.7), + Price(product=carolus, amount=1.7), + Price(product=cons, amount=1), + Price(product=dcons, amount=-1), + ] + ) + ) + Price.groups.through.objects.bulk_create( + [ + Price.groups.through(price=cotis, group=groups.subscribers), + Price.groups.through(price=cotis2, group=groups.subscribers), + Price.groups.through(price=refill, group=groups.subscribers), + Price.groups.through(price=barb, group=groups.subscribers), + Price.groups.through(price=cble, group=groups.subscribers), + Price.groups.through(price=cors, group=groups.subscribers), + Price.groups.through(price=carolus, group=groups.subscribers), + Price.groups.through(price=cotis, group=groups.old_subscribers), + Price.groups.through(price=cotis2, group=groups.old_subscribers), + Price.groups.through(price=cons, group=groups.old_subscribers), + Price.groups.through(price=dcons, group=groups.old_subscribers), + ] + ) + def _create_profile_pict(self, user: User): path = self.SAS_FIXTURE_PATH / "Family" / f"{user.username}.jpg" file = resize_image(Image.open(path), 400, "WEBP") diff --git a/core/management/commands/populate_more.py b/core/management/commands/populate_more.py index 562a46ad..47cb75d5 100644 --- a/core/management/commands/populate_more.py +++ b/core/management/commands/populate_more.py @@ -17,6 +17,7 @@ from counter.models import ( Counter, Customer, Permanency, + Price, Product, ProductType, Refilling, @@ -278,6 +279,7 @@ class Command(BaseCommand): # 2/3 of the products are owned by AE clubs = [ae, ae, ae, ae, ae, ae, *other_clubs] products = [] + prices = [] buying_groups = [] selling_places = [] for _ in range(200): @@ -288,25 +290,28 @@ class Command(BaseCommand): product_type=random.choice(categories), code="".join(self.faker.random_letters(length=random.randint(4, 8))), purchase_price=price, - selling_price=price, - special_selling_price=price - min(0.5, price), club=random.choice(clubs), limit_age=0 if random.random() > 0.2 else 18, - archived=bool(random.random() > 0.7), + archived=self.faker.boolean(60), ) products.append(product) - # there will be products without buying groups - # but there are also such products in the real database - buying_groups.extend( - Product.buying_groups.through(product=product, group=group) - for group in random.sample(groups, k=random.randint(0, 3)) - ) + for i in range(random.randint(0, 3)): + product_price = Price( + amount=price, product=product, is_always_shown=self.faker.boolean() + ) + # prices for non-subscribers will be higher than for subscribers + price *= 1.2 + prices.append(product_price) + buying_groups.append( + Price.groups.through(price=product_price, group=groups[i]) + ) selling_places.extend( Counter.products.through(counter=counter, product=product) for counter in random.sample(counters, random.randint(0, 4)) ) Product.objects.bulk_create(products) - Product.buying_groups.through.objects.bulk_create(buying_groups) + Price.objects.bulk_create(prices) + Price.groups.through.objects.bulk_create(buying_groups) Counter.products.through.objects.bulk_create(selling_places) def create_sales(self, sellers: list[User]): @@ -320,7 +325,7 @@ class Command(BaseCommand): ) ) ) - products = list(Product.objects.all()) + prices = list(Price.objects.select_related("product").all()) counters = list( Counter.objects.filter(name__in=["Foyer", "MDE", "La Gommette"]) ) @@ -330,14 +335,14 @@ class Command(BaseCommand): # the longer the customer has existed, the higher the mean of nb_products mu = 5 + (now().year - customer.since.year) * 2 nb_sales = max(0, int(random.normalvariate(mu=mu, sigma=mu * 5))) - favoured_products = random.sample(products, k=(random.randint(1, 5))) + favoured_prices = random.sample(prices, k=(random.randint(1, 5))) favoured_counter = random.choice(counters) this_customer_sales = [] for _ in range(nb_sales): - product = ( - random.choice(favoured_products) + price = ( + random.choice(favoured_prices) if random.random() > 0.7 - else random.choice(products) + else random.choice(prices) ) counter = ( favoured_counter @@ -346,11 +351,11 @@ class Command(BaseCommand): ) this_customer_sales.append( Selling( - product=product, + product=price.product, counter=counter, - club_id=product.club_id, + club_id=price.product.club_id, quantity=random.randint(1, 5), - unit_price=product.selling_price, + unit_price=price.amount, seller=random.choice(sellers), customer=customer, date=make_aware( diff --git a/core/tests/test_user.py b/core/tests/test_user.py index cacd43a3..6d2d75be 100644 --- a/core/tests/test_user.py +++ b/core/tests/test_user.py @@ -213,9 +213,9 @@ def test_user_invoice_with_multiple_items(): """Test that annotate_total() works when invoices contain multiple items.""" user: User = subscriber_user.make() item_recipe = Recipe(InvoiceItem, invoice=foreign_key(Recipe(Invoice, user=user))) - item_recipe.make(_quantity=3, quantity=1, product_unit_price=5) - item_recipe.make(_quantity=1, quantity=1, product_unit_price=5) - item_recipe.make(_quantity=2, quantity=1, product_unit_price=iter([5, 8])) + item_recipe.make(_quantity=3, quantity=1, unit_price=5) + item_recipe.make(_quantity=1, quantity=1, unit_price=5) + item_recipe.make(_quantity=2, quantity=1, unit_price=iter([5, 8])) res = list( Invoice.objects.filter(user=user) .annotate_total() diff --git a/counter/admin.py b/counter/admin.py index 425134ef..5caab364 100644 --- a/counter/admin.py +++ b/counter/admin.py @@ -24,6 +24,7 @@ from counter.models import ( Eticket, InvoiceCall, Permanency, + Price, Product, ProductType, Refilling, @@ -32,19 +33,24 @@ from counter.models import ( ) +class PriceInline(admin.TabularInline): + model = Price + autocomplete_fields = ("groups",) + + @admin.register(Product) class ProductAdmin(SearchModelAdmin): list_display = ( "name", "code", "product_type", - "selling_price", "archived", "created_at", "updated_at", ) list_select_related = ("product_type",) search_fields = ("name", "code") + inlines = [PriceInline] @admin.register(ReturnableProduct) diff --git a/counter/api.py b/counter/api.py index 72da74e5..9ab60437 100644 --- a/counter/api.py +++ b/counter/api.py @@ -101,13 +101,9 @@ class ProductController(ControllerBase): """Get the detailed information about the products.""" return filters.filter( Product.objects.select_related("club") - .prefetch_related("buying_groups") + .prefetch_related("prices", "prices__groups") .select_related("product_type") - .order_by( - F("product_type__order").asc(nulls_last=True), - "product_type", - "name", - ) + .order_by(F("product_type__order").asc(nulls_last=True), "name") ) diff --git a/counter/baker_recipes.py b/counter/baker_recipes.py index aa77fb06..094a77ba 100644 --- a/counter/baker_recipes.py +++ b/counter/baker_recipes.py @@ -2,10 +2,11 @@ from model_bakery.recipe import Recipe, foreign_key from club.models import Club from core.models import User -from counter.models import Counter, Product, Refilling, Selling +from counter.models import Counter, Price, Product, Refilling, Selling counter_recipe = Recipe(Counter) product_recipe = Recipe(Product, club=foreign_key(Recipe(Club))) +price_recipe = Recipe(Price, product=foreign_key(product_recipe)) sale_recipe = Recipe( Selling, product=foreign_key(product_recipe), diff --git a/counter/forms.py b/counter/forms.py index 52b7bae2..2003c9a7 100644 --- a/counter/forms.py +++ b/counter/forms.py @@ -1,12 +1,12 @@ import json import math import uuid +from collections import defaultdict from datetime import date, datetime, timezone from dateutil.relativedelta import relativedelta from django import forms from django.core.exceptions import ValidationError -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 @@ -37,6 +37,7 @@ from counter.models import ( Customer, Eticket, InvoiceCall, + Price, Product, ProductFormula, Refilling, @@ -374,7 +375,21 @@ ScheduledProductActionFormSet = forms.modelformset_factory( can_delete=True, can_delete_extra=False, extra=0, +) + + +ProductPriceFormSet = forms.inlineformset_factory( + parent_model=Product, + model=Price, + fields=["amount", "label", "groups", "is_always_shown"], + widgets={ + "groups": AutoCompleteSelectMultipleGroup, + "is_always_shown": forms.CheckboxInput(attrs={"class": "switch"}), + }, + absolute_max=None, + can_delete_extra=False, min_num=1, + extra=0, ) @@ -389,10 +404,7 @@ class ProductForm(forms.ModelForm): "description", "product_type", "code", - "buying_groups", "purchase_price", - "selling_price", - "special_selling_price", "icon", "club", "limit_age", @@ -407,8 +419,8 @@ class ProductForm(forms.ModelForm): } widgets = { "product_type": AutoCompleteSelect, - "buying_groups": AutoCompleteSelectMultipleGroup, "club": AutoCompleteSelectClub, + "tray": forms.CheckboxInput(attrs={"class": "switch"}), } counters = forms.ModelMultipleChoiceField( @@ -418,50 +430,40 @@ class ProductForm(forms.ModelForm): queryset=Counter.objects.all(), ) - def __init__(self, *args, instance=None, **kwargs): - super().__init__(*args, instance=instance, **kwargs) + def __init__(self, *args, prefix: str | None = None, instance=None, **kwargs): + super().__init__(*args, prefix=prefix, instance=instance, **kwargs) + self.fields["name"].widget.attrs["autofocus"] = "autofocus" if self.instance.id: self.fields["counters"].initial = self.instance.counters.all() if hasattr(self.instance, "formula"): self.formula_init(self.instance.formula) + self.price_formset = ProductPriceFormSet( + *args, instance=self.instance, prefix="price", **kwargs + ) self.action_formset = ScheduledProductActionFormSet( - *args, product=self.instance, **kwargs + *args, product=self.instance, prefix="action", **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() + return ( + super().is_valid() + and self.price_formset.is_valid() + and self.action_formset.is_valid() + ) def save(self, *args, **kwargs) -> Product: product = super().save(*args, **kwargs) product.counters.set(self.cleaned_data["counters"]) + # if it's a creation, the product given in the formset + # wasn't a persisted instance. + # So if we tried to persist the related objects in the current state, + # they would be linked to no product, thus be completely useless + # To make it work, we have to replace + # the initial product with a persisted one for form in self.action_formset: - # if it's a creation, the product given in the formset - # wasn't a persisted instance. - # So if we tried to persist the scheduled actions in the current state, - # they would be linked to no product, thus be completely useless - # To make it work, we have to replace - # the initial product with a persisted one form.set_product(product) self.action_formset.save() + self.price_formset.save() return product @@ -484,18 +486,6 @@ class ProductFormulaForm(forms.ModelForm): "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 @@ -546,48 +536,47 @@ class CloseCustomerAccountForm(forms.Form): ) -class BasketProductForm(forms.Form): +class BasketItemForm(forms.Form): quantity = forms.IntegerField(min_value=1, required=True) - id = forms.IntegerField(min_value=0, required=True) + price_id = forms.IntegerField(min_value=0, required=True) def __init__( self, customer: Customer, counter: Counter, - allowed_products: dict[int, Product], + allowed_prices: dict[int, Price], *args, **kwargs, ): self.customer = customer # Used by formset self.counter = counter # Used by formset - self.allowed_products = allowed_products + self.allowed_prices = allowed_prices super().__init__(*args, **kwargs) - def clean_id(self): - data = self.cleaned_data["id"] + def clean_price_id(self): + data = self.cleaned_data["price_id"] - # We store self.product so we can use it later on the formset validation + # We store self.price so we can use it later on the formset validation # And also in the global clean - self.product = self.allowed_products.get(data, None) - if self.product is None: + self.price = self.allowed_prices.get(data, None) + if self.price is None: raise forms.ValidationError( _("The selected product isn't available for this user") ) - return data def clean(self): cleaned_data = super().clean() if len(self.errors) > 0: - return + return cleaned_data # Compute prices cleaned_data["bonus_quantity"] = 0 - if self.product.tray: + if self.price.product.tray: cleaned_data["bonus_quantity"] = math.floor( cleaned_data["quantity"] / Product.QUANTITY_FOR_TRAY_PRICE ) - cleaned_data["total_price"] = self.product.price * ( + cleaned_data["total_price"] = self.price.amount * ( cleaned_data["quantity"] - cleaned_data["bonus_quantity"] ) @@ -611,8 +600,8 @@ class BaseBasketForm(forms.BaseFormSet): raise forms.ValidationError(_("Submitted basket is invalid")) def _check_product_are_unique(self): - product_ids = {form.cleaned_data["id"] for form in self.forms} - if len(product_ids) != len(self.forms): + price_ids = {form.cleaned_data["price_id"] for form in self.forms} + if len(price_ids) != len(self.forms): raise forms.ValidationError(_("Duplicated product entries.")) def _check_enough_money(self, counter: Counter, customer: Customer): @@ -622,10 +611,9 @@ class BaseBasketForm(forms.BaseFormSet): def _check_recorded_products(self, customer: Customer): """Check for, among other things, ecocups and pitchers""" - items = { - form.cleaned_data["id"]: form.cleaned_data["quantity"] - for form in self.forms - } + items = defaultdict(int) + for form in self.forms: + items[form.price.product_id] += form.cleaned_data["quantity"] ids = list(items.keys()) returnables = list( ReturnableProduct.objects.filter( @@ -651,7 +639,7 @@ class BaseBasketForm(forms.BaseFormSet): BasketForm = forms.formset_factory( - BasketProductForm, formset=BaseBasketForm, absolute_max=None, min_num=1 + BasketItemForm, formset=BaseBasketForm, absolute_max=None, min_num=1 ) diff --git a/counter/migrations/0039_price.py b/counter/migrations/0039_price.py new file mode 100644 index 00000000..d98159ea --- /dev/null +++ b/counter/migrations/0039_price.py @@ -0,0 +1,149 @@ +# Generated by Django 5.2.11 on 2026-02-18 13:30 + +import django.db.models.deletion +from django.db import migrations, models +from django.db.migrations.state import StateApps + +import counter.fields + + +def migrate_prices(apps: StateApps, schema_editor): + Product = apps.get_model("counter", "Product") + Price = apps.get_model("counter", "Price") + prices = [ + Price( + amount=p.selling_price, + product=p, + created_at=p.created_at, + updated_at=p.updated_at, + ) + for p in Product.objects.all() + ] + Price.objects.bulk_create(prices) + groups = [ + Price.groups.through(price=price, group=group) + for price in Price.objects.select_related("product").prefetch_related( + "product__buying_groups" + ) + for group in price.product.buying_groups.all() + ] + Price.groups.through.objects.bulk_create(groups) + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0048_alter_user_options"), + ("counter", "0038_countersellers"), + ] + + operations = [ + migrations.CreateModel( + name="Price", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "amount", + counter.fields.CurrencyField( + decimal_places=2, max_digits=12, verbose_name="amount" + ), + ), + ( + "is_always_shown", + models.BooleanField( + default=False, + help_text=( + "If this option is enabled, " + "people will see this price and be able to pay it, " + "even if another cheaper price exists. " + "Else it will visible only if it is the cheapest available price." + ), + verbose_name="always show", + ), + ), + ( + "label", + models.CharField( + default="", + help_text=( + "A short label for easier differentiation " + "if a user can see multiple prices." + ), + max_length=32, + verbose_name="label", + blank=True, + ), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="created at"), + ), + ( + "updated_at", + models.DateTimeField(auto_now=True, verbose_name="updated at"), + ), + ( + "groups", + models.ManyToManyField( + related_name="prices", to="core.group", verbose_name="groups" + ), + ), + ( + "product", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="prices", + to="counter.product", + verbose_name="product", + ), + ), + ], + options={"verbose_name": "price"}, + ), + migrations.AlterField( + model_name="product", + name="tray", + field=models.BooleanField( + default=False, + help_text="Buy five, get the sixth free", + verbose_name="tray price", + ), + ), + migrations.RunPython(migrate_prices, reverse_code=migrations.RunPython.noop), + migrations.RemoveField(model_name="product", name="selling_price"), + migrations.RemoveField(model_name="product", name="special_selling_price"), + migrations.AlterField( + model_name="product", + name="description", + field=models.TextField(blank=True, default="", verbose_name="description"), + ), + migrations.AlterField( + model_name="product", + name="product_type", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="products", + to="counter.producttype", + verbose_name="product type", + ), + ), + migrations.AlterField( + model_name="productformula", + name="result", + field=models.OneToOneField( + help_text="The product got with the formula.", + on_delete=django.db.models.deletion.CASCADE, + related_name="formula", + to="counter.product", + verbose_name="result product", + ), + ), + ] diff --git a/counter/models.py b/counter/models.py index 29ecad2f..c22a1469 100644 --- a/counter/models.py +++ b/counter/models.py @@ -22,7 +22,7 @@ import string from datetime import date, datetime, timedelta from datetime import timezone as tz from decimal import Decimal -from typing import Literal, Self +from typing import TYPE_CHECKING, Literal, Self from dict2xml import dict2xml from django.conf import settings @@ -47,6 +47,9 @@ from core.utils import get_start_of_semester from counter.fields import CurrencyField from subscription.models import Subscription +if TYPE_CHECKING: + from collections.abc import Sequence + def get_eboutic() -> Counter: return Counter.objects.filter(type="EBOUTIC").order_by("id").first() @@ -157,14 +160,7 @@ class Customer(models.Model): @property def can_buy(self) -> bool: - """Check if whether this customer has the right to purchase any item. - - This must be not confused with the Product.can_be_sold_to(user) - method as the present method returns an information - about a customer whereas the other tells something - about the relation between a User (not a Customer, - don't mix them) and a Product. - """ + """Check if whether this customer has the right to purchase any item.""" subscription = self.user.subscriptions.order_by("subscription_end").last() if subscription is None: return False @@ -363,13 +359,13 @@ class Product(models.Model): QUANTITY_FOR_TRAY_PRICE = 6 name = models.CharField(_("name"), max_length=64) - description = models.TextField(_("description"), default="") + description = models.TextField(_("description"), blank=True, default="") product_type = models.ForeignKey( ProductType, related_name="products", verbose_name=_("product type"), null=True, - blank=True, + blank=False, on_delete=models.SET_NULL, ) code = models.CharField(_("code"), max_length=16, blank=True) @@ -377,11 +373,6 @@ class Product(models.Model): _("purchase price"), help_text=_("Initial cost of purchasing the product"), ) - selling_price = CurrencyField(_("selling price")) - special_selling_price = CurrencyField( - _("special selling price"), - help_text=_("Price for barmen during their permanence"), - ) icon = ResizedImageField( height=70, force_format="WEBP", @@ -394,7 +385,9 @@ class Product(models.Model): Club, related_name="products", verbose_name=_("club"), on_delete=models.CASCADE ) limit_age = models.IntegerField(_("limit age"), default=0) - tray = models.BooleanField(_("tray price"), default=False) + tray = models.BooleanField( + _("tray price"), help_text=_("Buy five, get the sixth free"), default=False + ) buying_groups = models.ManyToManyField( Group, related_name="products", verbose_name=_("buying groups"), blank=True ) @@ -419,41 +412,77 @@ class Product(models.Model): pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID ) or user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID) - def can_be_sold_to(self, user: User) -> bool: - """Check if whether the user given in parameter has the right to buy - this product or not. - This must be not confused with the Customer.can_buy() - method as the present method returns an information - about the relation between a User and a Product, - whereas the other tells something about a Customer - (and not a user, they are not the same model). +class PriceQuerySet(models.QuerySet): + def for_user(self, user: User) -> Self: + age = user.age + if user.is_banned_alcohol: + age = min(age, 17) + return self.filter( + Q(is_always_shown=True, groups__in=user.all_groups) + | Q( + id=Subquery( + Price.objects.filter( + product_id=OuterRef("product_id"), groups__in=user.all_groups + ) + .order_by("amount") + .values("id")[:1] + ) + ), + product__archived=False, + product__limit_age__lte=age, + ) - Returns: - True if the user can buy this product else False - Warning: - This performs a db query, thus you can quickly have - a N+1 queries problem if you call it in a loop. - Hopefully, you can avoid that if you prefetch the buying_groups : +class Price(models.Model): + amount = CurrencyField(_("amount")) + product = models.ForeignKey( + Product, + verbose_name=_("product"), + related_name="prices", + on_delete=models.CASCADE, + ) + groups = models.ManyToManyField( + Group, verbose_name=_("groups"), related_name="prices" + ) + is_always_shown = models.BooleanField( + _("always show"), + help_text=_( + "If this option is enabled, " + "people will see this price and be able to pay it, " + "even if another cheaper price exists. " + "Else it will visible only if it is the cheapest available price." + ), + default=False, + ) + label = models.CharField( + _("label"), + help_text=_( + "A short label for easier differentiation " + "if a user can see multiple prices." + ), + max_length=32, + default="", + blank=True, + ) + created_at = models.DateTimeField(_("created at"), auto_now_add=True) + updated_at = models.DateTimeField(_("updated at"), auto_now=True) - ```python - user = User.objects.get(username="foobar") - products = [ - p - for p in Product.objects.prefetch_related("buying_groups") - if p.can_be_sold_to(user) - ] - ``` - """ - buying_groups = list(self.buying_groups.all()) - if not buying_groups: - return True - return any(user.is_in_group(pk=group.id) for group in buying_groups) + objects = PriceQuerySet.as_manager() + + class Meta: + verbose_name = _("price") + + def __str__(self): + if not self.label: + return f"{self.product.name} ({self.amount}€)" + return f"{self.product.name} {self.label} ({self.amount}€)" @property - def profit(self): - return self.selling_price - self.purchase_price + def full_label(self): + if not self.label: + return self.product.name + return f"{self.product.name} \u2013 {self.label}" class ProductFormula(models.Model): @@ -474,18 +503,6 @@ class ProductFormula(models.Model): 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: @@ -716,35 +733,20 @@ class Counter(models.Model): # but they share the same primary key return self.type == "BAR" and any(b.pk == customer.pk for b in self.barmen_list) - def get_products_for(self, customer: Customer) -> list[Product]: - """ - Get all allowed products for the provided customer on this counter - Prices will be annotated - """ - - products = ( - self.products.filter(archived=False) - .select_related("product_type") - .prefetch_related("buying_groups") + def get_prices_for( + self, customer: Customer, *, order_by: Sequence[str] | None = None + ) -> list[Price]: + qs = ( + Price.objects.filter( + product__counters=self, product__product_type__isnull=False + ) + .for_user(customer.user) + .select_related("product", "product__product_type") + .prefetch_related("groups") ) - - # Only include age appropriate products - age = customer.user.age - if customer.user.is_banned_alcohol: - age = min(age, 17) - products = products.filter(limit_age__lte=age) - - # Compute special price for customer if he is a barmen on that bar - if self.customer_is_barman(customer): - products = products.annotate(price=F("special_selling_price")) - else: - products = products.annotate(price=F("selling_price")) - - return [ - product - for product in products.all() - if product.can_be_sold_to(customer.user) - ] + if order_by: + qs = qs.order_by(*order_by) + return list(qs) class CounterSellers(models.Model): @@ -1025,7 +1027,9 @@ class Selling(models.Model): event = self.product.eticket.event_title or _("Unknown event") subject = _("Eticket bought for the event %(event)s") % {"event": event} message_html = _( - "You bought an eticket for the event %(event)s.\nYou can download it directly from this link %(eticket)s.\nYou can also retrieve all your e-tickets on your account page %(url)s." + "You bought an eticket for the event %(event)s.\n" + "You can download it directly from this link %(eticket)s.\n" + "You can also retrieve all your e-tickets on your account page %(url)s." ) % { "event": event, "url": ( diff --git a/counter/schemas.py b/counter/schemas.py index bec0606f..730a8315 100644 --- a/counter/schemas.py +++ b/counter/schemas.py @@ -6,8 +6,8 @@ from ninja import FilterLookup, FilterSchema, ModelSchema, Schema from pydantic import model_validator from club.schemas import SimpleClubSchema -from core.schemas import GroupSchema, NonEmptyStr, SimpleUserSchema -from counter.models import Counter, Product, ProductType +from core.schemas import NonEmptyStr, SimpleUserSchema +from counter.models import Counter, Price, Product, ProductType class CounterSchema(ModelSchema): @@ -66,6 +66,12 @@ class SimpleProductSchema(ModelSchema): fields = ["id", "name", "code"] +class ProductPriceSchema(ModelSchema): + class Meta: + model = Price + fields = ["amount", "groups"] + + class ProductSchema(ModelSchema): class Meta: model = Product @@ -75,13 +81,12 @@ class ProductSchema(ModelSchema): "code", "description", "purchase_price", - "selling_price", "icon", "limit_age", "archived", ] - buying_groups: list[GroupSchema] + prices: list[ProductPriceSchema] club: SimpleClubSchema product_type: SimpleProductTypeSchema | None url: str diff --git a/counter/static/bundled/counter/basket.ts b/counter/static/bundled/counter/basket.ts index 666d0874..f15bc72a 100644 --- a/counter/static/bundled/counter/basket.ts +++ b/counter/static/bundled/counter/basket.ts @@ -1,12 +1,11 @@ -import type { Product } from "#counter:counter/types.ts"; +import type { CounterItem } from "#counter:counter/types"; export class BasketItem { quantity: number; - product: Product; - quantityForTrayPrice: number; + product: CounterItem; errors: string[]; - constructor(product: Product, quantity: number) { + constructor(product: CounterItem, quantity: number) { this.quantity = quantity; this.product = product; this.errors = []; @@ -20,6 +19,6 @@ export class BasketItem { } sum(): number { - return (this.quantity - this.getBonusQuantity()) * this.product.price; + return (this.quantity - this.getBonusQuantity()) * this.product.price.amount; } } diff --git a/counter/static/bundled/counter/counter-click-index.ts b/counter/static/bundled/counter/counter-click-index.ts index 88f8627d..c8cea6a3 100644 --- a/counter/static/bundled/counter/counter-click-index.ts +++ b/counter/static/bundled/counter/counter-click-index.ts @@ -1,11 +1,12 @@ -import { AlertMessage } from "#core:utils/alert-message.ts"; -import { BasketItem } from "#counter:counter/basket.ts"; +import { AlertMessage } from "#core:utils/alert-message"; +import { BasketItem } from "#counter:counter/basket"; import type { CounterConfig, + CounterItem, ErrorMessage, ProductFormula, -} from "#counter:counter/types.ts"; -import type { CounterProductSelect } from "./components/counter-product-select-index.ts"; +} from "#counter:counter/types"; +import type { CounterProductSelect } from "./components/counter-product-select-index"; document.addEventListener("alpine:init", () => { Alpine.data("counter", (config: CounterConfig) => ({ @@ -63,8 +64,10 @@ document.addEventListener("alpine:init", () => { }, checkFormulas() { + // Try to find a formula. + // A formula is found if all its elements are already in the basket const products = new Set( - Object.keys(this.basket).map((i: string) => Number.parseInt(i, 10)), + Object.values(this.basket).map((item: BasketItem) => item.product.productId), ); const formula: ProductFormula = config.formulas.find((f: ProductFormula) => { return f.products.every((p: number) => products.has(p)); @@ -72,22 +75,29 @@ document.addEventListener("alpine:init", () => { if (formula === undefined) { return; } + // Now that the formula is found, remove the items composing it from the basket for (const product of formula.products) { - const key = product.toString(); + const key = Object.entries(this.basket).find( + ([_, i]: [string, BasketItem]) => i.product.productId === product, + )[0]; this.basket[key].quantity -= 1; if (this.basket[key].quantity <= 0) { this.removeFromBasket(key); } } + // Then add the result product of the formula to the basket + const result = Object.values(config.products) + .filter((item: CounterItem) => item.productId === formula.result) + .reduce((acc, curr) => (acc.price.amount < curr.price.amount ? acc : curr)); + this.addToBasket(result.price.id, 1); this.alertMessage.display( interpolate( gettext("Formula %(formula)s applied"), - { formula: config.products[formula.result.toString()].name }, + { formula: result.name }, true, ), { success: true }, ); - this.addToBasket(formula.result.toString(), 1); }, getBasketSize() { diff --git a/counter/static/bundled/counter/product-list-index.ts b/counter/static/bundled/counter/product-list-index.ts index 5c5abec6..30d26b9a 100644 --- a/counter/static/bundled/counter/product-list-index.ts +++ b/counter/static/bundled/counter/product-list-index.ts @@ -1,13 +1,9 @@ import { showSaveFilePicker } from "native-file-system-adapter"; import type TomSelect from "tom-select"; -import { paginated } from "#core:utils/api.ts"; -import { csv } from "#core:utils/csv.ts"; -import { - getCurrentUrlParams, - History, - updateQueryString, -} from "#core:utils/history.ts"; -import type { NestedKeyOf } from "#core:utils/types.ts"; +import { paginated } from "#core:utils/api"; +import { csv } from "#core:utils/csv"; +import { getCurrentUrlParams, History, updateQueryString } from "#core:utils/history"; +import type { NestedKeyOf } from "#core:utils/types"; import { type ProductSchema, type ProductSearchProductsDetailedData, @@ -20,6 +16,9 @@ type GroupedProducts = Record; const defaultPageSize = 100; const defaultPage = 1; +// biome-ignore lint/style/useNamingConvention: api is snake case +type ProductWithPriceSchema = ProductSchema & { selling_price: string }; + /** * Keys of the properties to include in the CSV. */ @@ -34,7 +33,7 @@ const csvColumns = [ "purchase_price", "selling_price", "archived", -] as NestedKeyOf[]; +] as NestedKeyOf[]; /** * Title of the csv columns. @@ -175,7 +174,16 @@ document.addEventListener("alpine:init", () => { this.nbPages > 1 ? await paginated(productSearchProductsDetailed, this.getQueryParams()) : Object.values(this.products).flat(); - const content = csv.stringify(products, { + // CSV cannot represent nested data + // so we create a row for each price of each product. + const productsWithPrice: ProductWithPriceSchema[] = products.flatMap( + (product: ProductSchema) => + product.prices.map((price) => + // biome-ignore lint/style/useNamingConvention: API is snake_case + Object.assign(product, { selling_price: price.amount }), + ), + ); + const content = csv.stringify(productsWithPrice, { columns: csvColumns, titleRow: csvColumnTitles, }); diff --git a/counter/static/bundled/counter/types.d.ts b/counter/static/bundled/counter/types.d.ts index 330b6f0e..cb4196cc 100644 --- a/counter/static/bundled/counter/types.d.ts +++ b/counter/static/bundled/counter/types.d.ts @@ -2,7 +2,7 @@ export type ErrorMessage = string; export interface InitialFormData { /* Used to refill the form when the backend raises an error */ - id?: keyof Record; + id?: keyof Record; quantity?: number; errors?: string[]; } @@ -15,17 +15,22 @@ export interface ProductFormula { export interface CounterConfig { customerBalance: number; customerId: number; - products: Record; + products: Record; formulas: ProductFormula[]; formInitial: InitialFormData[]; cancelUrl: string; } -export interface Product { - id: string; +interface Price { + id: number; + amount: number; +} + +export interface CounterItem { + productId: number; + price: Price; code: string; name: string; - price: number; hasTrayPrice: boolean; quantityForTrayPrice: number; } diff --git a/counter/templates/counter/counter_click.jinja b/counter/templates/counter/counter_click.jinja index 07bfd461..44220599 100644 --- a/counter/templates/counter/counter_click.jinja +++ b/counter/templates/counter/counter_click.jinja @@ -6,10 +6,10 @@ {% endblock %} {% block additional_css %} - - - - + + + + {% endblock %} @@ -65,10 +65,10 @@ - {%- for category in categories.keys() -%} + {%- for category, prices in categories.items() -%} - {%- for product in categories[category] -%} - + {%- for price in prices -%} + {%- endfor -%} {%- endfor -%} @@ -103,24 +103,25 @@
  • {% trans %}This basket is empty{% endtrans %}
  • -