Ship Print Forge shop site (colors, photos, 3D viewer) (#2)
Deploy Beta / unit-tests (push) Successful in 31s
Deploy Beta / docker (push) Failing after 34s
Deploy Beta / deploy-beta (push) Skipped

## 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:
2026-09-06 18:40:42 -07:00
parent 8a97e3fbe2
commit 1ca5a757d9
339 changed files with 9761 additions and 2541 deletions
View File
+37
View File
@@ -0,0 +1,37 @@
from django.contrib import admin
from shop.models import Order, OrderItem, Product, ProductColor, ProductImage
class ProductColorInline(admin.TabularInline):
model = ProductColor
extra = 0
class ProductImageInline(admin.TabularInline):
model = ProductImage
extra = 0
@admin.register(Product)
class ProductAdmin(admin.ModelAdmin):
list_display = ("name", "sku", "price", "stock_qty", "fulfillment", "is_published")
list_filter = ("fulfillment", "is_published")
search_fields = ("name", "sku")
prepopulated_fields = {"slug": ("name",)}
inlines = [ProductColorInline, ProductImageInline]
class OrderItemInline(admin.TabularInline):
model = OrderItem
extra = 0
fields = ("name", "sku", "color_name", "quantity", "unit_price")
readonly_fields = ("color_name",)
@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
list_display = ("number", "email", "amount", "status", "created_at")
list_filter = ("status",)
search_fields = ("number", "email", "customer_name")
inlines = [OrderItemInline]
+12
View File
@@ -0,0 +1,12 @@
from django.apps import AppConfig
class ShopConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "shop"
verbose_name = "E-commerce"
def ready(self):
from shop import hooks
hooks.register()
+57
View File
@@ -0,0 +1,57 @@
from core.registry import (
register_dashboard_collector,
register_feature,
register_portal_nav,
register_public_nav,
)
def register() -> None:
register_feature("shop")
register_public_nav(section="shop", label="Shop", url_name="shop:list", order=30)
register_portal_nav(
section="shop_sales",
label="Sales",
url_name="shop_portal:sales",
group="Retail",
order=5,
)
register_portal_nav(
section="shop_products",
label="Products",
url_name="shop_portal:product_list",
group="Retail",
order=10,
)
register_portal_nav(
section="shop_orders",
label="Orders",
url_name="shop_portal:order_list",
group="Retail",
order=20,
)
register_dashboard_collector(_dashboard)
def _dashboard(request) -> dict:
from django.db.models import Q
from shop.models import Order, Product
from shop.stats import sales_summary
sales = sales_summary()
return {
"open_shop_orders": Order.objects.filter(
status__in=[Order.Status.OPEN, Order.Status.PAID]
).count(),
"low_stock_products": Product.objects.filter(
is_published=True,
track_inventory=True,
fulfillment=Product.Fulfillment.STOCKED,
)
.filter(Q(colors__stock_qty__lte=3) | Q(colors__isnull=True, stock_qty__lte=3))
.distinct()
.count(),
"shop_sales_30d": sales["order_count"],
"shop_revenue_30d": sales["revenue"],
}
+121
View File
@@ -0,0 +1,121 @@
"""Turn a raw product photo into a centered cutout on a square canvas."""
from __future__ import annotations
import logging
from io import BytesIO
from pathlib import PurePosixPath
from PIL import Image, ImageOps
logger = logging.getLogger(__name__)
CANVAS_SIZE = 1200
CUTOUT_MAX_SIDE = 1600
PADDING = 0.1
ALPHA_THRESHOLD = 128
_session = None
def _rembg_session():
global _session
if _session is None:
from rembg import new_session
_session = new_session("u2net")
return _session
def open_image(data: bytes) -> Image.Image:
image = Image.open(BytesIO(data))
image.load()
image = ImageOps.exif_transpose(image) or image
if getattr(image, "n_frames", 1) > 1:
image.seek(0)
image = image.copy()
return image
def downscale(image: Image.Image, max_side: int) -> Image.Image:
width, height = image.size
longest = max(width, height)
if longest <= max_side:
return image
scale = max_side / longest
return image.resize(
(max(1, int(width * scale)), max(1, int(height * scale))),
Image.Resampling.LANCZOS,
)
def harden_cutout(image: Image.Image, threshold: int = ALPHA_THRESHOLD) -> Image.Image:
"""Drop faint shadow/halo pixels rembg leaves around the subject."""
rgba = image.convert("RGBA")
red, green, blue, alpha = rgba.split()
alpha = alpha.point(lambda value: value if value >= threshold else 0)
return Image.merge("RGBA", (red, green, blue, alpha))
def cutout_subject(image: Image.Image) -> Image.Image:
"""Knock out the background. Falls back to the original on failure."""
rgba = downscale(image.convert("RGBA"), CUTOUT_MAX_SIDE)
try:
from rembg import remove
buf = BytesIO()
rgba.save(buf, format="PNG")
result = remove(
buf.getvalue(),
session=_rembg_session(),
post_process_mask=True,
)
return harden_cutout(Image.open(BytesIO(result)).convert("RGBA"))
except Exception:
logger.exception("product photo cutout failed; using original")
return rgba
def center_on_square(
image: Image.Image,
*,
size: int = CANVAS_SIZE,
padding: float = PADDING,
) -> Image.Image:
rgba = image.convert("RGBA")
alpha = rgba.getchannel("A")
mask = alpha.point(lambda value: 255 if value > ALPHA_THRESHOLD else 0)
bbox = mask.getbbox()
canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0))
if bbox is None:
return canvas
cropped = rgba.crop(bbox)
max_inner = max(1, int(size * (1 - 2 * padding)))
width, height = cropped.size
scale = min(max_inner / width, max_inner / height)
new_w = max(1, int(width * scale))
new_h = max(1, int(height * scale))
resized = cropped.resize((new_w, new_h), Image.Resampling.LANCZOS)
x = (size - new_w) // 2
y = (size - new_h) // 2
canvas.paste(resized, (x, y), resized)
return canvas
def encode_png(image: Image.Image) -> bytes:
buf = BytesIO()
image.save(buf, format="PNG", optimize=True)
return buf.getvalue()
def prepare_product_photo(data: bytes) -> tuple[bytes, str]:
"""Return a PNG cutout on a square canvas, plus content type."""
image = open_image(data)
cut = cutout_subject(image)
framed = center_on_square(cut)
return encode_png(framed), "image/png"
def product_image_filename(original: str) -> str:
stem = PurePosixPath(original or "product").stem.strip() or "product"
return f"{stem[:200]}.png"
+80
View File
@@ -0,0 +1,80 @@
# Generated by Django 6.1 on 2026-09-06 11:17
import django.db.models.deletion
import uuid
from decimal import Decimal
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Order',
fields=[
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('number', models.CharField(max_length=32, unique=True)),
('email', models.EmailField(max_length=254)),
('customer_name', models.CharField(blank=True, max_length=200)),
('status', models.CharField(choices=[('draft', 'Draft'), ('open', 'Open'), ('paid', 'Paid'), ('fulfilled', 'Fulfilled'), ('cancelled', 'Cancelled')], default='draft', max_length=16)),
('amount', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=10)),
('currency', models.CharField(default='usd', max_length=8)),
('shipping_address', models.JSONField(blank=True, default=dict)),
('stripe_checkout_session_id', models.CharField(blank=True, max_length=255)),
('hosted_checkout_url', models.URLField(blank=True)),
('paid_at', models.DateTimeField(blank=True, null=True)),
('notes', models.TextField(blank=True)),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='Product',
fields=[
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('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(decimal_places=2, max_digits=10)),
('currency', models.CharField(default='usd', max_length=8)),
('fulfillment', models.CharField(choices=[('stocked', 'On-hand stock'), ('made_to_order', 'Made to order')], default='stocked', max_length=16)),
('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)),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='OrderItem',
fields=[
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=200)),
('sku', models.CharField(max_length=64)),
('quantity', models.PositiveIntegerField(default=1)),
('unit_price', models.DecimalField(decimal_places=2, max_digits=10)),
('print_minutes', models.PositiveIntegerField(default=0)),
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='shop.order')),
('product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='order_items', to='shop.product')),
],
options={
'ordering': ['created_at'],
},
),
]
@@ -0,0 +1,72 @@
# Generated by Django 6.1
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0002_storedfile_product_image"),
("shop", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="product",
name="image",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="shop_products",
to="core.storedfile",
),
),
migrations.CreateModel(
name="ProductColor",
fields=[
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("name", models.CharField(max_length=64)),
("hex", models.CharField(default="#808080", max_length=7)),
("sort_order", models.PositiveIntegerField(default=0)),
(
"product",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="colors",
to="shop.product",
),
),
],
options={
"ordering": ["sort_order", "name"],
},
),
migrations.AddField(
model_name="orderitem",
name="color",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="order_items",
to="shop.productcolor",
),
),
migrations.AddField(
model_name="orderitem",
name="color_name",
field=models.CharField(blank=True, max_length=64),
),
]
+25
View File
@@ -0,0 +1,25 @@
# Generated by Django 6.1
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0003_storedfile_product_stl"),
("shop", "0002_product_image_and_color"),
]
operations = [
migrations.AddField(
model_name="product",
name="stl",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="shop_product_stls",
to="core.storedfile",
),
),
]
@@ -0,0 +1,97 @@
# Generated by Django 6.1
import uuid
import django.db.models.deletion
from django.db import migrations, models
def copy_listing_image_and_color_stock(apps, schema_editor):
Product = apps.get_model("shop", "Product")
ProductImage = apps.get_model("shop", "ProductImage")
ProductColor = apps.get_model("shop", "ProductColor")
for product in Product.objects.exclude(image_id=None):
ProductImage.objects.create(
product=product,
color=None,
file_id=product.image_id,
sort_order=0,
)
seen = set()
for product in Product.objects.filter(colors__isnull=False).distinct():
if product.pk in seen:
continue
seen.add(product.pk)
first = (
ProductColor.objects.filter(product_id=product.pk)
.order_by("sort_order", "name")
.first()
)
if first and first.stock_qty == 0 and product.stock_qty:
first.stock_qty = product.stock_qty
first.save(update_fields=["stock_qty"])
class Migration(migrations.Migration):
dependencies = [
("core", "0003_storedfile_product_stl"),
("shop", "0003_product_stl"),
]
operations = [
migrations.AddField(
model_name="productcolor",
name="stock_qty",
field=models.IntegerField(default=0),
),
migrations.CreateModel(
name="ProductImage",
fields=[
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("sort_order", models.PositiveIntegerField(default=0)),
(
"color",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="images",
to="shop.productcolor",
),
),
(
"file",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="shop_gallery_images",
to="core.storedfile",
),
),
(
"product",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="images",
to="shop.product",
),
),
],
options={
"ordering": ["sort_order", "created_at"],
},
),
migrations.RunPython(
copy_listing_image_and_color_stock,
migrations.RunPython.noop,
),
]
View File
+245
View File
@@ -0,0 +1,245 @@
from decimal import Decimal
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)
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
+20
View File
@@ -0,0 +1,20 @@
from django.urls import path
from shop import views
app_name = "shop_portal"
urlpatterns = [
path("sales/", views.portal_sales, name="sales"),
path("products/", views.portal_product_list, name="product_list"),
path("products/new/", views.portal_product_edit, name="product_new"),
path("products/<uuid:pk>/", views.portal_product_edit, name="product_edit"),
path(
"products/<uuid:pk>/stock/",
views.portal_stock_adjust,
name="product_stock",
),
path("orders/", views.portal_order_list, name="order_list"),
path("orders/<uuid:pk>/", views.portal_order_detail, name="order_detail"),
path("webhooks/stripe/", views.stripe_webhook, name="stripe_webhook"),
]
+16
View File
@@ -0,0 +1,16 @@
from django.urls import path
from shop import views
app_name = "shop"
urlpatterns = [
path("", views.product_list, name="list"),
path("cart/", views.cart_view, name="cart"),
path("cart/add/<slug:slug>/", views.cart_add, name="cart_add"),
path("cart/update/<slug:slug>/", views.cart_update, name="cart_update"),
path("checkout/", views.checkout, name="checkout"),
path("checkout/<uuid:pk>/success/", views.checkout_success, name="checkout_success"),
path("checkout/<uuid:pk>/cancel/", views.checkout_cancel, name="checkout_cancel"),
path("<slug:slug>/", views.product_detail, name="detail"),
]
+610
View File
@@ -0,0 +1,610 @@
"""Catalog, cart, inventory, and Stripe checkout for FEATURE_SHOP."""
from __future__ import annotations
import logging
import re
import struct
from datetime import date
from decimal import Decimal
from django.conf import settings
from django.db import transaction
from django.db.models import F, Sum
from django.utils import timezone
from core.models import StoredFile
from shop.models import Order, OrderItem, Product, ProductColor, ProductImage
logger = logging.getLogger(__name__)
CART_SESSION_KEY = "shop_cart"
_ALLOWED_IMAGE_TYPES = frozenset(
{"image/jpeg", "image/png", "image/gif", "image/webp"}
)
_MAX_IMAGE_BYTES = 15 * 1024 * 1024
_ALLOWED_STL_TYPES = frozenset(
{
"model/stl",
"model/x.stl-ascii",
"model/x.stl-binary",
"application/sla",
"application/vnd.ms-pki.stl",
"application/octet-stream",
"",
}
)
_MAX_STL_BYTES = 25 * 1024 * 1024
_HEX_RE = re.compile(r"^#?[0-9A-Fa-f]{6}$")
class ShopError(RuntimeError):
pass
def _read_upload_bytes(upload) -> bytes:
"""Copy the request upload into memory, then drop any temp-file spool."""
try:
return upload.read()
finally:
closer = getattr(upload, "close", None)
if callable(closer):
closer()
def _stripe():
secret = (settings.STRIPE_SECRET_KEY or "").strip()
if not secret:
raise ShopError("STRIPE_SECRET_KEY is not configured")
try:
import stripe
except ImportError as exc:
raise ShopError("stripe package is not installed") from exc
stripe.api_key = secret
return stripe
def next_order_number() -> str:
today = date.today().strftime("%Y%m%d")
prefix = f"ORD-{today}-"
existing = Order.objects.filter(number__startswith=prefix).count()
return f"{prefix}{existing + 1:03d}"
def get_cart(session) -> dict[str, int]:
raw = session.get(CART_SESSION_KEY) or {}
cart: dict[str, int] = {}
for key, qty in raw.items():
try:
n = int(qty)
except (TypeError, ValueError):
continue
if n > 0:
cart[str(key)] = n
return cart
def save_cart(session, cart: dict[str, int]) -> None:
session[CART_SESSION_KEY] = cart
session.modified = True
def cart_line_key(product: Product, color: ProductColor | None = None) -> str:
if color is None:
return str(product.pk)
return f"{product.pk}:{color.pk}"
def _split_cart_key(key: str) -> tuple[str, str | None]:
text = str(key)
if ":" not in text:
return text, None
product_id, color_id = text.split(":", 1)
return product_id, color_id or None
def add_to_cart(
session,
product: Product,
quantity: int = 1,
color: ProductColor | None = None,
) -> dict[str, int]:
if quantity < 1:
raise ShopError("Quantity must be at least 1.")
cart = get_cart(session)
key = cart_line_key(product, color)
cart[key] = cart.get(key, 0) + quantity
save_cart(session, cart)
return cart
def set_cart_qty(
session,
product: Product,
quantity: int,
color: ProductColor | None = None,
) -> dict[str, int]:
cart = get_cart(session)
key = cart_line_key(product, color)
if quantity < 1:
cart.pop(key, None)
else:
cart[key] = quantity
save_cart(session, cart)
return cart
def cart_lines(session) -> list[dict]:
cart = get_cart(session)
parsed: list[tuple[str, str | None, int]] = []
product_ids: list[str] = []
color_ids: list[str] = []
for key, qty in cart.items():
product_id, color_id = _split_cart_key(key)
product_ids.append(product_id)
if color_id:
color_ids.append(color_id)
parsed.append((product_id, color_id, qty))
products = {
str(p.pk): p
for p in Product.objects.filter(
pk__in=product_ids, is_published=True
).select_related("image")
}
colors = {
str(c.pk): c
for c in ProductColor.objects.filter(pk__in=color_ids)
.select_related("product")
.prefetch_related("images")
}
lines = []
for product_id, color_id, qty in parsed:
product = products.get(product_id)
if not product:
continue
color = colors.get(color_id) if color_id else None
if color_id and (
color is None or str(color.product_id) != product_id
):
continue
image_url = ""
if color is not None:
image_url = color.image_url
if not image_url:
image_url = product.image_url
lines.append(
{
"product": product,
"color": color,
"quantity": qty,
"unit_price": product.price,
"line_total": product.price * qty,
"image_url": image_url,
}
)
return lines
def cart_total(lines: list[dict]) -> Decimal:
return sum((line["line_total"] for line in lines), Decimal("0"))
def queued_print_minutes() -> int:
total = (
OrderItem.objects.filter(
order__status__in=[Order.Status.OPEN, Order.Status.PAID],
product__fulfillment=Product.Fulfillment.MADE_TO_ORDER,
).aggregate(total=Sum(F("print_minutes") * F("quantity")))["total"]
or 0
)
return int(total)
def available_qty(
product: Product, color: ProductColor | None = None
) -> int | None:
"""Units that can be sold. None means unlimited (untracked made-to-order)."""
if not product.track_inventory:
return None
if product.fulfillment == Product.Fulfillment.STOCKED:
if color is not None:
return max(color.stock_qty, 0)
return max(product.stock_qty, 0)
limit = int(getattr(settings, "SHOP_PRINT_QUEUE_LIMIT_MINUTES", 0) or 0)
if not limit or not product.print_minutes:
return None
remaining_minutes = max(limit - queued_print_minutes(), 0)
return remaining_minutes // product.print_minutes
def assert_can_sell(
product: Product, quantity: int, color: ProductColor | None = None
) -> None:
if quantity < 1:
raise ShopError("Quantity must be at least 1.")
if not product.is_published:
raise ShopError("This product is not available.")
avail = available_qty(product, color=color)
if avail is not None and quantity > avail:
label = f"{product.name} ({color.name})" if color else product.name
raise ShopError(f"Only {avail} of {label} available.")
def adjust_stock(
product: Product, delta: int, color: ProductColor | None = None
) -> Product | ProductColor:
"""Increment (positive) or decrement (negative) on-hand stock."""
if color is not None:
color.stock_qty = color.stock_qty + delta
if color.stock_qty < 0:
raise ShopError(f"Insufficient stock for {product.sku} ({color.name}).")
color.save(update_fields=["stock_qty", "updated_at"])
return color
product.stock_qty = product.stock_qty + delta
if product.stock_qty < 0:
raise ShopError(f"Insufficient stock for {product.sku}.")
product.save(update_fields=["stock_qty", "updated_at"])
return product
def create_order_from_cart(
session,
*,
email: str,
customer_name: str = "",
shipping_address: dict | None = None,
notes: str = "",
) -> Order:
lines = cart_lines(session)
if not lines:
raise ShopError("Cart is empty.")
email = (email or "").strip()
if not email:
raise ShopError("Email is required.")
for line in lines:
assert_can_sell(line["product"], line["quantity"], color=line.get("color"))
currency = (settings.STRIPE_CURRENCY or "usd").lower()
with transaction.atomic():
order = Order.objects.create(
number=next_order_number(),
email=email,
customer_name=(customer_name or "").strip(),
status=Order.Status.DRAFT,
amount=cart_total(lines),
currency=currency,
shipping_address=shipping_address or {},
notes=notes,
)
for line in lines:
product = line["product"]
color = line.get("color")
display_name = (
f"{product.name} ({color.name})" if color else product.name
)
OrderItem.objects.create(
order=order,
product=product,
color=color,
name=display_name,
sku=product.sku,
color_name=color.name if color else "",
quantity=line["quantity"],
unit_price=product.price,
print_minutes=product.print_minutes,
)
return order
def create_checkout_session(order: Order, *, success_url: str, cancel_url: str) -> str:
stripe = _stripe()
line_items = [
{
"quantity": item.quantity,
"price_data": {
"currency": (order.currency or "usd").lower(),
"unit_amount": int(
(item.unit_price * Decimal("100")).quantize(Decimal("1"))
),
"product_data": {"name": item.name},
},
}
for item in order.items.all()
]
if not line_items:
raise ShopError("Order has no items.")
session = stripe.checkout.Session.create(
mode="payment",
customer_email=order.email or None,
line_items=line_items,
metadata={"shop_order_id": str(order.pk), "order_number": order.number},
success_url=success_url,
cancel_url=cancel_url,
)
order.stripe_checkout_session_id = session.id
order.hosted_checkout_url = session.url or ""
order.status = Order.Status.OPEN
order.save(
update_fields=[
"stripe_checkout_session_id",
"hosted_checkout_url",
"status",
"updated_at",
]
)
return session.url or ""
def _reserve_inventory(order: Order) -> None:
for item in order.items.select_related("product", "color"):
product = item.product
if product is None or not product.track_inventory:
continue
if product.fulfillment == Product.Fulfillment.STOCKED:
adjust_stock(product, -item.quantity, color=item.color)
def _notify_pos(order: Order) -> None:
from django.apps import apps
if not apps.is_installed("pos_sync"):
return
from pos_sync.services import enqueue_online_sale
enqueue_online_sale(order)
def mark_paid(order: Order, *, stripe_id: str = "") -> None:
if order.status == Order.Status.PAID:
return
with transaction.atomic():
locked = Order.objects.select_for_update().get(pk=order.pk)
if locked.status == Order.Status.PAID:
return
_reserve_inventory(locked)
locked.status = Order.Status.PAID
locked.paid_at = timezone.now()
locked.save(update_fields=["status", "paid_at", "updated_at"])
order.refresh_from_db()
try:
send_order_email(order)
except Exception:
logger.exception("order confirmation email failed for %s", order.number)
try:
_notify_pos(order)
except Exception:
logger.exception("POS notify failed for %s", order.number)
def send_order_email(order: Order) -> bool:
to_email = (order.email or "").strip()
if not to_email:
raise ShopError("Order has no email address")
from django.core.mail import EmailMultiAlternatives
name = order.customer_name or "there"
amount = f"{order.amount} {order.currency.upper()}"
lines = "\n".join(
f"- {item.quantity}× {item.name} ({item.sku})" for item in order.items.all()
)
subject = f"Order {order.number} from {settings.SITE_NAME}"
text = (
f"Hi {name},\n\n"
f"We received order {order.number} for {amount}.\n\n"
f"{lines}\n\nThank you.\n"
)
html = (
f"<p>Hi {name},</p>"
f"<p>We received order <strong>{order.number}</strong> for "
f"<strong>{amount}</strong>.</p>"
f"<pre>{lines}</pre>"
)
mail = EmailMultiAlternatives(
subject=subject,
body=text,
from_email=settings.DEFAULT_FROM_EMAIL,
to=[to_email],
)
mail.attach_alternative(html, "text/html")
mail.send(fail_silently=False)
return True
def normalize_hex(value: str) -> str:
raw = (value or "").strip()
if not _HEX_RE.fullmatch(raw):
return "#808080"
if not raw.startswith("#"):
raw = f"#{raw}"
return raw.lower()
def store_product_image(*, upload, user) -> StoredFile:
content_type = (getattr(upload, "content_type", None) or "").lower()
if content_type not in _ALLOWED_IMAGE_TYPES:
raise ShopError("Use a JPEG, PNG, GIF, or WebP image.")
data = _read_upload_bytes(upload)
if len(data) > _MAX_IMAGE_BYTES:
raise ShopError("Image must be 15 MB or smaller.")
original = (getattr(upload, "name", None) or "product")[:255]
try:
from shop.imaging import prepare_product_photo, product_image_filename
except ImportError as exc:
logger.exception("product photo processing dependencies missing")
raise ShopError(
"Image processing is not installed. Rebuild the app container."
) from exc
try:
data, content_type = prepare_product_photo(data)
original = product_image_filename(original)
except ShopError:
raise
except Exception as exc:
logger.exception("product photo processing failed")
raise ShopError("Could not process that image.") from exc
return StoredFile.objects.create(
kind=StoredFile.Kind.PRODUCT_IMAGE,
filename=original,
content_type=content_type,
size=len(data),
data=data,
uploaded_by=user if getattr(user, "is_authenticated", False) else None,
)
def looks_like_stl(data: bytes, filename: str = "") -> bool:
if not (filename or "").lower().endswith(".stl"):
return False
if len(data) < 84:
return False
head = data[:80].lstrip().lower()
sample = data[:8192].lower()
if head.startswith(b"solid") and b"facet" in sample:
return True
triangle_count = struct.unpack_from("<I", data, 80)[0]
if triangle_count < 1:
return False
return 84 + triangle_count * 50 == len(data)
def store_product_stl(*, upload, user) -> StoredFile:
original = (getattr(upload, "name", None) or "model.stl")[:255]
content_type = (getattr(upload, "content_type", None) or "").lower()
if content_type not in _ALLOWED_STL_TYPES:
raise ShopError("Use an STL file.")
data = _read_upload_bytes(upload)
if len(data) > _MAX_STL_BYTES:
raise ShopError("STL must be 25 MB or smaller.")
if not looks_like_stl(data, original):
raise ShopError("That file does not look like a valid STL.")
return StoredFile.objects.create(
kind=StoredFile.Kind.PRODUCT_STL,
filename=original,
content_type="model/stl",
size=len(data),
data=data,
uploaded_by=user if getattr(user, "is_authenticated", False) else None,
)
def sync_product_colors(
product: Product,
*,
ids: list[str],
names: list[str],
hexes: list[str],
keys: list[str] | None = None,
stocks: list[str] | None = None,
) -> dict[str, ProductColor]:
keep: list[str] = []
mapping: dict[str, ProductColor] = {}
for index, raw_name in enumerate(names):
name = (raw_name or "").strip()
if not name:
continue
hex_value = normalize_hex(hexes[index] if index < len(hexes) else "")
color_id = (ids[index] if index < len(ids) else "").strip()
key = ""
if keys and index < len(keys):
key = (keys[index] or "").strip()
try:
stock = int((stocks[index] if stocks and index < len(stocks) else "0") or "0")
except (TypeError, ValueError):
stock = 0
color = None
if color_id:
color = ProductColor.objects.filter(pk=color_id, product=product).first()
if color is None:
color = ProductColor(product=product)
color.name = name[:64]
color.hex = hex_value
color.stock_qty = max(stock, 0)
color.sort_order = index
color.save()
keep.append(str(color.pk))
mapping[str(color.pk)] = color
if key:
mapping[key] = color
leftover = product.colors.exclude(pk__in=keep)
stale_file_ids = list(
ProductImage.objects.filter(color__in=leftover).values_list("file_id", flat=True)
)
leftover.delete()
gc_product_image_files(stale_file_ids)
return mapping
def refresh_listing_image(product: Product) -> None:
first = (
product.images.filter(color_id__isnull=True)
.order_by("sort_order", "created_at")
.first()
or product.images.order_by("sort_order", "created_at").first()
)
new_id = first.file_id if first else None
if product.image_id != new_id:
product.image_id = new_id
product.save(update_fields=["image", "updated_at"])
def gc_product_image_files(file_ids) -> None:
ids = [item for item in file_ids if item]
if not ids:
return
used = set(
ProductImage.objects.filter(file_id__in=ids).values_list("file_id", flat=True)
)
used.update(
Product.objects.filter(image_id__in=ids).values_list("image_id", flat=True)
)
stale = [pk for pk in ids if pk not in used]
if stale:
StoredFile.objects.filter(
pk__in=stale, kind=StoredFile.Kind.PRODUCT_IMAGE
).delete()
def append_product_images(
product: Product,
*,
uploads,
user,
color: ProductColor | None = None,
) -> None:
existing = product.images.filter(color=color).count()
for offset, upload in enumerate(uploads):
if not upload:
continue
stored = store_product_image(upload=upload, user=user)
ProductImage.objects.create(
product=product,
color=color,
file=stored,
sort_order=existing + offset,
)
def remove_product_images(product: Product, image_ids: list[str]) -> None:
ids = [item for item in image_ids if item]
if not ids:
return
qs = ProductImage.objects.filter(product=product, pk__in=ids)
file_ids = list(qs.values_list("file_id", flat=True))
qs.delete()
gc_product_image_files(file_ids)
def product_media_payload(product: Product, colors: list[ProductColor]) -> dict:
images = list(product.images.all())
shared = [item.url for item in images if item.color_id is None]
if not shared and product.image_url:
shared = [product.image_url]
color_data = {}
for color in colors:
colored = [item.url for item in images if item.color_id == color.pk]
color_data[str(color.pk)] = {
"name": color.name,
"hex": color.hex,
"images": colored,
"available": available_qty(product, color),
}
return {"shared": shared, "stl": product.stl_url, "colors": color_data}
+166
View File
@@ -0,0 +1,166 @@
"""Sales dashboard aggregates for FEATURE_SHOP."""
from __future__ import annotations
from datetime import date, datetime, time, timedelta
from decimal import Decimal
from django.conf import settings
from django.db.models import Count, F, Max, Q, Sum
from django.utils import timezone
from shop.models import Order, OrderItem
SOLD_STATUSES = (Order.Status.PAID, Order.Status.FULFILLED)
SALES_WINDOW_DAYS = 30
def _money(value) -> Decimal:
return (value or Decimal("0")).quantize(Decimal("0.01"))
def _window_start(days: int):
today = timezone.localdate()
start_date = today - timedelta(days=days - 1)
start_dt = timezone.make_aware(
datetime.combine(start_date, time.min),
timezone.get_current_timezone(),
)
return today, start_date, start_dt
def _sold_orders(start_dt=None):
qs = Order.objects.filter(status__in=SOLD_STATUSES)
if start_dt is None:
return qs
return qs.filter(
Q(paid_at__gte=start_dt) | Q(paid_at__isnull=True, created_at__gte=start_dt)
)
def _sale_date(order) -> date:
when = order.paid_at or order.created_at
return timezone.localtime(when).date()
def _bar_pct(value: Decimal | int | float, peak: Decimal | int | float) -> int:
if not peak:
return 0
if not value:
return 0
return max(8, int(round((float(value) / float(peak)) * 100)))
def sales_summary(*, days: int = SALES_WINDOW_DAYS) -> dict:
"""Order count and revenue for the rolling sales window."""
_, _, start_dt = _window_start(days)
totals = _sold_orders(start_dt).aggregate(
order_count=Count("id"), revenue=Sum("amount")
)
return {
"order_count": int(totals["order_count"] or 0),
"revenue": _money(totals["revenue"]),
}
def sales_dashboard(*, days: int = SALES_WINDOW_DAYS) -> dict:
"""Paid/fulfilled order stats, daily series, and top products."""
_, start_date, start_dt = _window_start(days)
sold = _sold_orders(start_dt)
totals = sold.aggregate(order_count=Count("id"), revenue=Sum("amount"))
order_count = int(totals["order_count"] or 0)
revenue = _money(totals["revenue"])
units = int(
OrderItem.objects.filter(order__in=sold).aggregate(total=Sum("quantity"))[
"total"
]
or 0
)
aov = _money(revenue / order_count) if order_count else Decimal("0.00")
currency = (settings.STRIPE_CURRENCY or "usd").lower()
by_day: dict = {
start_date + timedelta(days=offset): {
"count": 0,
"revenue": Decimal("0.00"),
}
for offset in range(days)
}
for order in sold.only("paid_at", "created_at", "amount"):
day = _sale_date(order)
bucket = by_day.get(day)
if bucket is None:
continue
bucket["count"] += 1
bucket["revenue"] += order.amount or Decimal("0")
peak_count = max((row["count"] for row in by_day.values()), default=0)
peak_revenue = max((row["revenue"] for row in by_day.values()), default=Decimal("0"))
daily_sales = []
daily_revenue = []
for index, (day, row) in enumerate(by_day.items()):
tick = index == 0 or index == days - 1 or day.weekday() == 0
label = f"{day.strftime('%b')} {day.day}"
daily_sales.append(
{
"date": day,
"label": label,
"count": row["count"],
"revenue": _money(row["revenue"]),
"pct": _bar_pct(row["count"], peak_count),
"tick": tick,
"tick_label": label if tick else "",
}
)
daily_revenue.append(
{
"date": day,
"label": label,
"count": row["count"],
"revenue": _money(row["revenue"]),
"pct": _bar_pct(row["revenue"], peak_revenue),
"tick": tick,
"tick_label": label if tick else "",
}
)
product_rows = list(
OrderItem.objects.filter(order__in=sold)
.values("sku")
.annotate(
units=Sum("quantity"),
revenue=Sum(F("unit_price") * F("quantity")),
product_name=Max("product__name"),
item_name=Max("name"),
)
.order_by("-units", "-revenue")[:8]
)
peak_units = max((int(row["units"] or 0) for row in product_rows), default=0)
top_products = []
for row in product_rows:
units_sold = int(row["units"] or 0)
top_products.append(
{
"sku": row["sku"],
"name": row["product_name"] or row["item_name"] or row["sku"],
"units": units_sold,
"revenue": _money(row["revenue"]),
"bar_pct": _bar_pct(units_sold, peak_units),
}
)
recent_orders = list(sold.order_by("-paid_at", "-created_at")[:8])
return {
"days": days,
"currency": currency,
"order_count": order_count,
"revenue": revenue,
"units_sold": units,
"aov": aov,
"daily_sales": daily_sales,
"daily_revenue": daily_revenue,
"top_products": top_products,
"recent_orders": recent_orders,
"has_sales": order_count > 0,
}
@@ -0,0 +1,38 @@
{% load static %}
{% with fallback=product_img|default:'product-1-292x256.png' %}
<article class="product">
<div class="product-body">
<div class="product-info">
<div class="product-figure">
{% if product.image_id %}
<img src="{{ product.image_url }}" alt="{{ product.name }}" width="292" height="256"/>
{% else %}
{% with path="images/"|add:fallback %}
<img src="{% static path %}" alt="{{ product.name }}" width="292" height="256"/>
{% endwith %}
{% endif %}
</div>
<h5 class="product-title"><a href="{{ product.get_absolute_url }}">{{ product.name }}</a></h5>
<div class="product-price">${{ product.price }}</div>
{% with colors=product.colors.all %}
{% if colors %}
<div class="product-color-dots" aria-label="Available colors">
{% for color in colors %}
<span class="product-color-dot" style="background:{{ color.hex }}" title="{{ color.name }}"></span>
{% endfor %}
</div>
{% endif %}
{% endwith %}
</div>
{% if product.description %}
<div class="product-description">
<p class="product-text">{{ product.description|truncatewords:18 }}</p>
</div>
{% endif %}
</div>
<div class="product-panel">
<div class="product-price">${{ product.price }}</div>
<div class="product-link"><a href="{{ product.get_absolute_url }}">View</a></div>
</div>
</article>
{% endwith %}
@@ -0,0 +1,8 @@
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"
}
}
</script>
+12
View File
@@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block title %}Checkout cancelled{% endblock %}
{% block content %}
{% include "public/_breadcrumbs.html" with title="Checkout Cancelled" %}
<section class="section section-lg bg-default text-center">
<div class="container">
<h2>Checkout <span class="text-italic font-weight-thin">cancelled</span></h2>
<p class="big">Order {{ order.number }} was not paid.</p>
<a class="button button-lg button-primary" href="{% url 'shop:cart' %}">Return to cart</a>
</div>
</section>
{% endblock %}
+67
View File
@@ -0,0 +1,67 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Cart · {{ SITE_NAME }}{% endblock %}
{% block content %}
{% include "public/_breadcrumbs.html" with title="Cart" %}
<section class="section section-lg bg-default text-center text-sm-right">
<div class="container">
{% if lines %}
<div class="table-custom-responsive">
<table class="table-custom table-cart">
<thead>
<tr>
<th>Product name</th>
<th>Price</th>
<th>Quantity</th>
<th>Total</th>
</tr>
</thead>
<tbody>
{% for line in lines %}
<tr>
<td>
<div class="table-cart-item">
<a class="table-cart-figure" href="{{ line.product.get_absolute_url }}">
{% if line.image_url %}
<img src="{{ line.image_url }}" alt="" width="146" height="132"/>
{% elif line.product.image_id %}
<img src="{{ line.product.image_url }}" alt="" width="146" height="132"/>
{% else %}
<img src="{% static 'images/product-mini-1-146x132.png' %}" alt="" width="146" height="132"/>
{% endif %}
</a>
<div>
<a class="table-cart-link" href="{{ line.product.get_absolute_url }}">{{ line.product.name }}</a>
{% if line.color %}<div class="small">Color: {{ line.color.name }}</div>{% endif %}
</div>
</div>
</td>
<td>${{ line.unit_price }}</td>
<td>
<form method="post" action="{% url 'shop:cart_update' line.product.slug %}">
{% csrf_token %}
{% if line.color %}<input type="hidden" name="color" value="{{ line.color.pk }}">{% endif %}
<div class="stepper-style-2">
<input type="number" name="quantity" value="{{ line.quantity }}" min="0" max="999">
</div>
<button class="button button-sm button-default-outline" type="submit">Update</button>
</form>
</td>
<td>${{ line.line_total }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="group-xl group-middle justify-content-center">
<div class="big font-weight-normal">Total</div>
<div class="heading-3 font-family-base">${{ total }}</div>
<a class="button button-lg button-primary" href="{% url 'shop:checkout' %}">Checkout</a>
</div>
{% else %}
<p class="big text-center">Cart is empty.</p>
<p class="text-center"><a class="button button-lg button-primary" href="{% url 'shop:list' %}">Continue shopping</a></p>
{% endif %}
</div>
</section>
{% endblock %}
+81
View File
@@ -0,0 +1,81 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Checkout · {{ SITE_NAME }}{% endblock %}
{% block extra_head %}
<link rel="stylesheet" href="{% static 'css/address-autocomplete.css' %}">
{% endblock %}
{% block content %}
{% include "public/_breadcrumbs.html" with title="Checkout" %}
<section class="section section-lg bg-default">
<div class="container">
<div class="row row-50 justify-content-center">
<div class="col-md-10 col-lg-6">
<h3 class="font-base text-gray-800 text-uppercase">Shipping</h3>
<form class="rd-form form-checkout" method="post">
{% csrf_token %}
<div class="row row-20 gutter-20" data-address-autocomplete data-suggest-url="{% url 'address_suggest' %}">
<div class="col-12">
<div class="form-wrap">
<label class="form-label-outside" for="checkout-name">Name</label>
<input class="form-input" id="checkout-name" type="text" name="customer_name">
</div>
</div>
<div class="col-12">
<div class="form-wrap">
<label class="form-label-outside" for="checkout-email">E-Mail</label>
<input class="form-input" id="checkout-email" type="email" name="email" required>
</div>
</div>
<div class="col-12">
<div class="form-wrap">
<label class="form-label-outside" for="checkout-address">Address</label>
<input class="form-input" id="checkout-address" type="text" name="address_line1" data-ac="line1" autocomplete="off">
</div>
</div>
<div class="col-12">
<div class="form-wrap">
<label class="form-label-outside" for="checkout-address-2">Apt / suite</label>
<input class="form-input" id="checkout-address-2" type="text" name="address_line2" data-ac="line2" autocomplete="address-line2">
</div>
</div>
<div class="col-sm-6">
<div class="form-wrap">
<label class="form-label-outside" for="checkout-city">City</label>
<input class="form-input" id="checkout-city" type="text" name="address_city" data-ac="city" autocomplete="address-level2">
</div>
</div>
<div class="col-sm-6">
<div class="form-wrap">
<label class="form-label-outside" for="checkout-state">State</label>
<input class="form-input" id="checkout-state" type="text" name="address_state" data-ac="state" autocomplete="address-level1">
</div>
</div>
<div class="col-sm-6">
<div class="form-wrap">
<label class="form-label-outside" for="checkout-zip">ZIP</label>
<input class="form-input" id="checkout-zip" type="text" name="address_zip" data-ac="zip" autocomplete="postal-code">
</div>
</div>
<div class="col-12">
<button class="button button-lg button-primary" type="submit">Pay with Stripe</button>
</div>
</div>
</form>
</div>
<div class="col-md-10 col-lg-6">
<h3 class="font-base text-gray-800 text-uppercase">Order</h3>
<ul class="list-description">
{% for line in lines %}
<li><span>{{ line.quantity }}× {{ line.product.name }}{% if line.color %} ({{ line.color.name }}){% endif %}</span><span>${{ line.line_total }}</span></li>
{% endfor %}
<li><span>Total</span><span>${{ total }}</span></li>
</ul>
<p><a href="{% url 'shop:cart' %}">Edit cart</a></p>
</div>
</div>
</div>
</section>
{% endblock %}
{% block extra_js %}
<script src="{% static 'js/address-autocomplete.js' %}"></script>
{% endblock %}
+102
View File
@@ -0,0 +1,102 @@
{% extends "base.html" %}
{% load static %}
{% block title %}{{ product.name }} · {{ SITE_NAME }}{% endblock %}
{% block meta_description %}{{ product.name }} — {{ product.price }} {{ product.currency|upper }} at {{ SITE_NAME }}.{% endblock %}
{% block extra_head %}
{% include "shop/_stl_importmap.html" %}
{% endblock %}
{% block content %}
{% include "public/_breadcrumbs.html" with title=product.name %}
<section class="section section-lg bg-default">
<div class="container">
<div class="row row-30">
<div class="col-lg-6">
<div class="product-gallery" data-product-gallery>
{{ gallery_data|json_script:"product-gallery-data" }}
<div class="product-gallery-layout">
<div class="product-gallery-thumbs" data-gallery-thumbs role="tablist" aria-label="Product media">
{% for item in gallery_items %}
<button type="button" class="{% if forloop.first %}active{% endif %}" role="tab" aria-selected="{% if forloop.first %}true{% else %}false{% endif %}" data-gallery-thumb data-kind="{{ item.kind }}" {% if item.kind == 'photo' %}data-url="{{ item.url }}"{% endif %}>
{% if item.kind == 'photo' %}
<img src="{{ item.url }}" alt="">
{% else %}
<span class="product-gallery-thumb-3d">3D</span>
{% endif %}
</button>
{% endfor %}
</div>
<div class="product-gallery-stage">
<div class="slick-product-figure product-gallery-photo" data-gallery-photo {% if gallery_items.0.kind == 'model' %}hidden{% endif %}>
{% if gallery_photos %}
<img data-gallery-image src="{{ gallery_photos.0.url }}" alt="{{ product.name }}" width="530" height="480"/>
{% elif product.image_id %}
<img data-gallery-image src="{{ product.image_url }}" alt="{{ product.name }}" width="530" height="480"/>
{% else %}
<img data-gallery-image src="{% static 'images/single-product-1-530x480.png' %}" alt="{{ product.name }}" width="530" height="480" data-placeholder="{% static 'images/single-product-1-530x480.png' %}"/>
{% endif %}
</div>
{% if product.stl_id %}
<div class="slick-product-figure product-gallery-model" data-gallery-model {% if gallery_items.0.kind != 'model' %}hidden{% endif %}>
<div class="stl-viewer"
data-stl-viewer
data-src="{{ product.stl_url }}"
data-color="{{ selected_color.hex|default:'#ff6252' }}"
data-label="3D model of {{ product.name }}. Drag to rotate, scroll to zoom."></div>
<p class="product-media-hint">Drag to spin · scroll to zoom</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="single-product">
<h3 class="single-product-title">{{ product.name }}</h3>
<div class="group-md group-inline">
<div class="heading-5 single-product-price">${{ product.price }}</div>
</div>
<div>{{ product.description|linebreaks }}</div>
<hr class="hr-24 hr-gray-100">
<ul class="list-description">
<li><span>SKU:</span><span>{{ product.sku }}</span></li>
<li><span>Fulfillment:</span><span>{{ product.get_fulfillment_display }}</span></li>
<li data-availability-row {% if available is None %}hidden{% endif %}><span>Availability:</span><span data-availability>{% if available is not None %}{{ available }} in stock{% endif %}</span></li>
{% if product.print_minutes %}
<li><span>Print time:</span><span>{{ product.print_minutes }} min</span></li>
{% endif %}
</ul>
<form method="post" action="{% url 'shop:cart_add' product.slug %}">
{% csrf_token %}
{% if colors %}
<div class="product-color-picker">
<p class="product-color-label">Color: <span id="selected-color-name">{{ selected_color.name }}</span></p>
<div class="product-color-swatches">
{% for color in colors %}
<label class="product-color-swatch">
<input type="radio" name="color" value="{{ color.pk }}" data-color-name="{{ color.name }}" data-color-hex="{{ color.hex }}" {% if forloop.first %}checked{% endif %} required>
<span style="background:{{ color.hex }}" title="{{ color.name }}"></span>
</label>
{% endfor %}
</div>
</div>
{% endif %}
<div class="group-md group-middle">
<div class="stepper-style-1">
<input type="number" name="quantity" value="1" min="1" max="1000">
</div>
<div>
<button class="button button-lg button-primary" type="submit">Add to cart</button>
</div>
</div>
</form>
<hr class="hr-30 hr-gray-100">
<p><a href="{% url 'shop:list' %}">← Back to shop</a></p>
</div>
</div>
</div>
</div>
</section>
{% endblock %}
{% block extra_js %}
<script type="module" src="{% static 'js/stl-viewer.js' %}"></script>
{% endblock %}
+25
View File
@@ -0,0 +1,25 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Shop · {{ SITE_NAME }}{% endblock %}
{% block meta_description %}Shop 3D printed toys from {{ SITE_NAME }}. {{ CONTACT_SERVICE_AREA }}.{% endblock %}
{% block content %}
{% include "public/_breadcrumbs.html" with title="Shop" %}
<section class="section section-lg bg-default text-center">
<div class="container">
<h2>Shop <span class="text-italic font-weight-thin">Toys</span></h2>
{% if products %}
<div class="row row-xl row-30 row-md-50 row-xl-70">
{% for product in products %}
{% cycle 'product-1-292x256.png' 'product-2-292x256.png' 'product-3-292x256.png' 'product-4-292x256.png' 'product-5-292x256.png' 'product-6-292x256.png' as product_img silent %}
<div class="col-sm-6 col-lg-4">
{% include "shop/_product_card.html" with product=product product_img=product_img %}
</div>
{% endfor %}
</div>
{% else %}
<p class="big">No products listed yet. Ask us to print a custom toy.</p>
<a class="button button-lg button-primary" href="{% url 'public:contact' %}">Request a custom toy</a>
{% endif %}
</div>
</section>
{% endblock %}
@@ -0,0 +1,21 @@
{% extends "portal_base.html" %}
{% block title %}{{ order.number }} · Portal{% endblock %}
{% block topbar_title %}{{ order.number }}{% endblock %}
{% block portal_content %}
<p><strong>{{ order.email }}</strong> · {{ order.amount }} {{ order.currency|upper }} · {{ order.get_status_display }}</p>
{% if order.customer_name %}<p>{{ order.customer_name }}</p>{% endif %}
<table class="table">
<thead><tr><th>Item</th><th>SKU</th><th>Qty</th><th>Price</th></tr></thead>
<tbody>
{% for item in order.items.all %}
<tr>
<td>{{ item.name }}</td>
<td>{{ item.sku }}</td>
<td>{{ item.quantity }}</td>
<td>{{ item.unit_price }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<p><a href="{% url 'shop_portal:order_list' %}">← All orders</a></p>
{% endblock %}
@@ -0,0 +1,20 @@
{% extends "portal_base.html" %}
{% block title %}Orders · Portal{% endblock %}
{% block topbar_title %}Orders{% endblock %}
{% block portal_content %}
<table class="table">
<thead><tr><th>Number</th><th>Email</th><th>Amount</th><th>Status</th></tr></thead>
<tbody>
{% for order in orders %}
<tr>
<td><a href="{% url 'shop_portal:order_detail' order.pk %}">{{ order.number }}</a></td>
<td>{{ order.email }}</td>
<td>{{ order.amount }} {{ order.currency|upper }}</td>
<td>{{ order.get_status_display }}</td>
</tr>
{% empty %}
<tr><td colspan="4" class="empty-state">No orders yet.</td></tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
@@ -0,0 +1,253 @@
{% extends "portal_base.html" %}
{% load static %}
{% block title %}{% if product %}Edit{% else %}New{% endif %} product · Portal{% endblock %}
{% block topbar_title %}{% if product %}Edit product{% else %}New product{% endif %}{% endblock %}
{% block extra_head %}
{% include "shop/_stl_importmap.html" %}
{% endblock %}
{% block portal_content %}
<div class="product-edit-layout">
<form method="post" class="form-grid" id="product-form" enctype="multipart/form-data">
{% csrf_token %}
<div class="field"><label>Name</label><input name="name" required value="{{ product.name|default:'' }}"></div>
<div class="field"><label>SKU</label><input name="sku" value="{{ product.sku|default:'' }}" placeholder="auto from name"></div>
<div class="field"><label>Slug</label><input name="slug" value="{{ product.slug|default:'' }}" placeholder="auto from name"></div>
<div class="field"><label>Price</label><input name="price" type="number" step="0.01" min="0" required value="{{ product.price|default:'' }}"></div>
<div class="field">
<label>Fulfillment</label>
<select name="fulfillment">
<option value="stocked" {% if product.fulfillment == 'stocked' or not product %}selected{% endif %}>On-hand stock</option>
<option value="made_to_order" {% if product.fulfillment == 'made_to_order' %}selected{% endif %}>Made to order</option>
</select>
</div>
<div class="field" data-product-stock>
<label>Stock qty</label>
<input name="stock_qty" type="number" value="{{ product.stock_qty|default:0 }}">
<p class="hint">Used when this product has no colors. Color stock is set on each color below.</p>
</div>
<div class="field"><label>Print minutes</label><input name="print_minutes" type="number" min="0" value="{{ product.print_minutes|default:0 }}"></div>
<div class="field"><label>Filament grams</label><input name="filament_grams" type="number" min="0" value="{{ product.filament_grams|default:0 }}"></div>
<div class="field">
<label>Photos</label>
<input name="images" type="file" accept="image/jpeg,image/png,image/gif,image/webp" multiple>
<p class="hint">JPEG, PNG, GIF, or WebP. Multiple photos. Used on the listing card and as the default gallery. Color-specific photos below replace these when that color is selected.</p>
{% if product.catalog_images %}
<div class="photo-thumbs">
{% for image in product.catalog_images %}
<label class="photo-thumb">
<img src="{{ image.url }}" alt="">
<span><input type="checkbox" name="remove_image" value="{{ image.pk }}"> Remove</span>
</label>
{% endfor %}
</div>
{% endif %}
</div>
<div class="field">
<label>STL model</label>
<input name="stl" type="file" accept=".stl,model/stl,model/x.stl-ascii,model/x.stl-binary" data-stl-file-input data-stl-target="#product-stl-preview">
{% if product.stl_id %}
<p class="hint">Shared across all colors. Current model is shown in the 3D preview. Upload a new STL to replace it.</p>
<label class="inline"><input type="checkbox" name="clear_stl"> Remove current STL</label>
{% else %}
<p class="hint">Optional, shared across colors. Shoppers can spin the model on the product page. 25 MB max.</p>
{% endif %}
</div>
<div class="field"><label>Description</label><textarea name="description" style="min-height:120px">{{ product.description|default:'' }}</textarea></div>
<div class="field">
<label>Colors</label>
<p class="hint">Same name, price, description, and STL for every color. Stock and photos are per color.</p>
<div id="color-rows" class="color-rows">
{% for color in product.colors.all %}
<div class="color-row">
<div class="color-row-main">
<input type="hidden" name="color_id" value="{{ color.pk }}">
<input type="hidden" name="color_key" value="{{ color.pk }}">
<input name="color_name" value="{{ color.name }}" placeholder="Color name" aria-label="Color name">
<input name="color_hex" type="color" value="{{ color.hex }}" aria-label="Color swatch">
<input name="color_stock" type="number" min="0" value="{{ color.stock_qty }}" placeholder="Qty" aria-label="Color stock">
<button type="button" class="btn btn-sm btn-ghost color-remove">Remove</button>
</div>
<div class="color-row-photos">
{% for image in color.images.all %}
<label class="photo-thumb">
<img src="{{ image.url }}" alt="">
<span><input type="checkbox" name="remove_image" value="{{ image.pk }}"> Remove</span>
</label>
{% endfor %}
<input name="color_images_{{ color.pk }}" type="file" accept="image/jpeg,image/png,image/gif,image/webp" multiple data-color-images>
</div>
</div>
{% endfor %}
</div>
<button type="button" class="btn btn-sm btn-ghost" id="add-color">Add color</button>
</div>
<label><input type="checkbox" name="is_published" {% if product.is_published %}checked{% endif %}> Published</label>
<label><input type="checkbox" name="track_inventory" {% if product.track_inventory or not product %}checked{% endif %}> Track inventory</label>
<button class="btn btn-primary" type="submit">Save</button>
</form>
<aside class="product-preview-col">
<div class="field"><label>Shop card preview</label></div>
<article class="product-card-preview" id="product-card-preview">
<div class="product-card-preview-body">
<div class="product-card-preview-figure">
<img data-card-image
src="{% if product.image_id %}{{ product.image_url }}{% else %}{% static 'images/product-1-292x256.png' %}{% endif %}"
data-existing="{% if product.image_id %}{{ product.image_url }}{% endif %}"
data-placeholder="{% static 'images/product-1-292x256.png' %}"
alt="">
</div>
<h5 class="product-card-preview-title" data-card-title>{{ product.name|default:"Product name" }}</h5>
<div class="product-card-preview-price" data-card-price>${{ product.price|default:"0.00" }}</div>
<div class="product-color-dots" data-card-colors {% if not product.colors.all %}hidden{% endif %}>
{% for color in product.colors.all %}
<span class="product-color-dot" style="background:{{ color.hex }}" title="{{ color.name }}"></span>
{% endfor %}
</div>
<p class="product-card-preview-desc" data-card-description>{{ product.description|default:""|truncatewords:18 }}</p>
</div>
<div class="product-card-preview-panel">
<span data-card-price>${{ product.price|default:"0.00" }}</span>
<span>View</span>
</div>
</article>
<div class="field" style="margin-top:18px"><label>3D preview</label></div>
<div id="product-stl-preview"
class="stl-viewer stl-viewer-compact"
data-stl-viewer
data-color="#ff6252"
data-label="STL preview. Drag to rotate, scroll to zoom."
{% if product.stl_id %}data-src="{{ product.stl_url }}" data-existing="{{ product.stl_url }}"{% else %}hidden{% endif %}></div>
</aside>
</div>
<template id="color-row-template">
<div class="color-row">
<div class="color-row-main">
<input type="hidden" name="color_id" value="">
<input type="hidden" name="color_key" value="">
<input name="color_name" value="" placeholder="Color name" aria-label="Color name">
<input name="color_hex" type="color" value="#808080" aria-label="Color swatch">
<input name="color_stock" type="number" min="0" value="0" placeholder="Qty" aria-label="Color stock">
<button type="button" class="btn btn-sm btn-ghost color-remove">Remove</button>
</div>
<div class="color-row-photos">
<input type="file" accept="image/jpeg,image/png,image/gif,image/webp" multiple data-color-images>
</div>
</div>
</template>
{% if product %}
<form method="post" action="{% url 'shop_portal:product_stock' product.pk %}" class="form-grid" style="margin-top:24px" data-product-stock>
{% csrf_token %}
<div class="field"><label>Adjust stock (+/)</label><input name="delta" type="number" required value="1"></div>
<button class="btn btn-ghost" type="submit">Apply adjustment</button>
</form>
{% endif %}
{% endblock %}
{% block extra_js %}
<script type="module" src="{% static 'js/stl-viewer.js' %}"></script>
<script>
(function () {
const form = document.getElementById("product-form");
const preview = document.getElementById("product-card-preview");
const rows = document.getElementById("color-rows");
const addBtn = document.getElementById("add-color");
const tpl = document.getElementById("color-row-template");
if (!form || !preview || !rows || !addBtn || !tpl) return;
const nameEl = form.querySelector('[name="name"]');
const priceEl = form.querySelector('[name="price"]');
const descEl = form.querySelector('[name="description"]');
const imageEl = form.querySelector('[name="images"]');
const titleEls = preview.querySelectorAll("[data-card-title]");
const priceEls = preview.querySelectorAll("[data-card-price]");
const descOut = preview.querySelector("[data-card-description]");
const imgEl = preview.querySelector("[data-card-image]");
const colorsEl = preview.querySelector("[data-card-colors]");
const placeholder = imgEl.getAttribute("data-placeholder") || "";
const existing = imgEl.getAttribute("data-existing") || "";
let objectUrl = "";
let colorSeq = 0;
function money(value) {
const n = Number(value);
if (!Number.isFinite(n)) return "$0.00";
return "$" + n.toFixed(2);
}
function snippet(text) {
const words = (text || "").trim().split(/\s+/).filter(Boolean);
if (words.length <= 18) return words.join(" ");
return words.slice(0, 18).join(" ") + " …";
}
function namedColorCount() {
let count = 0;
rows.querySelectorAll(".color-row").forEach(function (row) {
if ((row.querySelector('[name="color_name"]').value || "").trim()) count += 1;
});
return count;
}
function sync() {
const name = (nameEl.value || "").trim() || "Product name";
titleEls.forEach(function (el) { el.textContent = name; });
const price = money(priceEl.value);
priceEls.forEach(function (el) { el.textContent = price; });
if (descOut) descOut.textContent = snippet(descEl.value);
colorsEl.replaceChildren();
let count = 0;
rows.querySelectorAll(".color-row").forEach(function (row) {
const label = (row.querySelector('[name="color_name"]').value || "").trim();
const hex = row.querySelector('[name="color_hex"]').value || "#808080";
if (!label) return;
count += 1;
const dot = document.createElement("span");
dot.className = "product-color-dot";
dot.style.background = hex;
dot.title = label;
colorsEl.appendChild(dot);
});
colorsEl.hidden = count === 0;
document.querySelectorAll("[data-product-stock]").forEach(function (el) {
el.hidden = count > 0;
});
}
function bindRemove(row) {
const btn = row.querySelector(".color-remove");
if (!btn) return;
btn.addEventListener("click", function () {
row.remove();
sync();
});
}
rows.querySelectorAll(".color-row").forEach(bindRemove);
addBtn.addEventListener("click", function () {
colorSeq += 1;
const key = "new-" + colorSeq;
const node = tpl.content.firstElementChild.cloneNode(true);
node.querySelector('[name="color_key"]').value = key;
const file = node.querySelector("[data-color-images]");
if (file) file.name = "color_images_" + key;
rows.appendChild(node);
bindRemove(node);
node.querySelector('[name="color_name"]').focus();
sync();
});
if (imageEl) {
imageEl.addEventListener("change", function () {
if (objectUrl) URL.revokeObjectURL(objectUrl);
const file = imageEl.files && imageEl.files[0];
if (file) {
objectUrl = URL.createObjectURL(file);
imgEl.src = objectUrl;
return;
}
imgEl.src = existing || placeholder;
});
}
form.addEventListener("input", sync);
sync();
})();
</script>
{% endblock %}
@@ -0,0 +1,29 @@
{% extends "portal_base.html" %}
{% block title %}Products · Portal{% endblock %}
{% block topbar_title %}Products{% endblock %}
{% block portal_content %}
<p><a class="btn btn-primary" href="{% url 'shop_portal:product_new' %}">New product</a></p>
<table class="table">
<thead><tr><th></th><th>Name</th><th>SKU</th><th>Price</th><th>Stock</th><th>Status</th></tr></thead>
<tbody>
{% for product in products %}
<tr>
<td>
{% if product.image_id %}
<img class="product-thumb" src="{{ product.image_url }}" alt="">
{% else %}
<span class="product-thumb product-thumb-empty"></span>
{% endif %}
</td>
<td><a href="{% url 'shop_portal:product_edit' product.pk %}">{{ product.name }}</a></td>
<td>{{ product.sku }}</td>
<td>{{ product.price }} {{ product.currency|upper }}</td>
<td>{{ product.display_stock_qty }}</td>
<td>{% if product.is_published %}Published{% else %}Draft{% endif %}</td>
</tr>
{% empty %}
<tr><td colspan="6" class="empty-state">No products yet.</td></tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
+147
View File
@@ -0,0 +1,147 @@
{% extends "portal_base.html" %}
{% block title %}Sales · Portal{% endblock %}
{% block topbar_title %}Sales{% endblock %}
{% block portal_content %}
<div class="stat-row">
<div class="stat-card">
<div class="label">Orders ({{ days }} days)</div>
<div class="value">{{ order_count }}</div>
</div>
<div class="stat-card">
<div class="label">Revenue ({{ days }} days)</div>
<div class="value">{{ revenue }} {{ currency|upper }}</div>
</div>
<div class="stat-card">
<div class="label">Units sold</div>
<div class="value">{{ units_sold }}</div>
</div>
<div class="stat-card">
<div class="label">Average order</div>
<div class="value">{{ aov }} {{ currency|upper }}</div>
</div>
</div>
<div class="panel">
<div class="panel-h">
<h2>Orders per day</h2>
<span class="muted">Last {{ days }} days</span>
</div>
<div class="panel-b">
<div class="chart-placeholder chart-daily" role="img"
aria-label="Paid orders per day for the last {{ days }} days">
{% for bar in daily_sales %}
<div class="chart-bar-col">
<div class="bar{% if not bar.count %} is-zero{% endif %}"
style="height:{{ bar.pct }}%"
title="{{ bar.label }}: {{ bar.count }} sale{{ bar.count|pluralize }} · {{ bar.revenue }} {{ currency|upper }}"></div>
<div class="chart-bar-meta">
<span class="chart-bar-label">{% if bar.tick %}{{ bar.tick_label }}{% else %}&nbsp;{% endif %}</span>
</div>
</div>
{% endfor %}
</div>
{% if not has_sales %}
<p class="empty-state" style="padding-top:12px">No paid orders in this window.</p>
{% endif %}
</div>
</div>
<div class="panel">
<div class="panel-h">
<h2>Revenue per day</h2>
<span class="muted">Last {{ days }} days</span>
</div>
<div class="panel-b">
<div class="chart-placeholder chart-daily chart-revenue" role="img"
aria-label="Revenue per day for the last {{ days }} days">
{% for bar in daily_revenue %}
<div class="chart-bar-col">
<div class="bar{% if not bar.revenue %} is-zero{% endif %}"
style="height:{{ bar.pct }}%"
title="{{ bar.label }}: {{ bar.revenue }} {{ currency|upper }}"></div>
<div class="chart-bar-meta">
<span class="chart-bar-label">{% if bar.tick %}{{ bar.tick_label }}{% else %}&nbsp;{% endif %}</span>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
<div class="split">
<div class="panel">
<div class="panel-h">
<h2>Top products</h2>
<span class="muted">By units sold</span>
</div>
<div class="panel-b">
{% if top_products %}
<div class="hbar-chart" role="img" aria-label="Top products by units sold">
{% for row in top_products %}
<div class="hbar-row" title="{{ row.name }} · {{ row.units }} sold · {{ row.revenue }} {{ currency|upper }}">
<div class="hbar-label">{{ row.name }}</div>
<div class="hbar-track">
<div class="hbar-fill" style="width:{{ row.bar_pct }}%"></div>
</div>
<div class="hbar-value">{{ row.units }}</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="empty-state" style="margin:0;padding:0">No product sales yet.</p>
{% endif %}
</div>
</div>
<div class="panel">
<div class="panel-h">
<h2>Product mix</h2>
<a class="btn btn-sm btn-ghost" href="{% url 'shop_portal:product_list' %}">Products</a>
</div>
<div class="panel-b" style="padding:0">
<table class="table">
<thead>
<tr><th>Product</th><th>SKU</th><th>Units</th><th>Revenue</th></tr>
</thead>
<tbody>
{% for row in top_products %}
<tr>
<td>{{ row.name }}</td>
<td>{{ row.sku }}</td>
<td>{{ row.units }}</td>
<td>{{ row.revenue }} {{ currency|upper }}</td>
</tr>
{% empty %}
<tr><td colspan="4" class="empty-state">No paid line items in the last {{ days }} days.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<div class="panel">
<div class="panel-h">
<h2>Recent sales</h2>
<a class="btn btn-sm btn-ghost" href="{% url 'shop_portal:order_list' %}">All orders</a>
</div>
<div class="panel-b" style="padding:0">
<table class="table">
<thead>
<tr><th>Number</th><th>Customer</th><th>Amount</th><th>Status</th></tr>
</thead>
<tbody>
{% for order in recent_orders %}
<tr>
<td><a href="{% url 'shop_portal:order_detail' order.pk %}">{{ order.number }}</a></td>
<td>{{ order.customer_name|default:order.email }}</td>
<td>{{ order.amount }} {{ order.currency|upper }}</td>
<td>{{ order.get_status_display }}</td>
</tr>
{% empty %}
<tr><td colspan="4" class="empty-state">No paid orders yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+13
View File
@@ -0,0 +1,13 @@
{% extends "base.html" %}
{% block title %}Order {{ order.number }}{% endblock %}
{% block content %}
{% include "public/_breadcrumbs.html" with title="Thank You" %}
<section class="section section-lg bg-default text-center">
<div class="container">
<h2>Thank <span class="text-italic font-weight-thin">you</span></h2>
<p class="big">Order {{ order.number }} is {{ order.get_status_display|lower }}.</p>
<p>A confirmation will go to {{ order.email }}.</p>
<a class="button button-lg button-primary" href="{% url 'shop:list' %}">Continue shopping</a>
</div>
</section>
{% endblock %}
+701
View File
@@ -0,0 +1,701 @@
import struct
from datetime import timedelta
from decimal import Decimal
from io import BytesIO
from pathlib import Path
from unittest.mock import patch
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core import mail
from django.core.files.storage import default_storage
from django.core.files.storage.memory import InMemoryStorage
from django.core.files.uploadedfile import SimpleUploadedFile, TemporaryUploadedFile
from django.db import models
from django.test import Client, TestCase, override_settings
from django.urls import reverse
from django.utils import timezone
from PIL import Image
from core.models import StoredFile
from shop.models import Order, OrderItem, Product, ProductColor, ProductImage
from shop.services import (
ShopError,
add_to_cart,
adjust_stock,
available_qty,
create_order_from_cart,
looks_like_stl,
mark_paid,
next_order_number,
store_product_image,
store_product_stl,
)
from shop.stats import sales_dashboard
def _tiny_png() -> bytes:
image = Image.new("RGBA", (8, 8), (200, 40, 40, 255))
buf = BytesIO()
image.save(buf, format="PNG")
return buf.getvalue()
def _tiny_stl() -> bytes:
header = b"tiny" + b"\x00" * 76
count = struct.pack("<I", 1)
triangle = struct.pack(
"<12fH",
0.0,
0.0,
1.0,
0.0,
0.0,
0.0,
1.0,
0.0,
0.0,
0.0,
1.0,
0.0,
0,
)
return header + count + triangle
def _product(**kwargs):
defaults = dict(
name="Dragon figurine",
sku="TOY-001",
price=Decimal("18.00"),
stock_qty=5,
is_published=True,
fulfillment=Product.Fulfillment.STOCKED,
)
defaults.update(kwargs)
return Product.objects.create(**defaults)
class ShopPublicTests(TestCase):
def test_list_hides_unpublished(self):
_product(name="Live", sku="LIVE-1")
_product(name="Draft", sku="DRAFT-1", is_published=False)
response = Client().get(reverse("shop:list"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Live")
self.assertNotContains(response, "Draft")
def test_add_to_cart_then_checkout_form(self):
product = _product()
client = Client()
response = client.post(
reverse("shop:cart_add", kwargs={"slug": product.slug}),
{"quantity": "2"},
)
self.assertEqual(response.status_code, 302)
cart = client.get(reverse("shop:cart"))
self.assertContains(cart, "Dragon figurine")
self.assertContains(cart, "36.00")
checkout = client.get(reverse("shop:checkout"))
self.assertEqual(checkout.status_code, 200)
self.assertContains(checkout, "Pay with Stripe")
def test_detail_and_list_show_product_image_and_colors(self):
stored = StoredFile.objects.create(
kind=StoredFile.Kind.PRODUCT_IMAGE,
filename="dragon.png",
content_type="image/png",
size=len(_tiny_png()),
data=_tiny_png(),
)
product = _product(image=stored)
ProductColor.objects.create(
product=product, name="Crimson", hex="#c41e3a", sort_order=0
)
ProductColor.objects.create(
product=product, name="Navy", hex="#1e3a8a", sort_order=1
)
client = Client()
listing = client.get(reverse("shop:list"))
self.assertContains(listing, product.image_url)
self.assertContains(listing, 'title="Crimson"')
detail = client.get(product.get_absolute_url())
self.assertEqual(detail.status_code, 200)
self.assertContains(detail, product.image_url)
self.assertContains(detail, "Crimson")
self.assertContains(detail, 'name="color"')
html = detail.content.decode()
self.assertIn('data-kind="photo"', html)
def test_detail_shows_photo_before_3d_and_multiple_photos(self):
photo_a = StoredFile.objects.create(
kind=StoredFile.Kind.PRODUCT_IMAGE,
filename="a.png",
content_type="image/png",
size=len(_tiny_png()),
data=_tiny_png(),
)
photo_b = StoredFile.objects.create(
kind=StoredFile.Kind.PRODUCT_IMAGE,
filename="b.png",
content_type="image/png",
size=len(_tiny_png()),
data=_tiny_png(),
)
stl = StoredFile.objects.create(
kind=StoredFile.Kind.PRODUCT_STL,
filename="dragon.stl",
content_type="model/stl",
size=len(_tiny_stl()),
data=_tiny_stl(),
)
product = _product(image=photo_a, stl=stl)
ProductImage.objects.create(product=product, file=photo_a, sort_order=0)
ProductImage.objects.create(product=product, file=photo_b, sort_order=1)
detail = Client().get(product.get_absolute_url())
html = detail.content.decode()
self.assertLess(html.index('data-kind="photo"'), html.index('data-kind="model"'))
self.assertIn("data-gallery-model", html)
self.assertContains(detail, reverse("core:stored_file", kwargs={"pk": photo_a.pk}))
self.assertContains(detail, reverse("core:stored_file", kwargs={"pk": photo_b.pk}))
self.assertRegex(html, r"data-gallery-model\s+hidden")
def test_detail_color_swaps_photos_and_keeps_shared_stl(self):
red_file = StoredFile.objects.create(
kind=StoredFile.Kind.PRODUCT_IMAGE,
filename="red.png",
content_type="image/png",
size=len(_tiny_png()),
data=_tiny_png(),
)
blue_file = StoredFile.objects.create(
kind=StoredFile.Kind.PRODUCT_IMAGE,
filename="blue.png",
content_type="image/png",
size=len(_tiny_png()),
data=_tiny_png(),
)
stl = StoredFile.objects.create(
kind=StoredFile.Kind.PRODUCT_STL,
filename="toy.stl",
content_type="model/stl",
size=len(_tiny_stl()),
data=_tiny_stl(),
)
product = _product(image=red_file, stl=stl)
red = ProductColor.objects.create(
product=product, name="Red", hex="#ff0000", stock_qty=2, sort_order=0
)
blue = ProductColor.objects.create(
product=product, name="Blue", hex="#0000ff", stock_qty=9, sort_order=1
)
ProductImage.objects.create(
product=product, color=red, file=red_file, sort_order=0
)
ProductImage.objects.create(
product=product, color=blue, file=blue_file, sort_order=0
)
detail = Client().get(product.get_absolute_url())
self.assertContains(detail, reverse("core:stored_file", kwargs={"pk": red_file.pk}))
self.assertContains(detail, reverse("core:stored_file", kwargs={"pk": blue_file.pk}))
self.assertContains(detail, product.stl_url)
self.assertContains(detail, "2 in stock")
payload = detail.context["gallery_data"]
self.assertEqual(payload["stl"], product.stl_url)
self.assertEqual(
payload["colors"][str(red.pk)]["images"][0],
reverse("core:stored_file", kwargs={"pk": red_file.pk}),
)
self.assertEqual(payload["colors"][str(blue.pk)]["available"], 9)
def test_detail_embeds_stl_viewer(self):
stored = StoredFile.objects.create(
kind=StoredFile.Kind.PRODUCT_STL,
filename="dragon.stl",
content_type="model/stl",
size=len(_tiny_stl()),
data=_tiny_stl(),
)
product = _product(stl=stored)
detail = Client().get(product.get_absolute_url())
self.assertEqual(detail.status_code, 200)
self.assertContains(detail, 'data-stl-viewer')
self.assertContains(detail, product.stl_url)
self.assertContains(detail, "stl-viewer.js")
self.assertContains(detail, "Drag to spin")
def test_add_to_cart_requires_color_when_product_has_colors(self):
product = _product()
color = ProductColor.objects.create(
product=product, name="Gold", hex="#d4af37"
)
client = Client()
missing = client.post(
reverse("shop:cart_add", kwargs={"slug": product.slug}),
{"quantity": "1"},
)
self.assertEqual(missing.status_code, 302)
self.assertEqual(missing["Location"], product.get_absolute_url())
added = client.post(
reverse("shop:cart_add", kwargs={"slug": product.slug}),
{"quantity": "1", "color": str(color.pk)},
)
self.assertEqual(added.status_code, 302)
cart = client.get(reverse("shop:cart"))
self.assertContains(cart, "Color: Gold")
checkout = client.get(reverse("shop:checkout"))
self.assertContains(checkout, "Gold")
def test_order_snapshots_selected_color(self):
product = _product()
color = ProductColor.objects.create(
product=product, name="Forest", hex="#228b22", stock_qty=2
)
session = self.client.session
add_to_cart(session, product, 1, color=color)
session.save()
order = create_order_from_cart(
self.client.session, email="buyer@example.com"
)
item = order.items.get()
self.assertEqual(item.color, color)
self.assertEqual(item.color_name, "Forest")
self.assertIn("Forest", item.name)
def test_paid_order_decrements_color_stock(self):
product = _product(stock_qty=10)
red = ProductColor.objects.create(
product=product, name="Red", hex="#ff0000", stock_qty=4
)
blue = ProductColor.objects.create(
product=product, name="Blue", hex="#0000ff", stock_qty=6
)
session = self.client.session
add_to_cart(session, product, 3, color=red)
session.save()
order = create_order_from_cart(
self.client.session, email="buyer@example.com"
)
mark_paid(order)
red.refresh_from_db()
blue.refresh_from_db()
product.refresh_from_db()
self.assertEqual(red.stock_qty, 1)
self.assertEqual(blue.stock_qty, 6)
self.assertEqual(product.stock_qty, 10)
class ShopInventoryTests(TestCase):
def test_stocked_availability_and_adjust(self):
product = _product(stock_qty=4)
self.assertEqual(available_qty(product), 4)
adjust_stock(product, -2)
product.refresh_from_db()
self.assertEqual(product.stock_qty, 2)
with self.assertRaises(ShopError):
adjust_stock(product, -5)
def test_mark_paid_decrements_stock_and_emails(self):
product = _product(stock_qty=3)
session = self.client.session
add_to_cart(session, product, 2)
session.save()
order = create_order_from_cart(
self.client.session, email="buyer@example.com", customer_name="Pat"
)
mark_paid(order)
product.refresh_from_db()
self.assertEqual(product.stock_qty, 1)
order.refresh_from_db()
self.assertEqual(order.status, Order.Status.PAID)
self.assertEqual(len(mail.outbox), 1)
self.assertIn(order.number, mail.outbox[0].subject)
def test_insufficient_stock_blocks_order(self):
product = _product(stock_qty=1)
session = self.client.session
add_to_cart(session, product, 3)
session.save()
with self.assertRaises(ShopError):
create_order_from_cart(self.client.session, email="buyer@example.com")
def test_insufficient_color_stock_blocks_order(self):
product = _product(stock_qty=10)
color = ProductColor.objects.create(
product=product, name="Red", hex="#ff0000", stock_qty=1
)
session = self.client.session
add_to_cart(session, product, 3, color=color)
session.save()
with self.assertRaises(ShopError):
create_order_from_cart(self.client.session, email="buyer@example.com")
@override_settings(SHOP_PRINT_QUEUE_LIMIT_MINUTES=60)
def test_made_to_order_queue_limit(self):
product = _product(
sku="MTO-1",
fulfillment=Product.Fulfillment.MADE_TO_ORDER,
print_minutes=30,
stock_qty=0,
)
self.assertEqual(available_qty(product), 2)
def test_next_number_increments(self):
n1 = next_order_number()
Order.objects.create(
number=n1,
email="a@example.com",
amount=Decimal("1.00"),
)
n2 = next_order_number()
self.assertNotEqual(n1, n2)
def test_looks_like_stl(self):
self.assertTrue(looks_like_stl(_tiny_stl(), "toy.stl"))
self.assertFalse(looks_like_stl(_tiny_stl(), "toy.bin"))
self.assertFalse(looks_like_stl(b"not an stl file at all" + b"x" * 80, "toy.stl"))
ascii_stl = (
b"solid test\n"
b" facet normal 0 0 1\n"
b" outer loop\n"
b" vertex 0 0 0\n"
b" vertex 1 0 0\n"
b" vertex 0 1 0\n"
b" endloop\n"
b" endfacet\n"
b"endsolid test\n"
)
self.assertTrue(looks_like_stl(ascii_stl, "ascii.stl"))
class ShopPortalTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user("merchant", password="test-pass-123")
self.client = Client()
self.client.login(username="merchant", password="test-pass-123")
def test_list_requires_login(self):
anon = Client()
self.assertEqual(anon.get(reverse("shop_portal:product_list")).status_code, 302)
def test_create_and_adjust_stock(self):
response = self.client.post(
reverse("shop_portal:product_new"),
{
"name": "Booster box",
"sku": "TCG-BOX",
"price": "89.99",
"stock_qty": "10",
"fulfillment": "stocked",
"is_published": "on",
"track_inventory": "on",
},
)
self.assertEqual(response.status_code, 302)
product = Product.objects.get(sku="TCG-BOX")
self.assertTrue(product.is_published)
self.assertEqual(product.slug, "booster-box")
adjust = self.client.post(
reverse("shop_portal:product_stock", kwargs={"pk": product.pk}),
{"delta": "-3"},
)
self.assertEqual(adjust.status_code, 302)
product.refresh_from_db()
self.assertEqual(product.stock_qty, 7)
def test_portal_uses_public_favicon(self):
response = self.client.get(reverse("shop_portal:product_list"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "brand/favicon.ico")
self.assertContains(response, "apple-touch-icon")
def test_new_product_form_has_card_preview(self):
response = self.client.get(reverse("shop_portal:product_new"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Shop card preview")
self.assertContains(response, 'name="images"')
self.assertContains(response, 'name="stl"')
self.assertContains(response, "Add color")
def test_create_product_with_stl(self):
upload = SimpleUploadedFile(
"dragon.stl", _tiny_stl(), content_type="application/octet-stream"
)
response = self.client.post(
reverse("shop_portal:product_new"),
{
"name": "Spinny toy",
"sku": "STL-1",
"price": "12.00",
"stock_qty": "2",
"fulfillment": "stocked",
"is_published": "on",
"track_inventory": "on",
"stl": upload,
},
)
self.assertEqual(response.status_code, 302)
product = Product.objects.get(sku="STL-1")
self.assertTrue(product.stl_id)
self.assertEqual(product.stl.kind, StoredFile.Kind.PRODUCT_STL)
self.assertEqual(product.stl.content_type, "model/stl")
detail = Client().get(product.get_absolute_url())
self.assertContains(detail, product.stl_url)
def test_rejects_invalid_stl(self):
upload = SimpleUploadedFile(
"dragon.stl", b"nope" + b"\x00" * 100, content_type="application/octet-stream"
)
response = self.client.post(
reverse("shop_portal:product_new"),
{
"name": "Bad mesh",
"sku": "STL-BAD",
"price": "12.00",
"stock_qty": "1",
"fulfillment": "stocked",
"stl": upload,
},
)
self.assertEqual(response.status_code, 200)
self.assertFalse(Product.objects.filter(sku="STL-BAD").exists())
def test_create_product_with_image_and_colors(self):
upload = SimpleUploadedFile("dot.png", _tiny_png(), content_type="image/png")
with patch(
"shop.imaging.cutout_subject",
side_effect=lambda image: image.convert("RGBA"),
):
response = self.client.post(
reverse("shop_portal:product_new"),
{
"name": "Mech dragon",
"sku": "MECH-1",
"price": "24.00",
"stock_qty": "3",
"fulfillment": "made_to_order",
"print_minutes": "45",
"filament_grams": "80",
"description": "Articulated toy.",
"is_published": "on",
"track_inventory": "on",
"images": upload,
"color_id": ["", ""],
"color_key": ["new-1", "new-2"],
"color_name": ["Red", "Blue"],
"color_hex": ["#ff0000", "#0000ff"],
"color_stock": ["4", "7"],
},
)
self.assertEqual(response.status_code, 302)
product = Product.objects.get(sku="MECH-1")
self.assertTrue(product.image_id)
self.assertEqual(product.image.kind, StoredFile.Kind.PRODUCT_IMAGE)
self.assertEqual(product.image.content_type, "image/png")
self.assertEqual(product.image.filename, "dot.png")
framed = Image.open(BytesIO(bytes(product.image.data)))
self.assertEqual(framed.size, (1200, 1200))
colors = list(product.colors.all())
self.assertEqual([c.name for c in colors], ["Red", "Blue"])
self.assertEqual(colors[0].hex, "#ff0000")
self.assertEqual([c.stock_qty for c in colors], [4, 7])
self.assertEqual(product.images.count(), 1)
self.assertIsNone(product.images.get().color_id)
listing = Client().get(reverse("shop:list"))
self.assertContains(listing, product.image_url)
self.assertContains(listing, 'title="Red"')
self.assertTrue(product.image_url.startswith("/files/"))
self.assertNotIn("/media/", product.image_url)
fetch = Client().get(product.image_url)
self.assertEqual(fetch.status_code, 200)
self.assertEqual(
b"".join(fetch.streaming_content), bytes(product.image.data)
)
def test_create_product_with_color_photos_and_shared_stl(self):
catalog = SimpleUploadedFile("card.png", _tiny_png(), content_type="image/png")
red_photo = SimpleUploadedFile("red.png", _tiny_png(), content_type="image/png")
extra = SimpleUploadedFile("red-2.png", _tiny_png(), content_type="image/png")
stl = SimpleUploadedFile(
"toy.stl", _tiny_stl(), content_type="application/octet-stream"
)
with patch(
"shop.imaging.cutout_subject",
side_effect=lambda image: image.convert("RGBA"),
):
response = self.client.post(
reverse("shop_portal:product_new"),
{
"name": "Color dragon",
"sku": "COLOR-1",
"price": "30.00",
"stock_qty": "0",
"fulfillment": "stocked",
"is_published": "on",
"track_inventory": "on",
"images": catalog,
"stl": stl,
"color_id": ["", ""],
"color_key": ["new-1", "new-2"],
"color_name": ["Crimson", "Navy"],
"color_hex": ["#c41e3a", "#1e3a8a"],
"color_stock": ["3", "5"],
"color_images_new-1": [red_photo, extra],
},
)
self.assertEqual(response.status_code, 302)
product = Product.objects.get(sku="COLOR-1")
crimson = product.colors.get(name="Crimson")
navy = product.colors.get(name="Navy")
self.assertTrue(product.stl_id)
self.assertEqual(product.images.filter(color=None).count(), 1)
self.assertEqual(product.images.filter(color=crimson).count(), 2)
self.assertEqual(product.images.filter(color=navy).count(), 0)
self.assertEqual(crimson.stock_qty, 3)
detail = Client().get(product.get_absolute_url())
html = detail.content.decode()
self.assertLess(html.index('data-kind="photo"'), html.index('data-kind="model"'))
self.assertContains(detail, product.stl_url)
self.assertEqual(detail.context["available"], 3)
def test_product_image_is_database_blob_not_file_field(self):
image_field = Product._meta.get_field("image")
self.assertIsInstance(image_field, models.ForeignKey)
self.assertFalse(isinstance(image_field, models.FileField))
self.assertEqual(image_field.related_model, StoredFile)
self.assertIsInstance(StoredFile._meta.get_field("data"), models.BinaryField)
self.assertIsInstance(default_storage, InMemoryStorage)
self.assertGreaterEqual(settings.FILE_UPLOAD_MAX_MEMORY_SIZE, 15 * 1024 * 1024)
def test_temporary_image_upload_copied_into_database_then_unlinked(self):
with TemporaryUploadedFile("dot.png", "image/png", 0, "utf-8") as tmp:
tmp.write(_tiny_png())
tmp.seek(0)
tmp_path = Path(tmp.temporary_file_path())
self.assertTrue(tmp_path.exists())
with patch(
"shop.imaging.cutout_subject",
side_effect=lambda image: image.convert("RGBA"),
):
stored = store_product_image(upload=tmp, user=self.user)
self.assertFalse(tmp_path.exists())
self.assertTrue(bytes(stored.data))
self.assertEqual(stored.kind, StoredFile.Kind.PRODUCT_IMAGE)
def test_temporary_stl_upload_copied_into_database_then_unlinked(self):
with TemporaryUploadedFile(
"toy.stl", "application/octet-stream", 0, "utf-8"
) as tmp:
tmp.write(_tiny_stl())
tmp.seek(0)
tmp_path = Path(tmp.temporary_file_path())
self.assertTrue(tmp_path.exists())
stored = store_product_stl(upload=tmp, user=self.user)
self.assertFalse(tmp_path.exists())
self.assertEqual(bytes(stored.data), _tiny_stl())
self.assertEqual(stored.kind, StoredFile.Kind.PRODUCT_STL)
def _sold_order(product, *, qty=1, paid_at=None, status=None, number=None):
n = Order.objects.count() + 1
order = Order.objects.create(
number=number or f"ORD-TEST-{n:04d}",
email=f"buyer{n}@example.com",
customer_name="Pat",
status=status or Order.Status.PAID,
amount=product.price * qty,
currency="usd",
paid_at=paid_at if paid_at is not None else timezone.now(),
)
OrderItem.objects.create(
order=order,
product=product,
name=product.name,
sku=product.sku,
quantity=qty,
unit_price=product.price,
)
return order
class ShopSalesDashboardTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user("merchant", password="test-pass-123")
self.client = Client()
self.client.login(username="merchant", password="test-pass-123")
self.dragon = _product(name="Dragon", sku="DRAGON", price=Decimal("18.00"))
self.fox = _product(name="Fox", sku="FOX", price=Decimal("12.00"))
def test_sales_requires_login(self):
anon = Client()
self.assertEqual(anon.get(reverse("shop_portal:sales")).status_code, 302)
def test_empty_dashboard(self):
data = sales_dashboard()
self.assertEqual(data["order_count"], 0)
self.assertEqual(data["revenue"], Decimal("0.00"))
self.assertEqual(len(data["daily_sales"]), 30)
self.assertFalse(data["has_sales"])
response = self.client.get(reverse("shop_portal:sales"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "No paid orders in this window")
self.assertContains(response, "Sales")
self.assertContains(response, "Orders per day")
self.assertContains(response, "Top products")
def test_counts_paid_and_fulfilled_in_window(self):
now = timezone.now()
_sold_order(self.dragon, qty=2, paid_at=now)
_sold_order(self.fox, qty=5, paid_at=now - timedelta(days=2))
_sold_order(
self.dragon,
qty=1,
paid_at=now - timedelta(days=1),
status=Order.Status.FULFILLED,
)
_sold_order(
self.fox,
qty=9,
paid_at=now - timedelta(days=40),
)
open_order = _sold_order(self.dragon, qty=3, paid_at=None, status=Order.Status.OPEN)
open_order.paid_at = None
open_order.save(update_fields=["paid_at"])
cancelled = _sold_order(
self.fox, qty=4, paid_at=now, status=Order.Status.CANCELLED
)
self.assertEqual(cancelled.status, Order.Status.CANCELLED)
data = sales_dashboard()
self.assertEqual(data["order_count"], 3)
self.assertEqual(data["units_sold"], 8)
self.assertEqual(data["revenue"], Decimal("114.00"))
self.assertEqual(data["aov"], Decimal("38.00"))
self.assertEqual([row["sku"] for row in data["top_products"]], ["FOX", "DRAGON"])
self.assertEqual(data["top_products"][0]["units"], 5)
self.assertEqual(data["top_products"][1]["units"], 3)
today_bar = data["daily_sales"][-1]
self.assertEqual(today_bar["count"], 1)
response = self.client.get(reverse("shop_portal:sales"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Dragon")
self.assertContains(response, "Fox")
self.assertContains(response, "114.00")
self.assertContains(response, "Orders per day")
self.assertContains(response, "Revenue per day")
def test_portal_nav_and_home_stats(self):
_sold_order(self.dragon, qty=1)
home = self.client.get(reverse("dashboard:home"))
self.assertEqual(home.status_code, 200)
self.assertEqual(home.context["shop_sales_30d"], 1)
self.assertEqual(home.context["shop_revenue_30d"], Decimal("18.00"))
self.assertContains(home, reverse("shop_portal:sales"))
self.assertContains(home, "Sales (30 days)")
products = self.client.get(reverse("shop_portal:product_list"))
self.assertContains(products, "Sales")
sales = self.client.get(reverse("shop_portal:sales"))
self.assertContains(sales, 'class="active"')
+50
View File
@@ -0,0 +1,50 @@
from io import BytesIO
from unittest.mock import patch
from django.test import SimpleTestCase
from PIL import Image
from shop.imaging import (
CANVAS_SIZE,
center_on_square,
prepare_product_photo,
product_image_filename,
)
def _rgba_png(width: int, height: int, box: tuple[int, int, int, int]) -> bytes:
image = Image.new("RGBA", (width, height), (0, 0, 0, 0))
swatch = Image.new("RGBA", (box[2] - box[0], box[3] - box[1]), (220, 40, 40, 255))
image.paste(swatch, (box[0], box[1]))
buf = BytesIO()
image.save(buf, format="PNG")
return buf.getvalue()
class ProductPhotoPrepTests(SimpleTestCase):
def test_center_on_square_fills_and_centers(self):
source = Image.open(BytesIO(_rgba_png(400, 300, (10, 20, 50, 80))))
framed = center_on_square(source, size=200, padding=0.1)
self.assertEqual(framed.size, (200, 200))
alpha = framed.getchannel("A")
bbox = alpha.point(lambda value: 255 if value > 24 else 0).getbbox()
self.assertIsNotNone(bbox)
left, top, right, bottom = bbox
cx = (left + right) / 2
cy = (top + bottom) / 2
self.assertAlmostEqual(cx, 100, delta=8)
self.assertAlmostEqual(cy, 100, delta=8)
self.assertGreaterEqual(max(right - left, bottom - top), 150)
def test_prepare_outputs_square_png_without_rembg(self):
data = _rgba_png(80, 60, (5, 8, 25, 40))
with patch("shop.imaging.cutout_subject", side_effect=lambda image: image.convert("RGBA")):
png, content_type = prepare_product_photo(data)
self.assertEqual(content_type, "image/png")
result = Image.open(BytesIO(png))
self.assertEqual(result.size, (CANVAS_SIZE, CANVAS_SIZE))
self.assertEqual(result.mode, "RGBA")
def test_filename_becomes_png(self):
self.assertEqual(product_image_filename("toy.JPG"), "toy.png")
self.assertEqual(product_image_filename(""), "product.png")
+396
View File
@@ -0,0 +1,396 @@
import logging
from decimal import Decimal, InvalidOperation
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.db import transaction
from django.db.models import Prefetch
from django.http import HttpResponse, HttpResponseBadRequest
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.text import slugify
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods, require_POST
from contacts.models import Contact
from shop.models import Order, Product, ProductColor, ProductImage
from shop.services import (
ShopError,
add_to_cart,
adjust_stock,
append_product_images,
available_qty,
cart_lines,
cart_total,
create_checkout_session,
create_order_from_cart,
mark_paid,
product_media_payload,
refresh_listing_image,
remove_product_images,
save_cart,
set_cart_qty,
store_product_stl,
sync_product_colors,
)
from shop.stats import sales_dashboard
logger = logging.getLogger(__name__)
def _delete_replaced_file(previous, current_id, kind):
if previous and previous.pk != current_id and previous.kind == kind:
previous.delete()
def _site_base(request) -> str:
base = (settings.PUBLIC_SITE_URL or "").rstrip("/")
if base:
return base
return request.build_absolute_uri("/").rstrip("/")
def _gallery_prefetch():
def images():
return ProductImage.objects.select_related("file").order_by(
"sort_order", "created_at"
)
return (
Prefetch("images", queryset=images()),
Prefetch(
"colors",
queryset=ProductColor.objects.prefetch_related(
Prefetch("images", queryset=images())
),
),
)
def product_list(request):
products = Product.objects.filter(is_published=True).select_related(
"image"
).prefetch_related("colors")
return render(request, "shop/list.html", {"products": products})
def product_detail(request, slug):
product = get_object_or_404(
Product.objects.select_related("image", "stl").prefetch_related(
*_gallery_prefetch()
),
slug=slug,
is_published=True,
)
colors = list(product.colors.all())
selected = colors[0] if colors else None
return render(
request,
"shop/detail.html",
{
"product": product,
"colors": colors,
"selected_color": selected,
"available": available_qty(product, selected),
"gallery_items": product.gallery_items(selected),
"gallery_photos": product.photos_for(selected),
"gallery_data": product_media_payload(product, colors),
},
)
def cart_view(request):
lines = cart_lines(request.session)
return render(
request,
"shop/cart.html",
{"lines": lines, "total": cart_total(lines)},
)
@require_POST
def cart_add(request, slug):
product = get_object_or_404(
Product.objects.prefetch_related("colors"),
slug=slug,
is_published=True,
)
try:
qty = int(request.POST.get("quantity") or "1")
except ValueError:
qty = 1
color = None
colors = list(product.colors.all())
if colors:
color_id = (request.POST.get("color") or "").strip()
color = next((item for item in colors if str(item.pk) == color_id), None)
if color is None:
messages.error(request, "Choose a color.")
return redirect("shop:detail", slug=product.slug)
try:
add_to_cart(request.session, product, qty, color=color)
except ShopError as exc:
messages.error(request, str(exc))
return redirect("shop:detail", slug=product.slug)
label = f"{product.name} ({color.name})" if color else product.name
messages.success(request, f"Added {label} to cart.")
return redirect("shop:cart")
@require_POST
def cart_update(request, slug):
product = get_object_or_404(Product, slug=slug)
try:
qty = int(request.POST.get("quantity") or "0")
except ValueError:
qty = 0
color = None
color_id = (request.POST.get("color") or "").strip()
if color_id:
color = get_object_or_404(ProductColor, pk=color_id, product=product)
set_cart_qty(request.session, product, qty, color=color)
return redirect("shop:cart")
@require_http_methods(["GET", "POST"])
def checkout(request):
lines = cart_lines(request.session)
if not lines:
messages.error(request, "Cart is empty.")
return redirect("shop:cart")
if request.method == "POST":
email = (request.POST.get("email") or "").strip()
name = (request.POST.get("customer_name") or "").strip()
address = Contact.make_postal_address(
line1=request.POST.get("address_line1") or "",
line2=request.POST.get("address_line2") or "",
city=request.POST.get("address_city") or "",
state=request.POST.get("address_state") or "",
zip_code=request.POST.get("address_zip") or "",
)
try:
order = create_order_from_cart(
request.session,
email=email,
customer_name=name,
shipping_address=address,
)
base = _site_base(request)
success = base + reverse("shop:checkout_success", kwargs={"pk": order.pk})
cancel = base + reverse("shop:checkout_cancel", kwargs={"pk": order.pk})
url = create_checkout_session(
order,
success_url=success + "?session_id={CHECKOUT_SESSION_ID}",
cancel_url=cancel,
)
except ShopError as exc:
messages.error(request, str(exc))
except Exception as exc: # noqa: BLE001
logger.exception("shop checkout failed")
messages.error(request, f"Could not start checkout: {exc}")
else:
save_cart(request.session, {})
return redirect(url)
return render(
request,
"shop/checkout.html",
{"lines": lines, "total": cart_total(lines)},
)
def checkout_success(request, pk):
order = get_object_or_404(Order, pk=pk)
return render(request, "shop/success.html", {"order": order})
def checkout_cancel(request, pk):
order = get_object_or_404(Order, pk=pk)
return render(request, "shop/cancel.html", {"order": order})
@login_required
def portal_product_list(request):
products = Product.objects.select_related("image").prefetch_related("colors")
return render(request, "shop/portal/products.html", {"products": products})
@login_required
@require_http_methods(["GET", "POST"])
def portal_product_edit(request, pk=None):
product = (
get_object_or_404(
Product.objects.select_related("image", "stl").prefetch_related(
*_gallery_prefetch()
),
pk=pk,
)
if pk
else None
)
if request.method == "POST":
name = (request.POST.get("name") or "").strip()
sku = (request.POST.get("sku") or "").strip()
description = (request.POST.get("description") or "").strip()
slug = (request.POST.get("slug") or "").strip()
fulfillment = request.POST.get("fulfillment") or Product.Fulfillment.STOCKED
errors = []
if not name:
errors.append("Name is required.")
try:
price = Decimal(request.POST.get("price") or "")
if price < 0:
raise InvalidOperation
except Exception:
price = None
errors.append("Enter a valid price.")
try:
stock_qty = int(request.POST.get("stock_qty") or "0")
except ValueError:
stock_qty = 0
errors.append("Stock must be a number.")
try:
print_minutes = int(request.POST.get("print_minutes") or "0")
filament_grams = int(request.POST.get("filament_grams") or "0")
except ValueError:
print_minutes = 0
filament_grams = 0
stl_upload = request.FILES.get("stl")
if errors:
for err in errors:
messages.error(request, err)
else:
try:
with transaction.atomic():
stored_stl = None
if stl_upload:
stored_stl = store_product_stl(
upload=stl_upload, user=request.user
)
if product is None:
product = Product()
previous_stl = product.stl
product.name = name
product.sku = sku
product.description = description
product.slug = slugify(slug)[:220] if slug else ""
product.price = price
product.currency = (settings.STRIPE_CURRENCY or "usd").lower()
product.fulfillment = fulfillment
product.stock_qty = stock_qty
product.print_minutes = max(print_minutes, 0)
product.filament_grams = max(filament_grams, 0)
product.is_published = request.POST.get("is_published") == "on"
product.track_inventory = request.POST.get("track_inventory") == "on"
if stored_stl is not None:
product.stl = stored_stl
elif request.POST.get("clear_stl") == "on":
product.stl = None
product.save()
colors = sync_product_colors(
product,
ids=request.POST.getlist("color_id"),
names=request.POST.getlist("color_name"),
hexes=request.POST.getlist("color_hex"),
keys=request.POST.getlist("color_key"),
stocks=request.POST.getlist("color_stock"),
)
remove_product_images(
product, request.POST.getlist("remove_image")
)
append_product_images(
product,
uploads=request.FILES.getlist("images"),
user=request.user,
)
for key, color in colors.items():
uploads = request.FILES.getlist(f"color_images_{key}")
if not uploads:
continue
append_product_images(
product,
uploads=uploads,
user=request.user,
color=color,
)
refresh_listing_image(product)
_delete_replaced_file(
previous_stl,
product.stl_id,
previous_stl.Kind.PRODUCT_STL if previous_stl else None,
)
except ShopError as exc:
messages.error(request, str(exc))
else:
messages.success(request, f"Saved {product.name}.")
return redirect("shop_portal:product_list")
return render(request, "shop/portal/product_edit.html", {"product": product})
@login_required
@require_POST
def portal_stock_adjust(request, pk):
product = get_object_or_404(Product, pk=pk)
try:
delta = int(request.POST.get("delta") or "0")
except ValueError:
messages.error(request, "Enter a whole-number adjustment.")
return redirect("shop_portal:product_edit", pk=product.pk)
try:
adjust_stock(product, delta)
except ShopError as exc:
messages.error(request, str(exc))
else:
messages.success(request, f"{product.sku} stock is now {product.stock_qty}.")
return redirect("shop_portal:product_edit", pk=product.pk)
@login_required
def portal_sales(request):
return render(request, "shop/portal/sales.html", sales_dashboard())
@login_required
def portal_order_list(request):
orders = Order.objects.all()[:200]
return render(request, "shop/portal/orders.html", {"orders": orders})
@login_required
def portal_order_detail(request, pk):
order = get_object_or_404(Order.objects.prefetch_related("items"), pk=pk)
return render(request, "shop/portal/order_detail.html", {"order": order})
@csrf_exempt
@require_http_methods(["POST"])
def stripe_webhook(request):
secret = (settings.STRIPE_WEBHOOK_SECRET or "").strip()
if not secret:
logger.error("STRIPE_WEBHOOK_SECRET unset")
return HttpResponseBadRequest("webhook not configured")
try:
import stripe
except ImportError:
return HttpResponseBadRequest("stripe not installed")
sig = request.headers.get("Stripe-Signature", "")
try:
event = stripe.Webhook.construct_event(request.body, sig, secret)
except Exception:
logger.exception("shop stripe webhook signature failed")
return HttpResponseBadRequest("invalid signature")
obj = event.get("data", {}).get("object", {}) or {}
if event.get("type") != "checkout.session.completed":
return HttpResponse("ok")
order_id = (obj.get("metadata") or {}).get("shop_order_id") or ""
order = None
if order_id:
order = Order.objects.filter(pk=order_id).first()
if order is None:
session_id = obj.get("id") or ""
order = Order.objects.filter(stripe_checkout_session_id=session_id).first()
if order and order.status != Order.Status.PAID:
mark_paid(order, stripe_id=obj.get("id") or "")
logger.info("shop order %s marked paid", order.number)
return HttpResponse("ok")