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}"