Add shop, POS sync, event ticketing, and shipping catalog apps.

Clients can run in-house retail without Shopify while keeping the same
Docker/Django/Postgres stack and data-ownership promise. Closes #5.
This commit is contained in:
2026-09-06 06:29:50 -05:00
parent 97b8607bf2
commit 80a5f5dadf
78 changed files with 3395 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()
+42
View File
@@ -0,0 +1,42 @@
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_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
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(),
}
+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
+19
View File
@@ -0,0 +1,19 @@
from django.urls import path
from shop import views
app_name = "shop_portal"
urlpatterns = [
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
+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 %}
+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 %}
+147
View File
@@ -0,0 +1,147 @@
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 shop.models import Order, Product
from shop.services import (
ShopError,
add_to_cart,
adjust_stock,
available_qty,
create_order_from_cart,
mark_paid,
next_order_number,
)
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)
+268
View File
@@ -0,0 +1,268 @@
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,
)
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_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")