refactor InvoiceItem and BasketItem models

This commit is contained in:
imperosol
2026-03-02 15:44:37 +01:00
parent 0a3f8b8e6f
commit 85f1a0b9cb
3 changed files with 135 additions and 99 deletions

View File

@@ -22,23 +22,22 @@ from eboutic.models import Basket, BasketItem, Invoice, InvoiceItem
class BasketAdmin(admin.ModelAdmin): class BasketAdmin(admin.ModelAdmin):
list_display = ("user", "date", "total") list_display = ("user", "date", "total")
autocomplete_fields = ("user",) autocomplete_fields = ("user",)
date_hierarchy = "date"
def get_queryset(self, request): def get_queryset(self, request):
return ( return (
super() super()
.get_queryset(request) .get_queryset(request)
.annotate( .annotate(
total=Sum( total=Sum(F("items__quantity") * F("items__unit_price"), default=0)
F("items__quantity") * F("items__product_unit_price"), default=0
)
) )
) )
@admin.register(BasketItem) @admin.register(BasketItem)
class BasketItemAdmin(admin.ModelAdmin): class BasketItemAdmin(admin.ModelAdmin):
list_display = ("basket", "product_name", "product_unit_price", "quantity") list_display = ("label", "unit_price", "quantity")
search_fields = ("product_name",) search_fields = ("label",)
@admin.register(Invoice) @admin.register(Invoice)
@@ -50,5 +49,6 @@ class InvoiceAdmin(admin.ModelAdmin):
@admin.register(InvoiceItem) @admin.register(InvoiceItem)
class InvoiceItemAdmin(admin.ModelAdmin): class InvoiceItemAdmin(admin.ModelAdmin):
list_display = ("invoice", "product_name", "product_unit_price", "quantity") list_display = ("label", "unit_price", "quantity")
search_fields = ("product_name",) search_fields = ("label",)
list_select_related = ("price",)

View File

@@ -0,0 +1,53 @@
# Generated by Django 5.2.11 on 2026-02-22 18:13
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("counter", "0038_price"), ("eboutic", "0002_auto_20221005_2243")]
operations = [
migrations.RenameField(
model_name="basketitem", old_name="product_name", new_name="label"
),
migrations.RenameField(
model_name="basketitem",
old_name="product_unit_price",
new_name="unit_price",
),
migrations.RenameField(
model_name="basketitem", old_name="product_id", new_name="product"
),
migrations.RenameField(
model_name="invoiceitem", old_name="product_name", new_name="label"
),
migrations.RenameField(
model_name="invoiceitem",
old_name="product_unit_price",
new_name="unit_price",
),
migrations.RenameField(
model_name="invoiceitem", old_name="product_id", new_name="product"
),
migrations.RemoveField(model_name="basketitem", name="type_id"),
migrations.RemoveField(model_name="invoiceitem", name="type_id"),
migrations.AlterField(
model_name="basketitem",
name="product",
field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
to="counter.product",
verbose_name="product",
),
),
migrations.AlterField(
model_name="invoiceitem",
name="product",
field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
to="counter.product",
verbose_name="product",
),
),
]

View File

