Source code for salesman.orders.models

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 "wagtail.contrib.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"