svthalia/concrexit

View on GitHub
website/sales/models/order.py

Summary

Maintainability
A
2 hrs
Test Coverage
import uuid
from decimal import Decimal

from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator
from django.db import models
from django.db.models import BooleanField, Count, F, IntegerField, Q, Sum, Value
from django.db.models.functions import Coalesce
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext_lazy as _

from queryable_properties.managers import QueryablePropertiesManager
from queryable_properties.properties import AnnotationProperty

from members.models import Member
from payments.models import Payment, PaymentAmountField
from sales.models.product import ProductListItem
from sales.models.shift import Shift


def default_order_shift():
    return Shift.objects.filter(active=True).first()


class Order(models.Model):
    objects = QueryablePropertiesManager()

    class Meta:
        verbose_name = _("order")
        verbose_name_plural = _("orders")
        permissions = [
            ("custom_prices", _("Can use custom prices and discounts in orders")),
        ]
        ordering = ["created_at"]

    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)

    created_at = models.DateTimeField(
        verbose_name=_("created at"), default=timezone.now
    )

    created_by = models.ForeignKey(
        Member,
        models.SET_NULL,
        verbose_name=_("created by"),
        related_name="sales_orders_created",
        blank=False,
        null=True,
    )

    shift = models.ForeignKey(
        Shift,
        verbose_name=_("shift"),
        related_name="orders",
        default=default_order_shift,
        null=False,
        blank=False,
        on_delete=models.PROTECT,
    )

    items = models.ManyToManyField(
        ProductListItem,
        through="OrderItem",
        verbose_name=_("items"),
    )

    payment = models.OneToOneField(
        Payment,
        verbose_name=_("payment"),
        related_name="sales_order",
        on_delete=models.PROTECT,
        blank=True,
        null=True,
    )

    discount = PaymentAmountField(
        verbose_name=_("discount"),
        null=True,
        blank=True,
        validators=[MinValueValidator(Decimal("0.00"))],
    )

    payer = models.ForeignKey(
        Member,
        models.SET_NULL,
        verbose_name=_("payer"),
        related_name="sales_order",
        blank=True,
        null=True,
    )

    age_restricted = AnnotationProperty(
        Count(
            "order_items__pk",
            filter=Q(order_items__product__product__age_restricted=True),
            output_field=BooleanField(),
        )
    )

    subtotal = AnnotationProperty(
        Coalesce(
            Sum("order_items__total"),
            Value(0.00),
            output_field=PaymentAmountField(allow_zero=True),
        )
    )

    total_amount = AnnotationProperty(
        Coalesce(
            Sum("order_items__total"),
            Value(0.00),
            output_field=PaymentAmountField(allow_zero=True),
        )
        - Coalesce(
            F("discount"), Value(0.00), output_field=PaymentAmountField(allow_zero=True)
        )
    )

    num_items = AnnotationProperty(
        Coalesce(Sum("order_items__amount"), Value(0), output_field=IntegerField())
    )

    _is_free = models.BooleanField(
        verbose_name=_("is free"),
        default=False,
    )
    _total_amount = PaymentAmountField(
        allow_zero=True,
        verbose_name=_("total amount"),
    )

    def save(
        self, force_insert=False, force_update=False, using=None, update_fields=None
    ):
        if self.shift.locked and (
            self._total_amount != 0 or (self._total_amount == 0 and not self._is_free)
        ):  # Fallback for initializing _total_amount during migration
            raise ValueError("The shift this order belongs to is locked.")
        if self.shift.start > timezone.now():
            raise ValueError("The shift hasn't started yet.")

        try:
            if hasattr(
                self, "total_amount"
            ):  # Fallback if the annotation is not available during migrations
                self._total_amount = self.total_amount
        except self.DoesNotExist:
            self._total_amount = 0

        self._is_free = bool(self._total_amount == 0)

        if self.payment and self._total_amount != self.payment.amount:
            raise ValueError(
                "The payment amount does not match the order total amount."
            )
        if self.payment and not self.payer:
            self.payer = self.payment.paid_by

        return super().save(force_insert, force_update, using, update_fields)

    def clean(self):
        super().clean()
        errors = {}

        if self.shift.start > timezone.now():
            errors.update({"shift": _("The shift hasn't started yet.")})

        if self.discount and self.discount > self.total_amount:
            errors.update(
                {"discount": _("Discount cannot be higher than total amount.")}
            )

        if errors:
            raise ValidationError(errors)

    @property
    def order_description(self):
        return ", ".join(str(x) for x in self.order_items.all())

    @property
    def accept_payment_from_any_user(self):
        return True

    @property
    def payment_url(self):
        return (
            settings.BASE_URL + reverse("sales:order-pay", kwargs={"pk": self.pk})
            if not self.payment and self.pk
            else None
        )

    def __str__(self):
        return f"Order {self.id} ({self.shift})"


class OrderItem(models.Model):
    class Meta:
        verbose_name = "item"
        verbose_name_plural = "items"
        ordering = ["pk"]
        indexes = [
            models.Index(fields=["order"]),
        ]

    product = models.ForeignKey(
        ProductListItem,
        verbose_name=_("product"),
        null=True,
        blank=False,
        on_delete=models.SET_NULL,
    )
    order = models.ForeignKey(
        Order,
        verbose_name=_("order"),
        related_name="order_items",
        null=False,
        blank=False,
        on_delete=models.CASCADE,
    )
    total = PaymentAmountField(
        verbose_name=_("total"),
        allow_zero=True,
        null=False,
        blank=True,
        validators=[MinValueValidator(Decimal("0.00"))],
        help_text="Only when overriding the default",
    )
    amount = models.PositiveSmallIntegerField(
        verbose_name=_("amount"), null=False, blank=False
    )
    product_name = models.CharField(
        verbose_name=_("product name"),
        max_length=50,
        null=False,
        blank=True,
    )

    def save(
        self, force_insert=False, force_update=False, using=None, update_fields=None
    ):
        if self.order.shift.locked:
            raise ValueError("The shift this order belongs to is locked.")
        if self.order.payment:
            raise ValueError("This order has already been paid for.")

        if self.amount == 0:
            if self.pk:
                self.delete()
            else:
                return

        if not self.total:
            self.total = self.product.price * self.amount

        if self.product:
            self.product_name = self.product.product_name

        super().save(force_insert, force_update, using, update_fields)

        self.order.save()

    def clean(self):
        super().clean()
        errors = {}

        if self.order.shift.locked:
            errors.update({"order": _("The shift is locked.")})

        if self.product not in self.order.shift.product_list.product_items.all():
            errors.update({"product": _("This product is not available.")})

        if errors:
            raise ValidationError(errors)

    def __str__(self):
        return f"{self.amount}x {self.product_name}"

    def delete(self, using=None, keep_parents=False):
        super().delete(using, keep_parents)
        self.order.save()