Sith/eboutic/models.py

294 lines
10 KiB
Python
Raw Permalink Normal View History

#
# Copyright 2023 © AE UTBM
# ae@utbm.fr / ae.info@utbm.fr
#
# This file is part of the website of the UTBM Student Association (AE UTBM),
# https://ae.utbm.fr.
#
# You can find the source code of the website at https://github.com/ae-utbm/sith
#
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
2024-09-23 08:25:27 +00:00
# SEE : https://raw.githubusercontent.com/ae-utbm/sith/master/LICENSE
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
2024-07-12 07:34:16 +00:00
from __future__ import annotations
import hmac
from datetime import datetime
2024-10-04 11:41:15 +00:00
from typing import Any, Self
from dict2xml import dict2xml
2022-09-25 19:29:42 +00:00
from django.conf import settings
2024-06-24 11:07:36 +00:00
from django.db import DataError, models
2024-10-04 11:41:15 +00:00
from django.db.models import F, OuterRef, Subquery, Sum
from django.utils.functional import cached_property
2022-09-25 19:29:42 +00:00
from django.utils.translation import gettext_lazy as _
from accounting.models import CurrencyField
2024-06-24 11:07:36 +00:00
from core.models import User
from counter.models import BillingInfo, Counter, Customer, Product, Refilling, Selling
2022-09-25 19:29:42 +00:00
2024-07-12 07:34:16 +00:00
def get_eboutic_products(user: User) -> list[Product]:
2022-09-25 19:29:42 +00:00
products = (
Counter.objects.get(type="EBOUTIC")
.products.filter(product_type__isnull=False)
.filter(archived=False)
.filter(limit_age__lte=user.age)
2024-12-15 17:55:09 +00:00
.annotate(order=F("product_type__order"))
2022-09-25 19:29:42 +00:00
.annotate(category=F("product_type__name"))
2022-11-16 19:41:24 +00:00
.annotate(category_comment=F("product_type__comment"))
2024-07-27 22:09:39 +00:00
.prefetch_related("buying_groups") # <-- used in `Product.can_be_sold_to`
2022-09-25 19:29:42 +00:00
)
return [p for p in products if p.can_be_sold_to(user)]
2017-06-12 07:50:08 +00:00
class Basket(models.Model):
2024-07-12 07:34:16 +00:00
"""Basket is built when the user connects to an eboutic page."""
2018-10-04 19:29:19 +00:00
user = models.ForeignKey(
User,
related_name="baskets",
verbose_name=_("user"),
blank=False,
on_delete=models.CASCADE,
2018-10-04 19:29:19 +00:00
)
date = models.DateTimeField(_("date"), auto_now=True)
def __str__(self):
return f"{self.user}'s basket ({self.items.all().count()} items)"
@cached_property
2022-09-25 19:29:42 +00:00
def contains_refilling_item(self) -> bool:
return self.items.filter(
type_id=settings.SITH_COUNTER_PRODUCTTYPE_REFILLING
).exists()
2024-07-27 22:09:39 +00:00
@cached_property
def total(self) -> float:
return float(
self.items.aggregate(
total=Sum(F("quantity") * F("product_unit_price"), default=0)
)["total"]
)
2022-09-25 19:29:42 +00:00
@classmethod
2024-07-12 07:34:16 +00:00
def from_session(cls, session) -> Basket | None:
"""The basket stored in the session object, if it exists."""
2022-09-25 19:29:42 +00:00
if "basket_id" in session:
2024-07-27 22:09:39 +00:00
return cls.objects.filter(id=session["basket_id"]).first()
2022-09-25 19:29:42 +00:00
return None
def generate_sales(self, counter, seller: User, payment_method: str):
2024-07-12 07:34:16 +00:00
"""Generate a list of sold items corresponding to the items
of this basket WITHOUT saving them NOR deleting the basket.
Example:
2024-07-12 07:34:16 +00:00
```python
counter = Counter.objects.get(name="Eboutic")
sales = basket.generate_sales(counter, "SITH_ACCOUNT")
# here the basket is in the same state as before the method call
with transaction.atomic():
for sale in sales:
sale.save()
basket.delete()
# all the basket items are deleted by the on_delete=CASCADE relation
# thus only the sales remain
```
"""
# I must proceed with two distinct requests instead of
# only one with a join because the AbstractBaseItem model has been
# poorly designed. If you refactor the model, please refactor this too.
items = self.items.order_by("product_id")
ids = [item.product_id for item in items]
products = Product.objects.filter(id__in=ids).order_by("id")
# items and products are sorted in the same order
sales = []
for item, product in zip(items, products):
sales.append(
Selling(
label=product.name,
counter=counter,
club=product.club,
product=product,
seller=seller,
customer=self.user.customer,
unit_price=item.product_unit_price,
quantity=item.quantity,
payment_method=payment_method,
)
)
return sales
2024-07-27 22:09:39 +00:00
def get_e_transaction_data(self) -> list[tuple[str, Any]]:
user = self.user
if not hasattr(user, "customer"):
raise Customer.DoesNotExist
customer = user.customer
if not hasattr(user.customer, "billing_infos"):
raise BillingInfo.DoesNotExist
2024-07-27 22:09:39 +00:00
cart = {
"shoppingcart": {"total": {"totalQuantity": min(self.items.count(), 99)}}
}
cart = '<?xml version="1.0" encoding="UTF-8" ?>' + dict2xml(
cart, newlines=False
)
data = [
("PBX_SITE", settings.SITH_EBOUTIC_PBX_SITE),
("PBX_RANG", settings.SITH_EBOUTIC_PBX_RANG),
("PBX_IDENTIFIANT", settings.SITH_EBOUTIC_PBX_IDENTIFIANT),
2024-07-27 22:09:39 +00:00
("PBX_TOTAL", str(int(self.total * 100))),
("PBX_DEVISE", "978"), # This is Euro
("PBX_CMD", str(self.id)),
("PBX_PORTEUR", user.email),
("PBX_RETOUR", "Amount:M;BasketID:R;Auto:A;Error:E;Sig:K"),
("PBX_HASH", "SHA512"),
("PBX_TYPEPAIEMENT", "CARTE"),
("PBX_TYPECARTE", "CB"),
("PBX_TIME", datetime.now().replace(microsecond=0).isoformat("T")),
("PBX_SHOPPINGCART", cart),
("PBX_BILLING", customer.billing_infos.to_3dsv2_xml()),
2022-12-10 19:41:35 +00:00
]
pbx_hmac = hmac.new(
settings.SITH_EBOUTIC_HMAC_KEY,
bytes("&".join("=".join(d) for d in data), "utf-8"),
"sha512",
)
2022-12-10 19:41:35 +00:00
data.append(("PBX_HMAC", pbx_hmac.hexdigest().upper()))
return data
2017-06-12 07:50:08 +00:00
2024-10-04 11:41:15 +00:00
class InvoiceQueryset(models.QuerySet):
def annotate_total(self) -> Self:
"""Annotate the queryset with the total amount of each invoice.
The total amount is the sum of (product_unit_price * quantity)
for all items related to the invoice.
"""
2024-10-12 12:58:23 +00:00
# aggregates within subqueries require a little bit of black magic,
# but hopefully, django gives a comprehensive documentation for that :
# https://docs.djangoproject.com/en/stable/ref/models/expressions/#using-aggregates-within-a-subquery-expression
2024-10-04 11:41:15 +00:00
return self.annotate(
total=Subquery(
InvoiceItem.objects.filter(invoice_id=OuterRef("pk"))
.values("invoice_id")
.annotate(total=Sum(F("product_unit_price") * F("quantity")))
2024-10-04 11:41:15 +00:00
.values("total")
)
)
class Invoice(models.Model):
2024-07-12 07:34:16 +00:00
"""Invoices are generated once the payment has been validated."""
2018-10-04 19:29:19 +00:00
user = models.ForeignKey(
User,
related_name="invoices",
verbose_name=_("user"),
blank=False,
on_delete=models.CASCADE,
2018-10-04 19:29:19 +00:00
)
date = models.DateTimeField(_("date"), auto_now=True)
validated = models.BooleanField(_("validated"), default=False)
2024-10-04 11:41:15 +00:00
objects = InvoiceQueryset.as_manager()
def __str__(self):
return f"{self.user} - {self.get_total()} - {self.date}"
2022-09-25 19:29:42 +00:00
def get_total(self) -> float:
2024-07-27 22:09:39 +00:00
return float(
self.items.aggregate(
total=Sum(F("quantity") * F("product_unit_price"), default=0)
)["total"]
)
def validate(self):
if self.validated:
raise DataError(_("Invoice already validated"))
2022-11-23 11:23:17 +00:00
customer, created = Customer.get_or_create(user=self.user)
eboutic = Counter.objects.filter(type="EBOUTIC").first()
for i in self.items.all():
if i.type_id == settings.SITH_COUNTER_PRODUCTTYPE_REFILLING:
new = Refilling(
2017-06-12 07:50:08 +00:00
counter=eboutic,
2022-11-23 11:23:17 +00:00
customer=customer,
2017-06-12 07:50:08 +00:00
operator=self.user,
amount=i.product_unit_price * i.quantity,
payment_method="CARD",
bank="OTHER",
date=self.date,
)
new.save()
else:
product = Product.objects.filter(id=i.product_id).first()
new = Selling(
2017-06-12 07:50:08 +00:00
label=i.product_name,
counter=eboutic,
club=product.club,
product=product,
seller=self.user,
2022-11-23 11:23:17 +00:00
customer=customer,
2017-06-12 07:50:08 +00:00
unit_price=i.product_unit_price,
quantity=i.quantity,
payment_method="CARD",
is_validated=True,
date=self.date,
)
new.save()
2016-07-26 13:10:48 +00:00
self.validated = True
self.save()
2017-06-12 07:50:08 +00:00
class AbstractBaseItem(models.Model):
2018-10-04 19:29:19 +00:00
product_id = models.IntegerField(_("product id"))
product_name = models.CharField(_("product name"), max_length=255)
type_id = models.IntegerField(_("product type id"))
product_unit_price = CurrencyField(_("unit price"))
2022-09-25 19:29:42 +00:00
quantity = models.PositiveIntegerField(_("quantity"))
class Meta:
abstract = True
def __str__(self):
2018-10-04 19:29:19 +00:00
return "Item: %s (%s) x%d" % (
self.product_name,
self.product_unit_price,
self.quantity,
)
2017-06-12 07:50:08 +00:00
class BasketItem(AbstractBaseItem):
basket = models.ForeignKey(
Basket, related_name="items", verbose_name=_("basket"), on_delete=models.CASCADE
)
@classmethod
2024-07-27 22:09:39 +00:00
def from_product(cls, product: Product, quantity: int, basket: Basket):
2024-07-12 07:34:16 +00:00
"""Create a BasketItem with the same characteristics as the
product passed in parameters, with the specified quantity.
Warning:
2024-07-12 07:34:16 +00:00
the basket field is not filled, so you must set
it yourself before saving the model.
"""
return cls(
2024-07-27 22:09:39 +00:00
basket=basket,
product_id=product.id,
product_name=product.name,
2024-07-27 22:09:39 +00:00
type_id=product.product_type_id,
quantity=quantity,
product_unit_price=product.selling_price,
)
2017-06-12 07:50:08 +00:00
class InvoiceItem(AbstractBaseItem):
2018-10-04 19:29:19 +00:00
invoice = models.ForeignKey(
Invoice,
related_name="items",
verbose_name=_("invoice"),
on_delete=models.CASCADE,
2018-10-04 19:29:19 +00:00
)