Statistics labels, shop help text, and the confirm/form modal defaults were plain strings; wrap them per CLAUDE.md's i18n convention so the app stays translation-ready as it's written.
304 lines
13 KiB
Python
304 lines
13 KiB
Python
from django.db import IntegrityError, models, transaction
|
|
from django.db.models import Q, UniqueConstraint
|
|
from django.utils import timezone
|
|
from django.utils.translation import gettext_lazy as _
|
|
|
|
from authentication.models import User
|
|
from club.models import Season
|
|
from club.tenancy import require_current_club
|
|
from members.models import Member
|
|
from rosterchief.base import ClubScopedModel, UUIDModel, validate_club_scope
|
|
from teams.models import Position, Team
|
|
|
|
|
|
class DiscountType(models.TextChoices):
|
|
"""Shared by ``Product`` (early-bird), ``Discount`` and ``AppliedDiscount`` (its
|
|
snapshot on an order) — one discount vocabulary, not three copies of it."""
|
|
|
|
PERCENTAGE = "percentage", _("Percentage")
|
|
FIXED_AMOUNT = "fixed_amount", _("Fixed amount")
|
|
|
|
|
|
def next_scoped_number(instance, code):
|
|
"""Next per-club sequential number for the current year: ``<code>-<year>-<seq>``."""
|
|
prefix = f"{code}-{timezone.now().year}-"
|
|
model = type(instance)
|
|
sequences = [int(suffix) for number in model.objects.filter(club=instance.club, number__startswith=prefix).values_list("number", flat=True) if (suffix := number.removeprefix(prefix)).isdigit()]
|
|
return f"{prefix}{max(sequences, default=0) + 1:05d}"
|
|
|
|
|
|
def save_with_number(instance, save):
|
|
"""Assign a unique per-club number (retrying on collision) then save.
|
|
|
|
Relies on the model's ``generate_number()`` and its ``(club, number)``
|
|
unique constraint as the source of truth.
|
|
"""
|
|
if instance.club_id is None:
|
|
instance.club = require_current_club()
|
|
if instance.number:
|
|
return save()
|
|
for attempt in range(5):
|
|
instance.number = instance.generate_number()
|
|
try:
|
|
with transaction.atomic():
|
|
return save()
|
|
except IntegrityError:
|
|
if attempt == 4:
|
|
raise
|
|
|
|
|
|
class Product(ClubScopedModel):
|
|
class ProductType(models.TextChoices):
|
|
MEMBERSHIP = "membership", _("Membership")
|
|
EVENT_FEE = "event_fee", _("Event fee")
|
|
MERCHANDISE = "merchandise", _("Merchandise")
|
|
DONATION = "donation", _("Donation")
|
|
|
|
name = models.CharField(_("name"), max_length=255)
|
|
slug = models.SlugField(_("slug"), max_length=255, blank=True)
|
|
|
|
product_type = models.CharField(_("product type"), max_length=255, choices=ProductType.choices, default=ProductType.MEMBERSHIP)
|
|
season = models.ForeignKey(Season, on_delete=models.PROTECT, related_name="products", verbose_name=_("season"), blank=True, null=True)
|
|
|
|
is_active = models.BooleanField(_("is active?"), default=True)
|
|
is_public = models.BooleanField(_("is public?"), default=True, help_text=_("Non-public products are only visible to staff members for adding on to an order later on."))
|
|
staff_role = models.ForeignKey(Position, on_delete=models.PROTECT, related_name="staff_products", verbose_name=_("staff role"), blank=True, null=True, limit_choices_to={"staff_position": True})
|
|
|
|
early_bird_discount_enabled = models.BooleanField(_("early bird discount enabled?"), default=False)
|
|
early_bird_discount_deadline = models.DateField(_("early bird discount deadline"), blank=True, null=True)
|
|
early_bird_discount_type = models.CharField(_("early bird discount type"), max_length=255, choices=DiscountType.choices, default=DiscountType.PERCENTAGE)
|
|
early_bird_discount_amount = models.DecimalField(_("early bird discount amount"), max_digits=10, decimal_places=2, default=0)
|
|
|
|
slug_source = "name"
|
|
|
|
class Meta:
|
|
verbose_name = _("product")
|
|
verbose_name_plural = _("products")
|
|
ordering = ["name"]
|
|
constraints = [
|
|
UniqueConstraint(fields=["club", "slug"], name="unique_product_slug_per_club"),
|
|
]
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
def clean(self):
|
|
validate_club_scope(self, self.club_id, same_club_fields=("season", "staff_role"))
|
|
|
|
|
|
class Cart(ClubScopedModel):
|
|
class CartStatus(models.TextChoices):
|
|
OPEN = "open", _("Open")
|
|
CHECKED_OUT = "checked_out", _("Checked out")
|
|
ABANDONED = "abandoned", _("Abandoned")
|
|
|
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="carts", verbose_name=_("user"))
|
|
status = models.CharField(_("status"), max_length=255, choices=CartStatus.choices, default=CartStatus.OPEN)
|
|
|
|
class Meta:
|
|
verbose_name = _("cart")
|
|
verbose_name_plural = _("carts")
|
|
constraints = [
|
|
# At most one open cart per user per club (CartStatus.OPEN == "open").
|
|
UniqueConstraint(fields=["club", "user"], condition=Q(status="open"), name="unique_open_cart_per_user_per_club"),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.user} - {self.status}"
|
|
|
|
|
|
class CartItem(UUIDModel):
|
|
cart = models.ForeignKey(Cart, on_delete=models.CASCADE, related_name="items", verbose_name=_("cart"))
|
|
product = models.ForeignKey(Product, on_delete=models.PROTECT, related_name="cart_items", verbose_name=_("product"))
|
|
|
|
quantity = models.PositiveSmallIntegerField(_("quantity"), default=1)
|
|
unit_price = models.DecimalField(_("unit price"), max_digits=10, decimal_places=2)
|
|
|
|
beneficiary = models.ForeignKey(Member, on_delete=models.PROTECT, related_name="cart_items", verbose_name=_("beneficiary"), blank=True, null=True)
|
|
team = models.ForeignKey(Team, on_delete=models.PROTECT, related_name="cart_items", verbose_name=_("team"), blank=True, null=True)
|
|
|
|
class Meta:
|
|
verbose_name = _("cart item")
|
|
verbose_name_plural = _("cart items")
|
|
constraints = [
|
|
UniqueConstraint(fields=["cart", "product", "beneficiary"], name="unique_product_per_cart_per_beneficiary"),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.product} - {self.quantity}x"
|
|
|
|
def clean(self):
|
|
club_id = self.cart.club_id if self.cart_id else None
|
|
validate_club_scope(self, club_id, same_club_fields=("product", "team"), member_fields=("beneficiary",))
|
|
|
|
|
|
class Order(ClubScopedModel):
|
|
class OrderStatus(models.TextChoices):
|
|
PENDING = "pending", _("Pending")
|
|
PAID = "paid", _("Paid")
|
|
PARTIALLY_PAID = "partially_paid", _("Partially paid")
|
|
CANCELLED = "cancelled", _("Cancelled")
|
|
REFUNDED = "refunded", _("Refunded")
|
|
DELIVERED = "delivered", _("Delivered")
|
|
|
|
number = models.CharField(_("number"), max_length=255, blank=True)
|
|
purchaser = models.ForeignKey(Member, on_delete=models.PROTECT, related_name="orders", verbose_name=_("purchaser"))
|
|
status = models.CharField(_("status"), max_length=255, choices=OrderStatus.choices, default=OrderStatus.PENDING)
|
|
total = models.DecimalField(_("total"), max_digits=10, decimal_places=2)
|
|
|
|
# `created`/`modified` come from TimeStampedModel — declaring them here as well
|
|
# would clash with the abstract base.
|
|
|
|
class Meta:
|
|
verbose_name = _("order")
|
|
verbose_name_plural = _("orders")
|
|
ordering = ["-created"]
|
|
constraints = [
|
|
UniqueConstraint(fields=["club", "number"], name="unique_order_number_per_club"),
|
|
]
|
|
|
|
def __str__(self):
|
|
return self.number
|
|
|
|
def clean(self):
|
|
validate_club_scope(self, self.club_id, member_fields=("purchaser",))
|
|
|
|
def generate_number(self):
|
|
"""Next per-club order number for the current year: ``ORD-<year>-<seq>``."""
|
|
return next_scoped_number(self, "ORD")
|
|
|
|
def save(self, *args, **kwargs):
|
|
return save_with_number(self, lambda: super(Order, self).save(*args, **kwargs))
|
|
|
|
|
|
class OrderLine(UUIDModel):
|
|
order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name="order_items", verbose_name=_("order"))
|
|
product = models.ForeignKey(Product, on_delete=models.PROTECT, related_name="order_items", verbose_name=_("product"))
|
|
|
|
quantity = models.PositiveSmallIntegerField(_("quantity"), default=1)
|
|
unit_price = models.DecimalField(_("unit price"), max_digits=10, decimal_places=2)
|
|
|
|
beneficiary = models.ForeignKey(Member, on_delete=models.PROTECT, related_name="order_items", verbose_name=_("beneficiary"), blank=True, null=True)
|
|
team = models.ForeignKey(Team, on_delete=models.PROTECT, related_name="order_items", verbose_name=_("team"), blank=True, null=True)
|
|
line_total = models.DecimalField(_("line total"), max_digits=10, decimal_places=2)
|
|
|
|
class Meta:
|
|
verbose_name = _("order line")
|
|
verbose_name_plural = _("order lines")
|
|
|
|
def __str__(self):
|
|
return f"{self.product} - {self.quantity}x"
|
|
|
|
def clean(self):
|
|
club_id = self.order.club_id if self.order_id else None
|
|
validate_club_scope(self, club_id, same_club_fields=("product", "team"), member_fields=("beneficiary",))
|
|
|
|
|
|
class Discount(ClubScopedModel):
|
|
name = models.CharField(_("name"), max_length=255)
|
|
slug = models.SlugField(_("slug"), max_length=255, blank=True)
|
|
description = models.TextField(_("description"), blank=True)
|
|
|
|
discount_type = models.CharField(_("discount type"), max_length=255, choices=DiscountType.choices, default=DiscountType.PERCENTAGE)
|
|
discount_amount = models.DecimalField(_("discount amount"), max_digits=10, decimal_places=2, default=0)
|
|
|
|
is_active = models.BooleanField(_("is active?"), default=True)
|
|
|
|
slug_source = "name"
|
|
|
|
class Meta:
|
|
verbose_name = _("order discount type")
|
|
verbose_name_plural = _("order discount types")
|
|
ordering = ["name"]
|
|
constraints = [
|
|
UniqueConstraint(fields=["club", "slug"], name="unique_order_discount_type_slug_per_club"),
|
|
]
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
|
|
class AppliedDiscount(UUIDModel):
|
|
order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name="applied_discounts", verbose_name=_("order"))
|
|
discount = models.ForeignKey(Discount, on_delete=models.PROTECT, related_name="applied_discounts", verbose_name=_("discount"))
|
|
|
|
discount_type = models.CharField(_("discount type"), max_length=255, choices=DiscountType.choices, default=DiscountType.PERCENTAGE)
|
|
discount_amount = models.DecimalField(_("discount amount"), max_digits=10, decimal_places=2)
|
|
description = models.TextField(_("description"), blank=True)
|
|
|
|
applied_by = models.ForeignKey(Member, on_delete=models.PROTECT, related_name="applied_discounts", verbose_name=_("applied by"), blank=True, null=True)
|
|
|
|
class Meta:
|
|
verbose_name = _("applied discount")
|
|
verbose_name_plural = _("applied discounts")
|
|
constraints = [
|
|
UniqueConstraint(fields=["order", "discount"], name="unique_discount_per_order"),
|
|
]
|
|
|
|
def __str__(self):
|
|
suffix = "%" if self.discount_type == DiscountType.PERCENTAGE else ""
|
|
return f"{self.discount} - {self.discount_amount}{suffix}"
|
|
|
|
def clean(self):
|
|
club_id = self.order.club_id if self.order_id else None
|
|
validate_club_scope(self, club_id, same_club_fields=("discount",), member_fields=("applied_by",))
|
|
|
|
|
|
class Payment(UUIDModel):
|
|
class PaymentMethod(models.TextChoices):
|
|
CREDIT_CARD = "credit_card", _("Credit card")
|
|
BANK_TRANSFER = "bank_transfer", _("Bank transfer")
|
|
CASH = "cash", _("Cash")
|
|
|
|
class PaymentStatus(models.TextChoices):
|
|
PENDING = "pending", _("Pending")
|
|
CONFIRMED = "confirmed", _("Confirmed")
|
|
FAILED = "failed", _("Failed")
|
|
REFUNDED = "refunded", _("Refunded")
|
|
|
|
order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name="payments", verbose_name=_("order"))
|
|
amount = models.DecimalField(_("amount"), max_digits=10, decimal_places=2)
|
|
method = models.CharField(_("method"), max_length=255, choices=PaymentMethod.choices, default=PaymentMethod.CREDIT_CARD)
|
|
status = models.CharField(_("status"), max_length=255, choices=PaymentStatus.choices, default=PaymentStatus.PENDING)
|
|
reference = models.CharField(_("reference"), max_length=255, blank=True)
|
|
paid_at = models.DateTimeField(_("paid at"), blank=True, null=True)
|
|
|
|
class Meta:
|
|
verbose_name = _("payment")
|
|
verbose_name_plural = _("payments")
|
|
|
|
def __str__(self):
|
|
return f"{self.order} - {self.status}"
|
|
|
|
|
|
class Invoice(ClubScopedModel):
|
|
number = models.CharField(_("number"), max_length=255, blank=True)
|
|
order = models.OneToOneField(Order, on_delete=models.CASCADE, related_name="invoice", verbose_name=_("order"))
|
|
|
|
issued_at = models.DateTimeField(_("issued at"), auto_now_add=True)
|
|
due_date = models.DateField(_("due date"), blank=True, null=True)
|
|
|
|
billing_snapshot = models.JSONField(_("billing snapshot"), blank=True, null=True) # Name/address information
|
|
pdf = models.FileField(_("PDF"), upload_to="invoices", blank=True, null=True)
|
|
|
|
class Meta:
|
|
verbose_name = _("invoice")
|
|
verbose_name_plural = _("invoices")
|
|
ordering = ["-issued_at"]
|
|
constraints = [
|
|
UniqueConstraint(fields=["club", "number"], name="unique_invoice_number_per_club"),
|
|
]
|
|
|
|
def __str__(self):
|
|
return self.number
|
|
|
|
def clean(self):
|
|
validate_club_scope(self, self.club_id, same_club_fields=("order",))
|
|
|
|
def generate_number(self):
|
|
"""Next per-club invoice number for the current year: ``INV-<year>-<seq>``."""
|
|
return next_scoped_number(self, "INV")
|
|
|
|
def save(self, *args, **kwargs):
|
|
return save_with_number(self, lambda: super(Invoice, self).save(*args, **kwargs))
|