Customer accounts, order tracking, and purchase reviews (#8)
Deploy Beta / docker (push) Successful in 37s
Deploy Beta / deploy-beta (push) Successful in 2m21s
Deploy Beta / unit-tests (push) Successful in 39s

## 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:
2026-09-07 04:53:41 -07:00
parent 23a6035ba8
commit dd37a2a268
66 changed files with 2366 additions and 297 deletions
+90 -11
View File
@@ -10,11 +10,11 @@ from decimal import Decimal
from django.conf import settings
from django.db import transaction
from django.db.models import F, Sum
from django.db.models import F, Q, Sum
from django.utils import timezone
from core.models import StoredFile
from shop.models import Order, OrderItem, Product, ProductColor, ProductImage
from shop.models import Order, OrderItem, Product, ProductColor, ProductImage, ProductReview
logger = logging.getLogger(__name__)
@@ -254,6 +254,7 @@ def create_order_from_cart(
customer_name: str = "",
shipping_address: dict | None = None,
notes: str = "",
user=None,
) -> Order:
lines = cart_lines(session)
if not lines:
@@ -267,6 +268,7 @@ def create_order_from_cart(
with transaction.atomic():
order = Order.objects.create(
number=next_order_number(),
user=user if getattr(user, "is_authenticated", False) else None,
email=email,
customer_name=(customer_name or "").strip(),
status=Order.Status.DRAFT,
@@ -295,6 +297,42 @@ def create_order_from_cart(
return order
def ensure_stripe_customer(user) -> str:
"""Create or reuse a Stripe Customer. Card data never leaves Stripe."""
if not user or not getattr(user, "is_authenticated", False):
return ""
from accounts.services import get_customer_profile
profile = get_customer_profile(user)
if profile.stripe_customer_id:
return profile.stripe_customer_id
stripe = _stripe()
customer = stripe.Customer.create(
email=(user.email or user.username or "") or None,
name=(user.get_full_name() or "") or None,
metadata={"user_id": str(user.pk)},
)
customer_id = getattr(customer, "id", None) or customer.get("id") or ""
if not customer_id:
raise ShopError("Stripe did not return a customer id.")
profile.stripe_customer_id = customer_id
profile.save(update_fields=["stripe_customer_id", "updated_at"])
return customer_id
def remember_stripe_customer(order: Order, customer_id: str) -> None:
customer_id = (customer_id or "").strip()
if not customer_id or not order.user_id:
return
from accounts.services import get_customer_profile
profile = get_customer_profile(order.user)
if profile.stripe_customer_id:
return
profile.stripe_customer_id = customer_id
profile.save(update_fields=["stripe_customer_id", "updated_at"])
def create_checkout_session(order: Order, *, success_url: str, cancel_url: str) -> str:
stripe = _stripe()
line_items = [
@@ -312,14 +350,27 @@ def create_checkout_session(order: Order, *, success_url: str, cancel_url: str)
]
if not line_items:
raise ShopError("Order has no items.")
session = stripe.checkout.Session.create(
mode="payment",
customer_email=order.email or None,
line_items=line_items,
metadata={"shop_order_id": str(order.pk), "order_number": order.number},
success_url=success_url,
cancel_url=cancel_url,
)
params = {
"mode": "payment",
"line_items": line_items,
"metadata": {"shop_order_id": str(order.pk), "order_number": order.number},
"success_url": success_url,
"cancel_url": cancel_url,
}
customer_id = ""
if order.user_id:
try:
customer_id = ensure_stripe_customer(order.user)
except ShopError:
raise
except Exception:
logger.exception("stripe customer create failed for order %s", order.number)
if customer_id:
params["customer"] = customer_id
params["payment_intent_data"] = {"setup_future_usage": "on_session"}
else:
params["customer_email"] = order.email or None
session = stripe.checkout.Session.create(**params)
order.stripe_checkout_session_id = session.id
order.hosted_checkout_url = session.url or ""
order.status = Order.Status.OPEN
@@ -353,7 +404,9 @@ def _notify_pos(order: Order) -> None:
enqueue_online_sale(order)
def mark_paid(order: Order, *, stripe_id: str = "") -> None:
def mark_paid(
order: Order, *, stripe_id: str = "", stripe_customer_id: str = ""
) -> None:
if order.status == Order.Status.PAID:
return
with transaction.atomic():
@@ -365,6 +418,7 @@ def mark_paid(order: Order, *, stripe_id: str = "") -> None:
locked.paid_at = timezone.now()
locked.save(update_fields=["status", "paid_at", "updated_at"])
order.refresh_from_db()
remember_stripe_customer(order, stripe_customer_id)
try:
send_order_email(order)
except Exception:
@@ -614,3 +668,28 @@ def product_media_payload(product: Product, colors: list[ProductColor]) -> dict:
"available": available_qty(product, color),
}
return {"shared": shared, "stl": product.stl_url, "colors": color_data}
_REVIEWABLE_STATUSES = (Order.Status.PAID, Order.Status.FULFILLED)
def qualifying_order_for_review(user, product: Product) -> Order | None:
if not user or not getattr(user, "is_authenticated", False):
return None
email = (user.email or user.username or "").strip()
qs = (
Order.objects.filter(
items__product=product,
status__in=_REVIEWABLE_STATUSES,
)
.filter(Q(user=user) | Q(email__iexact=email))
.distinct()
.order_by("-paid_at", "-created_at")
)
return qs.first()
def user_has_reviewed(user, product: Product) -> bool:
if not user or not getattr(user, "is_authenticated", False):
return False
return ProductReview.objects.filter(user=user, product=product).exists()