Files
westfarn 9cdce7a897 Add shop, POS sync, event ticketing, and shipping catalog apps (#6)
## Summary
- Closes #5
- Optional `shop`, `pos_sync`, `events`, and `shipping` apps gated by `FEATURE_*` flags
- Deps match the catalog: shop/events need email + Stripe; POS/shipping need shop
- Portal inventory, POS webhooks, capacity tickets, EasyPost/Pirate Ship shipping

## Test plan
- [ ] `manage.py test` (152 passed locally)
- [ ] Shop cart + paid order decrements stocked inventory
- [ ] POS inbound webhook decrements SKU; paid order queues outbound reserve
- [ ] Event capacity blocks overbook; paid order emails ticket codes
- [ ] Shipping stub label + Pirate Ship CSV of unshipped paid orders
- [ ] `validate-env.sh` rejects shop without email/payments, POS/shipping without shop

Reviewed-on: #6
2026-09-06 14:00:22 -07:00

112 lines
3.6 KiB
Python

from decimal import Decimal
from django.db import models
from django.urls import reverse
from django.utils import timezone
from django.utils.text import slugify
from core.models import TimeStampedModel, UUIDPrimaryKeyModel
class Event(UUIDPrimaryKeyModel, TimeStampedModel):
title = models.CharField(max_length=200)
slug = models.SlugField(max_length=220, unique=True)
description = models.TextField(blank=True)
starts_at = models.DateTimeField()
venue = models.CharField(max_length=200, blank=True)
capacity = models.PositiveIntegerField(default=0)
price = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal("0"))
currency = models.CharField(max_length=8, default="usd")
is_published = models.BooleanField(default=False)
class Meta:
ordering = ["starts_at"]
def __str__(self) -> str:
return self.title
def get_absolute_url(self) -> str:
return reverse("events:detail", kwargs={"slug": self.slug})
def save(self, *args, **kwargs):
if not self.slug:
base = slugify(self.title)[:200] or "event"
slug = base
n = 2
while Event.objects.filter(slug=slug).exclude(pk=self.pk).exists():
slug = f"{base}-{n}"
n += 1
self.slug = slug
super().save(*args, **kwargs)
def tickets_held(self, *, exclude_order_id=None) -> int:
qs = self.ticket_orders.filter(
status__in=[
TicketOrder.Status.DRAFT,
TicketOrder.Status.OPEN,
TicketOrder.Status.PAID,
]
)
if exclude_order_id:
qs = qs.exclude(pk=exclude_order_id)
return qs.aggregate(total=models.Sum("quantity"))["total"] or 0
@property
def tickets_sold(self) -> int:
return self.tickets_held()
@property
def seats_remaining(self) -> int | None:
if not self.capacity:
return None
return max(self.capacity - self.tickets_sold, 0)
@property
def is_upcoming(self) -> bool:
return self.starts_at >= timezone.now()
class TicketOrder(UUIDPrimaryKeyModel, TimeStampedModel):
class Status(models.TextChoices):
DRAFT = "draft", "Draft"
OPEN = "open", "Open"
PAID = "paid", "Paid"
CANCELLED = "cancelled", "Cancelled"
number = models.CharField(max_length=32, unique=True)
event = models.ForeignKey(
Event, on_delete=models.PROTECT, related_name="ticket_orders"
)
email = models.EmailField()
customer_name = models.CharField(max_length=200, blank=True)
quantity = models.PositiveIntegerField(default=1)
amount = models.DecimalField(max_digits=10, decimal_places=2)
currency = models.CharField(max_length=8, default="usd")
status = models.CharField(
max_length=16, choices=Status.choices, default=Status.DRAFT
)
stripe_checkout_session_id = models.CharField(max_length=255, blank=True)
hosted_checkout_url = models.URLField(blank=True)
paid_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ["-created_at"]
def __str__(self) -> str:
return f"{self.number} · {self.event}"
class Ticket(UUIDPrimaryKeyModel, TimeStampedModel):
order = models.ForeignKey(
TicketOrder, on_delete=models.CASCADE, related_name="tickets"
)
event = models.ForeignKey(Event, on_delete=models.PROTECT, related_name="tickets")
code = models.CharField(max_length=16, unique=True)
attendee_name = models.CharField(max_length=200, blank=True)
class Meta:
ordering = ["created_at"]
def __str__(self) -> str:
return self.code