generated from westfarn/web_django_template
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.
526 lines
18 KiB
Python
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")
|