Stand up Print Forge as a 3D-printed toy shop with color variants and printer photography.

Replaces the client_site template branding, adds shop/shipping, and points beta CI at master for easy deploy.

Closes #1
This commit is contained in:
2026-09-06 08:28:04 -05:00
parent 8a97e3fbe2
commit b2029b6c0f
336 changed files with 9241 additions and 2537 deletions
View File
+10
View File
@@ -0,0 +1,10 @@
from django.contrib import admin
from shipping.models import Shipment
@admin.register(Shipment)
class ShipmentAdmin(admin.ModelAdmin):
list_display = ("order", "status", "carrier", "tracking_number")
list_filter = ("status", "carrier")
search_fields = ("tracking_number", "order__number")
+12
View File
@@ -0,0 +1,12 @@
from django.apps import AppConfig
class ShippingConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "shipping"
verbose_name = "Shipping automation"
def ready(self):
from shipping import hooks
hooks.register()
+31
View File
@@ -0,0 +1,31 @@
from core.registry import (
register_dashboard_collector,
register_feature,
register_portal_nav,
)
def register() -> None:
register_feature("shipping")
register_portal_nav(
section="shipping",
label="Shipping",
url_name="shipping:shipment_list",
group="Retail",
order=50,
)
register_dashboard_collector(_dashboard)
def _dashboard(request) -> dict:
from shipping.models import Shipment
from shop.models import Order
labeled = Shipment.objects.filter(status=Shipment.Status.LABELED).values_list(
"order_id", flat=True
)
return {
"unshipped_orders": Order.objects.filter(status=Order.Status.PAID)
.exclude(pk__in=labeled)
.count()
}
+40
View File
@@ -0,0 +1,40 @@
# Generated by Django 6.1 on 2026-09-06 11:17
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('shop', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Shipment',
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)),
('status', models.CharField(choices=[('draft', 'Draft'), ('rated', 'Rated'), ('labeled', 'Labeled'), ('void', 'Void')], default='draft', max_length=16)),
('carrier', models.CharField(blank=True, max_length=32)),
('service', models.CharField(blank=True, max_length=64)),
('tracking_number', models.CharField(blank=True, max_length=64)),
('label_url', models.URLField(blank=True)),
('rate_amount', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
('currency', models.CharField(default='usd', max_length=8)),
('provider_shipment_id', models.CharField(blank=True, max_length=255)),
('rates', models.JSONField(blank=True, default=list)),
('weight_oz', models.PositiveIntegerField(default=16)),
('notes', models.TextField(blank=True)),
('order', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='shipments', to='shop.order')),
],
options={
'ordering': ['-created_at'],
},
),
]
+33
View File
@@ -0,0 +1,33 @@
from django.db import models
from core.models import TimeStampedModel, UUIDPrimaryKeyModel
from shop.models import Order
class Shipment(UUIDPrimaryKeyModel, TimeStampedModel):
class Status(models.TextChoices):
DRAFT = "draft", "Draft"
RATED = "rated", "Rated"
LABELED = "labeled", "Labeled"
VOID = "void", "Void"
order = models.ForeignKey(Order, on_delete=models.PROTECT, related_name="shipments")
status = models.CharField(
max_length=16, choices=Status.choices, default=Status.DRAFT
)
carrier = models.CharField(max_length=32, blank=True)
service = models.CharField(max_length=64, blank=True)
tracking_number = models.CharField(max_length=64, blank=True)
label_url = models.URLField(blank=True)
rate_amount = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
currency = models.CharField(max_length=8, default="usd")
provider_shipment_id = models.CharField(max_length=255, blank=True)
rates = models.JSONField(default=list, blank=True)
weight_oz = models.PositiveIntegerField(default=16)
notes = models.TextField(blank=True)
class Meta:
ordering = ["-created_at"]
def __str__(self) -> str:
return f"{self.order.number} · {self.tracking_number or self.status}"
+184
View File
@@ -0,0 +1,184 @@
"""EasyPost-style rates/labels plus Pirate Ship CSV export."""
from __future__ import annotations
import csv
import io
import logging
from decimal import Decimal
import requests
from django.conf import settings
from shipping.models import Shipment
from shop.models import Order
logger = logging.getLogger(__name__)
class ShippingError(RuntimeError):
pass
def _easypost_key() -> str:
return (getattr(settings, "EASYPOST_API_KEY", "") or "").strip()
def quote_rates(shipment: Shipment) -> list[dict]:
"""Return carrier rates. Uses EasyPost when configured; otherwise a USPS stub."""
key = _easypost_key()
if not key:
rates = [
{
"id": "stub-usps-ground",
"carrier": "USPS",
"service": "GroundAdvantage",
"rate": "5.40",
"currency": "USD",
},
{
"id": "stub-usps-priority",
"carrier": "USPS",
"service": "Priority",
"rate": "9.80",
"currency": "USD",
},
]
shipment.rates = rates
shipment.status = Shipment.Status.RATED
shipment.save(update_fields=["rates", "status", "updated_at"])
return rates
addr = shipment.order.shipping_address or {}
response = requests.post(
"https://api.easypost.com/v2/shipments",
auth=(key, ""),
json={
"shipment": {
"to_address": {
"name": shipment.order.customer_name or shipment.order.email,
"street1": addr.get("line1") or "",
"street2": addr.get("line2") or "",
"city": addr.get("city") or "",
"state": addr.get("state") or "",
"zip": addr.get("zip") or "",
"country": addr.get("country") or "US",
},
"from_address": {
"name": settings.SITE_NAME,
"street1": getattr(settings, "SHIP_FROM_LINE1", "") or "",
"city": getattr(settings, "SHIP_FROM_CITY", "") or "",
"state": getattr(settings, "SHIP_FROM_STATE", "") or "",
"zip": getattr(settings, "SHIP_FROM_ZIP", "") or "",
"country": "US",
},
"parcel": {"weight": shipment.weight_oz},
}
},
timeout=20,
)
response.raise_for_status()
data = response.json()
rates = data.get("rates") or []
shipment.provider_shipment_id = data.get("id") or ""
shipment.rates = rates
shipment.status = Shipment.Status.RATED
shipment.save(
update_fields=["provider_shipment_id", "rates", "status", "updated_at"]
)
return rates
def buy_label(shipment: Shipment, *, rate_id: str = "") -> Shipment:
rates = shipment.rates or quote_rates(shipment)
chosen = None
if rate_id:
chosen = next((r for r in rates if str(r.get("id")) == str(rate_id)), None)
if chosen is None and rates:
chosen = rates[0]
if chosen is None:
raise ShippingError("No shipping rates available.")
key = _easypost_key()
if key and shipment.provider_shipment_id:
response = requests.post(
f"https://api.easypost.com/v2/shipments/{shipment.provider_shipment_id}/buy",
auth=(key, ""),
json={"rate": {"id": chosen.get("id")}},
timeout=20,
)
response.raise_for_status()
data = response.json()
postage = data.get("postage_label") or {}
tracking = data.get("tracking_code") or ""
shipment.label_url = postage.get("label_url") or ""
shipment.tracking_number = tracking
else:
shipment.tracking_number = f"STUB{shipment.order.number[-6:]}"
shipment.label_url = ""
shipment.carrier = chosen.get("carrier") or ""
shipment.service = chosen.get("service") or ""
try:
shipment.rate_amount = Decimal(str(chosen.get("rate") or "0"))
except Exception:
shipment.rate_amount = None
shipment.status = Shipment.Status.LABELED
shipment.save(
update_fields=[
"carrier",
"service",
"tracking_number",
"label_url",
"rate_amount",
"status",
"updated_at",
]
)
return shipment
def create_shipment_for_order(order: Order, *, weight_oz: int = 16) -> Shipment:
if order.status not in {Order.Status.PAID, Order.Status.FULFILLED}:
raise ShippingError("Ship paid orders only.")
return Shipment.objects.create(order=order, weight_oz=weight_oz)
def pirate_ship_csv(orders=None) -> str:
"""CSV Pirate Ship can map: name, address, city, state, zip, email."""
if orders is None:
shipped = Shipment.objects.filter(
status=Shipment.Status.LABELED
).values_list("order_id", flat=True)
orders = Order.objects.filter(status=Order.Status.PAID).exclude(pk__in=shipped)
buffer = io.StringIO()
writer = csv.writer(buffer)
writer.writerow(
[
"Order Number",
"Name",
"Address 1",
"Address 2",
"City",
"State",
"Zip",
"Country",
"Email",
]
)
for order in orders:
addr = order.shipping_address or {}
writer.writerow(
[
order.number,
order.customer_name or order.email,
addr.get("line1") or "",
addr.get("line2") or "",
addr.get("city") or "",
addr.get("state") or "",
addr.get("zip") or "",
addr.get("country") or "US",
order.email,
]
)
return buffer.getvalue()
@@ -0,0 +1,25 @@
{% extends "portal_base.html" %}
{% block title %}{{ shipment.order.number }} · Shipping{% endblock %}
{% block topbar_title %}{{ shipment.order.number }}{% endblock %}
{% block portal_content %}
<p>{{ shipment.order.email }} · {{ shipment.get_status_display }}</p>
{% if shipment.tracking_number %}<p>Tracking: {{ shipment.tracking_number }}</p>{% endif %}
{% if shipment.label_url %}<p><a href="{{ shipment.label_url }}">Download label</a></p>{% endif %}
{% if shipment.status != 'labeled' %}
<form method="post" action="{% url 'shipping:shipment_buy' shipment.pk %}">
{% csrf_token %}
{% for rate in shipment.rates %}
<label>
<input type="radio" name="rate_id" value="{{ rate.id }}" {% if forloop.first %}checked{% endif %}>
{{ rate.carrier }} {{ rate.service }} — {{ rate.rate }} {{ rate.currency }}
</label><br>
{% empty %}
<p>No rates yet.</p>
{% endfor %}
{% if shipment.rates %}
<button class="btn btn-primary" type="submit">Buy label</button>
{% endif %}
</form>
{% endif %}
<p><a href="{% url 'shipping:shipment_list' %}">← All shipments</a></p>
{% endblock %}
@@ -0,0 +1,43 @@
{% extends "portal_base.html" %}
{% block title %}Shipping · Portal{% endblock %}
{% block topbar_title %}Shipping{% endblock %}
{% block portal_content %}
<p><a class="btn btn-ghost" href="{% url 'shipping:pirate_ship_export' %}">Pirate Ship CSV</a></p>
<h2>Unshipped paid orders</h2>
<table class="table">
<thead><tr><th>Order</th><th>Email</th><th></th></tr></thead>
<tbody>
{% for order in unshipped %}
<tr>
<td>{{ order.number }}</td>
<td>{{ order.email }}</td>
<td>
<form method="post" action="{% url 'shipping:shipment_create' %}">
{% csrf_token %}
<input type="hidden" name="order" value="{{ order.pk }}">
<input name="weight_oz" type="number" min="1" value="16" style="width:5rem"> oz
<button class="btn btn-primary" type="submit">Quote rates</button>
</form>
</td>
</tr>
{% empty %}
<tr><td colspan="3" class="empty-state">No paid orders waiting to ship.</td></tr>
{% endfor %}
</tbody>
</table>
<h2>Shipments</h2>
<table class="table">
<thead><tr><th>Order</th><th>Status</th><th>Tracking</th></tr></thead>
<tbody>
{% for shipment in shipments %}
<tr>
<td><a href="{% url 'shipping:shipment_detail' shipment.pk %}">{{ shipment.order.number }}</a></td>
<td>{{ shipment.get_status_display }}</td>
<td>{{ shipment.tracking_number|default:"—" }}</td>
</tr>
{% empty %}
<tr><td colspan="3" class="empty-state">No shipments yet.</td></tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
+84
View File
@@ -0,0 +1,84 @@
from decimal import Decimal
from django.contrib.auth import get_user_model
from django.test import Client, TestCase
from django.urls import reverse
from shipping.models import Shipment
from shipping.services import buy_label, create_shipment_for_order, pirate_ship_csv, quote_rates
from shop.models import Order
class ShippingServiceTests(TestCase):
def setUp(self):
self.order = Order.objects.create(
number="ORD-20260101-001",
email="buyer@example.com",
customer_name="Pat Lee",
status=Order.Status.PAID,
amount=Decimal("18.00"),
shipping_address={
"line1": "123 Main",
"city": "Aurora",
"state": "IL",
"zip": "60506",
"country": "US",
},
)
def test_quote_and_buy_stub_label(self):
shipment = create_shipment_for_order(self.order)
rates = quote_rates(shipment)
self.assertGreaterEqual(len(rates), 1)
shipment.refresh_from_db()
self.assertEqual(shipment.status, Shipment.Status.RATED)
buy_label(shipment, rate_id=rates[0]["id"])
shipment.refresh_from_db()
self.assertEqual(shipment.status, Shipment.Status.LABELED)
self.assertTrue(shipment.tracking_number)
def test_pirate_ship_csv_includes_unshipped(self):
csv_body = pirate_ship_csv()
self.assertIn("ORD-20260101-001", csv_body)
self.assertIn("123 Main", csv_body)
self.assertIn("buyer@example.com", csv_body)
def test_csv_omits_labeled_orders(self):
shipment = create_shipment_for_order(self.order)
quote_rates(shipment)
buy_label(shipment)
self.assertNotIn(self.order.number, pirate_ship_csv())
class ShippingPortalTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user("shipper", password="test-pass-123")
self.client = Client()
self.client.login(username="shipper", password="test-pass-123")
self.order = Order.objects.create(
number="ORD-20260101-002",
email="a@example.com",
status=Order.Status.PAID,
amount=Decimal("9.00"),
shipping_address={"line1": "9 Oak", "city": "Town", "state": "IL", "zip": "60189"},
)
def test_list_requires_login(self):
self.assertEqual(Client().get(reverse("shipping:shipment_list")).status_code, 302)
def test_create_shipment_from_portal(self):
response = self.client.post(
reverse("shipping:shipment_create"),
{"order": str(self.order.pk), "weight_oz": "8"},
)
self.assertEqual(response.status_code, 302)
shipment = Shipment.objects.get()
self.assertEqual(shipment.weight_oz, 8)
self.assertEqual(shipment.status, Shipment.Status.RATED)
def test_csv_export(self):
response = self.client.get(reverse("shipping:pirate_ship_export"))
self.assertEqual(response.status_code, 200)
self.assertEqual(response["Content-Type"], "text/csv")
self.assertIn(b"ORD-20260101-002", response.content)
+13
View File
@@ -0,0 +1,13 @@
from django.urls import path
from shipping import views
app_name = "shipping"
urlpatterns = [
path("", views.shipment_list, name="shipment_list"),
path("new/", views.shipment_create, name="shipment_create"),
path("export/pirate-ship.csv", views.pirate_ship_export, name="pirate_ship_export"),
path("<uuid:pk>/", views.shipment_detail, name="shipment_detail"),
path("<uuid:pk>/buy/", views.shipment_buy, name="shipment_buy"),
]
+86
View File
@@ -0,0 +1,86 @@
import logging
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.views.decorators.http import require_http_methods, require_POST
from shipping.models import Shipment
from shipping.services import (
ShippingError,
buy_label,
create_shipment_for_order,
pirate_ship_csv,
quote_rates,
)
from shop.models import Order
logger = logging.getLogger(__name__)
@login_required
def shipment_list(request):
shipments = Shipment.objects.select_related("order")[:200]
labeled = Shipment.objects.filter(status=Shipment.Status.LABELED).values_list(
"order_id", flat=True
)
unshipped = Order.objects.filter(status=Order.Status.PAID).exclude(pk__in=labeled)
return render(
request,
"shipping/list.html",
{"shipments": shipments, "unshipped": unshipped},
)
@login_required
@require_POST
def shipment_create(request):
order = get_object_or_404(Order, pk=request.POST.get("order"))
try:
weight = int(request.POST.get("weight_oz") or "16")
except ValueError:
weight = 16
try:
shipment = create_shipment_for_order(order, weight_oz=weight)
quote_rates(shipment)
except ShippingError as exc:
messages.error(request, str(exc))
return redirect("shipping:shipment_list")
except Exception as exc: # noqa: BLE001
logger.exception("rate quote failed")
messages.error(request, f"Could not quote rates: {exc}")
return redirect("shipping:shipment_list")
return redirect("shipping:shipment_detail", pk=shipment.pk)
@login_required
def shipment_detail(request, pk):
shipment = get_object_or_404(Shipment.objects.select_related("order"), pk=pk)
return render(request, "shipping/detail.html", {"shipment": shipment})
@login_required
@require_POST
def shipment_buy(request, pk):
shipment = get_object_or_404(Shipment, pk=pk)
rate_id = (request.POST.get("rate_id") or "").strip()
try:
buy_label(shipment, rate_id=rate_id)
except ShippingError as exc:
messages.error(request, str(exc))
except Exception as exc: # noqa: BLE001
logger.exception("buy label failed")
messages.error(request, f"Could not buy label: {exc}")
else:
messages.success(request, f"Label created for {shipment.order.number}.")
return redirect("shipping:shipment_detail", pk=shipment.pk)
@login_required
@require_http_methods(["GET"])
def pirate_ship_export(request):
body = pirate_ship_csv()
response = HttpResponse(body, content_type="text/csv")
response["Content-Disposition"] = 'attachment; filename="pirate-ship-orders.csv"'
return response