Source code for django_program.registration.views

"""Views for the registration app.

Provides ticket selection, cart management, checkout, and order views
scoped to a conference via the ``conference_slug`` URL kwarg.
"""

import logging
import secrets
import string
from datetime import timedelta
from decimal import Decimal
from typing import TYPE_CHECKING

from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import ValidationError
from django.db import models, transaction
from django.db.models.functions import Coalesce
from django.http import Http404
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone
from django.views import View
from django.views.generic import DetailView, ListView

from django_program.features import FeatureRequiredMixin
from django_program.pretalx.views import ConferenceMixin
from django_program.registration.forms import CartItemForm, CheckoutForm, VoucherApplyForm
from django_program.registration.models import (
    AddOn,
    Cart,
    CartItem,
    Order,
    OrderLineItem,
    TicketType,
    Voucher,
)

if TYPE_CHECKING:
    from django.db.models import QuerySet
    from django.http import HttpRequest, HttpResponse

logger = logging.getLogger(__name__)


def _generate_order_reference() -> str:
    """Generate a unique order reference like ``ORD-A1B2C3``.

    Returns:
        A string in the format ``ORD-`` followed by 6 random uppercase
        alphanumeric characters.
    """
    alphabet = string.ascii_uppercase + string.digits
    chars = [secrets.choice(alphabet) for _ in range(6)]
    return f"ORD-{''.join(chars)}"


def _calculate_discount(subtotal: Decimal, voucher: Voucher | None) -> Decimal:
    """Calculate the discount amount for a cart based on the applied voucher.

    Args:
        subtotal: The cart subtotal before discount.
        voucher: The voucher applied to the cart, or ``None``.

    Returns:
        The discount amount, clamped to the subtotal so it never exceeds it.
    """
    if voucher is None:
        return Decimal("0.00")

    if voucher.voucher_type == Voucher.VoucherType.COMP:
        return subtotal

    if voucher.voucher_type == Voucher.VoucherType.PERCENTAGE:
        discount = subtotal * voucher.discount_value / Decimal(100)
        return min(discount, subtotal)

    if voucher.voucher_type == Voucher.VoucherType.FIXED_AMOUNT:
        return min(voucher.discount_value, subtotal)

    return Decimal("0.00")


def _cart_totals(cart: Cart) -> tuple[Decimal, Decimal, Decimal]:
    """Compute subtotal, discount, and total for a cart.

    Args:
        cart: The cart to calculate totals for.

    Returns:
        A tuple of ``(subtotal, discount, total)`` where total is never
        less than zero.
    """
    items = cart.items.select_related("ticket_type", "addon")
    subtotal = sum((item.line_total for item in items), Decimal("0.00"))
    discount = _calculate_discount(subtotal, cart.voucher)
    total = max(subtotal - discount, Decimal("0.00"))
    return subtotal, discount, total


