Source code for django_program.sponsors.views

"""Views for the sponsors app.

Provides sponsor listing and detail views scoped to a conference
via the ``conference_slug`` URL kwarg, plus a self-service portal
where sponsor contacts can view purchases, download voucher CSVs,
and request new bulk purchases.
"""

import csv
import logging
from decimal import Decimal
from typing import TYPE_CHECKING

from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import PermissionDenied
from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.views import View
from django.views.generic import DetailView, FormView, ListView, TemplateView

from django_program.conference.models import Conference
from django_program.features import FeatureRequiredMixin
from django_program.pretalx.views import ConferenceMixin
from django_program.sponsors.forms import BulkPurchaseRequestForm
from django_program.sponsors.models import BulkPurchase, BulkPurchaseVoucher, Sponsor, SponsorLevel

if TYPE_CHECKING:
    from django.db.models import QuerySet

logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Public views
# ---------------------------------------------------------------------------


[docs] class SponsorListView(ConferenceMixin, FeatureRequiredMixin, ListView): """List view of all active sponsors for a conference, grouped by level.""" required_feature = ("sponsors", "public_ui") template_name = "django_program/sponsors/sponsor_list.html" context_object_name = "sponsors"
[docs] def get_queryset(self) -> QuerySet[Sponsor]: """Return active sponsors for the current conference. Returns: A queryset of active Sponsor instances ordered by level and name. """ return ( Sponsor.objects.filter(conference=self.conference, is_active=True) .select_related("level") .order_by("level__order", "name") )
[docs] def get_context_data(self, **kwargs: object) -> dict[str, object]: """Add sponsor levels to the template context. Returns: Context dict containing ``conference``, ``sponsors``, and ``levels``. """ context = super().get_context_data(**kwargs) context["levels"] = ( SponsorLevel.objects.filter(conference=self.conference, sponsors__is_active=True) .distinct() .order_by("order") ) return context
[docs] class SponsorDetailView(ConferenceMixin, FeatureRequiredMixin, DetailView): """Detail view for a single sponsor.""" required_feature = ("sponsors", "public_ui") template_name = "django_program/sponsors/sponsor_detail.html" context_object_name = "sponsor"
[docs] def get_object(self, queryset: QuerySet[Sponsor] | None = None) -> Sponsor: # noqa: ARG002 """Look up the sponsor by conference and slug. Returns: The matched Sponsor instance. Raises: Http404: If no active sponsor matches the conference and slug. """ return get_object_or_404( Sponsor.objects.select_related("level"), conference=self.conference, slug=self.kwargs["slug"], is_active=True, )
[docs] def get_context_data(self, **kwargs: object) -> dict[str, object]: """Add benefits to the template context. Returns: Context dict containing ``conference``, ``sponsor``, and ``benefits``. """ context = super().get_context_data(**kwargs) context["benefits"] = self.object.benefits.all() return context
# --------------------------------------------------------------------------- # Sponsor self-service portal # ---------------------------------------------------------------------------
[docs] class SponsorPortalMixin(LoginRequiredMixin): """Permission mixin for the sponsor self-service portal. Resolves the conference from the ``conference_slug`` URL kwarg and verifies that the authenticated user's email matches the sponsor's ``contact_email``, or that the user is staff/superuser. Sets ``self.conference`` and ``self.sponsor`` for use by subclasses. """ conference: Conference sponsor: Sponsor kwargs: dict[str, str]
[docs] def dispatch(self, request: HttpRequest, *args: str, **kwargs: str) -> HttpResponse: """Resolve the conference and sponsor, then enforce access. Performs conference resolution and sponsor authorization BEFORE calling ``super().dispatch()`` so that ``self.conference`` and ``self.sponsor`` are available when the view method executes. Args: request: The incoming HTTP request. *args: Positional arguments from the URL resolver. **kwargs: Keyword arguments from the URL pattern. Returns: The HTTP response. Raises: PermissionDenied: If the user is not authorized for this sponsor portal. """ if not request.user.is_authenticated: return self.handle_no_permission() # type: ignore[return-value] self.conference = get_object_or_404(Conference, slug=kwargs.get("conference_slug", "")) self.sponsor = self._resolve_sponsor() return super().dispatch(request, *args, **kwargs) # type: ignore[misc]
def _resolve_sponsor(self) -> Sponsor: """Find the sponsor this user is authorized to view. Returns: The matched Sponsor instance. Raises: PermissionDenied: If no sponsor matches the user's email and the user is not staff/superuser. """ request = self.request # type: ignore[attr-defined] if request.user.is_superuser or request.user.is_staff: sponsors = Sponsor.objects.filter(conference=self.conference, is_active=True) if sponsors.exists(): return sponsors.first() # type: ignore[return-value] raise PermissionDenied("No active sponsors found for this conference.") user_email = request.user.email if not user_email: raise PermissionDenied("Your account has no email address configured.") sponsors = Sponsor.objects.filter(conference=self.conference, is_active=True).select_related( "level", "override" ) for sponsor in sponsors: if sponsor.effective_contact_email.lower() == user_email.lower(): return sponsor raise PermissionDenied("You do not have access to any sponsor portal for this conference.")
[docs] def get_context_data(self, **kwargs: object) -> dict[str, object]: """Inject conference and sponsor into the template context. Args: **kwargs: Additional context data. Returns: Context dict with ``conference`` and ``sponsor``. """ context: dict[str, object] = super().get_context_data(**kwargs) # type: ignore[misc] context["conference"] = self.conference context["sponsor"] = self.sponsor return context
[docs] class SponsorPortalView(SponsorPortalMixin, TemplateView): """Landing page for the sponsor self-service portal. Shows sponsor info and a list of all bulk purchases with their current status. """ template_name = "django_program/sponsors/portal_home.html"
[docs] def get_context_data(self, **kwargs: object) -> dict[str, object]: """Add bulk purchases to the template context. Returns: Context dict with ``purchases`` queryset. """ context = super().get_context_data(**kwargs) context["purchases"] = ( BulkPurchase.objects.filter(sponsor=self.sponsor).select_related("ticket_type").order_by("-created_at") ) return context
[docs] class BulkPurchaseDetailView(SponsorPortalMixin, TemplateView): """Detail view for a specific bulk purchase. Shows all voucher codes associated with the purchase along with their redemption counts. """ template_name = "django_program/sponsors/purchase_detail.html"
[docs] def get_context_data(self, **kwargs: object) -> dict[str, object]: """Add purchase and voucher details to context. Returns: Context dict with ``purchase``, ``voucher_links``, and summary stats. """ context = super().get_context_data(**kwargs) purchase = get_object_or_404( BulkPurchase.objects.select_related("sponsor", "ticket_type"), pk=self.kwargs["pk"], sponsor=self.sponsor, ) voucher_links = ( BulkPurchaseVoucher.objects.filter(bulk_purchase=purchase) .select_related("voucher") .order_by("voucher__code") ) total_uses = sum(link.voucher.times_used for link in voucher_links) total_max = sum(link.voucher.max_uses for link in voucher_links) context["purchase"] = purchase context["voucher_links"] = voucher_links context["total_uses"] = total_uses context["total_max"] = total_max return context
[docs] class BulkPurchaseExportCSVView(SponsorPortalMixin, View): """Export voucher codes for a bulk purchase as a CSV download."""
[docs] def get(self, request: HttpRequest, *args: str, **kwargs: str) -> HttpResponse: # noqa: ARG002 """Generate and return the CSV response. Args: request: The incoming HTTP request. *args: Positional arguments from the URL resolver. **kwargs: Keyword arguments from the URL pattern. Returns: An HTTP response with CSV content disposition. """ purchase = get_object_or_404( BulkPurchase, pk=self.kwargs["pk"], sponsor=self.sponsor, ) voucher_links = ( BulkPurchaseVoucher.objects.filter(bulk_purchase=purchase) .select_related("voucher") .order_by("voucher__code") ) response = HttpResponse(content_type="text/csv") response["Content-Disposition"] = f'attachment; filename="vouchers-purchase-{purchase.pk}.csv"' writer = csv.writer(response) writer.writerow(["code", "times_used", "max_uses", "is_active", "valid_from", "valid_until"]) for link in voucher_links: v = link.voucher writer.writerow( [ str(v.code), v.times_used, v.max_uses, v.is_active, v.valid_from.isoformat() if v.valid_from else "", v.valid_until.isoformat() if v.valid_until else "", ] ) return response
[docs] class BulkPurchaseRequestView(SponsorPortalMixin, FormView): """Form view for sponsors to request a new bulk voucher purchase. Creates a BulkPurchase in PENDING state for organizer approval. """ template_name = "django_program/sponsors/purchase_request.html" form_class = BulkPurchaseRequestForm
[docs] def form_valid(self, form: BulkPurchaseRequestForm) -> HttpResponse: """Create a pending BulkPurchase from the form data. Args: form: The validated form instance. Returns: A redirect to the portal home page. """ quantity = form.cleaned_data["quantity"] notes = form.cleaned_data.get("notes", "") ticket_pref = form.cleaned_data.get("ticket_type_preference", "") BulkPurchase.objects.create( conference=self.conference, sponsor=self.sponsor, quantity=quantity, product_description=ticket_pref, payment_status=BulkPurchase.PaymentStatus.PENDING, unit_price=Decimal("0.00"), total_amount=Decimal("0.00"), voucher_config={"notes": notes, "ticket_type_preference": ticket_pref}, requested_by=self.request.user, ) logger.info( "Sponsor %s requested bulk purchase of %d vouchers for %s", self.sponsor.name, quantity, self.conference.slug, ) return redirect( reverse( "sponsors:portal-home", kwargs={"conference_slug": self.conference.slug}, ) )