Files
print_forge/site/shop/views.py
T
westfarn c9b81ceed7
CI / test (pull_request) Successful in 35s
Add customer accounts, shipment tracking, and purchase reviews.
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.
2026-09-07 06:37:52 -05:00

526 lines
18 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 import transaction
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
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, ProductColor, ProductImage, ProductReview
from shop.services import (
ShopError,
add_to_cart,
adjust_stock,
append_product_images,
available_qty,
cart_lines,
cart_total,
create_checkout_session,
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
logger = logging.getLogger(__name__)
def _delete_replaced_file(previous, current_id, kind):
if previous and previous.pk != current_id and previous.kind == kind:
previous.delete()
def _site_base(request) -> str:
base = (settings.PUBLIC_SITE_URL or "").rstrip("/")
if base:
return base
return request.build_absolute_uri("/").rstrip("/")
def _gallery_prefetch():
def images():
return ProductImage.objects.select_related("file").order_by(
"sort_order", "created_at"
)
return (
Prefetch("images", queryset=images()),
Prefetch(
"colors",
queryset=ProductColor.objects.prefetch_related(
Prefetch("images", queryset=images())
),
),
)
def product_list(request):
products = Product.objects.filter(is_published=True).select_related(
"image"
).prefetch_related("colors")
return render(request, "shop/list.html", {"products": products})
def product_detail(request, slug):
product = get_object_or_404(
Product.objects.select_related("image", "stl").prefetch_related(
*_gallery_prefetch()
),
slug=slug,
is_published=True,
)
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",
{
"product": product,
"colors": colors,
"selected_color": selected,
"available": available_qty(product, selected),
"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(
request,
"shop/cart.html",
{"lines": lines, "total": cart_total(lines)},
)
@require_POST
def cart_add(request, slug):
product = get_object_or_404(
Product.objects.prefetch_related("colors"),
slug=slug,
is_published=True,
)
try:
qty = int(request.POST.get("quantity") or "1")
except ValueError:
qty = 1
color = None
colors = list(product.colors.all())
if colors:
color_id = (request.POST.get("color") or "").strip()
color = next((item for item in colors if str(item.pk) == color_id), None)
if color is None:
messages.error(request, "Choose a color.")
return redirect("shop:detail", slug=product.slug)
try:
add_to_cart(request.session, product, qty, color=color)
except ShopError as exc:
messages.error(request, str(exc))
return redirect("shop:detail", slug=product.slug)
label = f"{product.name} ({color.name})" if color else product.name
messages.success(request, f"Added {label} 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
color = None
color_id = (request.POST.get("color") or "").strip()
if color_id:
color = get_object_or_404(ProductColor, pk=color_id, product=product)
set_cart_qty(request.session, product, qty, color=color)
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.select_related("image").prefetch_related("colors")
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.objects.select_related("image", "stl").prefetch_related(
*_gallery_prefetch()
),
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
stl_upload = request.FILES.get("stl")
if errors:
for err in errors:
messages.error(request, err)
else:
try:
with transaction.atomic():
stored_stl = None
if stl_upload:
stored_stl = store_product_stl(
upload=stl_upload, user=request.user
)
if product is None:
product = Product()
previous_stl = product.stl
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"
if stored_stl is not None:
product.stl = stored_stl
elif request.POST.get("clear_stl") == "on":
product.stl = None
product.save()
colors = sync_product_colors(
product,
ids=request.POST.getlist("color_id"),
names=request.POST.getlist("color_name"),
hexes=request.POST.getlist("color_hex"),
keys=request.POST.getlist("color_key"),
stocks=request.POST.getlist("color_stock"),
)
remove_product_images(
product, request.POST.getlist("remove_image")
)
smart_crop = request.POST.get("smart_crop", "on") == "on"
append_product_images(
product,
uploads=request.FILES.getlist("images"),
user=request.user,
smart_crop=smart_crop,
)
for key, color in colors.items():
uploads = request.FILES.getlist(f"color_images_{key}")
if not uploads:
continue
append_product_images(
product,
uploads=uploads,
user=request.user,
color=color,
smart_crop=smart_crop,
)
refresh_listing_image(product)
_delete_replaced_file(
previous_stl,
product.stl_id,
previous_stl.Kind.PRODUCT_STL if previous_stl else None,
)
except ShopError as exc:
messages.error(request, str(exc))
else:
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):
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):
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")