Files
print_forge/site/shop/models.py
T
westfarn 1ca5a757d9
Deploy Beta / unit-tests (push) Successful in 31s
Deploy Beta / docker (push) Failing after 34s
Deploy Beta / deploy-beta (push) Skipped
Ship Print Forge shop site (colors, photos, 3D viewer) (#2)
## Summary

- Rebrand the Django client template as Print Forge (`client_site` → `print_forge`) with shop + shipping enabled.
- Product listings support multiple photos, color variants (shared price/description/STL, per-color stock and photos), and a photo-first / 3D-second gallery.
- Public pages use 3D printer / printed-toy photography instead of leftover t-shirt mockups; beta CI deploys on `master`.

Closes #1
Infra: [server-infra#27](ai_ml_operations/server-infra#27) (easy deploy beta).

## Test plan

- [ ] Product page shows photo first, 3D model second; color swatches swap photos and stock
- [ ] Portal can upload multiple photos and per-color qty/images; STL stays shared
- [ ] Public home/about/gallery have no t-shirt mockups
- [ ] `manage.py test` passes
- [ ] After server-infra#27: beta deploy to `print-forge-preview.aimloperations.com`

Reviewed-on: #2
2026-09-06 18:40:42 -07:00

246 lines
7.9 KiB
Python
Raw 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 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