Match banner brand, print splash, optional crop, and demo seed (#6)
Deploy Beta / unit-tests (push) Successful in 32s
Deploy Beta / docker (push) Successful in 32s
Deploy Beta / deploy-beta (push) Successful in 2m23s

## Summary
- Rebrand public site, portal, and emails from Ecoprint orange to the PRINTFORGE banner palette (navy / cyan / lime).
- Replace the loading splash with a CSS 3D-print animation; product uploads get a default-on smart crop + background filter checkbox.
- Add `manage.py seed_demo` for client walkthroughs on dev/beta only (refuses prod).

Closes #5.

## Test plan
- [ ] Public pages and buttons are cyan/lime, not orange
- [ ] Splash shows a printer building a model (or a static print if reduced motion)
- [ ] Product edit: smart-crop checked by default; uncheck stores the original photo
- [ ] `uv run python manage.py seed_demo` fills shop, contacts, leads, orders, campaigns
- [ ] `DJANGO_ENV=prod` seed_demo exits with an error

Reviewed-on: #6
This commit was merged in pull request #6.
This commit is contained in:
2026-09-07 04:06:52 -07:00
parent 04e75ec7a3
commit 23a6035ba8
19 changed files with 34722 additions and 65 deletions
+807
View File
@@ -0,0 +1,807 @@
"""Populate a demo catalog, portal lists, and shop traffic for client walkthroughs.
Safe on DJANGO_ENV=dev and beta only. Never runs against prod. Does not send
mail or call EasyPost/Stripe. Re-runs are idempotent (DEMO- / demo+ keys).
Pass --reset to wipe tagged demo rows and seed again.
uv run python manage.py seed_demo
docker compose exec web uv run python manage.py seed_demo --reset
"""
from __future__ import annotations
import os
import struct
from datetime import timedelta
from decimal import Decimal
from io import BytesIO
from django.apps import apps
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone
from django.utils.text import slugify
DEMO_NOTE = "[demo-seed]"
DEMO_UTM_CAMPAIGN = "printforge-demo"
DEMO_EMAIL_DOMAIN = "@example.com"
DEMO_EMAIL_PREFIX = "demo+"
def _django_env() -> str:
return (os.environ.get("DJANGO_ENV") or "dev").lower()
def _parse_hex(value: str) -> tuple[int, int, int]:
raw = (value or "#808080").strip().lstrip("#")
if len(raw) != 6:
return (128, 128, 128)
return int(raw[0:2], 16), int(raw[2:4], 16), int(raw[4:6], 16)
def _toy_png(hex_color: str) -> bytes:
"""Square RGBA figurine swatch so shop cards look like product photos."""
from PIL import Image, ImageDraw
size = 800
rgb = _parse_hex(hex_color)
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
fill = (*rgb, 255)
shadow = tuple(max(0, c - 40) for c in rgb) + (255,)
highlight = tuple(min(255, c + 50) for c in rgb) + (220,)
draw.ellipse([250, 620, 550, 740], fill=(15, 20, 28, 40))
draw.rounded_rectangle([260, 300, 540, 660], radius=48, fill=fill)
draw.rounded_rectangle([280, 320, 400, 640], radius=36, fill=highlight)
draw.ellipse([230, 140, 570, 420], fill=fill)
draw.ellipse([250, 160, 430, 340], fill=highlight)
draw.polygon([(400, 80), (460, 200), (340, 200)], fill=shadow)
draw.ellipse([310, 230, 380, 300], fill=(255, 255, 255, 255))
draw.ellipse([420, 230, 490, 300], fill=(255, 255, 255, 255))
draw.ellipse([332, 250, 368, 286], fill=(10, 14, 20, 255))
draw.ellipse([442, 250, 478, 286], fill=(10, 14, 20, 255))
buf = BytesIO()
img.save(buf, format="PNG", optimize=True)
return buf.getvalue()
def _cube_stl(size: float = 24.0) -> bytes:
"""Binary STL of a cube so the shop 3D viewer has something to spin."""
s = size / 2.0
faces = (
((0, 0, 1), ((-s, -s, s), (s, -s, s), (s, s, s)), ((-s, -s, s), (s, s, s), (-s, s, s))),
((0, 0, -1), ((-s, -s, -s), (-s, s, -s), (s, s, -s)), ((-s, -s, -s), (s, s, -s), (s, -s, -s))),
((0, 1, 0), ((-s, s, -s), (-s, s, s), (s, s, s)), ((-s, s, -s), (s, s, s), (s, s, -s))),
((0, -1, 0), ((-s, -s, -s), (s, -s, -s), (s, -s, s)), ((-s, -s, -s), (s, -s, s), (-s, -s, s))),
((1, 0, 0), ((s, -s, -s), (s, s, -s), (s, s, s)), ((s, -s, -s), (s, s, s), (s, -s, s))),
((-1, 0, 0), ((-s, -s, -s), (-s, -s, s), (-s, s, s)), ((-s, -s, -s), (-s, s, s), (-s, s, -s))),
)
triangles: list[tuple[tuple[float, float, float], tuple[float, float, float], tuple[float, float, float], tuple[float, float, float]]] = []
for normal, tri_a, tri_b in faces:
triangles.append((normal, tri_a[0], tri_a[1], tri_a[2]))
triangles.append((normal, tri_b[0], tri_b[1], tri_b[2]))
buf = BytesIO()
buf.write(b"PrintForge demo cube".ljust(80, b"\x00"))
buf.write(struct.pack("<I", len(triangles)))
for normal, a, b, c in triangles:
buf.write(struct.pack("<12fH", *normal, *a, *b, *c, 0))
return buf.getvalue()
def _stamp(model, pk, when) -> None:
model.objects.filter(pk=pk).update(created_at=when, updated_at=when)
class Command(BaseCommand):
help = (
"Seed tagged fake catalog, contacts, leads, orders, campaigns, and "
"analytics for client demos. Allowed on DJANGO_ENV=dev and beta only."
)
def add_arguments(self, parser):
parser.add_argument(
"--reset",
action="store_true",
help="Delete previously tagged demo rows, then seed again.",
)
def handle(self, *args, **options):
env = _django_env()
if env == "prod":
raise CommandError(
"seed_demo refuses DJANGO_ENV=prod. Run it on dev or beta."
)
self.verbosity = int(options.get("verbosity", 1))
self.now = timezone.now()
self.owner = get_user_model().objects.order_by("pk").first()
if options["reset"]:
self._reset()
self.stdout.write("Cleared tagged demo rows.")
counts = {
"contacts": self._seed_contacts(),
"leads": self._seed_leads(),
"analytics": self._seed_analytics(),
}
if apps.is_installed("shop"):
counts["products"] = self._seed_products()
counts["orders"] = self._seed_orders()
if apps.is_installed("shipping"):
counts["shipments"] = self._seed_shipments()
if apps.is_installed("payments"):
counts["invoices"] = self._seed_invoices()
if apps.is_installed("email_sms"):
counts["campaigns"] = self._seed_campaigns()
summary = ", ".join(f"{key}={value}" for key, value in counts.items())
self.stdout.write(self.style.SUCCESS(f"Demo seed ready on {env} ({summary})."))
def _log(self, message: str) -> None:
if self.verbosity >= 2:
self.stdout.write(message)
def _reset(self) -> None:
from analytics.models import UTMVisit
from contacts.models import Contact
from leads.models import Lead
if apps.is_installed("shipping"):
from shipping.models import Shipment
Shipment.objects.filter(order__number__startswith="DEMO-").delete()
if apps.is_installed("shop"):
from core.models import StoredFile
from shop.models import Order, Product, ProductImage
Order.objects.filter(number__startswith="DEMO-").delete()
products = Product.objects.filter(sku__startswith="DEMO-")
file_ids = list(
ProductImage.objects.filter(product__in=products).values_list(
"file_id", flat=True
)
)
extra_ids = list(products.values_list("stl_id", "image_id"))
products.delete()
blob_ids = {pk for pk in file_ids if pk}
for stl_id, image_id in extra_ids:
blob_ids.update(pk for pk in (stl_id, image_id) if pk)
if blob_ids:
StoredFile.objects.filter(pk__in=blob_ids).delete()
if apps.is_installed("payments"):
from payments.models import Invoice
Invoice.objects.filter(number__startswith="DEMO-").delete()
if apps.is_installed("email_sms"):
from email_sms.models import Campaign
Campaign.objects.filter(name__startswith="DEMO:").delete()
Lead.objects.filter(message__startswith=DEMO_NOTE).delete()
Contact.objects.filter(
email__startswith=DEMO_EMAIL_PREFIX,
email__endswith=DEMO_EMAIL_DOMAIN,
).delete()
UTMVisit.objects.filter(utm_campaign=DEMO_UTM_CAMPAIGN).delete()
def _seed_contacts(self) -> int:
from contacts.consent import set_channel_consent
from contacts.models import Channel, Contact
people = (
(
"maya",
"Maya",
"Chen",
"630-555-0142",
Contact.Source.CONTACT_FORM,
True,
True,
"Naperville",
"Looking for classroom STEM kits.",
),
(
"jordan",
"Jordan",
"Walsh",
"630-555-0198",
Contact.Source.MANUAL,
True,
False,
"Aurora",
"Repeat buyer, prefers cyan filament.",
),
(
"priya",
"Priya",
"Shah",
"847-555-0110",
Contact.Source.IMPORT,
True,
True,
"Wheaton",
"Wedding favor quote, 40 pieces.",
),
(
"evan",
"Evan",
"Brooks",
"312-555-0177",
Contact.Source.NOTIFY_ME,
False,
True,
"Chicago",
"Notify list from the coming-soon page.",
),
(
"sam",
"Sam",
"Ortiz",
"630-555-0166",
Contact.Source.CONTACT_FORM,
True,
True,
"Lisle",
"Custom name keychains for a team.",
),
(
"riley",
"Riley",
"Nguyen",
"708-555-0133",
Contact.Source.OTHER,
True,
False,
"Downers Grove",
"Asked about made-to-order dragons.",
),
(
"alex",
"Alex",
"Patel",
"630-555-0121",
Contact.Source.MANUAL,
False,
False,
"Naperville",
"Opted out of email after one campaign.",
),
(
"casey",
"Casey",
"Miller",
"815-555-0188",
Contact.Source.IMPORT,
True,
True,
"Geneva",
"PTA bulk order for a school fair.",
),
)
created = 0
for slug, first, last, phone, source, email_on, sms_on, city, note in people:
email = f"{DEMO_EMAIL_PREFIX}{slug}{DEMO_EMAIL_DOMAIN}"
contact, was_created = Contact.objects.update_or_create(
email=email,
defaults={
"first_name": first,
"last_name": last,
"phone": phone,
"source": source,
"notes": f"{DEMO_NOTE} {note}",
"postal_address": Contact.make_postal_address(
line1=f"{100 + len(slug) * 17} Demo Ave",
city=city,
state="IL",
zip_code="60540",
),
},
)
set_channel_consent(
contact, Channel.EMAIL, opted_in=email_on, reason="demo_seed"
)
set_channel_consent(
contact, Channel.SMS, opted_in=sms_on, reason="demo_seed"
)
if was_created:
created += 1
self._log(f"contact {contact.email}")
return created
def _seed_leads(self) -> int:
from analytics.models import Attribution
from contacts.models import Contact
from leads.models import Lead, LeadNote
specs = (
(
"maya",
Lead.Status.NEW,
"Need 12 flexi dragons in school colors by next Friday.",
"instagram",
"social",
2,
),
(
"priya",
Lead.Status.CONTACTED,
"Quoted 40 keychains for wedding favors. Waiting on names.",
"google",
"cpc",
8,
),
(
"casey",
Lead.Status.WON,
"STEM night kits — 30 planters, paid via invoice.",
"google",
"organic",
18,
),
(
"riley",
Lead.Status.LOST,
"Wanted 200 units at $4. Could not hit that price.",
"",
"",
12,
),
)
created = 0
for slug, status, body, source, medium, days_ago in specs:
email = f"{DEMO_EMAIL_PREFIX}{slug}{DEMO_EMAIL_DOMAIN}"
contact = Contact.objects.filter(email=email).first()
if contact is None:
continue
message = f"{DEMO_NOTE} {body}"
lead, was_created = Lead.objects.get_or_create(
contact=contact,
message=message,
defaults={"status": status, "owner": self.owner},
)
if not was_created:
if lead.status != status:
lead.status = status
lead.owner = self.owner
lead.save(update_fields=["status", "owner", "updated_at"])
else:
created += 1
LeadNote.objects.create(
lead=lead,
author=self.owner,
body=f"{DEMO_NOTE} Demo walkthrough note.",
)
when = self.now - timedelta(days=days_ago)
_stamp(Lead, lead.pk, when)
if source:
Attribution.objects.update_or_create(
lead=lead,
defaults={
"utm_source": source,
"utm_medium": medium,
"utm_campaign": DEMO_UTM_CAMPAIGN,
},
)
self._log(f"lead {slug} {status}")
return created
def _seed_analytics(self) -> int:
from analytics.models import PageView, UTMVisit
if UTMVisit.objects.filter(utm_campaign=DEMO_UTM_CAMPAIGN).exists():
return 0
paths = (
"/",
"/",
"/",
"/shop/",
"/shop/",
"/shop/demo-flexi-dragon/",
"/shop/demo-rocket/",
"/about/",
"/contact/",
)
created = 0
for offset, path in enumerate(paths * 6):
when = self.now - timedelta(hours=4 * offset + 3)
view = PageView.objects.create(path=path)
_stamp(PageView, view.pk, when)
created += 1
sources = (
("google", "cpc", "/shop/"),
("google", "organic", "/"),
("instagram", "social", "/shop/demo-flexi-dragon/"),
("facebook", "social", "/contact/"),
("direct", "", "/"),
)
for i, (source, medium, path) in enumerate(sources * 8):
when = self.now - timedelta(hours=6 * i + 2)
visit = UTMVisit.objects.create(
correlation_id=f"demo-{i:03d}",
path=path,
utm_source=source,
utm_medium=medium,
utm_campaign=DEMO_UTM_CAMPAIGN,
user_agent="PrintForge demo seed",
)
_stamp(UTMVisit, visit.pk, when)
created += 1
return created
def _seed_products(self) -> int:
from core.models import StoredFile
from shop.models import Product, ProductColor, ProductImage
from shop.services import refresh_listing_image
stl_bytes = _cube_stl()
stl, _ = StoredFile.objects.get_or_create(
filename="demo-cube.stl",
kind=StoredFile.Kind.PRODUCT_STL,
defaults={
"content_type": "model/stl",
"size": len(stl_bytes),
"data": stl_bytes,
},
)
catalog = (
{
"sku": "DEMO-FLEXI-DRAGON",
"name": "Articulated Flexi Dragon",
"price": Decimal("24.00"),
"fulfillment": Product.Fulfillment.STOCKED,
"stock_qty": 0,
"print_minutes": 180,
"filament_grams": 42,
"published": True,
"description": (
"Print-in-place flexi dragon. No assembly. A desk toy that "
"actually wiggles when you pick it up."
),
"colors": (
("Flame Red", "#c41e3a", 6),
("Forge Cyan", "#00aeef", 8),
("Lime", "#8dc63f", 4),
),
"stl": True,
},
{
"sku": "DEMO-D20",
"name": "Oversized D20",
"price": Decimal("18.00"),
"fulfillment": Product.Fulfillment.STOCKED,
"stock_qty": 14,
"print_minutes": 90,
"filament_grams": 28,
"published": True,
"description": "Chunky twenty-sided die. Looks great in a dice tray.",
"colors": (("Obsidian", "#1f2937", 9), ("Gold", "#c4a574", 5)),
"stl": True,
},
{
"sku": "DEMO-ROCKET",
"name": "Desktop Rocket",
"price": Decimal("32.00"),
"fulfillment": Product.Fulfillment.MADE_TO_ORDER,
"stock_qty": 0,
"print_minutes": 210,
"filament_grams": 55,
"published": True,
"description": "Two-color rocket on a stand. Printed to order.",
"colors": (("Navy", "#0a0e14", 0), ("Cyan", "#00aeef", 0)),
"stl": True,
},
{
"sku": "DEMO-PLANTER",
"name": "Geometric Planter",
"price": Decimal("28.00"),
"fulfillment": Product.Fulfillment.STOCKED,
"stock_qty": 2,
"print_minutes": 240,
"filament_grams": 80,
"published": True,
"description": "Faceted succulent planter. Drainage hole included.",
"colors": (),
"stl": False,
},
{
"sku": "DEMO-KEYCHAIN",
"name": "Custom Name Keychain",
"price": Decimal("12.00"),
"fulfillment": Product.Fulfillment.MADE_TO_ORDER,
"stock_qty": 0,
"print_minutes": 35,
"filament_grams": 8,
"published": True,
"description": "Tell us the name. We print it on a split ring loop.",
"colors": (("Cyan", "#00aeef", 0), ("Lime", "#8dc63f", 0)),
"stl": False,
},
{
"sku": "DEMO-PROTO",
"name": "Prototype Mech (coming soon)",
"price": Decimal("45.00"),
"fulfillment": Product.Fulfillment.MADE_TO_ORDER,
"stock_qty": 0,
"print_minutes": 400,
"filament_grams": 120,
"published": False,
"description": "Unpublished prototype so the portal list is not empty of drafts.",
"colors": (),
"stl": False,
},
)
created = 0
for spec in catalog:
product, was_created = Product.objects.update_or_create(
sku=spec["sku"],
defaults={
"name": spec["name"],
"slug": spec["sku"].lower(),
"description": spec["description"],
"price": spec["price"],
"currency": "usd",
"fulfillment": spec["fulfillment"],
"stock_qty": spec["stock_qty"],
"print_minutes": spec["print_minutes"],
"filament_grams": spec["filament_grams"],
"is_published": spec["published"],
"track_inventory": True,
"stl": stl if spec["stl"] else None,
},
)
if was_created:
created += 1
if spec["colors"] and not product.colors.exists():
for index, (name, hex_color, qty) in enumerate(spec["colors"]):
ProductColor.objects.create(
product=product,
name=name,
hex=hex_color,
stock_qty=qty,
sort_order=index,
)
if not product.images.exists():
hex_color = spec["colors"][0][1] if spec["colors"] else "#00aeef"
png = _toy_png(hex_color)
stored = StoredFile.objects.create(
kind=StoredFile.Kind.PRODUCT_IMAGE,
filename=f"{spec['sku'].lower()}.png",
content_type="image/png",
size=len(png),
data=png,
)
ProductImage.objects.create(product=product, file=stored, sort_order=0)
for index, color in enumerate(product.colors.all(), start=1):
color_png = _toy_png(color.hex)
color_file = StoredFile.objects.create(
kind=StoredFile.Kind.PRODUCT_IMAGE,
filename=f"{spec['sku'].lower()}-{slugify(color.name)}.png",
content_type="image/png",
size=len(color_png),
data=color_png,
)
ProductImage.objects.create(
product=product,
color=color,
file=color_file,
sort_order=index,
)
refresh_listing_image(product)
self._log(f"product {product.sku}")
return created
def _seed_orders(self) -> int:
from contacts.models import Contact
from shop.models import Order, OrderItem, Product
dragon = Product.objects.filter(sku="DEMO-FLEXI-DRAGON").first()
d20 = Product.objects.filter(sku="DEMO-D20").first()
rocket = Product.objects.filter(sku="DEMO-ROCKET").first()
planter = Product.objects.filter(sku="DEMO-PLANTER").first()
if not all([dragon, d20, rocket, planter]):
return 0
dragon_cyan = dragon.colors.filter(name="Forge Cyan").first()
specs = (
(
"DEMO-ORD-001",
"jordan",
Order.Status.PAID,
((dragon, dragon_cyan, 1),),
3,
),
(
"DEMO-ORD-002",
"casey",
Order.Status.FULFILLED,
((planter, None, 2), (d20, None, 1)),
11,
),
(
"DEMO-ORD-003",
"sam",
Order.Status.PAID,
((d20, None, 2),),
1,
),
(
"DEMO-ORD-004",
"riley",
Order.Status.OPEN,
((rocket, None, 1),),
0,
),
(
"DEMO-ORD-005",
"alex",
Order.Status.CANCELLED,
((dragon, None, 1),),
16,
),
)
created = 0
for number, slug, status, lines, days_ago in specs:
contact = Contact.objects.filter(
email=f"{DEMO_EMAIL_PREFIX}{slug}{DEMO_EMAIL_DOMAIN}"
).first()
if contact is None:
continue
amount = sum(
(product.price * qty for product, _color, qty in lines),
Decimal("0.00"),
)
paid = status in {Order.Status.PAID, Order.Status.FULFILLED}
when = self.now - timedelta(days=days_ago, hours=5)
order, was_created = Order.objects.update_or_create(
number=number,
defaults={
"email": contact.email,
"customer_name": contact.full_name,
"status": status,
"amount": amount,
"currency": "usd",
"paid_at": when if paid else None,
"shipping_address": contact.postal_address,
"notes": f"{DEMO_NOTE} Demo order.",
},
)
if was_created:
created += 1
if not order.items.exists():
for product, color, qty in lines:
OrderItem.objects.create(
order=order,
product=product,
color=color,
name=product.name,
sku=product.sku,
color_name=color.name if color else "",
quantity=qty,
unit_price=product.price,
print_minutes=product.print_minutes,
)
_stamp(Order, order.pk, when)
self._log(f"order {order.number} {status}")
return created
def _seed_shipments(self) -> int:
from shipping.models import Shipment
from shop.models import Order
order = Order.objects.filter(number="DEMO-ORD-002").first()
if order is None:
return 0
shipment, created = Shipment.objects.get_or_create(
order=order,
defaults={
"status": Shipment.Status.LABELED,
"carrier": "USPS",
"service": "Priority",
"tracking_number": "940011189922DEMO02",
"rate_amount": Decimal("8.45"),
"currency": "usd",
"weight_oz": 18,
"notes": f"{DEMO_NOTE} Stub label. No EasyPost call.",
},
)
if created:
self._log(f"shipment {shipment.tracking_number}")
return 1
return 0
def _seed_invoices(self) -> int:
from contacts.models import Contact
from payments.models import Invoice
specs = (
(
"DEMO-INV-001",
"casey",
Invoice.Status.PAID,
Decimal("210.00"),
"STEM night planter kits (30)",
14,
),
(
"DEMO-INV-002",
"priya",
Invoice.Status.OPEN,
Decimal("480.00"),
"Wedding favor keychains (40) — deposit due",
4,
),
)
created = 0
for number, slug, status, amount, description, days_ago in specs:
contact = Contact.objects.filter(
email=f"{DEMO_EMAIL_PREFIX}{slug}{DEMO_EMAIL_DOMAIN}"
).first()
if contact is None:
continue
when = self.now - timedelta(days=days_ago)
paid = status == Invoice.Status.PAID
invoice, was_created = Invoice.objects.update_or_create(
number=number,
defaults={
"contact": contact,
"description": description,
"amount": amount,
"currency": "usd",
"status": status,
"paid_at": when if paid else None,
"created_by": self.owner,
"notes": f"{DEMO_NOTE} Demo invoice. No Stripe session.",
},
)
if was_created:
created += 1
_stamp(Invoice, invoice.pk, when)
self._log(f"invoice {invoice.number} {status}")
return created
def _seed_campaigns(self) -> int:
from email_sms.models import Campaign, Message
from email_sms.services import create_campaign_draft
created = 0
draft_name = "DEMO: New filament colors"
if not Campaign.objects.filter(name=draft_name).exists():
create_campaign_draft(
name=draft_name,
audience=Campaign.Audience.EMAIL_OPT_IN,
subject="Cyan and lime just landed",
body=(
"<p>We restocked Forge Cyan and Lime flexi dragons. "
"Shop the drop before they print out.</p>"
),
created_by=self.owner,
)
created += 1
done_name = "DEMO: STEM night recap"
if not Campaign.objects.filter(name=done_name).exists():
campaign = create_campaign_draft(
name=done_name,
audience=Campaign.Audience.EMAIL_OPT_IN,
subject="Thanks for coming to STEM night",
body="<p>Your geometric planters are packed. Tracking goes out tomorrow.</p>",
created_by=self.owner,
)
campaign.status = Campaign.Status.COMPLETED
campaign.notify_sent_at = self.now - timedelta(days=9)
campaign.save(update_fields=["status", "notify_sent_at", "updated_at"])
messages = list(campaign.messages.all())
for index, message in enumerate(messages):
message.status = (
Message.Status.OPENED
if index % 3 == 0
else Message.Status.DELIVERED
)
message.sent_at = self.now - timedelta(days=10)
message.save(update_fields=["status", "sent_at", "updated_at"])
created += 1
self._log(f"campaign {done_name} completed")
sms_name = "DEMO: Print-ready SMS"
if not Campaign.objects.filter(name=sms_name).exists():
create_campaign_draft(
name=sms_name,
audience=Campaign.Audience.SMS_OPT_IN,
body="Your Print Forge order is on the bed. We will text when it ships.",
created_by=self.owner,
)
created += 1
return created
+54
View File
@@ -1,4 +1,6 @@
import os
from io import StringIO
from unittest.mock import patch
from django.core.management import call_command
from django.test import Client, TestCase, override_settings
@@ -23,6 +25,9 @@ class UnderConstructionTests(TestCase):
def test_home_ok_when_open(self):
response = Client().get("/")
self.assertEqual(response.status_code, 200)
self.assertContains(response, "pf-print-splash")
self.assertContains(response, "pf-printer")
self.assertNotContains(response, "cssload-container")
class PublicSmokeTests(TestCase):
@@ -37,3 +42,52 @@ class DispatchDueTests(TestCase):
out = StringIO()
call_command("dispatch_due", stdout=out)
self.assertEqual(out.getvalue(), "")
class SeedDemoTests(TestCase):
def test_refuses_prod(self):
from django.core.management.base import CommandError
with patch.dict(os.environ, {"DJANGO_ENV": "prod"}):
with self.assertRaises(CommandError) as ctx:
call_command("seed_demo", stdout=StringIO())
self.assertIn("prod", str(ctx.exception).lower())
def test_seeds_shop_and_portal_rows(self):
out = StringIO()
call_command("seed_demo", stdout=out)
self.assertIn("Demo seed ready", out.getvalue())
from contacts.models import Contact
from leads.models import Lead
from shop.models import Order, Product
self.assertTrue(Product.objects.filter(sku="DEMO-FLEXI-DRAGON").exists())
self.assertTrue(Product.objects.filter(sku="DEMO-PROTO", is_published=False).exists())
self.assertGreaterEqual(Contact.objects.filter(email__startswith="demo+").count(), 8)
self.assertTrue(Lead.objects.filter(message__startswith="[demo-seed]").exists())
self.assertTrue(Order.objects.filter(number="DEMO-ORD-001", status="paid").exists())
listing = Client().get(reverse("shop:list"))
self.assertContains(listing, "Articulated Flexi Dragon")
def test_second_run_is_idempotent(self):
call_command("seed_demo", stdout=StringIO())
from shop.models import Product
count = Product.objects.filter(sku__startswith="DEMO-").count()
call_command("seed_demo", stdout=StringIO())
self.assertEqual(Product.objects.filter(sku__startswith="DEMO-").count(), count)
def test_reset_rebuilds_tagged_rows(self):
call_command("seed_demo", stdout=StringIO())
from shop.models import Product
Product.objects.filter(sku="DEMO-FLEXI-DRAGON").delete()
call_command("seed_demo", reset=True, stdout=StringIO())
self.assertTrue(Product.objects.filter(sku="DEMO-FLEXI-DRAGON").exists())
@patch.dict(os.environ, {"DJANGO_ENV": "beta"})
def test_allows_beta(self):
call_command("seed_demo", stdout=StringIO())
from shop.models import Product
self.assertTrue(Product.objects.filter(sku__startswith="DEMO-").exists())