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

119 lines
4.0 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from decimal import Decimal
from django.db import models
from django.urls import reverse
from django.utils.text import slugify
from core.models import TimeStampedModel, UUIDPrimaryKeyModel
class Product(UUIDPrimaryKeyModel, TimeStampedModel):
class Fulfillment(models.TextChoices):
STOCKED = "stocked", "On-hand stock"
MADE_TO_ORDER = "made_to_order", "Made to order"
name = models.CharField(max_length=200)
slug = models.SlugField(max_length=220, unique=True)
sku = models.CharField(max_length=64, unique=True)
description = models.TextField(blank=True)
price = models.DecimalField(max_digits=10, decimal_places=2)
currency = models.CharField(max_length=8, default="usd")
fulfillment = models.CharField(
max_length=16,
choices=Fulfillment.choices,
default=Fulfillment.STOCKED,
)
stock_qty = models.IntegerField(default=0)
print_minutes = models.PositiveIntegerField(
default=0,
help_text="Estimated print time per unit (made-to-order).",
)
filament_grams = models.PositiveIntegerField(default=0)
is_published = models.BooleanField(default=False)
track_inventory = models.BooleanField(default=True)
class Meta:
ordering = ["name"]
def __str__(self) -> str:
return f"{self.name} ({self.sku})"
def get_absolute_url(self) -> str:
return reverse("shop:detail", kwargs={"slug": self.slug})
@property
def amount_cents(self) -> int:
return int((self.price * Decimal("100")).quantize(Decimal("1")))
def save(self, *args, **kwargs):
if not self.slug:
base = slugify(self.name)[:200] or "product"
slug = base
n = 2
while Product.objects.filter(slug=slug).exclude(pk=self.pk).exists():
slug = f"{base}-{n}"
n += 1
self.slug = slug
if not self.sku:
self.sku = (self.slug or "sku").upper().replace("-", "")[:64]
super().save(*args, **kwargs)
class Order(UUIDPrimaryKeyModel, TimeStampedModel):
class Status(models.TextChoices):
DRAFT = "draft", "Draft"
OPEN = "open", "Open"
PAID = "paid", "Paid"
FULFILLED = "fulfilled", "Fulfilled"
CANCELLED = "cancelled", "Cancelled"
number = models.CharField(max_length=32, unique=True)
email = models.EmailField()
customer_name = models.CharField(max_length=200, blank=True)
status = models.CharField(
max_length=16, choices=Status.choices, default=Status.DRAFT
)
amount = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal("0"))
currency = models.CharField(max_length=8, default="usd")
shipping_address = models.JSONField(default=dict, blank=True)
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)
notes = models.TextField(blank=True)
class Meta:
ordering = ["-created_at"]
def __str__(self) -> str:
return f"{self.number} · {self.email} · {self.amount}"
@property
def amount_cents(self) -> int:
return int((self.amount * Decimal("100")).quantize(Decimal("1")))
class OrderItem(UUIDPrimaryKeyModel, TimeStampedModel):
order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name="items")
product = models.ForeignKey(
Product,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="order_items",
)
name = models.CharField(max_length=200)
sku = models.CharField(max_length=64)
quantity = models.PositiveIntegerField(default=1)
unit_price = models.DecimalField(max_digits=10, decimal_places=2)
print_minutes = models.PositiveIntegerField(default=0)
class Meta:
ordering = ["created_at"]
def __str__(self) -> str:
return f"{self.quantity}× {self.name}"
@property
def line_total(self) -> Decimal:
return self.unit_price * self.quantity