@@ -17,12 +17,12 @@ from __future__ import annotations
import hmac import hmac
from datetime import datetime from datetime import datetime
from enum import Enum from enum import Enum
from typing import Any, Self from typing import Self
from dict2xml import dict2xml from dict2xml import dict2xml
from django.conf import settings from django.conf import settings
from django.db import DataError, models from django.db import DataError, models
from django.db.models import F, OuterRef, Subquery, Sum from django.db.models import F, OuterRef, Q, Subquery, Sum
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -30,8 +30,8 @@ from core.models import User
from counter.fields import CurrencyField from counter.fields import CurrencyField
from counter.models import ( from counter.models import (
BillingInfo, BillingInfo,
Counter,
Customer, Customer,
Price,
Product, Product,
Refilling, Refilling,
Selling, Selling,
@@ -39,20 +39,28 @@ from counter.models import (
) )
def get_eboutic_products(user: User) -> list[Product]: def get_eboutic_prices(user: User) -> list[Price]:
products = ( return list(
get_eboutic() Price.objects.filter(
.products.filter(product_type__isnull=False) Q(is_always_shown=True, groups__in=user.all_groups)
.filter(archived=False, limit_age__lte=user.age) | Q(
.annotate( id=Subquery(
order=F("product_type__order"), Price.objects.filter(
category=F("product_type__name"), product_id=OuterRef("product_id"), groups__in=user.all_groups
category_comment=F("product_type__comment"), )
price=F("selling_price"), # <-- selected price for basket validation .order_by("amount")
.values("id")[:1]
)
),
product__product_type__isnull=False,
product__archived=False,
product__limit_age__lte=user.age,
product__counters=get_eboutic(),
) )
.prefetch_related("buying_groups") # <-- used in `Product.can_be_sold_to` .select_related("product", "product__product_type")
.order_by("product__product_type__order", "product_id", "amount")
.distinct()
) )
return [p for p in products if p.can_be_sold_to(user)]
class BillingInfoState(Enum): class BillingInfoState(Enum):
@@ -94,21 +102,21 @@ class Basket(models.Model):
def __str__(self): def __str__(self):
return f"{self.user}'s basket ({self.items.all().count()} items)" return f"{self.user}'s basket ({self.items.all().count()} items)"
def can_be_viewed_by(self, user): def can_be_viewed_by(self, user: User):
return self.user == user return self.user == user
@cached_property @cached_property
def contains_refilling_item(self) -> bool: def contains_refilling_item(self) -> bool:
return self.items.filter( return self.items.filter(
type_id=settings.SITH_COUNTER_PRODUCTTYPE_REFILLING product__product_type_id=settings.SITH_COUNTER_PRODUCTTYPE_REFILLING
).exists() ).exists()
@cached_property @cached_property
def total(self) -> float: def total(self) -> float:
return float( return float(
self.items.aggregate( self.items.aggregate(total=Sum(F("quantity") * F("unit_price"), default=0))[
total=Sum(F("quantity") * F("product_unit_price"), default=0) "total"
)["total"] ]
) )
def generate_sales( def generate_sales(
@@ -120,7 +128,8 @@ class Basket(models.Model):
Example: Example:
```python ```python
counter = Counter.objects.get(name="Eboutic") counter = Counter.objects.get(name="Eboutic")
sales = basket.generate_sales(counter, "SITH_ACCOUNT") user = User.objects.get(username="bibou")
sales = basket.generate_sales(counter, user, Selling.PaymentMethod.SITH_ACCOUNT)
# here the basket is in the same state as before the method call # here the basket is in the same state as before the method call
with transaction.atomic(): with transaction.atomic():
@@ -131,31 +140,23 @@ class Basket(models.Model):
# thus only the sales remain # thus only the sales remain
``` ```
""" """
# I must proceed with two distinct requests instead of customer = Customer.get_or_create(self.user)[0]
# only one with a join because the AbstractBaseItem model has been return [
# poorly designed. If you refactor the model, please refactor this too. Selling(
items = self.items.order_by("product_id") label=item.label,
ids = [item.product_id for item in items] counter=counter,
products = Product.objects.filter(id__in=ids).order_by("id") club_id=item.product.club_id,
# items and products are sorted in the same order product=item.product,
sales = [] seller=seller,
for item, product in zip(items, products, strict=False): customer=customer,
sales.append( unit_price=item.unit_price,
Selling( quantity=item.quantity,
label=product.name, payment_method=payment_method,
counter=counter,
club=product.club,
product=product,
seller=seller,
customer=Customer.get_or_create(self.user)[0],
unit_price=item.product_unit_price,
quantity=item.quantity,
payment_method=payment_method,
)
) )
return sales for item in self.items.select_related("product")
]
def get_e_transaction_data(self) -> list[tuple[str, Any]]: def get_e_transaction_data(self) -> list[tuple[str, str]]:
user = self.user user = self.user
if not hasattr(user, "customer"): if not hasattr(user, "customer"):
raise Customer.DoesNotExist raise Customer.DoesNotExist
@@ -201,7 +202,7 @@ class InvoiceQueryset(models.QuerySet):
def annotate_total(self) -> Self: def annotate_total(self) -> Self:
"""Annotate the queryset with the total amount of each invoice. """Annotate the queryset with the total amount of each invoice.
The total amount is the sum of (product_unit_price * quantity) The total amount is the sum of (unit_price * quantity)
for all items related to the invoice. for all items related to the invoice.
""" """
# aggregates within subqueries require a little bit of black magic, # aggregates within subqueries require a little bit of black magic,
@@ -211,7 +212,7 @@ class InvoiceQueryset(models.QuerySet):
total=Subquery( total=Subquery(
InvoiceItem.objects.filter(invoice_id=OuterRef("pk")) InvoiceItem.objects.filter(invoice_id=OuterRef("pk"))
.values("invoice_id") .values("invoice_id")
.annotate(total=Sum(F("product_unit_price") * F("quantity"))) .annotate(total=Sum(F("unit_price") * F("quantity")))
.values("total") .values("total")
) )
) )
@@ -221,11 +222,7 @@ class Invoice(models.Model):
"""Invoices are generated once the payment has been validated.""" """Invoices are generated once the payment has been validated."""
user = models.ForeignKey( user = models.ForeignKey(
User, User, related_name="invoices", verbose_name=_("user"), on_delete=models.CASCADE
related_name="invoices",
verbose_name=_("user"),
blank=False,
on_delete=models.CASCADE,
) )
date = models.DateTimeField(_("date"), auto_now=True) date = models.DateTimeField(_("date"), auto_now=True)
validated = models.BooleanField(_("validated"), default=False) validated = models.BooleanField(_("validated"), default=False)
@@ -246,53 +243,44 @@ class Invoice(models.Model):
if self.validated: if self.validated:
raise DataError(_("Invoice already validated")) raise DataError(_("Invoice already validated"))
customer, _created = Customer.get_or_create(user=self.user) customer, _created = Customer.get_or_create(user=self.user)
eboutic = Counter.objects.filter(type="EBOUTIC").first() kwargs = {
for i in self.items.all(): "counter": get_eboutic(),
if i.type_id == settings.SITH_COUNTER_PRODUCTTYPE_REFILLING: "customer": customer,
new = Refilling( "date": self.date,
counter=eboutic, "payment_method": Selling.PaymentMethod.CARD,
customer=customer, }
operator=self.user, for i in self.items.select_related("product"):
amount=i.product_unit_price * i.quantity, if i.product.product_type_id == settings.SITH_COUNTER_PRODUCTTYPE_REFILLING:
payment_method=Refilling.PaymentMethod.CARD, Refilling.objects.create(
date=self.date, **kwargs, operator=self.user, amount=i.unit_price * i.quantity
) )
new.save()
else: else:
product = Product.objects.filter(id=i.product_id).first() Selling.objects.create(
new = Selling( **kwargs,
label=i.product_name, label=i.label,
counter=eboutic, club_id=i.product.club_id,
club=product.club, product=i.product,
product=product,
seller=self.user, seller=self.user,
customer=customer, unit_price=i.unit_price,
unit_price=i.product_unit_price,
quantity=i.quantity, quantity=i.quantity,
payment_method=Selling.PaymentMethod.CARD,
date=self.date,
) )
new.save()
self.validated = True self.validated = True
self.save() self.save()
class AbstractBaseItem(models.Model): class AbstractBaseItem(models.Model):
product_id = models.IntegerField(_("product id")) product = models.ForeignKey(
product_name = models.CharField(_("product name"), max_length=255) Product, verbose_name=_("product"), on_delete=models.PROTECT
type_id = models.IntegerField(_("product type id")) )
product_unit_price = CurrencyField(_("unit price")) label = models.CharField(_("product name"), max_length=255)
unit_price = CurrencyField(_("unit price"))
quantity = models.PositiveIntegerField(_("quantity")) quantity = models.PositiveIntegerField(_("quantity"))
class Meta: class Meta:
abstract = True abstract = True
def __str__(self): def __str__(self):
return "Item: %s (%s) x%d" % ( return "Item: %s (%s) x%d" % (self.product.name, self.unit_price, self.quantity)
self.product_name,
self.product_unit_price,
self.quantity,
)
class BasketItem(AbstractBaseItem): class BasketItem(AbstractBaseItem):
@@ -301,21 +289,16 @@ class BasketItem(AbstractBaseItem):
) )
@classmethod @classmethod
def from_product(cls, product: Product, quantity: int, basket: Basket): def from_price(cls, price: Price, quantity: int, basket: Basket):
"""Create a BasketItem with the same characteristics as the """Create a BasketItem with the same characteristics as the
product passed in parameters, with the specified quantity. product price passed in parameters, with the specified quantity.
Warning:
the basket field is not filled, so you must set
it yourself before saving the model.
""" """
return cls( return cls(
basket=basket, basket=basket,
product_id=product.id, label=price.full_label,
product_name=product.name, product_id=price.product_id,
type_id=product.product_type_id,
quantity=quantity, quantity=quantity,
product_unit_price=product.selling_price, unit_price=price.amount,
) )