"""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 IntegrityError, models, transaction
from django.db.models.functions import Coalesce
from django.http import Http404, HttpResponse
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, LetterRequestForm, VoucherApplyForm
from django_program.registration.models import (
AddOn,
Cart,
CartItem,
LetterRequest,
Order,
OrderLineItem,
TicketType,
Voucher,
)
if TYPE_CHECKING:
from django.db.models import QuerySet
from django.http import HttpRequest
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
[docs]
class LetterRequestCreateView(LoginRequiredMixin, ConferenceMixin, FeatureRequiredMixin, View):
"""Attendee-facing view to request a visa invitation letter.
If the user already has a request for this conference, redirects to
the detail view instead of allowing a duplicate submission.
"""
required_feature = ("registration", "visa_letters")
template_name = "django_program/registration/letter_request_form.html"
[docs]
def get(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002
"""Render the letter request form, pre-filling the name if available.
Args:
request: The incoming HTTP request.
**kwargs: URL keyword arguments (unused).
Returns:
The rendered form page, or a redirect to the detail view if a
request already exists.
"""
existing = LetterRequest.objects.filter(
user=request.user,
conference=self.conference,
).first()
if existing:
return redirect(reverse("registration:letter-request-detail", args=[self.conference.slug]))
full_name = f"{request.user.first_name} {request.user.last_name}".strip()
form = LetterRequestForm(initial={"passport_name": full_name} if full_name else None)
return render(
request,
self.template_name,
{
"conference": self.conference,
"form": form,
},
)
[docs]
def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002
"""Validate and create a letter request.
Args:
request: The incoming HTTP request.
**kwargs: URL keyword arguments (unused).
Returns:
A redirect to the detail view on success, or the form with
errors on validation failure.
"""
existing = LetterRequest.objects.filter(
user=request.user,
conference=self.conference,
).first()
if existing:
return redirect(reverse("registration:letter-request-detail", args=[self.conference.slug]))
form = LetterRequestForm(request.POST)
if not form.is_valid():
return render(
request,
self.template_name,
{
"conference": self.conference,
"form": form,
},
)
letter_request = form.save(commit=False)
letter_request.user = request.user
letter_request.conference = self.conference
letter_request.status = LetterRequest.Status.SUBMITTED
try:
letter_request.save()
except IntegrityError:
return redirect(reverse("registration:letter-request-detail", args=[self.conference.slug]))
messages.success(request, "Your visa invitation letter request has been submitted.")
return redirect(reverse("registration:letter-request-detail", args=[self.conference.slug]))
[docs]
class LetterRequestDetailView(LoginRequiredMixin, ConferenceMixin, FeatureRequiredMixin, DetailView):
"""Detail view for an attendee's own visa letter request.
Shows current status, submitted details, and a download link for the
generated PDF when available.
"""
required_feature = ("registration", "visa_letters")
template_name = "django_program/registration/letter_request_detail.html"
context_object_name = "letter_request"
[docs]
def get_object(self, queryset: QuerySet[LetterRequest] | None = None) -> LetterRequest: # noqa: ARG002
"""Look up the letter request for the current user and conference.
Returns:
The user's LetterRequest for this conference.
Raises:
Http404: If no letter request exists for this user/conference.
"""
return get_object_or_404(
LetterRequest,
user=self.request.user,
conference=self.conference,
)
[docs]
def get_context_data(self, **kwargs: object) -> dict[str, object]:
"""Add PDF availability flag to the template context."""
context = super().get_context_data(**kwargs)
lr = self.object
context["pdf_available"] = (
lr.status in (LetterRequest.Status.GENERATED, LetterRequest.Status.SENT) and lr.generated_pdf
)
return context
[docs]
class LetterRequestDownloadView(LoginRequiredMixin, ConferenceMixin, FeatureRequiredMixin, View):
"""Download the generated invitation letter PDF.
Only the requesting user can download their own letter. Serves the
PDF through an authenticated, owner-checked view instead of exposing
a direct media URL.
"""
required_feature = ("registration", "visa_letters")
[docs]
def get(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002
"""Return the generated PDF as a downloadable attachment.
Args:
request: The incoming HTTP request.
**kwargs: URL keyword arguments (unused).
Returns:
An HTTP response with the PDF content.
Raises:
Http404: If no letter request exists or no PDF has been generated.
"""
letter_request = get_object_or_404(
LetterRequest,
conference=self.conference,
user=request.user,
)
if not letter_request.generated_pdf:
raise Http404
with letter_request.generated_pdf.open("rb") as f:
pdf_data = f.read()
response = HttpResponse(pdf_data, content_type="application/pdf")
response["Content-Disposition"] = 'attachment; filename="invitation-letter.pdf"'
return response