Stand up Print Forge as a 3D-printed toy shop with color variants and printer photography.

Replaces the client_site template branding, adds shop/shipping, and points beta CI at master for easy deploy.

Closes #1
This commit is contained in:
2026-09-06 08:28:04 -05:00
parent 8a97e3fbe2
commit b2029b6c0f
336 changed files with 9241 additions and 2537 deletions
+245
View File
@@ -0,0 +1,245 @@
from decimal import Decimal
from django.db import models
from django.urls import reverse
from django.utils.text import slugify
from core.models import StoredFile, 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)
image = models.ForeignKey(
StoredFile,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="shop_products",
help_text="Listing photo; synced from the first gallery image.",
)
stl = models.ForeignKey(
StoredFile,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="shop_product_stls",
help_text="Print mesh stored as a database blob (not filesystem media).",
)
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 image_url(self) -> str:
if not self.image_id:
return ""
return reverse("core:stored_file", kwargs={"pk": self.image_id})
@property
def stl_url(self) -> str:
if not self.stl_id:
return ""
return reverse("core:stored_file", kwargs={"pk": self.stl_id})
@property
def catalog_images(self) -> list["ProductImage"]:
return [item for item in self.images.all() if item.color_id is None]
@property
def display_stock_qty(self) -> int:
colors = list(self.colors.all())
if colors:
return sum(item.stock_qty for item in colors)
return self.stock_qty
def photos_for(self, color: "ProductColor | None" = None) -> list["ProductImage"]:
images = list(self.images.all())
if color is not None:
colored = [item for item in images if item.color_id == color.pk]
if colored:
return colored
return [item for item in images if item.color_id is None]
def photo_urls_for(self, color: "ProductColor | None" = None) -> list[str]:
photos = self.photos_for(color)
if photos:
return [item.url for item in photos]
if self.image_id:
return [self.image_url]
return []
def gallery_items(self, color: "ProductColor | None" = None) -> list[dict]:
photos = self.photo_urls_for(color)
items: list[dict] = []
if photos:
items.append({"kind": "photo", "url": photos[0]})
if self.stl_id:
items.append({"kind": "model", "url": self.stl_url})
items.extend({"kind": "photo", "url": url} for url in photos[1:])
elif self.stl_id:
items.append({"kind": "model", "url": self.stl_url})
return items
@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 ProductColor(UUIDPrimaryKeyModel, TimeStampedModel):
product = models.ForeignKey(
Product, on_delete=models.CASCADE, related_name="colors"
)
name = models.CharField(max_length=64)
hex = models.CharField(max_length=7, default="#808080")
stock_qty = models.IntegerField(default=0)
sort_order = models.PositiveIntegerField(default=0)
class Meta:
ordering = ["sort_order", "name"]
def __str__(self) -> str:
return f"{self.product.name} · {self.name}"
@property
def image_url(self) -> str:
photos = list(self.images.all())
if photos:
return photos[0].url
return ""
class ProductImage(UUIDPrimaryKeyModel, TimeStampedModel):
product = models.ForeignKey(
Product, on_delete=models.CASCADE, related_name="images"
)
color = models.ForeignKey(
ProductColor,
null=True,
blank=True,
on_delete=models.CASCADE,
related_name="images",
)
file = models.ForeignKey(
StoredFile,
on_delete=models.CASCADE,
related_name="shop_gallery_images",
)
sort_order = models.PositiveIntegerField(default=0)
class Meta:
ordering = ["sort_order", "created_at"]
def __str__(self) -> str:
return self.file.filename or str(self.pk)
@property
def url(self) -> str:
return reverse("core:stored_file", kwargs={"pk": self.file_id})
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",
)
color = models.ForeignKey(
ProductColor,
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)
color_name = models.CharField(max_length=64, blank=True)
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