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 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) 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 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 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", ) name = models.CharField(max_length=200) sku = models.CharField(max_length=64) 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}"