generated from westfarn/web_django_template
Ship Print Forge shop site (colors, photos, 3D viewer) (#2)
## Summary - Rebrand the Django client template as Print Forge (`client_site` → `print_forge`) with shop + shipping enabled. - Product listings support multiple photos, color variants (shared price/description/STL, per-color stock and photos), and a photo-first / 3D-second gallery. - Public pages use 3D printer / printed-toy photography instead of leftover t-shirt mockups; beta CI deploys on `master`. Closes #1 Infra: [server-infra#27](ai_ml_operations/server-infra#27) (easy deploy beta). ## Test plan - [ ] Product page shows photo first, 3D model second; color swatches swap photos and stock - [ ] Portal can upload multiple photos and per-color qty/images; STL stays shared - [ ] Public home/about/gallery have no t-shirt mockups - [ ] `manage.py test` passes - [ ] After server-infra#27: beta deploy to `print-forge-preview.aimloperations.com` Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.text import slugify
|
||||
|
||||
from core.models import TimeStampedModel, UUIDPrimaryKeyModel
|
||||
|
||||
|
||||
class Event(UUIDPrimaryKeyModel, TimeStampedModel):
|
||||
title = models.CharField(max_length=200)
|
||||
slug = models.SlugField(max_length=220, unique=True)
|
||||
description = models.TextField(blank=True)
|
||||
starts_at = models.DateTimeField()
|
||||
venue = models.CharField(max_length=200, blank=True)
|
||||
capacity = models.PositiveIntegerField(default=0)
|
||||
price = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal("0"))
|
||||
currency = models.CharField(max_length=8, default="usd")
|
||||
is_published = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
ordering = ["starts_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.title
|
||||
|
||||
def get_absolute_url(self) -> str:
|
||||
return reverse("events:detail", kwargs={"slug": self.slug})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.slug:
|
||||
base = slugify(self.title)[:200] or "event"
|
||||
slug = base
|
||||
n = 2
|
||||
while Event.objects.filter(slug=slug).exclude(pk=self.pk).exists():
|
||||
slug = f"{base}-{n}"
|
||||
n += 1
|
||||
self.slug = slug
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def tickets_held(self, *, exclude_order_id=None) -> int:
|
||||
qs = self.ticket_orders.filter(
|
||||
status__in=[
|
||||
TicketOrder.Status.DRAFT,
|
||||
TicketOrder.Status.OPEN,
|
||||
TicketOrder.Status.PAID,
|
||||
]
|
||||
)
|
||||
if exclude_order_id:
|
||||
qs = qs.exclude(pk=exclude_order_id)
|
||||
return qs.aggregate(total=models.Sum("quantity"))["total"] or 0
|
||||
|
||||
@property
|
||||
def tickets_sold(self) -> int:
|
||||
return self.tickets_held()
|
||||
|
||||
@property
|
||||
def seats_remaining(self) -> int | None:
|
||||
if not self.capacity:
|
||||
return None
|
||||
return max(self.capacity - self.tickets_sold, 0)
|
||||
|
||||
@property
|
||||
def is_upcoming(self) -> bool:
|
||||
return self.starts_at >= timezone.now()
|
||||
|
||||
|
||||
class TicketOrder(UUIDPrimaryKeyModel, TimeStampedModel):
|
||||
class Status(models.TextChoices):
|
||||
DRAFT = "draft", "Draft"
|
||||
OPEN = "open", "Open"
|
||||
PAID = "paid", "Paid"
|
||||
CANCELLED = "cancelled", "Cancelled"
|
||||
|
||||
number = models.CharField(max_length=32, unique=True)
|
||||
event = models.ForeignKey(
|
||||
Event, on_delete=models.PROTECT, related_name="ticket_orders"
|
||||
)
|
||||
email = models.EmailField()
|
||||
customer_name = models.CharField(max_length=200, blank=True)
|
||||
quantity = models.PositiveIntegerField(default=1)
|
||||
amount = models.DecimalField(max_digits=10, decimal_places=2)
|
||||
currency = models.CharField(max_length=8, default="usd")
|
||||
status = models.CharField(
|
||||
max_length=16, choices=Status.choices, default=Status.DRAFT
|
||||
)
|
||||
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)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.number} · {self.event}"
|
||||
|
||||
|
||||
class Ticket(UUIDPrimaryKeyModel, TimeStampedModel):
|
||||
order = models.ForeignKey(
|
||||
TicketOrder, on_delete=models.CASCADE, related_name="tickets"
|
||||
)
|
||||
event = models.ForeignKey(Event, on_delete=models.PROTECT, related_name="tickets")
|
||||
code = models.CharField(max_length=16, unique=True)
|
||||
attendee_name = models.CharField(max_length=200, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["created_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.code
|
||||
Reference in New Issue
Block a user