generated from westfarn/web_django_template
Ship Print Forge shop site (colors, photos, 3D viewer) (#2)
## Summary - Rebrand the Django client template as Print Forge (`client_site` → `print_forge`) with shop + shipping enabled. - Product listings support multiple photos, color variants (shared price/description/STL, per-color stock and photos), and a photo-first / 3D-second gallery. - Public pages use 3D printer / printed-toy photography instead of leftover t-shirt mockups; beta CI deploys on `master`. Closes #1 Infra: [server-infra#27](ai_ml_operations/server-infra#27) (easy deploy beta). ## Test plan - [ ] Product page shows photo first, 3D model second; color swatches swap photos and stock - [ ] Portal can upload multiple photos and per-color qty/images; STL stays shared - [ ] Public home/about/gallery have no t-shirt mockups - [ ] `manage.py test` passes - [ ] After server-infra#27: beta deploy to `print-forge-preview.aimloperations.com` Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
@@ -0,0 +1,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]
|
||||
@@ -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()
|
||||
@@ -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"],
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %} {% 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 %} {% 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 %}
|
||||
@@ -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 %}
|
||||
@@ -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"')
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user