Template
Add shopper accounts, reviews, tracking, and seed_demo (#9)
Closes #9. Shop-gated buyer accounts, purchase reviews, Stripe customer ids, shipment tracking, slim public contact form, and a template-neutral seed_demo command.
This commit is contained in:
@@ -0,0 +1,848 @@
|
||||
"""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
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
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
|
||||
|
||||
DEMO_NOTE = "[demo-seed]"
|
||||
DEMO_UTM_CAMPAIGN = "site-demo"
|
||||
DEMO_EMAIL_DOMAIN = "@example.com"
|
||||
DEMO_EMAIL_PREFIX = "demo+"
|
||||
DEMO_SHOPPER_PASSWORD = "Demo-buyer-pass-123"
|
||||
DEMO_SHOPPER_SLUGS = ("jordan", "casey", "sam")
|
||||
|
||||
|
||||
def _django_env() -> str:
|
||||
return (os.environ.get("DJANGO_ENV") or "dev").lower()
|
||||
|
||||
|
||||
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, shoppers, reviews, tracking, contacts, "
|
||||
"leads, orders, campaigns, and analytics for client demos. "
|
||||
"Allowed on DJANGO_ENV=dev and beta only. Demo shoppers use "
|
||||
f"{DEMO_EMAIL_PREFIX}jordan{DEMO_EMAIL_DOMAIN} / {DEMO_SHOPPER_PASSWORD}."
|
||||
)
|
||||
|
||||
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()
|
||||
if options["reset"]:
|
||||
self._reset()
|
||||
self.stdout.write("Cleared tagged demo rows.")
|
||||
User = get_user_model()
|
||||
self.owner = User.objects.filter(is_staff=True).order_by("pk").first()
|
||||
if self.owner is None:
|
||||
self.owner = (
|
||||
User.objects.exclude(username__startswith=DEMO_EMAIL_PREFIX)
|
||||
.order_by("pk")
|
||||
.first()
|
||||
)
|
||||
counts = {
|
||||
"contacts": self._seed_contacts(),
|
||||
"leads": self._seed_leads(),
|
||||
"analytics": self._seed_analytics(),
|
||||
}
|
||||
if apps.is_installed("shop"):
|
||||
counts["products"] = self._seed_products()
|
||||
counts["shoppers"] = self._seed_shoppers()
|
||||
counts["orders"] = self._seed_orders()
|
||||
counts["reviews"] = self._seed_reviews()
|
||||
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})."))
|
||||
if apps.is_installed("shop"):
|
||||
self.stdout.write(
|
||||
"Demo shopper: "
|
||||
f"{DEMO_EMAIL_PREFIX}jordan{DEMO_EMAIL_DOMAIN} / {DEMO_SHOPPER_PASSWORD}"
|
||||
)
|
||||
|
||||
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 shop.models import Order, Product
|
||||
|
||||
Order.objects.filter(number__startswith="DEMO-").delete()
|
||||
Product.objects.filter(sku__startswith="DEMO-").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()
|
||||
User = get_user_model()
|
||||
User.objects.filter(
|
||||
username__startswith=DEMO_EMAIL_PREFIX,
|
||||
username__endswith=DEMO_EMAIL_DOMAIN,
|
||||
is_staff=False,
|
||||
).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",
|
||||
"Asked about bulk pricing.",
|
||||
),
|
||||
(
|
||||
"jordan",
|
||||
"Jordan",
|
||||
"Walsh",
|
||||
"630-555-0198",
|
||||
Contact.Source.MANUAL,
|
||||
True,
|
||||
False,
|
||||
"Aurora",
|
||||
"Repeat buyer.",
|
||||
),
|
||||
(
|
||||
"priya",
|
||||
"Priya",
|
||||
"Shah",
|
||||
"847-555-0110",
|
||||
Contact.Source.IMPORT,
|
||||
True,
|
||||
True,
|
||||
"Wheaton",
|
||||
"Event favor quote.",
|
||||
),
|
||||
(
|
||||
"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",
|
||||
"Team gifts.",
|
||||
),
|
||||
(
|
||||
"riley",
|
||||
"Riley",
|
||||
"Nguyen",
|
||||
"708-555-0133",
|
||||
Contact.Source.OTHER,
|
||||
True,
|
||||
False,
|
||||
"Downers Grove",
|
||||
"Asked about made-to-order items.",
|
||||
),
|
||||
(
|
||||
"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",
|
||||
"School fair bulk order.",
|
||||
),
|
||||
)
|
||||
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 a dozen units by next Friday.",
|
||||
"instagram",
|
||||
"social",
|
||||
2,
|
||||
),
|
||||
(
|
||||
"priya",
|
||||
Lead.Status.CONTACTED,
|
||||
"Quoted 40 keychains. Waiting on names.",
|
||||
"google",
|
||||
"cpc",
|
||||
8,
|
||||
),
|
||||
(
|
||||
"casey",
|
||||
Lead.Status.WON,
|
||||
"Fair kits — 30 tote bags, paid via invoice.",
|
||||
"google",
|
||||
"organic",
|
||||
18,
|
||||
),
|
||||
(
|
||||
"riley",
|
||||
Lead.Status.LOST,
|
||||
"Wanted 200 units at a price we could not hit.",
|
||||
"",
|
||||
"",
|
||||
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-notebook/",
|
||||
"/shop/demo-tote/",
|
||||
"/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-notebook/"),
|
||||
("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="Demo seed",
|
||||
)
|
||||
_stamp(UTMVisit, visit.pk, when)
|
||||
created += 1
|
||||
return created
|
||||
|
||||
def _seed_products(self) -> int:
|
||||
from shop.models import Product
|
||||
|
||||
catalog = (
|
||||
{
|
||||
"sku": "DEMO-NOTEBOOK",
|
||||
"name": "Notebook",
|
||||
"price": Decimal("18.00"),
|
||||
"fulfillment": Product.Fulfillment.STOCKED,
|
||||
"stock_qty": 24,
|
||||
"print_minutes": 0,
|
||||
"filament_grams": 0,
|
||||
"published": True,
|
||||
"description": "Lined notebook. A simple catalog item for demos.",
|
||||
},
|
||||
{
|
||||
"sku": "DEMO-STICKER",
|
||||
"name": "Sticker pack",
|
||||
"price": Decimal("8.00"),
|
||||
"fulfillment": Product.Fulfillment.STOCKED,
|
||||
"stock_qty": 40,
|
||||
"print_minutes": 0,
|
||||
"filament_grams": 0,
|
||||
"published": True,
|
||||
"description": "Pack of five stickers.",
|
||||
},
|
||||
{
|
||||
"sku": "DEMO-TOTE",
|
||||
"name": "Tote bag",
|
||||
"price": Decimal("22.00"),
|
||||
"fulfillment": Product.Fulfillment.STOCKED,
|
||||
"stock_qty": 12,
|
||||
"print_minutes": 0,
|
||||
"filament_grams": 0,
|
||||
"published": True,
|
||||
"description": "Canvas tote. Good for a fulfilled demo order.",
|
||||
},
|
||||
{
|
||||
"sku": "DEMO-MUG",
|
||||
"name": "Ceramic mug",
|
||||
"price": Decimal("16.00"),
|
||||
"fulfillment": Product.Fulfillment.MADE_TO_ORDER,
|
||||
"stock_qty": 0,
|
||||
"print_minutes": 45,
|
||||
"filament_grams": 0,
|
||||
"published": True,
|
||||
"description": "Printed to order. Used as the guest checkout demo.",
|
||||
},
|
||||
{
|
||||
"sku": "DEMO-KEYCHAIN",
|
||||
"name": "Custom keychain",
|
||||
"price": Decimal("12.00"),
|
||||
"fulfillment": Product.Fulfillment.MADE_TO_ORDER,
|
||||
"stock_qty": 0,
|
||||
"print_minutes": 20,
|
||||
"filament_grams": 0,
|
||||
"published": True,
|
||||
"description": "Made to order with a name on the loop.",
|
||||
},
|
||||
{
|
||||
"sku": "DEMO-PROTO",
|
||||
"name": "Draft product (coming soon)",
|
||||
"price": Decimal("45.00"),
|
||||
"fulfillment": Product.Fulfillment.MADE_TO_ORDER,
|
||||
"stock_qty": 0,
|
||||
"print_minutes": 90,
|
||||
"filament_grams": 0,
|
||||
"published": False,
|
||||
"description": "Unpublished so the portal list is not empty of drafts.",
|
||||
},
|
||||
)
|
||||
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,
|
||||
},
|
||||
)
|
||||
if was_created:
|
||||
created += 1
|
||||
self._log(f"product {product.sku}")
|
||||
return created
|
||||
|
||||
def _seed_shoppers(self) -> int:
|
||||
from accounts.services import get_customer_profile
|
||||
from contacts.models import Contact
|
||||
|
||||
User = get_user_model()
|
||||
created = 0
|
||||
for slug in DEMO_SHOPPER_SLUGS:
|
||||
email = f"{DEMO_EMAIL_PREFIX}{slug}{DEMO_EMAIL_DOMAIN}"
|
||||
contact = Contact.objects.filter(email=email).first()
|
||||
if contact is None:
|
||||
continue
|
||||
user = User.objects.filter(username__iexact=email).first()
|
||||
if user is None:
|
||||
user = User.objects.create_user(
|
||||
username=email,
|
||||
email=email,
|
||||
password=DEMO_SHOPPER_PASSWORD,
|
||||
first_name=contact.first_name,
|
||||
last_name=contact.last_name,
|
||||
is_staff=False,
|
||||
)
|
||||
created += 1
|
||||
else:
|
||||
user.first_name = contact.first_name
|
||||
user.last_name = contact.last_name
|
||||
user.email = email
|
||||
user.is_staff = False
|
||||
user.set_password(DEMO_SHOPPER_PASSWORD)
|
||||
user.save(
|
||||
update_fields=[
|
||||
"first_name",
|
||||
"last_name",
|
||||
"email",
|
||||
"is_staff",
|
||||
"password",
|
||||
]
|
||||
)
|
||||
profile = get_customer_profile(user)
|
||||
profile.phone = contact.phone
|
||||
profile.shipping_address = contact.postal_address or {}
|
||||
profile.save(update_fields=["phone", "shipping_address", "updated_at"])
|
||||
self._log(f"shopper {email}")
|
||||
return created
|
||||
|
||||
def _seed_orders(self) -> int:
|
||||
from contacts.models import Contact
|
||||
from shop.models import Order, OrderItem, Product
|
||||
|
||||
notebook = Product.objects.filter(sku="DEMO-NOTEBOOK").first()
|
||||
sticker = Product.objects.filter(sku="DEMO-STICKER").first()
|
||||
tote = Product.objects.filter(sku="DEMO-TOTE").first()
|
||||
mug = Product.objects.filter(sku="DEMO-MUG").first()
|
||||
if not all([notebook, sticker, tote, mug]):
|
||||
return 0
|
||||
specs = (
|
||||
(
|
||||
"DEMO-ORD-001",
|
||||
"jordan",
|
||||
Order.Status.PAID,
|
||||
((notebook, 1),),
|
||||
3,
|
||||
),
|
||||
(
|
||||
"DEMO-ORD-002",
|
||||
"casey",
|
||||
Order.Status.FULFILLED,
|
||||
((tote, 2), (sticker, 1)),
|
||||
11,
|
||||
),
|
||||
(
|
||||
"DEMO-ORD-003",
|
||||
"sam",
|
||||
Order.Status.PAID,
|
||||
((sticker, 2),),
|
||||
1,
|
||||
),
|
||||
(
|
||||
"DEMO-ORD-004",
|
||||
"riley",
|
||||
Order.Status.OPEN,
|
||||
((mug, 1),),
|
||||
0,
|
||||
),
|
||||
(
|
||||
"DEMO-ORD-005",
|
||||
"alex",
|
||||
Order.Status.CANCELLED,
|
||||
((notebook, 1),),
|
||||
16,
|
||||
),
|
||||
)
|
||||
created = 0
|
||||
User = get_user_model()
|
||||
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
|
||||
buyer = User.objects.filter(username__iexact=contact.email).first()
|
||||
amount = sum(
|
||||
(product.price * qty for product, 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={
|
||||
"user": buyer,
|
||||
"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, qty in lines:
|
||||
OrderItem.objects.create(
|
||||
order=order,
|
||||
product=product,
|
||||
name=product.name,
|
||||
sku=product.sku,
|
||||
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_reviews(self) -> int:
|
||||
from shop.models import Order, Product, ProductReview
|
||||
|
||||
User = get_user_model()
|
||||
specs = (
|
||||
(
|
||||
"jordan",
|
||||
"DEMO-ORD-001",
|
||||
"DEMO-NOTEBOOK",
|
||||
5,
|
||||
"Solid notebook",
|
||||
"Paper quality is great for daily notes.",
|
||||
),
|
||||
(
|
||||
"casey",
|
||||
"DEMO-ORD-002",
|
||||
"DEMO-TOTE",
|
||||
4,
|
||||
"Sturdy tote",
|
||||
"Held up for the school fair. Would buy again.",
|
||||
),
|
||||
(
|
||||
"sam",
|
||||
"DEMO-ORD-003",
|
||||
"DEMO-STICKER",
|
||||
5,
|
||||
"Bright stickers",
|
||||
"Colors popped. Kids loved them.",
|
||||
),
|
||||
)
|
||||
created = 0
|
||||
for slug, order_number, sku, rating, title, body in specs:
|
||||
email = f"{DEMO_EMAIL_PREFIX}{slug}{DEMO_EMAIL_DOMAIN}"
|
||||
user = User.objects.filter(username__iexact=email).first()
|
||||
order = Order.objects.filter(number=order_number, user=user).first()
|
||||
product = Product.objects.filter(sku=sku).first()
|
||||
if user is None or order is None or product is None:
|
||||
continue
|
||||
review, was_created = ProductReview.objects.update_or_create(
|
||||
user=user,
|
||||
product=product,
|
||||
defaults={
|
||||
"order": order,
|
||||
"rating": rating,
|
||||
"title": title,
|
||||
"body": f"{DEMO_NOTE} {body}",
|
||||
},
|
||||
)
|
||||
if was_created:
|
||||
created += 1
|
||||
self._log(f"review {sku} {rating}")
|
||||
return created
|
||||
|
||||
def _seed_shipments(self) -> int:
|
||||
from shipping.models import Shipment
|
||||
from shop.models import Order
|
||||
|
||||
specs = (
|
||||
(
|
||||
"DEMO-ORD-002",
|
||||
{
|
||||
"status": Shipment.Status.LABELED,
|
||||
"carrier": "USPS",
|
||||
"service": "Priority",
|
||||
"tracking_number": "940011189922DEMO02",
|
||||
"tracking_status": Shipment.TrackingStatus.DELIVERED,
|
||||
"tracking_url": (
|
||||
"https://tools.usps.com/go/TrackConfirmAction"
|
||||
"?tLabels=940011189922DEMO02"
|
||||
),
|
||||
"rate_amount": Decimal("8.45"),
|
||||
"currency": "usd",
|
||||
"weight_oz": 18,
|
||||
"last_tracked_at": self.now - timedelta(hours=6),
|
||||
"tracking_events": [
|
||||
{
|
||||
"status": "in_transit",
|
||||
"message": "Departed USPS facility",
|
||||
"datetime": (self.now - timedelta(days=2)).isoformat(),
|
||||
"location": "Chicago IL",
|
||||
},
|
||||
{
|
||||
"status": "delivered",
|
||||
"message": "Delivered, front door",
|
||||
"datetime": (self.now - timedelta(days=1)).isoformat(),
|
||||
"location": "Geneva IL",
|
||||
},
|
||||
],
|
||||
"notes": f"{DEMO_NOTE} Stub delivered label. No EasyPost call.",
|
||||
},
|
||||
),
|
||||
(
|
||||
"DEMO-ORD-001",
|
||||
{
|
||||
"status": Shipment.Status.LABELED,
|
||||
"carrier": "USPS",
|
||||
"service": "GroundAdvantage",
|
||||
"tracking_number": "940011189922DEMO01",
|
||||
"tracking_status": Shipment.TrackingStatus.IN_TRANSIT,
|
||||
"tracking_url": (
|
||||
"https://tools.usps.com/go/TrackConfirmAction"
|
||||
"?tLabels=940011189922DEMO01"
|
||||
),
|
||||
"rate_amount": Decimal("5.40"),
|
||||
"currency": "usd",
|
||||
"weight_oz": 12,
|
||||
"last_tracked_at": self.now - timedelta(hours=2),
|
||||
"tracking_events": [
|
||||
{
|
||||
"status": "pre_transit",
|
||||
"message": "Shipping label created",
|
||||
"datetime": (self.now - timedelta(days=1)).isoformat(),
|
||||
"location": "Aurora IL",
|
||||
},
|
||||
{
|
||||
"status": "in_transit",
|
||||
"message": "Arrived at USPS origin facility",
|
||||
"datetime": (self.now - timedelta(hours=8)).isoformat(),
|
||||
"location": "Chicago IL",
|
||||
},
|
||||
],
|
||||
"notes": f"{DEMO_NOTE} Stub in-transit label. No EasyPost call.",
|
||||
},
|
||||
),
|
||||
)
|
||||
created = 0
|
||||
for number, defaults in specs:
|
||||
order = Order.objects.filter(number=number).first()
|
||||
if order is None:
|
||||
continue
|
||||
shipment = Shipment.objects.filter(order=order).first()
|
||||
if shipment is None:
|
||||
Shipment.objects.create(order=order, **defaults)
|
||||
created += 1
|
||||
else:
|
||||
for key, value in defaults.items():
|
||||
setattr(shipment, key, value)
|
||||
shipment.save()
|
||||
self._log(f"shipment {number} {defaults['tracking_status']}")
|
||||
return created
|
||||
|
||||
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"),
|
||||
"Fair tote kits (30)",
|
||||
14,
|
||||
),
|
||||
(
|
||||
"DEMO-INV-002",
|
||||
"priya",
|
||||
Invoice.Status.OPEN,
|
||||
Decimal("480.00"),
|
||||
"Event 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 arrivals"
|
||||
if not Campaign.objects.filter(name=draft_name).exists():
|
||||
create_campaign_draft(
|
||||
name=draft_name,
|
||||
audience=Campaign.Audience.EMAIL_OPT_IN,
|
||||
subject="New items just landed",
|
||||
body="<p>Restocked notebooks and tote bags. Shop the drop.</p>",
|
||||
created_by=self.owner,
|
||||
)
|
||||
created += 1
|
||||
done_name = "DEMO: Event 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",
|
||||
body="<p>Your order is 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: Order update SMS"
|
||||
if not Campaign.objects.filter(name=sms_name).exists():
|
||||
create_campaign_draft(
|
||||
name=sms_name,
|
||||
audience=Campaign.Audience.SMS_OPT_IN,
|
||||
body="Your order is packing. 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
|
||||
@@ -30,6 +32,11 @@ class PublicSmokeTests(TestCase):
|
||||
client = Client()
|
||||
self.assertEqual(client.get(reverse("public:about")).status_code, 200)
|
||||
self.assertEqual(client.get(reverse("public:contact")).status_code, 200)
|
||||
contact = client.get(reverse("public:contact"))
|
||||
self.assertContains(contact, "contact-email")
|
||||
self.assertNotContains(contact, "contact-first-name")
|
||||
self.assertNotContains(contact, "contact-phone")
|
||||
self.assertNotContains(contact, "contact-address-line1")
|
||||
|
||||
|
||||
class DispatchDueTests(TestCase):
|
||||
@@ -37,3 +44,107 @@ 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-NOTEBOOK").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, "Notebook")
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from shop.models import ProductReview
|
||||
from shipping.models import Shipment
|
||||
|
||||
User = get_user_model()
|
||||
jordan = User.objects.get(username="demo+jordan@example.com")
|
||||
self.assertFalse(jordan.is_staff)
|
||||
self.assertTrue(jordan.check_password("Demo-buyer-pass-123"))
|
||||
self.assertEqual(
|
||||
Order.objects.get(number="DEMO-ORD-001").user_id, jordan.pk
|
||||
)
|
||||
self.assertIsNone(Order.objects.get(number="DEMO-ORD-004").user_id)
|
||||
self.assertTrue(
|
||||
ProductReview.objects.filter(
|
||||
user=jordan, product__sku="DEMO-NOTEBOOK", rating=5
|
||||
).exists()
|
||||
)
|
||||
delivered = Shipment.objects.get(order__number="DEMO-ORD-002")
|
||||
self.assertEqual(delivered.tracking_status, "delivered")
|
||||
self.assertGreaterEqual(len(delivered.tracking_events), 2)
|
||||
transit = Shipment.objects.get(order__number="DEMO-ORD-001")
|
||||
self.assertEqual(transit.tracking_status, "in_transit")
|
||||
|
||||
client = Client()
|
||||
self.assertTrue(
|
||||
client.login(
|
||||
username="demo+jordan@example.com", password="Demo-buyer-pass-123"
|
||||
)
|
||||
)
|
||||
history = client.get(reverse("account:orders"))
|
||||
self.assertContains(history, "DEMO-ORD-001")
|
||||
self.assertNotContains(history, "DEMO-ORD-004")
|
||||
detail = client.get(reverse("shop:detail", kwargs={"slug": "demo-notebook"}))
|
||||
self.assertContains(detail, "Solid notebook")
|
||||
|
||||
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-NOTEBOOK").delete()
|
||||
call_command("seed_demo", reset=True, stdout=StringIO())
|
||||
self.assertTrue(Product.objects.filter(sku="DEMO-NOTEBOOK").exists())
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
self.assertTrue(
|
||||
get_user_model().objects.filter(username="demo+jordan@example.com").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())
|
||||
|
||||
def test_succeeds_when_shop_not_installed(self):
|
||||
from django.apps import apps
|
||||
from contacts.models import Contact
|
||||
|
||||
real = apps.is_installed
|
||||
|
||||
def fake(name):
|
||||
if name in {"shop", "shipping"}:
|
||||
return False
|
||||
return real(name)
|
||||
|
||||
with patch("django.apps.apps.is_installed", side_effect=fake):
|
||||
call_command("seed_demo", stdout=StringIO())
|
||||
self.assertTrue(Contact.objects.filter(email__startswith="demo+").exists())
|
||||
|
||||
@@ -111,8 +111,7 @@ class CampaignShortLinkViewTests(TestCase):
|
||||
|
||||
User = get_user_model()
|
||||
self.user = User.objects.create_user(
|
||||
username="utm-composer", password="test-pass-123"
|
||||
)
|
||||
username="utm-composer", password="test-pass-123", is_staff=True)
|
||||
self.client.login(username="utm-composer", password="test-pass-123")
|
||||
|
||||
def test_requires_login(self):
|
||||
|
||||
@@ -122,9 +122,11 @@ class InstalledOptionalAppsTests(TestCase):
|
||||
self.assertTrue(reverse("social:composer").startswith("/portal/social/"))
|
||||
self.assertTrue(reverse("social_ai:generate").startswith("/portal/social/api/generate/"))
|
||||
self.assertTrue(reverse("shop:list").startswith("/shop/"))
|
||||
self.assertTrue(reverse("account:login").startswith("/account/"))
|
||||
self.assertTrue(reverse("shop_portal:product_list").startswith("/portal/shop/"))
|
||||
self.assertTrue(reverse("shop_portal:sales").startswith("/portal/shop/sales/"))
|
||||
self.assertTrue(reverse("pos_sync:event_list").startswith("/portal/pos/"))
|
||||
self.assertTrue(reverse("events:list").startswith("/events/"))
|
||||
self.assertTrue(reverse("events_portal:event_list").startswith("/portal/events/"))
|
||||
self.assertTrue(reverse("shipping:shipment_list").startswith("/portal/shipping/"))
|
||||
self.assertTrue(reverse("shipping:easypost_webhook").startswith("/portal/shipping/webhooks/"))
|
||||
|
||||
Reference in New Issue
Block a user