Files
print_forge/site/shop/models.py
T
westfarn c9b81ceed7
CI / test (pull_request) Successful in 35s
Add customer accounts, shipment tracking, and purchase reviews.
Shoppers can register, save shipping details, and view order history while cards stay on Stripe. EasyPost tracker updates (including numbers from Pirate Ship) and 1–5 star reviews are limited to buyers. The contact form now only asks for email and a message.
2026-09-07 06:37:52 -05:00

290 lines
9.3 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.conf import settings
from django.core.validators import MaxValueValidator, MinValueValidator
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)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="shop_orders",
)
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
class ProductReview(UUIDPrimaryKeyModel, TimeStampedModel):
product = models.ForeignKey(
Product, on_delete=models.CASCADE, related_name="reviews"
)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="product_reviews",
)
order = models.ForeignKey(
Order, on_delete=models.CASCADE, related_name="reviews"
)
rating = models.PositiveSmallIntegerField(
validators=[MinValueValidator(1), MaxValueValidator(5)]
)
title = models.CharField(max_length=120, blank=True)
body = models.TextField(blank=True)
class Meta:
ordering = ["-created_at"]
constraints = [
models.UniqueConstraint(
fields=["user", "product"],
name="shop_review_user_product",
),
models.CheckConstraint(
condition=models.Q(rating__gte=1) & models.Q(rating__lte=5),
name="shop_review_rating_range",
),
]
def __str__(self) -> str:
return f"{self.rating}{self.product.name}"