[docs] class TicketSelectView(ConferenceMixin, FeatureRequiredMixin, ListView): """Lists available ticket types for a conference. Shows ticket types that are active and do not require a voucher, ordered by display order and name. """ required_feature = ("registration", "public_ui") template_name = "django_program/registration/ticket_select.html" context_object_name = "ticket_types"
[docs] def get_queryset(self) -> QuerySet[TicketType]: """Return available public ticket types for the current conference. Returns: A queryset of active, non-voucher-required TicketType instances. """ return TicketType.objects.filter( conference=self.conference, is_active=True, requires_voucher=False, ).order_by("order", "name")
[docs] def get_context_data(self, **kwargs: object) -> dict[str, object]: """Add current timestamp for availability display logic.""" context = super().get_context_data(**kwargs) context["now"] = timezone.now() return context
[docs] class CartView(LoginRequiredMixin, ConferenceMixin, FeatureRequiredMixin, View): """Shopping cart view for adding/removing items and applying vouchers. Handles multiple POST actions distinguished by a hidden ``action`` field: ``add_item``, ``remove_item``, and ``apply_voucher``. """ required_feature = ("registration", "public_ui") template_name = "django_program/registration/cart.html" def _get_or_create_cart(self, request: HttpRequest) -> Cart: """Get or create an open cart for the current user and conference. Args: request: The incoming HTTP request. Returns: The user's open Cart for this conference. """ cart, _created = Cart.objects.get_or_create( user=request.user, conference=self.conference, status=Cart.Status.OPEN, ) return cart def _build_context(self, cart: Cart) -> dict[str, object]: """Build template context with cart data and available tickets/add-ons. Args: cart: The user's cart. Returns: Context dict with cart, items, available tickets/addons, voucher form, and totals. """ items = cart.items.select_related("ticket_type", "addon") now = timezone.now() sold_filter = models.Q( order_line_items__order__status__in=[Order.Status.PAID, Order.Status.PARTIALLY_REFUNDED] ) | models.Q( order_line_items__order__status=Order.Status.PENDING, order_line_items__order__hold_expires_at__gt=now, ) available_tickets = ( TicketType.objects.filter( conference=self.conference, is_active=True, requires_voucher=False, ) .filter( models.Q(available_from__isnull=True) | models.Q(available_from__lte=now), ) .filter( models.Q(available_until__isnull=True) | models.Q(available_until__gte=now), ) .annotate( sold_quantity=Coalesce(models.Sum("order_line_items__quantity", filter=sold_filter), 0), ) .filter( models.Q(total_quantity=0) | models.Q(total_quantity__gt=models.F("sold_quantity")), ) .order_by("order", "name") ) available_addons = AddOn.objects.filter( conference=self.conference, is_active=True, ).order_by("order", "name") subtotal, discount, total = _cart_totals(cart) return { "conference": self.conference, "cart": cart, "items": items, "available_tickets": list(available_tickets), "available_addons": available_addons, "voucher_form": VoucherApplyForm(), "subtotal": subtotal, "discount": discount, "total": total, }
[docs] def get(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002 """Render the cart page with current items and totals. Handles an optional ``add_ticket`` query parameter to add a ticket by slug directly from the ticket selection page. Args: request: The incoming HTTP request. **kwargs: URL keyword arguments (unused). Returns: The rendered cart page (or redirect after adding a ticket). """ cart = self._get_or_create_cart(request) add_slug = request.GET.get("add_ticket") if add_slug: ticket_type = TicketType.objects.filter( conference=self.conference, slug=add_slug, is_active=True, ).first() if ticket_type and ticket_type.is_available: item, created = CartItem.objects.get_or_create( cart=cart, ticket_type=ticket_type, defaults={"quantity": 1}, ) if not created: item.quantity += 1 item.save(update_fields=["quantity"]) messages.success(request, f"Added {ticket_type.name} to your cart.") elif ticket_type: messages.error(request, "This ticket type is no longer available.") else: messages.error(request, "Ticket type not found.") return redirect(reverse("registration:cart", args=[self.conference.slug])) return render(request, self.template_name, self._build_context(cart))
[docs] def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002 """Handle cart actions dispatched by the ``action`` hidden field. Supported actions: ``add_item``, ``add_ticket``, ``add_addon``, ``remove_item``, ``apply_voucher``, ``remove_voucher``. Args: request: The incoming HTTP request. **kwargs: URL keyword arguments (unused). Returns: A redirect back to the cart page on success, or the cart page with errors on validation failure. """ cart = self._get_or_create_cart(request) action = request.POST.get("action", "") handlers = { "add_item": self._handle_add_item, "add_ticket": self._handle_add_ticket, "add_addon": self._handle_add_addon, "remove_item": self._handle_remove_item, "apply_voucher": self._handle_apply_voucher, "remove_voucher": self._handle_remove_voucher, } handler = handlers.get(action) if handler: return handler(request, cart) messages.error(request, "Unknown cart action.") return redirect(reverse("registration:cart", args=[self.conference.slug]))
def _handle_add_item(self, request: HttpRequest, cart: Cart) -> HttpResponse: """Validate and add a cart item. Args: request: The incoming HTTP request. cart: The user's open cart. Returns: A redirect to the cart page. """ form = CartItemForm(request.POST) if not form.is_valid(): messages.error(request, "Invalid item data.") return redirect(reverse("registration:cart", args=[self.conference.slug])) ticket_type_id = form.cleaned_data.get("ticket_type_id") addon_id = form.cleaned_data.get("addon_id") quantity = form.cleaned_data["quantity"] if ticket_type_id is not None: ticket_type = get_object_or_404( TicketType, pk=ticket_type_id, conference=self.conference, is_active=True, ) if not ticket_type.is_available: messages.error(request, "This ticket type is no longer available.") return redirect(reverse("registration:cart", args=[self.conference.slug])) item, created = CartItem.objects.get_or_create( cart=cart, ticket_type=ticket_type, defaults={"quantity": quantity}, ) if not created: item.quantity += quantity item.save(update_fields=["quantity"]) messages.success(request, f"Added {ticket_type.name} to your cart.") elif addon_id is not None: addon = get_object_or_404( AddOn, pk=addon_id, conference=self.conference, is_active=True, ) item, created = CartItem.objects.get_or_create( cart=cart, addon=addon, defaults={"quantity": quantity}, ) if not created: item.quantity += quantity item.save(update_fields=["quantity"]) messages.success(request, f"Added {addon.name} to your cart.") return redirect(reverse("registration:cart", args=[self.conference.slug])) def _handle_add_ticket(self, request: HttpRequest, cart: Cart) -> HttpResponse: """Add a ticket to the cart by slug (from the cart page dropdown). Args: request: The incoming HTTP request. cart: The user's open cart. Returns: A redirect to the cart page. """ slug = request.POST.get("ticket_type", "") raw_quantity = request.POST.get("quantity", 1) try: quantity = int(raw_quantity or 1) except TypeError, ValueError: messages.error(request, "Quantity must be a number.") return redirect(reverse("registration:cart", args=[self.conference.slug])) if quantity < 1: messages.error(request, "Quantity must be at least 1.") return redirect(reverse("registration:cart", args=[self.conference.slug])) ticket_type = TicketType.objects.filter( conference=self.conference, slug=slug, is_active=True, ).first() if not ticket_type: messages.error(request, "Ticket type not found.") return redirect(reverse("registration:cart", args=[self.conference.slug])) if not ticket_type.is_available: messages.error(request, "This ticket type is no longer available.") return redirect(reverse("registration:cart", args=[self.conference.slug])) item, created = CartItem.objects.get_or_create( cart=cart, ticket_type=ticket_type, defaults={"quantity": quantity}, ) if not created: item.quantity += quantity item.save(update_fields=["quantity"]) messages.success(request, f"Added {ticket_type.name} to your cart.") return redirect(reverse("registration:cart", args=[self.conference.slug])) def _handle_add_addon(self, request: HttpRequest, cart: Cart) -> HttpResponse: """Add an add-on to the cart by slug (from the cart page). Args: request: The incoming HTTP request. cart: The user's open cart. Returns: A redirect to the cart page. """ slug = request.POST.get("addon_slug", "") addon = AddOn.objects.filter( conference=self.conference, slug=slug, is_active=True, ).first() if not addon: messages.error(request, "Add-on not found.") return redirect(reverse("registration:cart", args=[self.conference.slug])) item, created = CartItem.objects.get_or_create( cart=cart, addon=addon, defaults={"quantity": 1}, ) if not created: item.quantity += 1 item.save(update_fields=["quantity"]) messages.success(request, f"Added {addon.name} to your cart.") return redirect(reverse("registration:cart", args=[self.conference.slug])) def _handle_remove_item(self, request: HttpRequest, cart: Cart) -> HttpResponse: """Remove a cart item by its ID. Args: request: The incoming HTTP request. cart: The user's open cart. Returns: A redirect to the cart page. """ item_id = request.POST.get("item_id") if item_id: deleted_count, _ = CartItem.objects.filter(pk=item_id, cart=cart).delete() if deleted_count: messages.success(request, "Item removed from your cart.") else: messages.error(request, "Item not found in your cart.") return redirect(reverse("registration:cart", args=[self.conference.slug])) def _handle_apply_voucher(self, request: HttpRequest, cart: Cart) -> HttpResponse: """Validate and apply a voucher code to the cart. Args: request: The incoming HTTP request. cart: The user's open cart. Returns: A redirect to the cart page. """ code = request.POST.get("voucher_code", "").strip() or request.POST.get("code", "").strip() if not code: messages.error(request, "Please enter a voucher code.") return redirect(reverse("registration:cart", args=[self.conference.slug])) try: voucher = Voucher.objects.get( conference=self.conference, code__iexact=code, ) except Voucher.DoesNotExist: messages.error(request, "Invalid voucher code.") return redirect(reverse("registration:cart", args=[self.conference.slug])) if not voucher.is_valid: messages.error(request, "This voucher is no longer valid.") return redirect(reverse("registration:cart", args=[self.conference.slug])) cart.voucher = voucher cart.save(update_fields=["voucher", "updated_at"]) messages.success(request, f"Voucher '{code}' applied.") return redirect(reverse("registration:cart", args=[self.conference.slug])) def _handle_remove_voucher(self, request: HttpRequest, cart: Cart) -> HttpResponse: # noqa: ARG002 """Remove the applied voucher from the cart. Args: request: The incoming HTTP request (unused). cart: The user's open cart. Returns: A redirect to the cart page. """ cart.voucher = None cart.save(update_fields=["voucher", "updated_at"]) messages.success(self.request, "Voucher removed.") return redirect(reverse("registration:cart", args=[self.conference.slug]))
[docs] class CheckoutView(LoginRequiredMixin, ConferenceMixin, FeatureRequiredMixin, View): """Checkout view for creating an order from the current cart. Collects billing information, creates Order and OrderLineItem records inside a transaction, marks the cart as checked out, and redirects to the order confirmation page. """ required_feature = ("registration", "public_ui") template_name = "django_program/registration/checkout.html" def _get_open_cart(self, request: HttpRequest) -> Cart | None: """Fetch the user's open cart for this conference, if any. Args: request: The incoming HTTP request. Returns: The open Cart, or ``None`` if no open cart exists. """ return Cart.objects.filter( user=request.user, conference=self.conference, status=Cart.Status.OPEN, ).first()
[docs] def get(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002 """Render the checkout form with the cart summary. Args: request: The incoming HTTP request. **kwargs: URL keyword arguments (unused). Returns: The rendered checkout page, or a redirect to the cart if no open cart exists. """ cart = self._get_open_cart(request) if cart is None: messages.error(request, "Your cart is empty.") return redirect(reverse("registration:cart", args=[self.conference.slug])) items = cart.items.select_related("ticket_type", "addon") if not items.exists(): messages.error(request, "Your cart is empty.") return redirect(reverse("registration:cart", args=[self.conference.slug])) subtotal, discount, total = _cart_totals(cart) form = CheckoutForm( initial={ "billing_email": request.user.email, } ) return render( request, self.template_name, { "conference": self.conference, "form": form, "cart": cart, "items": items, "subtotal": subtotal, "discount": discount, "total": total, }, )
[docs] def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002 """Validate billing info and create the order atomically. Creates Order and OrderLineItem records from the cart, marks the cart as checked out, and sets a 30-minute hold on the order for inventory reservation. Args: request: The incoming HTTP request. **kwargs: URL keyword arguments (unused). Returns: A redirect to the order confirmation page on success, or the checkout form with errors on validation failure. """ cart = self._get_open_cart(request) if cart is None: messages.error(request, "Your cart is empty.") return redirect(reverse("registration:cart", args=[self.conference.slug])) items = cart.items.select_related("ticket_type", "addon") if not items.exists(): messages.error(request, "Your cart is empty.") return redirect(reverse("registration:cart", args=[self.conference.slug])) form = CheckoutForm(request.POST) subtotal, discount, total = _cart_totals(cart) if not form.is_valid(): return render( request, self.template_name, { "conference": self.conference, "form": form, "cart": cart, "items": items, "subtotal": subtotal, "discount": discount, "total": total, }, ) try: with transaction.atomic(): reference = _generate_order_reference() while Order.objects.filter(reference=reference).exists(): reference = _generate_order_reference() voucher_code = "" voucher_details = "" voucher = None if cart.voucher is not None: voucher = Voucher.objects.select_for_update().get(pk=cart.voucher_id) if not voucher.is_valid: raise ValidationError(f"Voucher code '{voucher.code}' is no longer valid.") # noqa: TRY301 voucher_code = str(voucher.code) voucher_details = f"type={voucher.voucher_type}, value={voucher.discount_value}" order = Order.objects.create( conference=self.conference, user=request.user, status=Order.Status.PENDING, subtotal=subtotal, discount_amount=discount, total=total, voucher_code=voucher_code, voucher_details=voucher_details, billing_name=form.cleaned_data["billing_name"], billing_email=form.cleaned_data["billing_email"], billing_company=form.cleaned_data.get("billing_company", ""), reference=reference, hold_expires_at=timezone.now() + timedelta(minutes=30), ) for item in items: description = str(item.ticket_type.name if item.ticket_type else item.addon.name) OrderLineItem.objects.create( order=order, description=description, quantity=item.quantity, unit_price=item.unit_price, line_total=item.line_total, ticket_type=item.ticket_type, addon=item.addon, ) cart.status = Cart.Status.CHECKED_OUT cart.save(update_fields=["status", "updated_at"]) if voucher is not None: voucher.times_used = models.F("times_used") + 1 voucher.save(update_fields=["times_used"]) except ValidationError as exc: messages.error(request, str(exc)) return render( request, self.template_name, { "conference": self.conference, "form": form, "cart": cart, "items": items, "subtotal": subtotal, "discount": discount, "total": total, }, ) logger.info("Order %s created for user %s", order.reference, request.user) return redirect(reverse("registration:order-confirmation", args=[self.conference.slug, order.reference]))
[docs] class OrderConfirmationView(LoginRequiredMixin, ConferenceMixin, FeatureRequiredMixin, DetailView): """Confirmation page shown immediately after checkout. Displays the order summary and line items for the just-completed checkout. """ required_feature = ("registration", "public_ui") template_name = "django_program/registration/order_confirmation.html" context_object_name = "order"
[docs] def get_object(self, queryset: QuerySet[Order] | None = None) -> Order: # noqa: ARG002 """Look up the order by reference within the conference. Ensures the order belongs to the requesting user. Returns: The matched Order instance. Raises: Http404: If no matching order is found or the user does not own it. """ order = get_object_or_404( Order, conference=self.conference, reference=self.kwargs["reference"], ) if order.user != self.request.user: raise Http404 return order
[docs] def get_context_data(self, **kwargs: object) -> dict[str, object]: """Add line items to the template context. Returns: Context dict containing ``conference``, ``order``, and ``line_items``. """ context = super().get_context_data(**kwargs) context["line_items"] = self.object.line_items.all() return context
[docs] class OrderDetailView(LoginRequiredMixin, ConferenceMixin, FeatureRequiredMixin, DetailView): """Detail view for any order owned by the current user. Displays order information, line items, and payment history. """ required_feature = ("registration", "public_ui") template_name = "django_program/registration/order_detail.html" context_object_name = "order"
[docs] def get_object(self, queryset: QuerySet[Order] | None = None) -> Order: # noqa: ARG002 """Look up the order by reference within the conference. Ensures the order belongs to the requesting user. Returns: The matched Order instance. Raises: Http404: If no matching order is found or the user does not own it. """ order = get_object_or_404( Order, conference=self.conference, reference=self.kwargs["reference"], ) if order.user != self.request.user: raise Http404 return order
[docs] def get_context_data(self, **kwargs: object) -> dict[str, object]: """Add line items and payments to the template context. Returns: Context dict containing ``conference``, ``order``, ``line_items``, and ``payments``. """ context = super().get_context_data(**kwargs) context["line_items"] = self.object.line_items.all() context["payments"] = self.object.payments.all() return context