generated from westfarn/web_django_template
Customer accounts, order tracking, and purchase reviews (#8)
## Summary - Slim the public contact form to email, interest, and message. Name, phone, and address live on the customer profile instead. - Customers can register, sign in, save shipping details, and view order history. Logged-in checkout creates a Stripe Customer and saves cards on Stripe (`setup_future_usage`); we only store `stripe_customer_id`. - Shipment tracking: EasyPost tracker lookup + webhook, plus paste-in numbers from Pirate Ship/Shippo. Customers see carrier status on their orders; `dispatch_due` refreshes open shipments. - Product reviews (1–5) only after a paid/fulfilled purchase of that product. Fixes #7 ## Test plan - [ ] Contact form submits with only email + message; extra name/phone/address fields are ignored - [ ] Register, sign in, save profile (name/phone/shipping) - [ ] Guest checkout still works; after signup, prior orders with that email show in history - [ ] Logged-in checkout prefills shipping and does not collect card data locally - [ ] Portal: buy label or paste a Pirate Ship tracking number, confirm status/events; customer order page shows tracking - [ ] Product page: non-buyers cannot review; buyers can leave one 1–5 star review - [ ] Non-staff users hitting `/portal/` redirect to `/account/` Reviewed-on: #8
This commit was merged in pull request #8.
This commit is contained in:
@@ -26,6 +26,8 @@ DEMO_NOTE = "[demo-seed]"
|
||||
DEMO_UTM_CAMPAIGN = "printforge-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:
|
||||
@@ -94,8 +96,10 @@ def _stamp(model, pk, when) -> None:
|
||||
|
||||
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."
|
||||
"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):
|
||||
@@ -113,10 +117,17 @@ class Command(BaseCommand):
|
||||
)
|
||||
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.")
|
||||
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(),
|
||||
@@ -124,7 +135,9 @@ class Command(BaseCommand):
|
||||
}
|
||||
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"):
|
||||
@@ -133,6 +146,11 @@ class Command(BaseCommand):
|
||||
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:
|
||||
@@ -178,6 +196,12 @@ class Command(BaseCommand):
|
||||
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:
|
||||
@@ -586,6 +610,50 @@ class Command(BaseCommand):
|
||||
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
|
||||
@@ -635,12 +703,14 @@ class Command(BaseCommand):
|
||||
),
|
||||
)
|
||||
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, _color, qty in lines),
|
||||
Decimal("0.00"),
|
||||
@@ -650,6 +720,7 @@ class Command(BaseCommand):
|
||||
order, was_created = Order.objects.update_or_create(
|
||||
number=number,
|
||||
defaults={
|
||||
"user": buyer,
|
||||
"email": contact.email,
|
||||
"customer_name": contact.full_name,
|
||||
"status": status,
|
||||
@@ -679,30 +750,146 @@ class Command(BaseCommand):
|
||||
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-FLEXI-DRAGON",
|
||||
5,
|
||||
"Wiggles like crazy",
|
||||
"The cyan flexi dragon is the hit of the desk.",
|
||||
),
|
||||
(
|
||||
"casey",
|
||||
"DEMO-ORD-002",
|
||||
"DEMO-PLANTER",
|
||||
4,
|
||||
"Sturdy planter",
|
||||
"Held up for STEM night. Would buy again.",
|
||||
),
|
||||
(
|
||||
"sam",
|
||||
"DEMO-ORD-003",
|
||||
"DEMO-D20",
|
||||
5,
|
||||
"Chunky and readable",
|
||||
"Gold face reads well across the table.",
|
||||
),
|
||||
)
|
||||
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
|
||||
|
||||
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.",
|
||||
},
|
||||
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.",
|
||||
},
|
||||
),
|
||||
)
|
||||
if created:
|
||||
self._log(f"shipment {shipment.tracking_number}")
|
||||
return 1
|
||||
return 0
|
||||
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
|
||||
|
||||
@@ -35,6 +35,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):
|
||||
@@ -69,6 +74,39 @@ class SeedDemoTests(TestCase):
|
||||
listing = Client().get(reverse("shop:list"))
|
||||
self.assertContains(listing, "Articulated Flexi Dragon")
|
||||
|
||||
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-FLEXI-DRAGON", 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")
|
||||
|
||||
def test_second_run_is_idempotent(self):
|
||||
call_command("seed_demo", stdout=StringIO())
|
||||
from shop.models import Product
|
||||
@@ -84,6 +122,11 @@ class SeedDemoTests(TestCase):
|
||||
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())
|
||||
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):
|
||||
|
||||
@@ -111,7 +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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user