from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import Sum
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from enumfields import Enum, EnumIntegerField
from jsonfield import JSONField
from shuup.core.fields import MoneyValueField, QuantityField, UnsavedForeignKey
from shuup.core.pricing import Priceful
from shuup.core.taxing import LineTax
from shuup.core.utils.line_unit_mixin import LineWithUnit
from shuup.utils.analog import define_log_model
from shuup.utils.money import Money
from shuup.utils.properties import MoneyProperty, MoneyPropped, PriceProperty
from ._base import ShuupModel
from ._shipments import ShipmentProduct
class OrderLineType(Enum):
PRODUCT = 1
SHIPPING = 2
PAYMENT = 3
DISCOUNT = 4
OTHER = 5
REFUND = 6
ROUNDING = 7
class Labels:
PRODUCT = _("product")
SHIPPING = _("shipping")
PAYMENT = _("payment")
DISCOUNT = _("discount")
OTHER = _("other")
REFUND = _("refund")
ROUNDING = _("rounding")
class OrderLineManager(models.Manager):
def products(self): # pragma: no cover
return self.filter(type=OrderLineType.PRODUCT)
def shipping(self): # pragma: no cover
return self.filter(type=OrderLineType.SHIPPING)
def payment(self): # pragma: no cover
return self.filter(type=OrderLineType.PAYMENT)
def discounts(self):
return self.filter(type=OrderLineType.DISCOUNT)
def refunds(self):
return self.filter(type=OrderLineType.REFUND)
def other(self): # pragma: no cover
return self.filter(type=OrderLineType.OTHER)
class AbstractOrderLine(MoneyPropped, models.Model, Priceful):
product = UnsavedForeignKey(
"shuup.Product",
blank=True,
null=True,
related_name="order_lines",
on_delete=models.PROTECT,
verbose_name=_("product"),
)
supplier = UnsavedForeignKey(
"shuup.Supplier",
blank=True,
null=True,
related_name="order_lines",
on_delete=models.PROTECT,
verbose_name=_("supplier"),
)
parent_line = UnsavedForeignKey(
"self",
related_name="child_lines",
blank=True,
null=True,
on_delete=models.PROTECT,
verbose_name=_("parent line"),
)
ordering = models.IntegerField(default=0, verbose_name=_("ordering"))
type = EnumIntegerField(OrderLineType, default=OrderLineType.PRODUCT, verbose_name=_("line type"))
sku = models.CharField(max_length=128, blank=True, verbose_name=_("line SKU"))
text = models.CharField(max_length=256, verbose_name=_("line text"))
accounting_identifier = models.CharField(max_length=32, blank=True, verbose_name=_("accounting identifier"))
require_verification = models.BooleanField(default=False, verbose_name=_("require verification"))
verified = models.BooleanField(default=False, verbose_name=_("verified"))
extra_data = JSONField(blank=True, null=True, verbose_name=_("extra data"))
labels = models.ManyToManyField("Label", blank=True, verbose_name=_("labels"))
# The following fields govern calculation of the prices
quantity = QuantityField(verbose_name=_("quantity"), default=1)
base_unit_price = PriceProperty("base_unit_price_value", "order.currency", "order.prices_include_tax")
discount_amount = PriceProperty("discount_amount_value", "order.currency", "order.prices_include_tax")
base_unit_price_value = MoneyValueField(verbose_name=_("unit price amount (undiscounted)"), default=0)
discount_amount_value = MoneyValueField(verbose_name=_("total amount of discount"), default=0)
created_on = models.DateTimeField(auto_now_add=True, editable=False, db_index=True, verbose_name=_("created on"))
modified_on = models.DateTimeField(
default=timezone.now,
editable=False,
db_index=True,
verbose_name=_("modified on"),
)
objects = OrderLineManager()
[docs]
def get_type_display(self):
"""
Return the display label for the order line type.
"""
label = OrderLineType.Labels.__dict__.get(self.type.name, str(self.type))
if label:
label = label.titleCase()
return label
def __str__(self):
sku_part = f"[SKU: {self.sku}]" if self.sku else ""
product_name = self.product.name if self.product and hasattr(self.product, "name") else ""
text_part = self.text or product_name or "Order Line"
return f"{self.quantity}x {text_part} {sku_part} ({self.get_type_display()})"
@property
def tax_amount(self):
"""
:rtype: shuup.utils.money.Money
"""
zero = Money(0, self.order.currency) # type: ignore
return sum((x.amount for x in self.taxes.all()), zero) # type: ignore
@property
def max_refundable_amount(self):
"""
:rtype: shuup.utils.money.Money
"""
refunds = self.child_lines.refunds().filter(parent_line=self)
refund_total_value = sum(refund.taxful_price.amount.value for refund in refunds)
return self.taxful_price.amount + Money(refund_total_value, self.order.currency)
@property
def max_refundable_quantity(self):
if self.type == OrderLineType.REFUND:
return 0
return self.quantity - self.refunded_quantity
@property
def refunded_quantity(self):
return self.child_lines.filter(type=OrderLineType.REFUND).aggregate(total=Sum("quantity"))["total"] or 0
@property
def shipped_quantity(self):
if not self.product:
return 0
return (
ShipmentProduct.objects.filter(
shipment__supplier=self.supplier.id,
product_id=self.product.id,
shipment__order=self.order,
).aggregate(total=Sum("quantity"))["total"]
or 0
)
[docs]
def save(self, *args, **kwargs):
if not self.sku:
self.sku = ""
if self.type == OrderLineType.PRODUCT and not self.product_id:
raise ValidationError("Error! Product-type order line can not be saved without a set product.")
if self.product_id and self.type != OrderLineType.PRODUCT:
raise ValidationError("Error! Order line has product but is not of Product-type.")
if self.product_id and not self.supplier_id:
raise ValidationError("Error! Order line has product, but no supplier.")
super().save(*args, **kwargs)
if self.product_id:
self.supplier.update_stock(self.product_id)
class OrderLine(LineWithUnit, AbstractOrderLine):
order = UnsavedForeignKey("Order", related_name="lines", on_delete=models.PROTECT, verbose_name=_("order"))
# TODO: Store the display and sales unit to OrderLine
@property
def shop(self):
return self.order.shop
class OrderLineTax(MoneyPropped, ShuupModel, LineTax):
order_line = models.ForeignKey(
OrderLine,
related_name="taxes",
on_delete=models.PROTECT,
verbose_name=_("order line"),
)
tax = models.ForeignKey(
"Tax",
related_name="order_line_taxes",
on_delete=models.PROTECT,
verbose_name=_("tax"),
)
name = models.CharField(max_length=200, verbose_name=_("tax name"))
amount = MoneyProperty("amount_value", "order_line.order.currency")
base_amount = MoneyProperty("base_amount_value", "order_line.order.currency")
amount_value = MoneyValueField(verbose_name=_("tax amount"))
base_amount_value = MoneyValueField(
verbose_name=_("base amount"),
help_text=_("Amount that this tax is calculated from."),
)
ordering = models.IntegerField(default=0, verbose_name=_("ordering"))
class Meta:
ordering = ["ordering"]
def __str__(self):
return f"{self.name}: {str(self.amount)} on {str(self.base_amount)}"
OrderLineLogEntry = define_log_model(OrderLine)
OrderLineTaxLogEntry = define_log_model(OrderLineTax)