Files
print_forge/site/shop/models.py
T
westfarn dd37a2a268
Deploy Beta / docker (push) Successful in 37s
Deploy Beta / deploy-beta (push) Successful in 2m21s
Deploy Beta / unit-tests (push) Successful in 39s
Customer accounts, order tracking, and purchase reviews (#8)
## Summary
- Slim the public contact form to email, interest, and message. Name, phone, and address live on the customer profile instead.
- Customers can register, sign in, save shipping details, and view order history. Logged-in checkout creates a Stripe Customer and saves cards on Stripe (`setup_future_usage`); we only store `stripe_customer_id`.
- Shipment tracking: EasyPost tracker lookup + webhook, plus paste-in numbers from Pirate Ship/Shippo. Customers see carrier status on their orders; `dispatch_due` refreshes open shipments.
- Product reviews (1–5) only after a paid/fulfilled purchase of that product.

Fixes #7

## Test plan
- [ ] Contact form submits with only email + message; extra name/phone/address fields are ignored
- [ ] Register, sign in, save profile (name/phone/shipping)
- [ ] Guest checkout still works; after signup, prior orders with that email show in history
- [ ] Logged-in checkout prefills shipping and does not collect card data locally
- [ ] Portal: buy label or paste a Pirate Ship tracking number, confirm status/events; customer order page shows tracking
- [ ] Product page: non-buyers cannot review; buyers can leave one 1–5 star review
- [ ] Non-staff users hitting `/portal/` redirect to `/account/`

Reviewed-on: #8
2026-09-07 04:53:41 -07: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}"