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
+131 -5
View File
@@ -5,7 +5,7 @@ from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.db import transaction
from django.db.models import Prefetch
from django.db.models import Avg, Count, Prefetch
from django.http import HttpResponse, HttpResponseBadRequest
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
@@ -14,7 +14,7 @@ from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods, require_POST
from contacts.models import Contact
from shop.models import Order, Product, ProductColor, ProductImage
from shop.models import Order, Product, ProductColor, ProductImage, ProductReview
from shop.services import (
ShopError,
add_to_cart,
@@ -27,12 +27,14 @@ from shop.services import (
create_order_from_cart,
mark_paid,
product_media_payload,
qualifying_order_for_review,
refresh_listing_image,
remove_product_images,
save_cart,
set_cart_qty,
store_product_stl,
sync_product_colors,
user_has_reviewed,
)
from shop.stats import sales_dashboard
@@ -85,6 +87,16 @@ def product_detail(request, slug):
)
colors = list(product.colors.all())
selected = colors[0] if colors else None
reviews = list(product.reviews.select_related("user").all()[:50])
stats = product.reviews.aggregate(avg=Avg("rating"), n=Count("id"))
can_review = False
already_reviewed = False
if request.user.is_authenticated:
already_reviewed = user_has_reviewed(request.user, product)
can_review = (
not already_reviewed
and qualifying_order_for_review(request.user, product) is not None
)
return render(
request,
"shop/detail.html",
@@ -96,10 +108,47 @@ def product_detail(request, slug):
"gallery_items": product.gallery_items(selected),
"gallery_photos": product.photos_for(selected),
"gallery_data": product_media_payload(product, colors),
"reviews": reviews,
"review_avg": stats["avg"],
"review_count": stats["n"] or 0,
"can_review": can_review,
"already_reviewed": already_reviewed,
},
)
@login_required(login_url="account:login")
@require_POST
def product_review(request, slug):
product = get_object_or_404(Product, slug=slug, is_published=True)
if user_has_reviewed(request.user, product):
messages.info(request, "You already reviewed this product.")
return redirect("shop:detail", slug=product.slug)
order = qualifying_order_for_review(request.user, product)
if order is None:
messages.error(request, "Only customers who purchased this product can review it.")
return redirect("shop:detail", slug=product.slug)
try:
rating = int(request.POST.get("rating") or "0")
except ValueError:
rating = 0
if rating < 1 or rating > 5:
messages.error(request, "Choose a rating from 1 to 5.")
return redirect("shop:detail", slug=product.slug)
title = (request.POST.get("title") or "").strip()[:120]
body = (request.POST.get("body") or "").strip()
ProductReview.objects.create(
product=product,
user=request.user,
order=order,
rating=rating,
title=title,
body=body,
)
messages.success(request, "Thanks for the review.")
return redirect("shop:detail", slug=product.slug)
def cart_view(request):
lines = cart_lines(request.session)
return render(
@@ -169,12 +218,18 @@ def checkout(request):
state=request.POST.get("address_state") or "",
zip_code=request.POST.get("address_zip") or "",
)
buyer = request.user if request.user.is_authenticated else None
if buyer:
email = (buyer.email or buyer.username or email).strip()
if not name:
name = buyer.get_full_name()
try:
order = create_order_from_cart(
request.session,
email=email,
customer_name=name,
shipping_address=address,
user=buyer,
)
base = _site_base(request)
success = base + reverse("shop:checkout_success", kwargs={"pk": order.pk})
@@ -192,10 +247,28 @@ def checkout(request):
else:
save_cart(request.session, {})
return redirect(url)
checkout_initial = {
"email": "",
"customer_name": "",
"address": {},
}
if request.user.is_authenticated:
from accounts.services import get_customer_profile
profile = get_customer_profile(request.user)
checkout_initial = {
"email": request.user.email or request.user.username,
"customer_name": request.user.get_full_name(),
"address": profile.shipping_address or {},
}
return render(
request,
"shop/checkout.html",
{"lines": lines, "total": cart_total(lines)},
{
"lines": lines,
"total": cart_total(lines),
"checkout_initial": checkout_initial,
},
)
@@ -361,10 +434,59 @@ def portal_order_list(request):
@login_required
def portal_order_detail(request, pk):
order = get_object_or_404(Order.objects.prefetch_related("items"), pk=pk)
order = get_object_or_404(
Order.objects.prefetch_related("items", "shipments"), pk=pk
)
return render(request, "shop/portal/order_detail.html", {"order": order})
@login_required(login_url="account:login")
def account_order_list(request):
from accounts.services import claim_orders_for_user
claim_orders_for_user(request.user)
orders = (
Order.objects.filter(user=request.user)
.prefetch_related("items", "shipments")
.exclude(status=Order.Status.DRAFT)
)
return render(request, "shop/account/orders.html", {"orders": orders})
@login_required(login_url="account:login")
def account_order_detail(request, pk):
order = get_object_or_404(
Order.objects.prefetch_related("items", "shipments"),
pk=pk,
user=request.user,
)
from django.apps import apps as django_apps
if django_apps.is_installed("shipping"):
from datetime import timedelta
from django.utils import timezone
from shipping.models import Shipment
from shipping.services import refresh_tracking
stale_after = timezone.now() - timedelta(minutes=15)
for shipment in order.shipments.all():
if (
shipment.status == Shipment.Status.LABELED
and shipment.tracking_number
and shipment.tracking_status != Shipment.TrackingStatus.DELIVERED
and (
shipment.last_tracked_at is None
or shipment.last_tracked_at < stale_after
)
):
try:
refresh_tracking(shipment)
except Exception:
logger.exception("order tracking refresh failed for %s", order.number)
return render(request, "shop/account/order_detail.html", {"order": order})
@csrf_exempt
@require_http_methods(["POST"])
def stripe_webhook(request):
@@ -394,6 +516,10 @@ def stripe_webhook(request):
session_id = obj.get("id") or ""
order = Order.objects.filter(stripe_checkout_session_id=session_id).first()
if order and order.status != Order.Status.PAID:
mark_paid(order, stripe_id=obj.get("id") or "")
mark_paid(
order,
stripe_id=obj.get("id") or "",
stripe_customer_id=obj.get("customer") or "",
)
logger.info("shop order %s marked paid", order.number)
return HttpResponse("ok")