Files
web_django_template/site/shop/views.py
westfarn 5b11cc18c7 Add shopper accounts, reviews, tracking, and seed_demo (#9)
Closes #9. Shop-gated buyer accounts, purchase reviews, Stripe customer ids, shipment tracking, slim public contact form, and a template-neutral seed_demo command.
2026-09-07 08:35:55 -05:00

408 lines
14 KiB
Python

import logging
from decimal import Decimal, InvalidOperation
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.db.models import Avg, Count
from django.http import HttpResponse, HttpResponseBadRequest
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.text import slugify
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, ProductReview
from shop.services import (
ShopError,
add_to_cart,
adjust_stock,
available_qty,
cart_lines,
cart_total,
create_checkout_session,
create_order_from_cart,
mark_paid,
qualifying_order_for_review,
save_cart,
set_cart_qty,
user_has_reviewed,
)
from shop.stats import sales_dashboard
logger = logging.getLogger(__name__)
def _site_base(request) -> str:
base = (settings.PUBLIC_SITE_URL or "").rstrip("/")
if base:
return base
return request.build_absolute_uri("/").rstrip("/")
def product_list(request):
products = Product.objects.filter(is_published=True)
return render(request, "shop/list.html", {"products": products})
def product_detail(request, slug):
product = get_object_or_404(Product, slug=slug, is_published=True)
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",
{
"product": product,
"available": available_qty(product),
"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(
request,
"shop/cart.html",
{"lines": lines, "total": cart_total(lines)},
)
@require_POST
def cart_add(request, slug):
product = get_object_or_404(Product, slug=slug, is_published=True)
try:
qty = int(request.POST.get("quantity") or "1")
except ValueError:
qty = 1
try:
add_to_cart(request.session, product, qty)
except ShopError as exc:
messages.error(request, str(exc))
return redirect("shop:detail", slug=product.slug)
messages.success(request, f"Added {product.name} to cart.")
return redirect("shop:cart")
@require_POST
def cart_update(request, slug):
product = get_object_or_404(Product, slug=slug)
try:
qty = int(request.POST.get("quantity") or "0")
except ValueError:
qty = 0
set_cart_qty(request.session, product, qty)
return redirect("shop:cart")
@require_http_methods(["GET", "POST"])
def checkout(request):
lines = cart_lines(request.session)
if not lines:
messages.error(request, "Cart is empty.")
return redirect("shop:cart")
if request.method == "POST":
email = (request.POST.get("email") or "").strip()
name = (request.POST.get("customer_name") or "").strip()
address = Contact.make_postal_address(
line1=request.POST.get("address_line1") or "",
line2=request.POST.get("address_line2") or "",
city=request.POST.get("address_city") or "",
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})
cancel = base + reverse("shop:checkout_cancel", kwargs={"pk": order.pk})
url = create_checkout_session(
order,
success_url=success + "?session_id={CHECKOUT_SESSION_ID}",
cancel_url=cancel,
)
except ShopError as exc:
messages.error(request, str(exc))
except Exception as exc: # noqa: BLE001
logger.exception("shop checkout failed")
messages.error(request, f"Could not start checkout: {exc}")
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),
"checkout_initial": checkout_initial,
},
)
def checkout_success(request, pk):
order = get_object_or_404(Order, pk=pk)
return render(request, "shop/success.html", {"order": order})
def checkout_cancel(request, pk):
order = get_object_or_404(Order, pk=pk)
return render(request, "shop/cancel.html", {"order": order})
@login_required
def portal_product_list(request):
products = Product.objects.all()
return render(request, "shop/portal/products.html", {"products": products})
@login_required
@require_http_methods(["GET", "POST"])
def portal_product_edit(request, pk=None):
product = get_object_or_404(Product, pk=pk) if pk else None
if request.method == "POST":
name = (request.POST.get("name") or "").strip()
sku = (request.POST.get("sku") or "").strip()
description = (request.POST.get("description") or "").strip()
slug = (request.POST.get("slug") or "").strip()
fulfillment = request.POST.get("fulfillment") or Product.Fulfillment.STOCKED
errors = []
if not name:
errors.append("Name is required.")
try:
price = Decimal(request.POST.get("price") or "")
if price < 0:
raise InvalidOperation
except Exception:
price = None
errors.append("Enter a valid price.")
try:
stock_qty = int(request.POST.get("stock_qty") or "0")
except ValueError:
stock_qty = 0
errors.append("Stock must be a number.")
try:
print_minutes = int(request.POST.get("print_minutes") or "0")
filament_grams = int(request.POST.get("filament_grams") or "0")
except ValueError:
print_minutes = 0
filament_grams = 0
if errors:
for err in errors:
messages.error(request, err)
else:
if product is None:
product = Product()
product.name = name
product.sku = sku
product.description = description
product.slug = slugify(slug)[:220] if slug else ""
product.price = price
product.currency = (settings.STRIPE_CURRENCY or "usd").lower()
product.fulfillment = fulfillment
product.stock_qty = stock_qty
product.print_minutes = max(print_minutes, 0)
product.filament_grams = max(filament_grams, 0)
product.is_published = request.POST.get("is_published") == "on"
product.track_inventory = request.POST.get("track_inventory") == "on"
product.save()
messages.success(request, f"Saved {product.name}.")
return redirect("shop_portal:product_list")
return render(request, "shop/portal/product_edit.html", {"product": product})
@login_required
@require_POST
def portal_stock_adjust(request, pk):
product = get_object_or_404(Product, pk=pk)
try:
delta = int(request.POST.get("delta") or "0")
except ValueError:
messages.error(request, "Enter a whole-number adjustment.")
return redirect("shop_portal:product_edit", pk=product.pk)
try:
adjust_stock(product, delta)
except ShopError as exc:
messages.error(request, str(exc))
else:
messages.success(request, f"{product.sku} stock is now {product.stock_qty}.")
return redirect("shop_portal:product_edit", pk=product.pk)
@login_required
def portal_sales(request):
return render(request, "shop/portal/sales.html", sales_dashboard())
@login_required
def portal_order_list(request):
orders = Order.objects.all()[:200]
return render(request, "shop/portal/orders.html", {"orders": orders})
@login_required
def portal_order_detail(request, pk):
qs = Order.objects.prefetch_related("items")
from django.apps import apps as django_apps
if django_apps.is_installed("shipping"):
qs = qs.prefetch_related("shipments")
order = get_object_or_404(qs, 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
from django.apps import apps as django_apps
claim_orders_for_user(request.user)
qs = Order.objects.filter(user=request.user).prefetch_related("items")
if django_apps.is_installed("shipping"):
qs = qs.prefetch_related("shipments")
orders = qs.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):
from django.apps import apps as django_apps
qs = Order.objects.prefetch_related("items")
if django_apps.is_installed("shipping"):
qs = qs.prefetch_related("shipments")
order = get_object_or_404(qs, pk=pk, user=request.user)
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):
secret = (settings.STRIPE_WEBHOOK_SECRET or "").strip()
if not secret:
logger.error("STRIPE_WEBHOOK_SECRET unset")
return HttpResponseBadRequest("webhook not configured")
try:
import stripe
except ImportError:
return HttpResponseBadRequest("stripe not installed")
sig = request.headers.get("Stripe-Signature", "")
try:
event = stripe.Webhook.construct_event(request.body, sig, secret)
except Exception:
logger.exception("shop stripe webhook signature failed")
return HttpResponseBadRequest("invalid signature")
obj = event.get("data", {}).get("object", {}) or {}
if event.get("type") != "checkout.session.completed":
return HttpResponse("ok")
order_id = (obj.get("metadata") or {}).get("shop_order_id") or ""
order = None
if order_id:
order = Order.objects.filter(pk=order_id).first()
if order is None:
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 "",
stripe_customer_id=obj.get("customer") or "",
)
logger.info("shop order %s marked paid", order.number)
return HttpResponse("ok")