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")