From 0a3f8b8e6f66feb0a415c88ac2ab5bce6b64b26a Mon Sep 17 00:00:00 2001 From: imperosol Date: Mon, 2 Mar 2026 15:32:57 +0100 Subject: [PATCH] Price model --- counter/admin.py | 8 ++- counter/api.py | 8 +-- counter/migrations/0038_price.py | 108 +++++++++++++++++++++++++++++++ counter/models.py | 76 +++++++++++++++++++++- counter/schemas.py | 13 ++-- 5 files changed, 201 insertions(+), 12 deletions(-) create mode 100644 counter/migrations/0038_price.py 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/migrations/0038_price.py b/counter/migrations/0038_price.py new file mode 100644 index 00000000..e6c234da --- /dev/null +++ b/counter/migrations/0038_price.py @@ -0,0 +1,108 @@ +# 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", "0037_productformula"), + ] + + 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.RunPython(migrate_prices, reverse_code=migrations.RunPython.noop), + ] diff --git a/counter/models.py b/counter/models.py index d53d129c..10377635 100644 --- a/counter/models.py +++ b/counter/models.py @@ -456,6 +456,78 @@ class Product(models.Model): return self.selling_price - self.purchase_price +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, + ) + + +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) + + 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 full_label(self): + if not self.label: + return self.product.name + return f"{self.product.name} \u2013 {self.label}" + + class ProductFormula(models.Model): products = models.ManyToManyField( Product, @@ -1001,7 +1073,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