Add customer accounts, shipment tracking, and purchase reviews.
CI / test (pull_request) Successful in 35s

Shoppers can register, save shipping details, and view order history while cards stay on Stripe. EasyPost tracker updates (including numbers from Pirate Ship) and 1–5 star reviews are limited to buyers. The contact form now only asks for email and a message.
This commit is contained in:
2026-09-07 06:37:52 -05:00
parent 23a6035ba8
commit c9b81ceed7
59 changed files with 1905 additions and 275 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()