from __future__ import annotations
import copy
from decimal import Decimal
from secrets import token_urlsafe
from typing import TYPE_CHECKING, Any
from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.db import models, transaction
from django.http import HttpRequest
from django.utils.functional import cached_property, classproperty
from django.utils.text import Truncator
from django.utils.translation import gettext_lazy as _
from salesman.basket.models import BaseBasket, BaseBasketItem
from salesman.conf import app_settings
from salesman.core.typing import Product
from salesman.core.utils import get_salesman_model
from salesman.orders.status import BaseOrderStatus
from .signals import status_changed
if TYPE_CHECKING: # pragma: no cover
from salesman.checkout.payment import PaymentMethod
try:
# Add support for Wagtail admin.
from modelcluster.fields import ParentalKey
from modelcluster.models import ClusterableModel
except ImportError: # pragma: no cover
ClusterableModel = models.Model
ParentalKey = models.ForeignKey
[docs]class ParentalForeignKey(ParentalKey):
"""
Use this foreign key to add support for Wagtail
in case modelcluster is installed.
"""
class OrderManager(models.Manager):
def create_from_request(self, request: HttpRequest, **kwargs: Any) -> BaseOrder:
"""
Create new order with reference. Items are still in basket and should
be added using ``order.populate_from_basket(basket, request)`` method.
Returns:
Order: Order instance
"""
kwargs["ref"] = app_settings.SALESMAN_ORDER_REFERENCE_GENERATOR(request)
order: BaseOrder = super().create(**kwargs)
return order
def create_from_basket(
self,
basket: BaseBasket,
request: HttpRequest,
**kwargs: Any,
) -> BaseOrder:
"""
Create and populate new order from basket.
Returns:
Order: Order instance
"""
kwargs["ref"] = app_settings.SALESMAN_ORDER_REFERENCE_GENERATOR(request)
order: BaseOrder = self.model(**kwargs)
order.populate_from_basket(basket, request)
return order
[docs]class BaseOrder(ClusterableModel):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
verbose_name=_("User"),
)
# A unique reference to this order, could be used as order number.
ref = models.CharField(
_("Reference"),
max_length=128,
unique=True,
help_text=_("A unique order reference."),
)
# Current order status.
status = models.CharField(
_("Status"),
max_length=128,
default="NEW",
help_text=_("Changing order status might trigger a notification to customer."),
)
# A unique token to allow non-authenticated user access to order.
token = models.CharField(
_("Token"),
max_length=128,
unique=True,
default=token_urlsafe,
help_text=_(
"Allow non-authenticated customer to access the order with token. "
"To access order suply a '?token={token}' in url querystring."
),
)
# Customer contact info.
email = models.EmailField(_("Email"), blank=True)
shipping_address = models.TextField(_("Shipping address"), blank=True)
billing_address = models.TextField(_("Billing address"), blank=True)
subtotal = models.DecimalField(
_("Subtotal"), max_digits=18, decimal_places=2, default=Decimal(0)
)
total = models.DecimalField(
_("Total"), max_digits=18, decimal_places=2, default=Decimal(0)
)
_extra = models.JSONField(_("Extra"), blank=True, default=dict)
date_created = models.DateTimeField(_("Date created"), auto_now_add=True)
date_updated = models.DateTimeField(_("Date updated"), auto_now=True)
objects = OrderManager()
# Separate rows from `_extra` to `extra_rows`.
extra: dict[str, Any] | None = None
extra_rows: list[Any] | None = None
_current_status: str | None = None
_cached_items: list[BaseOrderItem] | None = None
class Meta:
abstract = True
verbose_name = _("Order")
verbose_name_plural = _("Orders")
ordering = ["-date_created"]
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
extra = copy.deepcopy(self._extra)
self.extra_rows = extra.pop("rows", [])
self.extra = extra
self._current_status = self.status
def __str__(self) -> str:
return self.ref
[docs] def save(self, *args: Any, **kwargs: Any) -> None:
self._extra = dict(self.extra or {}, rows=self.extra_rows)
if "extra" in kwargs.get("update_fields", []):
kwargs["update_fields"].remove("extra")
kwargs["update_fields"].append("_extra")
new_status, old_status = self.status, self._current_status
super().save(*args, **kwargs)
# Send signal if status changed.
if new_status != old_status:
status_changed.send(
get_salesman_model("Order"),
order=self,
new_status=new_status,
old_status=old_status,
)
[docs] def pay(
self,
amount: Decimal,
transaction_id: str,
payment_method: str = "",
) -> BaseOrderPayment:
"""
Create a new payment for order.
Args:
amount (Decimal): Amount to add
transaction_id (str): ID of transaction
payment_method (str, optional): Payment method identifier. Defaults to ''.
Returns:
OrderPayment: New order payment instance
"""
OrderPayment = get_salesman_model("OrderPayment")
payment: BaseOrderPayment = OrderPayment.objects.create(
order=self,
amount=amount,
transaction_id=transaction_id,
payment_method=payment_method,
)
return payment
[docs] @transaction.atomic
def populate_from_basket(
self,
basket: BaseBasket,
request: HttpRequest,
**kwargs: Any,
) -> None:
"""
Populate order with items from basket.
Args:
basket (Basket): Basket instance
request (HttpRequest): Django request
"""
from salesman.basket.serializers import ExtraRowsField
if not hasattr(basket, "total"):
basket.update(request)
self.user = basket.user
self.email = basket.extra.pop("email", "")
self.shipping_address = basket.extra.pop("shipping_address", "")
self.billing_address = basket.extra.pop("billing_address", "")
self.subtotal = basket.subtotal
self.total = basket.total
self.extra = basket.extra
self.extra_rows = ExtraRowsField().to_representation(basket.extra_rows)
if self.status == self.Status.NEW:
self.status = self.Status.CREATED
for attr, value in kwargs.items():
setattr(self, attr, value)
self.save()
OrderItem = get_salesman_model("OrderItem")
for item in basket.get_items():
obj = OrderItem(order=self)
obj.populate_from_basket_item(item, request)
obj.save()
[docs] def get_items(self) -> list[BaseOrderItem]:
"""
Returns items from cache or stores new ones.
"""
if self._cached_items is None:
self._cached_items = list(self.items.all())
return self._cached_items
@classproperty
def Status(cls) -> type[BaseOrderStatus]:
"""
Return order status enum from settings.
"""
return app_settings.SALESMAN_ORDER_STATUS
@property
def status_display(self) -> str:
"""
Returns display label for current status.
"""
return str(dict(self.Status.choices).get(self.status, self.status))
status_display.fget.short_description = _("Status") # type: ignore
status_display.fget.admin_order_field = "status" # type: ignore
@cached_property
def amount_paid(self) -> Decimal:
"""
Returns amount already paid for this order.
"""
return Decimal(sum([x.amount for x in self.payments.all()]))
@property
def amount_outstanding(self) -> Decimal:
"""
Returns amount still needed for order to be paid.
"""
return Decimal(self.total - self.amount_paid)
@property
def is_paid(self) -> bool:
"""
Returns if order is paid in full.
"""
return self.amount_paid >= self.total
[docs] @classmethod
def get_wagtail_admin_attribute(cls, name: str) -> Any | None:
"""
Return attribute from Wagtail order admin mixin.
Args:
name (str): Attribute name
Returns:
Optional[Any]: Attribute value
"""
if (
"salesman.admin" in settings.INSTALLED_APPS
and "wagtail.admin" in settings.INSTALLED_APPS
and any(
[
"wagtail.contrib.modeladmin" in settings.INSTALLED_APPS,
"wagtail_modeladmin" in settings.INSTALLED_APPS,
]
)
):
from salesman.admin.wagtail.mixins import WagtailOrderAdminMixin
return getattr(WagtailOrderAdminMixin, name, None)
return None
@classproperty
def default_panels(cls) -> list[Any] | None:
return cls.get_wagtail_admin_attribute("default_panels")
@classproperty
def default_items_panels(cls) -> list[Any] | None:
return cls.get_wagtail_admin_attribute("default_items_panels")
@classproperty
def default_payments_panels(cls) -> list[Any] | None:
return cls.get_wagtail_admin_attribute("default_payments_panels")
@classproperty
def default_notes_panels(cls) -> list[Any] | None:
return cls.get_wagtail_admin_attribute("default_notes_panels")
@classproperty
def default_edit_handler(cls) -> Any | None:
return cls.get_wagtail_admin_attribute("default_edit_handler")
[docs]class Order(BaseOrder):
"""
Model that can be swapped by overriding `SALESMAN_ORDER_MODEL` setting.
"""
class Meta(BaseOrder.Meta):
swappable = "SALESMAN_ORDER_MODEL"
[docs]class BaseOrderItem(models.Model):
order = ParentalForeignKey(
app_settings.SALESMAN_ORDER_MODEL,
on_delete=models.CASCADE,
related_name="items",
verbose_name=_("Order"),
)
product_type = models.CharField(_("Product type"), max_length=128)
# Generic relation to product (optional).
product_content_type = models.ForeignKey(ContentType, models.SET_NULL, null=True)
product_id = models.PositiveIntegerField(_("Product id"), null=True)
product = GenericForeignKey("product_content_type", "product_id")
# Stored product serializer data at the moment of purchase.
product_data = models.JSONField(_("Product data"), blank=True, default=dict)
unit_price = models.DecimalField(_("Unit price"), max_digits=18, decimal_places=2)
subtotal = models.DecimalField(_("Subtotal"), max_digits=18, decimal_places=2)
total = models.DecimalField(_("Total"), max_digits=18, decimal_places=2)
quantity = models.PositiveIntegerField(_("Quantity"))
_extra = models.JSONField(_("Extra"), blank=True, default=dict)
# Separate rows from `_extra` to `extra_rows`.
extra: dict[str, Any] | None = None
extra_rows: list[Any] | None = None
class Meta:
abstract = True
verbose_name = _("Item")
verbose_name_plural = _("Items")
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
extra = copy.deepcopy(self._extra)
self.extra_rows = extra.pop("rows", [])
self.extra = extra
def __str__(self) -> str:
return f"{self.quantity}x {self.name} ({self.code})"
[docs] def save(self, *args: Any, **kwargs: Any) -> None:
self._extra = dict(self.extra or {}, rows=self.extra_rows)
if "extra" in kwargs.get("update_fields", []):
kwargs["update_fields"].remove("extra")
kwargs["update_fields"].append("_extra")
super().save(*args, **kwargs)
[docs] def populate_from_basket_item(
self,
item: BaseBasketItem,
request: HttpRequest,
) -> None:
"""
Populate order with items from basket.
Args:
item (BasketItem): Basket item instance
request (HttpRequest): Django request
"""
from salesman.basket.serializers import ExtraRowsField, ProductField
product: Product = item.product
self.product_type = product._meta.label
self.product_content_type = item.product_content_type
self.product_id = item.product_id
# Store serialized product with `name` and `code`.
product_field = ProductField()
setattr(product_field, "_context", {"request": request})
product_data = product_field.to_representation(product)
product_data.update({"name": product.name, "code": product.code})
self.product_data = product_data
self.unit_price = item.unit_price
self.subtotal = item.subtotal
self.total = item.total
self.quantity = item.quantity
self.extra = item.extra
self.extra_rows = ExtraRowsField().to_representation(item.extra_rows)
@property
def name(self) -> str:
"""
Returns product `name` from stored data.
"""
return str(self.product_data.get("name", "(no name)"))
@property
def code(self) -> str:
"""
Returns product `name` from stored data.
"""
return str(self.product_data.get("code", "(no code)"))
[docs]class OrderItem(BaseOrderItem):
"""
Model that can be swapped by overriding `SALESMAN_ORDER_ITEM_MODEL` setting.
"""
class Meta(BaseOrderItem.Meta):
swappable = "SALESMAN_ORDER_ITEM_MODEL"
[docs]class BaseOrderPayment(models.Model):
order = ParentalForeignKey(
app_settings.SALESMAN_ORDER_MODEL,
on_delete=models.CASCADE,
related_name="payments",
verbose_name=_("Order"),
)
amount = models.DecimalField(_("Amount"), max_digits=18, decimal_places=2)
transaction_id = models.CharField(_("Transaction ID"), max_length=128)
payment_method = models.CharField(_("Payment method"), max_length=128, blank=True)
date_created = models.DateTimeField(_("Date created"), auto_now_add=True)
class Meta:
abstract = True
verbose_name = _("Payment")
verbose_name_plural = _("Payments")
unique_together = ("order", "transaction_id")
def __str__(self) -> str:
return f"{self.amount} ({self.transaction_id})"
[docs] def get_payment_method(self) -> PaymentMethod | None:
"""
Returns payment method instance.
"""
from salesman.checkout.payment import payment_methods_pool
return payment_methods_pool.get_payment(self.payment_method)
@property
def payment_method_display(self) -> str:
"""
Returns display label for payment method.
"""
payment = self.get_payment_method()
return payment.label if payment else self.payment_method
payment_method_display.fget.short_description = _("Payment method") # type: ignore
[docs]class OrderPayment(BaseOrderPayment):
"""
Model that can be swapped by overriding `SALESMAN_ORDER_PAYMENT_MODEL` setting.
"""
class Meta(BaseOrderPayment.Meta):
swappable = "SALESMAN_ORDER_PAYMENT_MODEL"
[docs]class BaseOrderNote(models.Model):
order = ParentalForeignKey(
app_settings.SALESMAN_ORDER_MODEL,
on_delete=models.CASCADE,
related_name="notes",
verbose_name=_("Order"),
)
message = models.TextField(_("Message"))
public = models.BooleanField(
_("Public"), default=False, help_text=_("Is accessible to the customer?")
)
date_created = models.DateTimeField(_("Date created"), auto_now_add=True)
class Meta:
abstract = True
verbose_name = _("Note")
verbose_name_plural = _("Notes")
def __str__(self) -> str:
return Truncator(self.message).words(3)
[docs]class OrderNote(BaseOrderNote):
"""
Model that can be swapped by overriding `SALESMAN_ORDER_NOTE_MODEL` setting.
"""
class Meta(BaseOrderNote.Meta):
swappable = "SALESMAN_ORDER_NOTE_MODEL"