generated from westfarn/web_django_template
Align the public site with the PRINTFORGE banner for client demos.
CI / test (pull_request) Successful in 38s
CI / test (pull_request) Successful in 38s
Replace orange with cyan/lime/navy, swap the splash for a print animation, make smart-crop optional, and add a prod-safe seed_demo command. Closes #5. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user