generated from westfarn/web_django_template
CI / test (pull_request) Successful in 35s
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.
290 lines
9.3 KiB
Python
290 lines
9.3 KiB
Python
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}"
|