Sith/eboutic/models.py
Théo DURR fe8b8f46aa
Fix 3DSv2 implementation (#542)
* Fixed wrong HMAC signature generation

* Fix xml du panier

Co-authored-by: Julien Constant <julienconstant190@gmail.com>
2023-01-06 20:02:45 +01:00

331 lines
11 KiB
Python

# -*- coding:utf-8 -*
#
# Copyright 2016,2017
# - Skia <skia@libskia.so>
#
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
# http://ae.utbm.fr.
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License a published by the Free Software
# Foundation; either version 3 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
import hmac
import html
import typing
from datetime import datetime
from typing import List
from dict2xml import dict2xml
from django.conf import settings
from django.db import models, DataError
from django.db.models import Sum, F
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from accounting.models import CurrencyField
from core.models import Group, User
from counter.models import Counter, Product, Selling, Refilling, BillingInfo, Customer
def get_eboutic_products(user: User) -> List[Product]:
products = (
Counter.objects.get(type="EBOUTIC")
.products.filter(product_type__isnull=False)
.filter(archived=False)
.filter(limit_age__lte=user.age)
.annotate(priority=F("product_type__priority"))
.annotate(category=F("product_type__name"))
.annotate(category_comment=F("product_type__comment"))
)
return [p for p in products if p.can_be_sold_to(user)]
class Basket(models.Model):
"""
Basket is built when the user connects to an eboutic page
"""
user = models.ForeignKey(
User,
related_name="baskets",
verbose_name=_("user"),
blank=False,
on_delete=models.CASCADE,
)
date = models.DateTimeField(_("date"), auto_now=True)
def add_product(self, p: Product, q: int = 1):
"""
Given p an object of the Product model and q an integer,
add q items corresponding to this Product from the basket.
If this function is called with a product not in the basket, no error will be raised
"""
item = self.items.filter(product_id=p.id).first()
if item is None:
BasketItem(
basket=self,
product_id=p.id,
product_name=p.name,
type_id=p.product_type.id,
quantity=q,
product_unit_price=p.selling_price,
).save()
else:
item.quantity += q
item.save()
def del_product(self, p: Product, q: int = 1):
"""
Given p an object of the Product model and q an integer,
remove q items corresponding to this Product from the basket.
If this function is called with a product not in the basket, no error will be raised
"""
try:
item = self.items.get(product_id=p.id)
except BasketItem.DoesNotExist:
return
item.quantity -= q
if item.quantity <= 0:
item.delete()
else:
item.save()
def clear(self) -> None:
"""
Remove all items from this basket without deleting the basket
"""
self.items.all().delete()
@cached_property
def contains_refilling_item(self) -> bool:
return self.items.filter(
type_id=settings.SITH_COUNTER_PRODUCTTYPE_REFILLING
).exists()
def get_total(self) -> float:
total = self.items.aggregate(
total=Sum(F("quantity") * F("product_unit_price"))
)["total"]
return float(total) if total is not None else 0
@classmethod
def from_session(cls, session) -> typing.Union["Basket", None]:
"""
Given an HttpRequest django object, return the basket used in the current session
if it exists else None
"""
if "basket_id" in session:
try:
return cls.objects.get(id=session["basket_id"])
except cls.DoesNotExist:
return None
return None
def generate_sales(self, counter, seller: User, payment_method: str):
"""
Generate a list of sold items corresponding to the items
of this basket WITHOUT saving them NOR deleting the basket
Example:
::
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
def get_e_transaction_data(self):
user = self.user
if not hasattr(user, "customer"):
raise Customer.DoesNotExist
customer = user.customer
if not hasattr(user.customer, "billing_infos"):
raise BillingInfo.DoesNotExist
data = [
("PBX_SITE", settings.SITH_EBOUTIC_PBX_SITE),
("PBX_RANG", settings.SITH_EBOUTIC_PBX_RANG),
("PBX_IDENTIFIANT", settings.SITH_EBOUTIC_PBX_IDENTIFIANT),
("PBX_TOTAL", str(int(self.get_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")),
]
cart = {
"shoppingcart": {"total": {"totalQuantity": min(self.items.count(), 99)}}
}
cart = '<?xml version="1.0" encoding="UTF-8" ?>' + dict2xml(
cart, newlines=False
)
data += [
("PBX_SHOPPINGCART", html.escape(cart)),
("PBX_BILLING", html.escape(customer.billing_infos.to_3dsv2_xml())),
]
pbx_hmac = hmac.new(
settings.SITH_EBOUTIC_HMAC_KEY,
bytes("&".join("=".join(d) for d in data), "utf-8"),
"sha512",
)
data.append(("PBX_HMAC", pbx_hmac.hexdigest().upper()))
return data
def __str__(self):
return "%s's basket (%d items)" % (self.user, self.items.all().count())
class Invoice(models.Model):
"""
Invoices are generated once the payment has been validated
"""
user = models.ForeignKey(
User,
related_name="invoices",
verbose_name=_("user"),
blank=False,
on_delete=models.CASCADE,
)
date = models.DateTimeField(_("date"), auto_now=True)
validated = models.BooleanField(_("validated"), default=False)
def get_total(self) -> float:
total = self.items.aggregate(
total=Sum(F("quantity") * F("product_unit_price"))
)["total"]
return float(total) if total is not None else 0
def validate(self):
if self.validated:
raise DataError(_("Invoice already validated"))
eboutic = Counter.objects.filter(type="EBOUTIC").first()
for i in self.items.all():
if i.type_id == settings.SITH_COUNTER_PRODUCTTYPE_REFILLING:
new = Refilling(
counter=eboutic,
customer=self.user.customer,
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(
label=i.product_name,
counter=eboutic,
club=product.club,
product=product,
seller=self.user,
customer=self.user.customer,
unit_price=i.product_unit_price,
quantity=i.quantity,
payment_method="CARD",
is_validated=True,
date=self.date,
)
new.save()
self.validated = True
self.save()
def __str__(self):
return "%s - %s - %s" % (self.user, self.get_total(), self.date)
class AbstractBaseItem(models.Model):
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"))
quantity = models.PositiveIntegerField(_("quantity"))
class Meta:
abstract = True
def __str__(self):
return "Item: %s (%s) x%d" % (
self.product_name,
self.product_unit_price,
self.quantity,
)
class BasketItem(AbstractBaseItem):
basket = models.ForeignKey(
Basket, related_name="items", verbose_name=_("basket"), on_delete=models.CASCADE
)
@classmethod
def from_product(cls, product: Product, quantity: int):
"""
Create a BasketItem with the same characteristics as the
product 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(
product_id=product.id,
product_name=product.name,
type_id=product.product_type.id,
quantity=quantity,
product_unit_price=product.selling_price,
)
class InvoiceItem(AbstractBaseItem):
invoice = models.ForeignKey(
Invoice,
related_name="items",
verbose_name=_("invoice"),
on_delete=models.CASCADE,
)