Add shop, POS sync, event ticketing, and shipping catalog apps (#6)

## Summary
- Closes #5
- Optional `shop`, `pos_sync`, `events`, and `shipping` apps gated by `FEATURE_*` flags
- Deps match the catalog: shop/events need email + Stripe; POS/shipping need shop
- Portal inventory, POS webhooks, capacity tickets, EasyPost/Pirate Ship shipping

## Test plan
- [ ] `manage.py test` (152 passed locally)
- [ ] Shop cart + paid order decrements stocked inventory
- [ ] POS inbound webhook decrements SKU; paid order queues outbound reserve
- [ ] Event capacity blocks overbook; paid order emails ticket codes
- [ ] Shipping stub label + Pirate Ship CSV of unshipped paid orders
- [ ] `validate-env.sh` rejects shop without email/payments, POS/shipping without shop

Reviewed-on: #6
This commit was merged in pull request #6.
This commit is contained in:
2026-09-06 14:00:22 -07:00
parent 97b8607bf2
commit 9cdce7a897
82 changed files with 3908 additions and 1 deletions
View File
+24
View File
@@ -0,0 +1,24 @@
from django.contrib import admin
from shop.models import Order, OrderItem, Product
@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",)}
class OrderItemInline(admin.TabularInline):
model = OrderItem
extra = 0
@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
list_display = ("number", "email", "amount", "status", "created_at")
list_filter = ("status",)
search_fields = ("number", "email", "customer_name")
inlines = [OrderItemInline]
+12
View File
@@ -0,0 +1,12 @@
from django.apps import AppConfig
class ShopConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "shop"
verbose_name = "E-commerce"
def ready(self):
from shop import hooks
hooks.register()
+53
View File
@@ -0,0 +1,53 @@
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 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,
stock_qty__lte=3,
).count(),
"shop_sales_30d": sales["order_count"],
"shop_revenue_30d": sales["revenue"],
}
+80
View File
@@ -0,0 +1,80 @@
# Generated by Django 6.1 on 2026-09-06 11:17
import django.db.models.deletion
import uuid
from decimal import Decimal
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Order',
fields=[
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('number', models.CharField(max_length=32, unique=True)),
('email', models.EmailField(max_length=254)),
('customer_name', models.CharField(blank=True, max_length=200)),
('status', models.CharField(choices=[('draft', 'Draft'), ('open', 'Open'), ('paid', 'Paid'), ('fulfilled', 'Fulfilled'), ('cancelled', 'Cancelled')], default='draft', max_length=16)),
('amount', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=10)),
('currency', models.CharField(default='usd', max_length=8)),
('shipping_address', models.JSONField(blank=True, default=dict)),
('stripe_checkout_session_id', models.CharField(blank=True, max_length=255)),
('hosted_checkout_url', models.URLField(blank=True)),
('paid_at', models.DateTimeField(blank=True, null=True)),
('notes', models.TextField(blank=True)),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='Product',
fields=[
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=200)),
('slug', models.SlugField(max_length=220, unique=True)),
('sku', models.CharField(max_length=64, unique=True)),
('description', models.TextField(blank=True)),
('price', models.DecimalField(decimal_places=2, max_digits=10)),
('currency', models.CharField(default='usd', max_length=8)),
('fulfillment', models.CharField(choices=[('stocked', 'On-hand stock'), ('made_to_order', 'Made to order')], default='stocked', max_length=16)),
('stock_qty', models.IntegerField(default=0)),
('print_minutes', models.PositiveIntegerField(default=0, help_text='Estimated print time per unit (made-to-order).')),
('filament_grams', models.PositiveIntegerField(default=0)),
('is_published', models.BooleanField(default=False)),
('track_inventory', models.BooleanField(default=True)),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='OrderItem',
fields=[
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=200)),
('sku', models.CharField(max_length=64)),
('quantity', models.PositiveIntegerField(default=1)),
('unit_price', models.DecimalField(decimal_places=2, max_digits=10)),
('print_minutes', models.PositiveIntegerField(default=0)),
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='shop.order')),
('product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='order_items', to='shop.product')),
],
options={
'ordering': ['created_at'],
},
),
]
View File
+118
View File
@@ -0,0 +1,118 @@
from decimal import Decimal
from django.db import models
from django.urls import reverse
from django.utils.text import slugify
from core.models import TimeStampedModel, UUIDPrimaryKeyModel
class Product(UUIDPrimaryKeyModel, TimeStampedModel):
class Fulfillment(models.TextChoices):
STOCKED = "stocked", "On-hand stock"
MADE_TO_ORDER = "made_to_order", "Made to order"
name = models.CharField(max_length=200)
slug = models.SlugField(max_length=220, unique=True)
sku = models.CharField(max_length=64, unique=True)
description = models.TextField(blank=True)
price = models.DecimalField(max_digits=10, decimal_places=2)
currency = models.CharField(max_length=8, default="usd")
fulfillment = models.CharField(
max_length=16,
choices=Fulfillment.choices,
default=Fulfillment.STOCKED,
)
stock_qty = models.IntegerField(default=0)
print_minutes = models.PositiveIntegerField(
default=0,
help_text="Estimated print time per unit (made-to-order).",
)
filament_grams = models.PositiveIntegerField(default=0)
is_published = models.BooleanField(default=False)
track_inventory = models.BooleanField(default=True)
class Meta:
ordering = ["name"]
def __str__(self) -> str:
return f"{self.name} ({self.sku})"
def get_absolute_url(self) -> str:
return reverse("shop:detail", kwargs={"slug": self.slug})
@property
def amount_cents(self) -> int:
return int((self.price * Decimal("100")).quantize(Decimal("1")))
def save(self, *args, **kwargs):
if not self.slug:
base = slugify(self.name)[:200] or "product"
slug = base
n = 2
while Product.objects.filter(slug=slug).exclude(pk=self.pk).exists():
slug = f"{base}-{n}"
n += 1
self.slug = slug
if not self.sku:
self.sku = (self.slug or "sku").upper().replace("-", "")[:64]
super().save(*args, **kwargs)
class Order(UUIDPrimaryKeyModel, TimeStampedModel):
class Status(models.TextChoices):
DRAFT = "draft", "Draft"
OPEN = "open", "Open"
PAID = "paid", "Paid"
FULFILLED = "fulfilled", "Fulfilled"
CANCELLED = "cancelled", "Cancelled"
number = models.CharField(max_length=32, unique=True)
email = models.EmailField()
customer_name = models.CharField(max_length=200, blank=True)
status = models.CharField(
max_length=16, choices=Status.choices, default=Status.DRAFT
)
amount = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal("0"))
currency = models.CharField(max_length=8, default="usd")
shipping_address = models.JSONField(default=dict, blank=True)
stripe_checkout_session_id = models.CharField(max_length=255, blank=True)
hosted_checkout_url = models.URLField(blank=True)
paid_at = models.DateTimeField(null=True, blank=True)
notes = models.TextField(blank=True)
class Meta:
ordering = ["-created_at"]
def __str__(self) -> str:
return f"{self.number} · {self.email} · {self.amount}"
@property
def amount_cents(self) -> int:
return int((self.amount * Decimal("100")).quantize(Decimal("1")))
class OrderItem(UUIDPrimaryKeyModel, TimeStampedModel):
order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name="items")
product = models.ForeignKey(
Product,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="order_items",
)
name = models.CharField(max_length=200)
sku = models.CharField(max_length=64)
quantity = models.PositiveIntegerField(default=1)
unit_price = models.DecimalField(max_digits=10, decimal_places=2)
print_minutes = models.PositiveIntegerField(default=0)
class Meta:
ordering = ["created_at"]
def __str__(self) -> str:
return f"{self.quantity}× {self.name}"
@property
def line_total(self) -> Decimal:
return self.unit_price * self.quantity
+20
View File
@@ -0,0 +1,20 @@
from django.urls import path
from shop import views
app_name = "shop_portal"
urlpatterns = [
path("sales/", views.portal_sales, name="sales"),
path("products/", views.portal_product_list, name="product_list"),
path("products/new/", views.portal_product_edit, name="product_new"),
path("products/<uuid:pk>/", views.portal_product_edit, name="product_edit"),
path(
"products/<uuid:pk>/stock/",
views.portal_stock_adjust,
name="product_stock",
),
path("orders/", views.portal_order_list, name="order_list"),
path("orders/<uuid:pk>/", views.portal_order_detail, name="order_detail"),
path("webhooks/stripe/", views.stripe_webhook, name="stripe_webhook"),
]
+16
View File
@@ -0,0 +1,16 @@
from django.urls import path
from shop import views
app_name = "shop"
urlpatterns = [
path("", views.product_list, name="list"),
path("cart/", views.cart_view, name="cart"),
path("cart/add/<slug:slug>/", views.cart_add, name="cart_add"),
path("cart/update/<slug:slug>/", views.cart_update, name="cart_update"),
path("checkout/", views.checkout, name="checkout"),
path("checkout/<uuid:pk>/success/", views.checkout_success, name="checkout_success"),
path("checkout/<uuid:pk>/cancel/", views.checkout_cancel, name="checkout_cancel"),
path("<slug:slug>/", views.product_detail, name="detail"),
]
+305
View File
@@ -0,0 +1,305 @@
"""Catalog, cart, inventory, and Stripe checkout for FEATURE_SHOP."""
from __future__ import annotations
import logging
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 shop.models import Order, OrderItem, Product
logger = logging.getLogger(__name__)
CART_SESSION_KEY = "shop_cart"
class ShopError(RuntimeError):
pass
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 add_to_cart(session, product: Product, quantity: int = 1) -> dict[str, int]:
if quantity < 1:
raise ShopError("Quantity must be at least 1.")
cart = get_cart(session)
pid = str(product.pk)
cart[pid] = cart.get(pid, 0) + quantity
save_cart(session, cart)
return cart
def set_cart_qty(session, product: Product, quantity: int) -> dict[str, int]:
cart = get_cart(session)
pid = str(product.pk)
if quantity < 1:
cart.pop(pid, None)
else:
cart[pid] = quantity
save_cart(session, cart)
return cart
def cart_lines(session) -> list[dict]:
cart = get_cart(session)
products = {
str(p.pk): p
for p in Product.objects.filter(pk__in=cart.keys(), is_published=True)
}
lines = []
for pid, qty in cart.items():
product = products.get(pid)
if not product:
continue
lines.append(
{
"product": product,
"quantity": qty,
"unit_price": product.price,
"line_total": product.price * qty,
}
)
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) -> 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:
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) -> 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)
if avail is not None and quantity > avail:
raise ShopError(f"Only {avail} of {product.name} available.")
def adjust_stock(product: Product, delta: int) -> Product:
"""Increment (positive) or decrement (negative) on-hand stock."""
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"])
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"]
OrderItem.objects.create(
order=order,
product=product,
name=product.name,
sku=product.sku,
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"):
product = item.product
if product is None or not product.track_inventory:
continue
if product.fulfillment == Product.Fulfillment.STOCKED:
adjust_stock(product, -item.quantity)
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
+166
View File
@@ -0,0 +1,166 @@
"""Sales dashboard aggregates for FEATURE_SHOP."""
from __future__ import annotations
from datetime import date, datetime, time, timedelta
from decimal import Decimal
from django.conf import settings
from django.db.models import Count, F, Max, Q, Sum
from django.utils import timezone
from shop.models import Order, OrderItem
SOLD_STATUSES = (Order.Status.PAID, Order.Status.FULFILLED)
SALES_WINDOW_DAYS = 30
def _money(value) -> Decimal:
return (value or Decimal("0")).quantize(Decimal("0.01"))
def _window_start(days: int):
today = timezone.localdate()
start_date = today - timedelta(days=days - 1)
start_dt = timezone.make_aware(
datetime.combine(start_date, time.min),
timezone.get_current_timezone(),
)
return today, start_date, start_dt
def _sold_orders(start_dt=None):
qs = Order.objects.filter(status__in=SOLD_STATUSES)
if start_dt is None:
return qs
return qs.filter(
Q(paid_at__gte=start_dt) | Q(paid_at__isnull=True, created_at__gte=start_dt)
)
def _sale_date(order) -> date:
when = order.paid_at or order.created_at
return timezone.localtime(when).date()
def _bar_pct(value: Decimal | int | float, peak: Decimal | int | float) -> int:
if not peak:
return 0
if not value:
return 0
return max(8, int(round((float(value) / float(peak)) * 100)))
def sales_summary(*, days: int = SALES_WINDOW_DAYS) -> dict:
"""Order count and revenue for the rolling sales window."""
_, _, start_dt = _window_start(days)
totals = _sold_orders(start_dt).aggregate(
order_count=Count("id"), revenue=Sum("amount")
)
return {
"order_count": int(totals["order_count"] or 0),
"revenue": _money(totals["revenue"]),
}
def sales_dashboard(*, days: int = SALES_WINDOW_DAYS) -> dict:
"""Paid/fulfilled order stats, daily series, and top products."""
_, start_date, start_dt = _window_start(days)
sold = _sold_orders(start_dt)
totals = sold.aggregate(order_count=Count("id"), revenue=Sum("amount"))
order_count = int(totals["order_count"] or 0)
revenue = _money(totals["revenue"])
units = int(
OrderItem.objects.filter(order__in=sold).aggregate(total=Sum("quantity"))[
"total"
]
or 0
)
aov = _money(revenue / order_count) if order_count else Decimal("0.00")
currency = (settings.STRIPE_CURRENCY or "usd").lower()
by_day: dict = {
start_date + timedelta(days=offset): {
"count": 0,
"revenue": Decimal("0.00"),
}
for offset in range(days)
}
for order in sold.only("paid_at", "created_at", "amount"):
day = _sale_date(order)
bucket = by_day.get(day)
if bucket is None:
continue
bucket["count"] += 1
bucket["revenue"] += order.amount or Decimal("0")
peak_count = max((row["count"] for row in by_day.values()), default=0)
peak_revenue = max((row["revenue"] for row in by_day.values()), default=Decimal("0"))
daily_sales = []
daily_revenue = []
for index, (day, row) in enumerate(by_day.items()):
tick = index == 0 or index == days - 1 or day.weekday() == 0
label = f"{day.strftime('%b')} {day.day}"
daily_sales.append(
{
"date": day,
"label": label,
"count": row["count"],
"revenue": _money(row["revenue"]),
"pct": _bar_pct(row["count"], peak_count),
"tick": tick,
"tick_label": label if tick else "",
}
)
daily_revenue.append(
{
"date": day,
"label": label,
"count": row["count"],
"revenue": _money(row["revenue"]),
"pct": _bar_pct(row["revenue"], peak_revenue),
"tick": tick,
"tick_label": label if tick else "",
}
)
product_rows = list(
OrderItem.objects.filter(order__in=sold)
.values("sku")
.annotate(
units=Sum("quantity"),
revenue=Sum(F("unit_price") * F("quantity")),
product_name=Max("product__name"),
item_name=Max("name"),
)
.order_by("-units", "-revenue")[:8]
)
peak_units = max((int(row["units"] or 0) for row in product_rows), default=0)
top_products = []
for row in product_rows:
units_sold = int(row["units"] or 0)
top_products.append(
{
"sku": row["sku"],
"name": row["product_name"] or row["item_name"] or row["sku"],
"units": units_sold,
"revenue": _money(row["revenue"]),
"bar_pct": _bar_pct(units_sold, peak_units),
}
)
recent_orders = list(sold.order_by("-paid_at", "-created_at")[:8])
return {
"days": days,
"currency": currency,
"order_count": order_count,
"revenue": revenue,
"units_sold": units,
"aov": aov,
"daily_sales": daily_sales,
"daily_revenue": daily_revenue,
"top_products": top_products,
"recent_orders": recent_orders,
"has_sales": order_count > 0,
}
+8
View File
@@ -0,0 +1,8 @@
{% extends "base.html" %}
{% block title %}Checkout cancelled{% endblock %}
{% block content %}
<section class="section section-lg"><div class="container">
<h1>Checkout cancelled</h1>
<p>Order {{ order.number }} was not paid. You can return to the <a href="{% url 'shop:cart' %}">cart</a>.</p>
</div></section>
{% endblock %}
+26
View File
@@ -0,0 +1,26 @@
{% extends "base.html" %}
{% block title %}Cart · {{ SITE_NAME }}{% endblock %}
{% block content %}
<section class="section section-lg">
<div class="container">
<h1>Cart</h1>
{% for line in lines %}
<p>
<a href="{{ line.product.get_absolute_url }}">{{ line.product.name }}</a>
· {{ line.quantity }} × {{ line.unit_price }} = {{ line.line_total }}
</p>
<form method="post" action="{% url 'shop:cart_update' line.product.slug %}" style="margin:0 0 16px">
{% csrf_token %}
<input name="quantity" type="number" min="0" value="{{ line.quantity }}">
<button type="submit">Update</button>
</form>
{% empty %}
<p>Cart is empty.</p>
{% endfor %}
{% if lines %}
<p><strong>Total:</strong> {{ total }}</p>
<p><a class="button button-primary" href="{% url 'shop:checkout' %}">Checkout</a></p>
{% endif %}
</div>
</section>
{% endblock %}
+21
View File
@@ -0,0 +1,21 @@
{% extends "base.html" %}
{% block title %}Checkout · {{ SITE_NAME }}{% endblock %}
{% block content %}
<section class="section section-lg">
<div class="container">
<h1>Checkout</h1>
<p>Total: {{ total }}</p>
<form method="post">
{% csrf_token %}
<p><label>Email <input name="email" type="email" required></label></p>
<p><label>Name <input name="customer_name"></label></p>
<p><label>Address <input name="address_line1"></label></p>
<p><label>Address 2 <input name="address_line2"></label></p>
<p><label>City <input name="address_city"></label></p>
<p><label>State <input name="address_state"></label></p>
<p><label>ZIP <input name="address_zip"></label></p>
<button class="button button-primary" type="submit">Pay with Stripe</button>
</form>
</div>
</section>
{% endblock %}
+20
View File
@@ -0,0 +1,20 @@
{% extends "base.html" %}
{% block title %}{{ product.name }} · {{ SITE_NAME }}{% endblock %}
{% block content %}
<section class="section section-lg">
<div class="container">
<p><a href="{% url 'shop:list' %}">← Shop</a></p>
<h1>{{ product.name }}</h1>
<p class="muted">{{ product.price }} {{ product.currency|upper }} · {{ product.sku }}</p>
<div>{{ product.description|linebreaks }}</div>
{% if available is not None %}
<p>{{ available }} in stock</p>
{% endif %}
<form method="post" action="{% url 'shop:cart_add' product.slug %}">
{% csrf_token %}
<label>Qty <input name="quantity" type="number" min="1" value="1"></label>
<button class="button button-primary" type="submit">Add to cart</button>
</form>
</div>
</section>
{% endblock %}
+19
View File
@@ -0,0 +1,19 @@
{% extends "base.html" %}
{% block title %}Shop · {{ SITE_NAME }}{% endblock %}
{% block content %}
<section class="section section-lg">
<div class="container">
<h1 class="text-uppercase">Shop</h1>
<p><a href="{% url 'shop:cart' %}">View cart</a></p>
{% for product in products %}
<article style="margin:0 0 32px">
<h2><a href="{{ product.get_absolute_url }}">{{ product.name }}</a></h2>
<p class="muted">{{ product.price }} {{ product.currency|upper }} · {{ product.sku }}</p>
<p>{{ product.description|truncatewords:40 }}</p>
</article>
{% empty %}
<p>No products yet.</p>
{% endfor %}
</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,33 @@
{% extends "portal_base.html" %}
{% block title %}{% if product %}Edit{% else %}New{% endif %} product · Portal{% endblock %}
{% block topbar_title %}{% if product %}Edit product{% else %}New product{% endif %}{% endblock %}
{% block portal_content %}
<form method="post" class="form-grid">
{% 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"><label>Stock qty</label><input name="stock_qty" type="number" value="{{ product.stock_qty|default:0 }}"></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>Description</label><textarea name="description" style="min-height:120px">{{ product.description|default:'' }}</textarea></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>
{% if product %}
<form method="post" action="{% url 'shop_portal:product_stock' product.pk %}" class="form-grid" style="margin-top:24px">
{% 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 %}
@@ -0,0 +1,22 @@
{% 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>Name</th><th>SKU</th><th>Price</th><th>Stock</th><th>Status</th></tr></thead>
<tbody>
{% for product in products %}
<tr>
<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.stock_qty }}</td>
<td>{% if product.is_published %}Published{% else %}Draft{% endif %}</td>
</tr>
{% empty %}
<tr><td colspan="5" class="empty-state">No products yet.</td></tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
+147
View File
@@ -0,0 +1,147 @@
{% extends "portal_base.html" %}
{% block title %}Sales · Portal{% endblock %}
{% block topbar_title %}Sales{% endblock %}
{% block portal_content %}
<div class="stat-row">
<div class="stat-card">
<div class="label">Orders ({{ days }} days)</div>
<div class="value">{{ order_count }}</div>
</div>
<div class="stat-card">
<div class="label">Revenue ({{ days }} days)</div>
<div class="value">{{ revenue }} {{ currency|upper }}</div>
</div>
<div class="stat-card">
<div class="label">Units sold</div>
<div class="value">{{ units_sold }}</div>
</div>
<div class="stat-card">
<div class="label">Average order</div>
<div class="value">{{ aov }} {{ currency|upper }}</div>
</div>
</div>
<div class="panel">
<div class="panel-h">
<h2>Orders per day</h2>
<span class="muted">Last {{ days }} days</span>
</div>
<div class="panel-b">
<div class="chart-placeholder chart-daily" role="img"
aria-label="Paid orders per day for the last {{ days }} days">
{% for bar in daily_sales %}
<div class="chart-bar-col">
<div class="bar{% if not bar.count %} is-zero{% endif %}"
style="height:{{ bar.pct }}%"
title="{{ bar.label }}: {{ bar.count }} sale{{ bar.count|pluralize }} · {{ bar.revenue }} {{ currency|upper }}"></div>
<div class="chart-bar-meta">
<span class="chart-bar-label">{% if bar.tick %}{{ bar.tick_label }}{% else %}&nbsp;{% endif %}</span>
</div>
</div>
{% endfor %}
</div>
{% if not has_sales %}
<p class="empty-state" style="padding-top:12px">No paid orders in this window.</p>
{% endif %}
</div>
</div>
<div class="panel">
<div class="panel-h">
<h2>Revenue per day</h2>
<span class="muted">Last {{ days }} days</span>
</div>
<div class="panel-b">
<div class="chart-placeholder chart-daily chart-revenue" role="img"
aria-label="Revenue per day for the last {{ days }} days">
{% for bar in daily_revenue %}
<div class="chart-bar-col">
<div class="bar{% if not bar.revenue %} is-zero{% endif %}"
style="height:{{ bar.pct }}%"
title="{{ bar.label }}: {{ bar.revenue }} {{ currency|upper }}"></div>
<div class="chart-bar-meta">
<span class="chart-bar-label">{% if bar.tick %}{{ bar.tick_label }}{% else %}&nbsp;{% endif %}</span>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
<div class="split">
<div class="panel">
<div class="panel-h">
<h2>Top products</h2>
<span class="muted">By units sold</span>
</div>
<div class="panel-b">
{% if top_products %}
<div class="hbar-chart" role="img" aria-label="Top products by units sold">
{% for row in top_products %}
<div class="hbar-row" title="{{ row.name }} · {{ row.units }} sold · {{ row.revenue }} {{ currency|upper }}">
<div class="hbar-label">{{ row.name }}</div>
<div class="hbar-track">
<div class="hbar-fill" style="width:{{ row.bar_pct }}%"></div>
</div>
<div class="hbar-value">{{ row.units }}</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="empty-state" style="margin:0;padding:0">No product sales yet.</p>
{% endif %}
</div>
</div>
<div class="panel">
<div class="panel-h">
<h2>Product mix</h2>
<a class="btn btn-sm btn-ghost" href="{% url 'shop_portal:product_list' %}">Products</a>
</div>
<div class="panel-b" style="padding:0">
<table class="table">
<thead>
<tr><th>Product</th><th>SKU</th><th>Units</th><th>Revenue</th></tr>
</thead>
<tbody>
{% for row in top_products %}
<tr>
<td>{{ row.name }}</td>
<td>{{ row.sku }}</td>
<td>{{ row.units }}</td>
<td>{{ row.revenue }} {{ currency|upper }}</td>
</tr>
{% empty %}
<tr><td colspan="4" class="empty-state">No paid line items in the last {{ days }} days.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<div class="panel">
<div class="panel-h">
<h2>Recent sales</h2>
<a class="btn btn-sm btn-ghost" href="{% url 'shop_portal:order_list' %}">All orders</a>
</div>
<div class="panel-b" style="padding:0">
<table class="table">
<thead>
<tr><th>Number</th><th>Customer</th><th>Amount</th><th>Status</th></tr>
</thead>
<tbody>
{% for order in recent_orders %}
<tr>
<td><a href="{% url 'shop_portal:order_detail' order.pk %}">{{ order.number }}</a></td>
<td>{{ order.customer_name|default:order.email }}</td>
<td>{{ order.amount }} {{ order.currency|upper }}</td>
<td>{{ order.get_status_display }}</td>
</tr>
{% empty %}
<tr><td colspan="4" class="empty-state">No paid orders yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+9
View File
@@ -0,0 +1,9 @@
{% extends "base.html" %}
{% block title %}Order {{ order.number }}{% endblock %}
{% block content %}
<section class="section section-lg"><div class="container">
<h1>Thank you</h1>
<p>Order {{ order.number }} is {{ order.get_status_display|lower }}.</p>
<p>A confirmation will go to {{ order.email }}.</p>
</div></section>
{% endblock %}
+254
View File
@@ -0,0 +1,254 @@
from datetime import timedelta
from decimal import Decimal
from django.contrib.auth import get_user_model
from django.core import mail
from django.test import Client, TestCase, override_settings
from django.urls import reverse
from django.utils import timezone
from shop.models import Order, OrderItem, Product
from shop.services import (
ShopError,
add_to_cart,
adjust_stock,
available_qty,
create_order_from_cart,
mark_paid,
next_order_number,
)
from shop.stats import sales_dashboard
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")
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")
@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)
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 _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"')
+274
View File
@@ -0,0 +1,274 @@
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.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
from shop.services import (
ShopError,
add_to_cart,
adjust_stock,
available_qty,
cart_lines,
cart_total,
create_checkout_session,
create_order_from_cart,
mark_paid,
save_cart,
set_cart_qty,
)
from shop.stats import sales_dashboard
logger = logging.getLogger(__name__)
def _site_base(request) -> str:
base = (settings.PUBLIC_SITE_URL or "").rstrip("/")
if base:
return base
return request.build_absolute_uri("/").rstrip("/")
def product_list(request):
products = Product.objects.filter(is_published=True)
return render(request, "shop/list.html", {"products": products})
def product_detail(request, slug):
product = get_object_or_404(Product, slug=slug, is_published=True)
return render(
request,
"shop/detail.html",
{"product": product, "available": available_qty(product)},
)
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, slug=slug, is_published=True)
try:
qty = int(request.POST.get("quantity") or "1")
except ValueError:
qty = 1
try:
add_to_cart(request.session, product, qty)
except ShopError as exc:
messages.error(request, str(exc))
return redirect("shop:detail", slug=product.slug)
messages.success(request, f"Added {product.name} 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
set_cart_qty(request.session, product, qty)
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.all()
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, 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
if errors:
for err in errors:
messages.error(request, err)
else:
if product is None:
product = Product()
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"
product.save()
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")