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:
+90
-11
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user