Align the public site with the PRINTFORGE banner for client demos.
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:
2026-09-07 06:05:29 -05:00
co-authored by Cursor
parent 04e75ec7a3
commit 10263e08ff
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 io import StringIO
from unittest.mock import patch
from django.core.management import call_command from django.core.management import call_command
from django.test import Client, TestCase, override_settings from django.test import Client, TestCase, override_settings
@@ -23,6 +25,9 @@ class UnderConstructionTests(TestCase):
def test_home_ok_when_open(self): def test_home_ok_when_open(self):
response = Client().get("/") response = Client().get("/")
self.assertEqual(response.status_code, 200) 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): class PublicSmokeTests(TestCase):
@@ -37,3 +42,52 @@ class DispatchDueTests(TestCase):
out = StringIO() out = StringIO()
call_command("dispatch_due", stdout=out) call_command("dispatch_due", stdout=out)
self.assertEqual(out.getvalue(), "") 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())
+1 -1
View File
@@ -336,7 +336,7 @@ class CampaignSendTests(TestCase):
self.assertEqual(summary.to, [self.user.email]) self.assertEqual(summary.to, [self.user.email])
self.assertTrue(mail.outbox[0].alternatives) self.assertTrue(mail.outbox[0].alternatives)
self.assertEqual(mail.outbox[0].alternatives[0][1], "text/html") self.assertEqual(mail.outbox[0].alternatives[0][1], "text/html")
self.assertIn("#00626c", mail.outbox[0].alternatives[0][0]) self.assertIn("#00aeef", mail.outbox[0].alternatives[0][0])
self.assertTrue(summary.alternatives) self.assertTrue(summary.alternatives)
+28 -28
View File
@@ -1,17 +1,17 @@
:root { :root {
--monica-primary: #00626c; --monica-primary: #00aeef;
--monica-primary-light: #008898; --monica-primary-light: #33c4f4;
--monica-ink: #212121; --monica-ink: #212121;
--monica-muted: #6b7280; --monica-muted: #6b7280;
--monica-surface: #f4f7f7; --monica-surface: #f4f8fb;
--monica-border: #d9e3e4; --monica-border: #d5e3ea;
--monica-ok: #1a7f4b; --monica-ok: #1a7f4b;
--monica-warn: #b45309; --monica-warn: #b45309;
--monica-danger: #b91c1c; --monica-danger: #b91c1c;
--portal-sidebar: 240px; --portal-sidebar: 240px;
--exit-dark: #080808; --exit-dark: #0a0e14;
--exit-teal: #00626c; --exit-teal: #00aeef;
--exit-teal-bright: #008898; --exit-teal-bright: #8dc63f;
} }
/* Portal shell */ /* Portal shell */
@@ -24,7 +24,7 @@ body.portal {
.portal-shell { display: flex; min-height: 100vh; } .portal-shell { display: flex; min-height: 100vh; }
.portal-sidebar { .portal-sidebar {
width: var(--portal-sidebar); width: var(--portal-sidebar);
background: #080808; background: #0a0e14;
color: #cbd5e1; color: #cbd5e1;
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
@@ -47,7 +47,7 @@ body.portal {
width: auto; width: auto;
display: block; display: block;
} }
.portal-brand span { color: #7dd3da; font-size: 12px; letter-spacing: 0.04em; } .portal-brand span { color: #8dc63f; font-size: 12px; letter-spacing: 0.04em; }
.portal-nav { padding: 12px 0; flex: 1; } .portal-nav { padding: 12px 0; flex: 1; }
.portal-nav a { .portal-nav a {
display: block; display: block;
@@ -58,7 +58,7 @@ body.portal {
border-left: 3px solid transparent; border-left: 3px solid transparent;
} }
.portal-nav a:hover { color: #fff; background: rgba(255,255,255,0.04); } .portal-nav a:hover { color: #fff; background: rgba(255,255,255,0.04); }
.portal-nav a.active { color: #fff; background: rgba(0,98,108,0.28); border-left-color: #00a0ab; } .portal-nav a.active { color: #fff; background: rgba(0,174,239,0.22); border-left-color: #8dc63f; }
.portal-nav .nav-section { .portal-nav .nav-section {
font-size: 10px; font-size: 10px;
text-transform: uppercase; text-transform: uppercase;
@@ -135,7 +135,7 @@ body.portal {
font-family: "Work Sans", sans-serif; font-family: "Work Sans", sans-serif;
} }
.btn-primary { background: var(--monica-primary); color: #fff; } .btn-primary { background: var(--monica-primary); color: #fff; }
.btn-primary:hover { background: #004e56; color: #fff; text-decoration: none; } .btn-primary:hover { background: #0072bc; color: #fff; text-decoration: none; }
.btn-ghost { background: transparent; border: 1px solid var(--monica-border); color: var(--monica-ink); } .btn-ghost { background: transparent; border: 1px solid var(--monica-border); color: var(--monica-ink); }
.btn-sm { padding: 6px 12px; font-size: 12px; } .btn-sm { padding: 6px 12px; font-size: 12px; }
@@ -163,11 +163,11 @@ body.portal {
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.04em; letter-spacing: 0.04em;
} }
.badge-new { background: #d0eef0; color: #00626c; } .badge-new { background: #d6f3fc; color: #00aeef; }
.badge-contacted { background: #fef3c7; color: #92400e; } .badge-contacted { background: #fef3c7; color: #92400e; }
.badge-won { background: #d1fae5; color: #065f46; } .badge-won { background: #d1fae5; color: #065f46; }
.badge-lost { background: #fee2e2; color: #991b1b; } .badge-lost { background: #fee2e2; color: #991b1b; }
.badge-sent { background: #d0eef0; color: #00626c; } .badge-sent { background: #d6f3fc; color: #00aeef; }
.badge-scheduled { background: #ede9fe; color: #5b21b6; } .badge-scheduled { background: #ede9fe; color: #5b21b6; }
.badge-delivered { background: #d1fae5; color: #065f46; } .badge-delivered { background: #d1fae5; color: #065f46; }
.badge-opened { background: #dbeafe; color: #1e40af; } .badge-opened { background: #dbeafe; color: #1e40af; }
@@ -178,11 +178,11 @@ body.portal {
.badge-draft { background: #f3f4f6; color: #4b5563; } .badge-draft { background: #f3f4f6; color: #4b5563; }
.badge-completed { background: #d1fae5; color: #065f46; } .badge-completed { background: #d1fae5; color: #065f46; }
.badge-cancelled { background: #f3f4f6; color: #4b5563; } .badge-cancelled { background: #f3f4f6; color: #4b5563; }
.badge-sending { background: #d0eef0; color: #00626c; } .badge-sending { background: #d6f3fc; color: #00aeef; }
.badge-queued { background: #ede9fe; color: #5b21b6; } .badge-queued { background: #ede9fe; color: #5b21b6; }
.badge-bounced { background: #fee2e2; color: #991b1b; } .badge-bounced { background: #fee2e2; color: #991b1b; }
.badge-published { background: #d1fae5; color: #065f46; } .badge-published { background: #d1fae5; color: #065f46; }
.badge-publishing { background: #d0eef0; color: #00626c; } .badge-publishing { background: #d6f3fc; color: #00aeef; }
.form-grid { display: grid; gap: 16px; } .form-grid { display: grid; gap: 16px; }
.form-grid.cols-2 { grid-template-columns: 1fr 1fr; } .form-grid.cols-2 { grid-template-columns: 1fr 1fr; }
@@ -213,7 +213,7 @@ body.portal {
.chart-placeholder { .chart-placeholder {
height: 220px; height: 220px;
background: linear-gradient(180deg, #e8f4f5 0%, #fff 100%); background: linear-gradient(180deg, #e6f7fd 0%, #fff 100%);
border: 1px dashed #a8d8dc; border: 1px dashed #a8d8dc;
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
@@ -306,7 +306,7 @@ body.portal {
} }
.hbar-track { .hbar-track {
height: 16px; height: 16px;
background: #e8f4f5; background: #e6f7fd;
overflow: hidden; overflow: hidden;
} }
.hbar-fill { .hbar-fill {
@@ -344,7 +344,7 @@ body.portal {
padding: 6px; padding: 6px;
position: relative; position: relative;
} }
.calendar-grid .day.has-post { background: #e8f4f5; } .calendar-grid .day.has-post { background: #e6f7fd; }
.calendar-grid .day .dot { .calendar-grid .day .dot {
width: 6px; height: 6px; border-radius: 50%; width: 6px; height: 6px; border-radius: 50%;
background: var(--monica-primary); background: var(--monica-primary);
@@ -356,7 +356,7 @@ body.portal {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: linear-gradient(160deg, #080808, #00626c 60%, #e8f4f5 60%); background: linear-gradient(160deg, #0a0e14, #00aeef 55%, #8dc63f 60%, #e6f7fd 60%);
padding: 24px; padding: 24px;
} }
.login-card { .login-card {
@@ -376,7 +376,7 @@ body.portal {
.portal-nav { display: flex; flex-wrap: wrap; padding: 8px; } .portal-nav { display: flex; flex-wrap: wrap; padding: 8px; }
.portal-nav .nav-section { display: none; } .portal-nav .nav-section { display: none; }
.portal-nav a { border-left: none; border-bottom: 2px solid transparent; padding: 8px 12px; } .portal-nav a { border-left: none; border-bottom: 2px solid transparent; padding: 8px 12px; }
.portal-nav a.active { border-bottom-color: #00a0ab; } .portal-nav a.active { border-bottom-color: #8dc63f; }
.split { grid-template-columns: 1fr; } .split { grid-template-columns: 1fr; }
} }
@@ -399,7 +399,7 @@ body.portal {
font-weight: 700; font-weight: 700;
font-size: 13px; font-size: 13px;
letter-spacing: 0.04em; letter-spacing: 0.04em;
color: #00626c; color: #00aeef;
text-decoration: none; text-decoration: none;
line-height: 1.2; line-height: 1.2;
} }
@@ -546,7 +546,7 @@ body.portal {
background: #fff; background: #fff;
} }
.pcm-designer .table tr.is-active td { .pcm-designer .table tr.is-active td {
background: #e8f4f5; background: #e6f7fd;
} }
/* Size picker */ /* Size picker */
@@ -557,7 +557,7 @@ body.portal {
} }
.size-option:hover { border-color: var(--monica-primary); } .size-option:hover { border-color: var(--monica-primary); }
.size-option.active { .size-option.active {
border-color: var(--monica-primary); background: #e8f4f5; border-color: var(--monica-primary); background: #e6f7fd;
box-shadow: inset 3px 0 0 var(--monica-primary); box-shadow: inset 3px 0 0 var(--monica-primary);
} }
.size-option .sz-name { font-weight: 700; font-size: 13px; font-family: Poppins, sans-serif; } .size-option .sz-name { font-weight: 700; font-size: 13px; font-family: Poppins, sans-serif; }
@@ -603,7 +603,7 @@ body.portal {
.ai-chat-h h2 { margin: 0; font-size: 15px; font-family: Poppins, sans-serif; font-weight: 600; } .ai-chat-h h2 { margin: 0; font-size: 15px; font-family: Poppins, sans-serif; font-weight: 600; }
.ai-pill { .ai-pill {
font-size: 10px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; font-size: 10px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase;
background: #e8f4f5; color: var(--monica-primary); padding: 3px 8px; background: #e6f7fd; color: var(--monica-primary); padding: 3px 8px;
} }
.ai-chat-messages { .ai-chat-messages {
flex: 1; overflow-y: auto; padding: 14px; display: flex; flex-direction: column; gap: 12px; flex: 1; overflow-y: auto; padding: 14px; display: flex; flex-direction: column; gap: 12px;
@@ -671,14 +671,14 @@ body.portal {
} }
.postcard-headline { .postcard-headline {
font-family: Poppins, sans-serif; font-weight: 700; font-size: 28px; letter-spacing: 0.04em; font-family: Poppins, sans-serif; font-weight: 700; font-size: 28px; letter-spacing: 0.04em;
border-left: 4px solid #00626c; padding-left: 10px; line-height: 1.1; border-left: 4px solid #00aeef; padding-left: 10px; line-height: 1.1;
} }
.postcard-sub { margin-top: 8px; font-size: 14px; opacity: 0.95; } .postcard-sub { margin-top: 8px; font-size: 14px; opacity: 0.95; }
.postcard-back { background: #fafafa; color: #212121; } .postcard-back { background: #fafafa; color: #212121; }
.postcard-back-grid { display: grid; grid-template-columns: 1.2fr 1fr; height: 100%; } .postcard-back-grid { display: grid; grid-template-columns: 1.2fr 1fr; height: 100%; }
.postcard-back-msg { padding: 16px; display: flex; flex-direction: column; gap: 10px; border-right: 1px dashed #cbd5e1; } .postcard-back-msg { padding: 16px; display: flex; flex-direction: column; gap: 10px; border-right: 1px dashed #cbd5e1; }
.postcard-back-body { font-size: 12px; white-space: pre-wrap; flex: 1; line-height: 1.45; } .postcard-back-body { font-size: 12px; white-space: pre-wrap; flex: 1; line-height: 1.45; }
.postcard-agent { font-size: 11px; font-weight: 600; color: #00626c; } .postcard-agent { font-size: 11px; font-weight: 600; color: #00aeef; }
.postcard-qr { .postcard-qr {
width: 48px; height: 48px; background: #111; color: #fff; font-size: 10px; width: 48px; height: 48px; background: #111; color: #fff; font-size: 10px;
display: flex; align-items: center; justify-content: center; letter-spacing: 0.05em; display: flex; align-items: center; justify-content: center; letter-spacing: 0.05em;
@@ -709,7 +709,7 @@ body.portal {
display: flex; align-items: center; gap: 10px; padding: 12px 14px; position: relative; display: flex; align-items: center; gap: 10px; padding: 12px 14px; position: relative;
} }
.soc-avatar { .soc-avatar {
width: 40px; height: 40px; background: #00626c; color: #fff; display: flex; width: 40px; height: 40px; background: #00aeef; color: #fff; display: flex;
align-items: center; justify-content: center; font-weight: 700; font-size: 13px; flex-shrink: 0; align-items: center; justify-content: center; font-weight: 700; font-size: 13px; flex-shrink: 0;
} }
.soc-avatar.round { border-radius: 50%; background: linear-gradient(45deg, #f58529, #dd2a7b); } .soc-avatar.round { border-radius: 50%; background: linear-gradient(45deg, #f58529, #dd2a7b); }
@@ -737,7 +737,7 @@ body.portal {
} }
.portal-flash { list-style: none; margin: 0 0 16px; padding: 0; } .portal-flash { list-style: none; margin: 0 0 16px; padding: 0; }
.portal-flash li { .portal-flash li {
padding: 10px 14px; background: #e8f4f5; border-left: 3px solid var(--monica-primary); padding: 10px 14px; background: #e6f7fd; border-left: 3px solid var(--monica-primary);
margin-bottom: 8px; font-size: 14px; margin-bottom: 8px; font-size: 14px;
} }
.portal-flash li.error { background: #fee2e2; border-left-color: #b91c1c; } .portal-flash li.error { background: #fee2e2; border-left-color: #b91c1c; }
File diff suppressed because one or more lines are too long
+198 -3
View File
@@ -1,12 +1,207 @@
/* Print Forge public brand overrides on the Ecoprint theme */ /* Print Forge public brand overrides on the Ecoprint theme */
:root { :root {
--pf-primary: #ff6252; --pf-primary: #00aeef;
--pf-primary-dark: #e24f41; --pf-primary-dark: #0072bc;
--pf-accent: #8dc63f;
--pf-navy: #0a0e14;
--pf-ink: #151515; --pf-ink: #151515;
--pf-muted: #6b7280; --pf-muted: #6b7280;
} }
/* Banner-matched splash: FDM printer building a model layer by layer. */
.preloader {
background: var(--pf-navy) !important;
overflow: hidden;
}
.pf-print-splash {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 28px;
overflow: hidden;
}
.pf-print-glow {
position: absolute;
width: 280px;
height: 280px;
pointer-events: none;
filter: blur(8px);
}
.pf-print-glow-tl {
top: -80px;
left: -80px;
background: radial-gradient(circle, rgba(0, 174, 239, 0.55) 0%, transparent 68%);
}
.pf-print-glow-br {
right: -80px;
bottom: -80px;
background: radial-gradient(circle, rgba(141, 198, 63, 0.45) 0%, transparent 68%);
}
.pf-printer {
position: relative;
width: 200px;
height: 200px;
}
.pf-printer-post,
.pf-printer-top,
.pf-printer-bed {
position: absolute;
background: linear-gradient(180deg, #1b2733, #0f141c);
box-shadow: inset 0 0 0 1px rgba(0, 174, 239, 0.45), 0 0 18px rgba(0, 174, 239, 0.18);
}
.pf-printer-post {
top: 8px;
width: 10px;
height: 148px;
border-radius: 2px;
}
.pf-printer-post-l { left: 18px; }
.pf-printer-post-r { right: 18px; }
.pf-printer-top {
top: 8px;
left: 18px;
right: 18px;
height: 10px;
border-radius: 2px;
box-shadow: inset 0 0 0 1px rgba(0, 174, 239, 0.7), 0 0 16px rgba(0, 174, 239, 0.28);
}
.pf-printer-gantry {
position: absolute;
top: 22px;
left: 28px;
right: 28px;
height: 8px;
background: linear-gradient(90deg, #00aeef, #8dc63f);
border-radius: 2px;
box-shadow: 0 0 12px rgba(0, 174, 239, 0.55);
animation: pf-gantry-y 4.8s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
.pf-printer-head {
position: absolute;
top: 0;
left: 0;
width: 28px;
margin-left: -14px;
display: flex;
flex-direction: column;
align-items: center;
animation: pf-head-x 1.2s linear infinite alternate;
}
.pf-nozzle {
display: block;
filter: drop-shadow(0 0 6px rgba(0, 174, 239, 0.8));
}
.pf-bead {
width: 5px;
height: 10px;
margin-top: -2px;
border-radius: 0 0 3px 3px;
background: linear-gradient(180deg, #00aeef, #8dc63f);
opacity: 0.9;
animation: pf-bead 0.35s ease-in infinite;
}
.pf-printer-bed {
left: 28px;
right: 28px;
bottom: 16px;
height: 12px;
border-radius: 2px;
background: linear-gradient(180deg, #243040, #10151c);
box-shadow: 0 0 22px rgba(141, 198, 63, 0.2), inset 0 0 0 1px rgba(141, 198, 63, 0.35);
}
.pf-model {
position: absolute;
left: 50%;
bottom: 28px;
width: 72px;
height: 72px;
margin-left: -36px;
display: flex;
flex-direction: column-reverse;
align-items: center;
gap: 1px;
transform: perspective(180px) rotateX(58deg);
transform-origin: bottom center;
}
.pf-model i {
display: block;
width: 56px;
height: 8px;
border-radius: 1px;
background: linear-gradient(90deg, #00aeef, #8dc63f);
box-shadow: 0 0 8px rgba(0, 174, 239, 0.45);
transform: scaleX(0);
opacity: 0;
animation: pf-layer 4.8s ease-in-out infinite;
}
.pf-model i:nth-child(1) { animation-delay: 0.15s; width: 58px; }
.pf-model i:nth-child(2) { animation-delay: 0.55s; width: 58px; }
.pf-model i:nth-child(3) { animation-delay: 0.95s; width: 58px; }
.pf-model i:nth-child(4) { animation-delay: 1.35s; width: 50px; }
.pf-model i:nth-child(5) { animation-delay: 1.75s; width: 50px; }
.pf-model i:nth-child(6) { animation-delay: 2.15s; width: 38px; }
.pf-model i:nth-child(7) { animation-delay: 2.55s; width: 28px; }
.pf-model i:nth-child(8) { animation-delay: 2.95s; width: 18px; }
.pf-print-caption {
margin: 0;
font-family: Roboto, sans-serif;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.42em;
text-transform: uppercase;
color: #8dc63f;
text-shadow: 0 0 12px rgba(141, 198, 63, 0.45);
animation: pf-caption 1.6s ease-in-out infinite;
}
@keyframes pf-gantry-y {
0%, 8% { top: 124px; }
82%, 88% { top: 28px; }
100% { top: 124px; }
}
@keyframes pf-head-x {
0% { left: 12%; }
100% { left: 88%; }
}
@keyframes pf-bead {
0% { transform: translateY(-4px); opacity: 0; }
40% { opacity: 1; }
100% { transform: translateY(10px); opacity: 0; }
}
@keyframes pf-layer {
0%, 6% { transform: scaleX(0); opacity: 0; }
12%, 78% { transform: scaleX(1); opacity: 1; }
90%, 100% { transform: scaleX(0); opacity: 0; }
}
@keyframes pf-caption {
0%, 100% { opacity: 0.55; }
50% { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.pf-printer-gantry,
.pf-printer-head,
.pf-bead,
.pf-model i,
.pf-print-caption {
animation: none;
}
.pf-printer-gantry { top: 70px; }
.pf-printer-head { left: 50%; }
.pf-bead { display: none; }
.pf-model i { transform: scaleX(1); opacity: 1; }
}
.button-primary:hover,
.button-primary:active,
.button-primary.active {
background-color: var(--pf-primary-dark);
border-color: var(--pf-primary-dark);
color: #fff;
}
/* Theme CSS starts `.page` at opacity:0 until a `.preloader` runs. /* Theme CSS starts `.page` at opacity:0 until a `.preloader` runs.
Keep this so a missed preloader never blanks the site. */ Keep this so a missed preloader never blanks the site. */
.page { .page {
@@ -118,7 +313,7 @@
color: var(--pf-primary-dark); color: var(--pf-primary-dark);
} }
.cookie-consent-link { .cookie-consent-link {
color: #ffd3ce; color: #9be4fb;
text-decoration: underline; text-decoration: underline;
text-underline-offset: 2px; text-underline-offset: 2px;
} }
+1 -1
View File
@@ -101,7 +101,7 @@
return QRCode.toCanvas(canvas, url, { return QRCode.toCanvas(canvas, url, {
width: 192, width: 192,
margin: 1, margin: 1,
color: { dark: "#00626c", light: "#ffffff" }, color: { dark: "#0072bc", light: "#ffffff" },
}); });
} }
+1 -1
View File
@@ -4,7 +4,7 @@ import { STLLoader } from "three/addons/loaders/STLLoader.js";
const instances = new WeakMap(); const instances = new WeakMap();
function hexColor(value, fallback = "#ff6252") { function hexColor(value, fallback = "#00aeef") {
const raw = (value || "").trim(); const raw = (value || "").trim();
if (/^#[0-9a-fA-F]{6}$/.test(raw)) return raw; if (/^#[0-9a-fA-F]{6}$/.test(raw)) return raw;
return fallback; return fallback;
+29 -10
View File
@@ -44,16 +44,35 @@
{% endif %} {% endif %}
{% block body %} {% block body %}
<div class="preloader"> <div class="preloader">
<div class="cssload-container"> <div class="pf-print-splash" role="status" aria-label="Loading">
<div class="load-circle-item"><div class="load-circle"></div></div> <span class="pf-print-glow pf-print-glow-tl"></span>
<div class="load-circle-item"><div class="load-circle"></div></div> <span class="pf-print-glow pf-print-glow-br"></span>
<div class="load-circle-item"><div class="load-circle"></div></div> <div class="pf-printer" aria-hidden="true">
<div class="load-circle-item"><div class="load-circle"></div></div> <span class="pf-printer-post pf-printer-post-l"></span>
<div class="load-circle-item"><div class="load-circle"></div></div> <span class="pf-printer-post pf-printer-post-r"></span>
<div class="load-circle-item"><div class="load-circle"></div></div> <span class="pf-printer-top"></span>
<div class="load-circle-item"><div class="load-circle"></div></div> <span class="pf-printer-gantry">
<div class="load-circle-item"><div class="load-circle"></div></div> <span class="pf-printer-head">
<div class="load-circle-item"><div class="load-circle"></div></div> <svg class="pf-nozzle" viewBox="0 0 32 40" width="28" height="36">
<defs>
<linearGradient id="pf-nozzle-grad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#00aeef"/>
<stop offset="100%" stop-color="#8dc63f"/>
</linearGradient>
</defs>
<rect x="8" y="0" width="16" height="8" rx="1.5" fill="url(#pf-nozzle-grad)"/>
<path d="M6 8h20l-4 10H10z" fill="url(#pf-nozzle-grad)"/>
<path d="M12 18h8l-4 10z" fill="#e8fbff"/>
</svg>
<span class="pf-bead"></span>
</span>
</span>
<span class="pf-printer-bed"></span>
<span class="pf-model">
<i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i>
</span>
</div>
<p class="pf-print-caption">Printing</p>
</div> </div>
</div> </div>
<div class="page"> <div class="page">
+1 -1
View File
@@ -82,7 +82,7 @@ def plain_text_to_email_html(text: str) -> str:
lines = [html.escape(line) for line in para.split("\n")] lines = [html.escape(line) for line in para.split("\n")]
joined = "<br>\n".join(lines) joined = "<br>\n".join(lines)
joined = _URL_RE.sub( joined = _URL_RE.sub(
r'<a href="\1" style="color:#00626c;text-decoration:underline;">\1</a>', r'<a href="\1" style="color:#00aeef;text-decoration:underline;">\1</a>',
joined, joined,
) )
blocks.append( blocks.append(
+5 -5
View File
@@ -36,11 +36,11 @@
color: #212121; color: #212121;
font-family: "Work Sans", Poppins, -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; font-family: "Work Sans", Poppins, -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
} }
a { color: #00626c; } a { color: #00aeef; }
.email-btn { .email-btn {
display: inline-block; display: inline-block;
padding: 12px 24px; padding: 12px 24px;
background-color: #00626c; background-color: #00aeef;
color: #ffffff !important; color: #ffffff !important;
text-decoration: none; text-decoration: none;
border-radius: 4px; border-radius: 4px;
@@ -66,7 +66,7 @@
<img src="{{ logo_url }}" alt="{{ brand_name|default:'Print Forge' }}" width="160" style="display:block;width:160px;max-width:70%;height:auto;"> <img src="{{ logo_url }}" alt="{{ brand_name|default:'Print Forge' }}" width="160" style="display:block;width:160px;max-width:70%;height:auto;">
</a> </a>
{% else %} {% else %}
<p style="margin:0;font-size:20px;font-weight:700;letter-spacing:0.5px;color:#00626c;"> <p style="margin:0;font-size:20px;font-weight:700;letter-spacing:0.5px;color:#00aeef;">
{{ brand_name }} {{ brand_name }}
</p> </p>
{% endif %} {% endif %}
@@ -82,7 +82,7 @@
</td> </td>
</tr> </tr>
<tr> <tr>
<td style="height:3px;line-height:3px;font-size:0;background-color:#00626c;">&nbsp;</td> <td style="height:3px;line-height:3px;font-size:0;background-color:#00aeef;">&nbsp;</td>
</tr> </tr>
<tr> <tr>
<td style="padding:28px 28px 8px;color:#212121;font-size:15px;line-height:1.6;font-family:'Work Sans',Poppins,-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;"> <td style="padding:28px 28px 8px;color:#212121;font-size:15px;line-height:1.6;font-family:'Work Sans',Poppins,-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;">
@@ -102,7 +102,7 @@
{% if brand_tagline_html %} {% if brand_tagline_html %}
{{ brand_tagline_html|safe }} {{ brand_tagline_html|safe }}
{% else %} {% else %}
<a href="{{ site_url|default:'https://mkdrealtor.com' }}" style="color:#00626c;text-decoration:none;">{{ host_label|default:"mkdrealtor.com" }}</a> <a href="{{ site_url|default:'https://mkdrealtor.com' }}" style="color:#00aeef;text-decoration:none;">{{ host_label|default:"mkdrealtor.com" }}</a>
{% if brand_tagline %} {% if brand_tagline %}
&nbsp;·&nbsp; {{ brand_tagline }} &nbsp;·&nbsp; {{ brand_tagline }}
{% endif %} {% endif %}
@@ -5,7 +5,7 @@
{% block content %} {% block content %}
<p style="margin:0 0 16px;color:#212121;">Your {{ channel_display }} campaign has finished sending.</p> <p style="margin:0 0 16px;color:#212121;">Your {{ channel_display }} campaign has finished sending.</p>
<p style="margin:0 0 8px;font-size:18px;font-weight:600;color:#00626c;">{{ campaign_name }}</p> <p style="margin:0 0 8px;font-size:18px;font-weight:600;color:#00aeef;">{{ campaign_name }}</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin:20px 0;"> <table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin:20px 0;">
<tr> <tr>
@@ -32,7 +32,7 @@
{% if report_url %} {% if report_url %}
<p style="margin:24px 0 0;"> <p style="margin:24px 0 0;">
<a class="email-btn" href="{{ report_url }}" style="display:inline-block;padding:12px 24px;background-color:#00626c;color:#ffffff !important;text-decoration:none;border-radius:4px;font-weight:600;font-size:14px;">Open campaign report</a> <a class="email-btn" href="{{ report_url }}" style="display:inline-block;padding:12px 24px;background-color:#00aeef;color:#ffffff !important;text-decoration:none;border-radius:4px;font-weight:600;font-size:14px;">Open campaign report</a>
</p> </p>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
@@ -11,7 +11,7 @@
<p class="field-label" style="color:#6b7280;font-size:12px;text-transform:uppercase;letter-spacing:0.6px;margin:0 0 4px;">Email</p> <p class="field-label" style="color:#6b7280;font-size:12px;text-transform:uppercase;letter-spacing:0.6px;margin:0 0 4px;">Email</p>
<p class="field-value" style="color:#212121;font-size:15px;margin:0 0 16px;"> <p class="field-value" style="color:#212121;font-size:15px;margin:0 0 16px;">
<a href="mailto:{{ email }}" style="color:#00626c;text-decoration:none;">{{ email }}</a> <a href="mailto:{{ email }}" style="color:#00aeef;text-decoration:none;">{{ email }}</a>
</p> </p>
<p class="field-label" style="color:#6b7280;font-size:12px;text-transform:uppercase;letter-spacing:0.6px;margin:0 0 4px;">Phone</p> <p class="field-label" style="color:#6b7280;font-size:12px;text-transform:uppercase;letter-spacing:0.6px;margin:0 0 4px;">Phone</p>
@@ -25,7 +25,7 @@
{% if portal_url %} {% if portal_url %}
<p style="margin:24px 0 0;"> <p style="margin:24px 0 0;">
<a class="email-btn" href="{{ portal_url }}" style="display:inline-block;padding:12px 24px;background-color:#00626c;color:#ffffff !important;text-decoration:none;border-radius:4px;font-weight:600;font-size:14px;">View in portal</a> <a class="email-btn" href="{{ portal_url }}" style="display:inline-block;padding:12px 24px;background-color:#00aeef;color:#ffffff !important;text-decoration:none;border-radius:4px;font-weight:600;font-size:14px;">View in portal</a>
</p> </p>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
@@ -15,11 +15,11 @@
{% block footer_note %} {% block footer_note %}
{% if prefs_url or one_click_url %} {% if prefs_url or one_click_url %}
{% if prefs_url %} {% if prefs_url %}
<a href="{{ prefs_url }}" style="color:#00626c;text-decoration:underline;">Manage preferences</a> <a href="{{ prefs_url }}" style="color:#00aeef;text-decoration:underline;">Manage preferences</a>
{% endif %} {% endif %}
{% if prefs_url and one_click_url %}&nbsp;·&nbsp;{% endif %} {% if prefs_url and one_click_url %}&nbsp;·&nbsp;{% endif %}
{% if one_click_url %} {% if one_click_url %}
<a href="{{ one_click_url }}" style="color:#00626c;text-decoration:underline;">Unsubscribe from email</a> <a href="{{ one_click_url }}" style="color:#00aeef;text-decoration:underline;">Unsubscribe from email</a>
{% endif %} {% endif %}
{% endif %} {% endif %}
{% endblock %} {% endblock %}
+9 -3
View File
@@ -418,7 +418,7 @@ def normalize_hex(value: str) -> str:
return raw.lower() return raw.lower()
def store_product_image(*, upload, user) -> StoredFile: def store_product_image(*, upload, user, smart_crop: bool = True) -> StoredFile:
content_type = (getattr(upload, "content_type", None) or "").lower() content_type = (getattr(upload, "content_type", None) or "").lower()
if content_type not in _ALLOWED_IMAGE_TYPES: if content_type not in _ALLOWED_IMAGE_TYPES:
raise ShopError("Use a JPEG, PNG, GIF, or WebP image.") raise ShopError("Use a JPEG, PNG, GIF, or WebP image.")
@@ -427,15 +427,18 @@ def store_product_image(*, upload, user) -> StoredFile:
raise ShopError("Image must be 15 MB or smaller.") raise ShopError("Image must be 15 MB or smaller.")
original = (getattr(upload, "name", None) or "product")[:255] original = (getattr(upload, "name", None) or "product")[:255]
try: try:
from shop.imaging import prepare_product_photo, product_image_filename from shop.imaging import open_image, prepare_product_photo, product_image_filename
except ImportError as exc: except ImportError as exc:
logger.exception("product photo processing dependencies missing") logger.exception("product photo processing dependencies missing")
raise ShopError( raise ShopError(
"Image processing is not installed. Rebuild the app container." "Image processing is not installed. Rebuild the app container."
) from exc ) from exc
try: try:
if smart_crop:
data, content_type = prepare_product_photo(data) data, content_type = prepare_product_photo(data)
original = product_image_filename(original) original = product_image_filename(original)
else:
open_image(data)
except ShopError: except ShopError:
raise raise
except Exception as exc: except Exception as exc:
@@ -569,12 +572,15 @@ def append_product_images(
uploads, uploads,
user, user,
color: ProductColor | None = None, color: ProductColor | None = None,
smart_crop: bool = True,
) -> None: ) -> None:
existing = product.images.filter(color=color).count() existing = product.images.filter(color=color).count()
for offset, upload in enumerate(uploads): for offset, upload in enumerate(uploads):
if not upload: if not upload:
continue continue
stored = store_product_image(upload=upload, user=user) stored = store_product_image(
upload=upload, user=user, smart_crop=smart_crop
)
ProductImage.objects.create( ProductImage.objects.create(
product=product, product=product,
color=color, color=color,
+1 -1
View File
@@ -40,7 +40,7 @@
<div class="stl-viewer" <div class="stl-viewer"
data-stl-viewer data-stl-viewer
data-src="{{ product.stl_url }}" data-src="{{ product.stl_url }}"
data-color="{{ selected_color.hex|default:'#ff6252' }}" data-color="{{ selected_color.hex|default:'#00aeef' }}"
data-label="3D model of {{ product.name }}. Drag to rotate, scroll to zoom."></div> data-label="3D model of {{ product.name }}. Drag to rotate, scroll to zoom."></div>
<p class="product-media-hint">Drag to spin · scroll to zoom</p> <p class="product-media-hint">Drag to spin · scroll to zoom</p>
</div> </div>
@@ -30,7 +30,12 @@
<div class="field"> <div class="field">
<label>Photos</label> <label>Photos</label>
<input name="images" type="file" accept="image/jpeg,image/png,image/gif,image/webp" multiple> <input name="images" type="file" accept="image/jpeg,image/png,image/gif,image/webp" multiple>
<p class="hint">JPEG, PNG, GIF, or WebP. Multiple photos. Used on the listing card and as the default gallery. Color-specific photos below replace these when that color is selected.</p> <input type="hidden" name="smart_crop" value="off">
<label class="inline">
<input type="checkbox" name="smart_crop" value="on" checked>
Smart crop and background filter
</label>
<p class="hint">JPEG, PNG, GIF, or WebP. Multiple photos. Used on the listing card and as the default gallery. Color-specific photos below replace these when that color is selected. Leave the filter on to knock out the background and center the subject on a square canvas.</p>
{% if product.catalog_images %} {% if product.catalog_images %}
<div class="photo-thumbs"> <div class="photo-thumbs">
{% for image in product.catalog_images %} {% for image in product.catalog_images %}
@@ -114,7 +119,7 @@
<div id="product-stl-preview" <div id="product-stl-preview"
class="stl-viewer stl-viewer-compact" class="stl-viewer stl-viewer-compact"
data-stl-viewer data-stl-viewer
data-color="#ff6252" data-color="#00aeef"
data-label="STL preview. Drag to rotate, scroll to zoom." data-label="STL preview. Drag to rotate, scroll to zoom."
{% if product.stl_id %}data-src="{{ product.stl_url }}" data-existing="{{ product.stl_url }}"{% else %}hidden{% endif %}></div> {% if product.stl_id %}data-src="{{ product.stl_url }}" data-existing="{{ product.stl_url }}"{% else %}hidden{% endif %}></div>
</aside> </aside>
+42
View File
@@ -414,6 +414,8 @@ class ShopPortalTests(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, "Shop card preview") self.assertContains(response, "Shop card preview")
self.assertContains(response, 'name="images"') self.assertContains(response, 'name="images"')
self.assertContains(response, 'name="smart_crop"')
self.assertContains(response, "Smart crop and background filter")
self.assertContains(response, 'name="stl"') self.assertContains(response, 'name="stl"')
self.assertContains(response, "Add color") self.assertContains(response, "Add color")
@@ -512,6 +514,32 @@ class ShopPortalTests(TestCase):
b"".join(fetch.streaming_content), bytes(product.image.data) b"".join(fetch.streaming_content), bytes(product.image.data)
) )
def test_create_product_skips_smart_crop_when_unchecked(self):
raw = _tiny_png()
upload = SimpleUploadedFile("keep.png", raw, content_type="image/png")
with patch("shop.imaging.cutout_subject") as cut:
response = self.client.post(
reverse("shop_portal:product_new"),
{
"name": "Raw photo toy",
"sku": "RAW-1",
"price": "10.00",
"stock_qty": "1",
"fulfillment": "stocked",
"track_inventory": "on",
"smart_crop": "off",
"images": upload,
},
)
self.assertEqual(response.status_code, 302)
cut.assert_not_called()
product = Product.objects.get(sku="RAW-1")
stored = product.images.get().file
self.assertEqual(bytes(stored.data), raw)
self.assertEqual(stored.filename, "keep.png")
framed = Image.open(BytesIO(bytes(stored.data)))
self.assertEqual(framed.size, (8, 8))
def test_create_product_with_color_photos_and_shared_stl(self): def test_create_product_with_color_photos_and_shared_stl(self):
catalog = SimpleUploadedFile("card.png", _tiny_png(), content_type="image/png") catalog = SimpleUploadedFile("card.png", _tiny_png(), content_type="image/png")
red_photo = SimpleUploadedFile("red.png", _tiny_png(), content_type="image/png") red_photo = SimpleUploadedFile("red.png", _tiny_png(), content_type="image/png")
@@ -582,6 +610,20 @@ class ShopPortalTests(TestCase):
self.assertTrue(bytes(stored.data)) self.assertTrue(bytes(stored.data))
self.assertEqual(stored.kind, StoredFile.Kind.PRODUCT_IMAGE) self.assertEqual(stored.kind, StoredFile.Kind.PRODUCT_IMAGE)
def test_store_product_image_can_skip_smart_crop(self):
raw = _tiny_png()
with TemporaryUploadedFile("dot.png", "image/png", 0, "utf-8") as tmp:
tmp.write(raw)
tmp.seek(0)
with patch("shop.imaging.cutout_subject") as cut:
stored = store_product_image(
upload=tmp, user=self.user, smart_crop=False
)
cut.assert_not_called()
self.assertEqual(bytes(stored.data), raw)
self.assertEqual(stored.content_type, "image/png")
self.assertEqual(stored.filename, "dot.png")
def test_temporary_stl_upload_copied_into_database_then_unlinked(self): def test_temporary_stl_upload_copied_into_database_then_unlinked(self):
with TemporaryUploadedFile( with TemporaryUploadedFile(
"toy.stl", "application/octet-stream", 0, "utf-8" "toy.stl", "application/octet-stream", 0, "utf-8"
+3
View File
@@ -298,10 +298,12 @@ def portal_product_edit(request, pk=None):
remove_product_images( remove_product_images(
product, request.POST.getlist("remove_image") product, request.POST.getlist("remove_image")
) )
smart_crop = request.POST.get("smart_crop", "on") == "on"
append_product_images( append_product_images(
product, product,
uploads=request.FILES.getlist("images"), uploads=request.FILES.getlist("images"),
user=request.user, user=request.user,
smart_crop=smart_crop,
) )
for key, color in colors.items(): for key, color in colors.items():
uploads = request.FILES.getlist(f"color_images_{key}") uploads = request.FILES.getlist(f"color_images_{key}")
@@ -312,6 +314,7 @@ def portal_product_edit(request, pk=None):
uploads=uploads, uploads=uploads,
user=request.user, user=request.user,
color=color, color=color,
smart_crop=smart_crop,
) )
refresh_listing_image(product) refresh_listing_image(product)
_delete_replaced_file( _delete_replaced_file(