Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions src/django_program/manage/forms_vouchers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""Forms for voucher bulk operations in the management dashboard."""

from django import forms

from django_program.registration.models import AddOn, TicketType, Voucher


class VoucherBulkGenerateForm(forms.Form):
"""Form for bulk-generating a batch of voucher codes.

Accepts configuration for the code prefix, quantity, discount type, and
optional constraints (validity window, applicable ticket types/add-ons).
"""

prefix = forms.CharField(
max_length=20,
help_text="Fixed prefix for generated codes (e.g. SPEAKER-, SPONSOR-).",
)
count = forms.IntegerField(
min_value=1,
max_value=500,
help_text="Number of voucher codes to generate (1-500).",
)
voucher_type = forms.ChoiceField(
choices=Voucher.VoucherType.choices,
help_text="Type of discount each voucher provides.",
)
discount_value = forms.DecimalField(
max_digits=10,
decimal_places=2,
initial=0,
help_text="Percentage (0-100) or fixed amount depending on voucher type. Ignored for comp vouchers.",
)
applicable_ticket_types = forms.ModelMultipleChoiceField(
queryset=TicketType.objects.none(),
required=False,
widget=forms.CheckboxSelectMultiple,
help_text="Restrict vouchers to these ticket types. Leave empty for all.",
)
applicable_addons = forms.ModelMultipleChoiceField(
queryset=AddOn.objects.none(),
required=False,
widget=forms.CheckboxSelectMultiple,
help_text="Restrict vouchers to these add-ons. Leave empty for all.",
)
max_uses = forms.IntegerField(
min_value=1,
initial=1,
help_text="Maximum redemptions per voucher code.",
)
valid_from = forms.DateTimeField(
required=False,
widget=forms.DateTimeInput(
attrs={"type": "datetime-local"},
format="%Y-%m-%dT%H:%M",
),
help_text="Optional start of validity window.",
)
valid_until = forms.DateTimeField(
required=False,
widget=forms.DateTimeInput(
attrs={"type": "datetime-local"},
format="%Y-%m-%dT%H:%M",
),
help_text="Optional end of validity window.",
)
unlocks_hidden_tickets = forms.BooleanField(
required=False,
initial=False,
help_text="When checked, these vouchers reveal ticket types that require a voucher.",
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
{% extends "django_program/manage/base.html" %}

{% block title %}Bulk Generate Vouchers{% endblock %}

{% block breadcrumb %}
<nav class="breadcrumb" aria-label="Breadcrumb">
<a href="{% url 'manage:dashboard' conference.slug %}">Dashboard</a>
<span class="breadcrumb-separator"></span>
<a href="{% url 'manage:voucher-list' conference.slug %}">Vouchers</a>
<span class="breadcrumb-separator"></span>
<span>Bulk Generate</span>
</nav>
{% endblock %}

{% block page_title %}
<h1>Bulk Generate Vouchers</h1>
<p>Generate a batch of unique voucher codes with shared configuration</p>
{% endblock %}

{% block content %}
<div class="form-container">
<form method="post">
{% csrf_token %}
{% for field in form %}
<div class="form-group">
<label for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
{% if field.help_text %}
<span class="helptext">{{ field.help_text }}</span>
{% endif %}
{% if field.errors %}
<ul class="errorlist">
{% for error in field.errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% endfor %}
<div class="form-actions">
<button type="submit" class="btn btn-primary">Generate Vouchers</button>
<a href="{% url 'manage:voucher-list' conference.slug %}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
{% endblock %}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ <h1>Vouchers</h1>

{% block page_actions %}
<a href="{% url 'manage:voucher-add' conference.slug %}" class="btn btn-primary">Add Voucher</a>
<a href="{% url 'manage:voucher-bulk-generate' conference.slug %}" class="btn btn-secondary">Bulk Generate</a>
{% endblock %}

{% block content %}
Expand Down
4 changes: 3 additions & 1 deletion src/django_program/manage/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
]
"""

from django.urls import path
from django.urls import include, path

from django_program.manage.views import (
ActivityCreateView,
Expand Down Expand Up @@ -195,4 +195,6 @@
ManualPaymentView.as_view(),
name="order-manual-payment",
),
# --- Voucher Bulk Generation ---
path("<slug:conference_slug>/vouchers/bulk/", include("django_program.manage.urls_vouchers")),
]
13 changes: 13 additions & 0 deletions src/django_program/manage/urls_vouchers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""URL patterns for voucher bulk operations.

Included under ``<slug:conference_slug>/vouchers/bulk/`` in the main
management URL configuration.
"""

from django.urls import path

from django_program.manage.views_vouchers import VoucherBulkGenerateView

urlpatterns = [
path("generate/", VoucherBulkGenerateView.as_view(), name="voucher-bulk-generate"),
]
77 changes: 77 additions & 0 deletions src/django_program/manage/views_vouchers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""Views for voucher bulk operations in the management dashboard."""

import logging
from typing import TYPE_CHECKING

from django.contrib import messages
from django.db import IntegrityError
from django.urls import reverse
from django.views.generic import FormView

from django_program.manage.forms_vouchers import VoucherBulkGenerateForm
from django_program.manage.views import ManagePermissionMixin
from django_program.registration.models import AddOn, TicketType
from django_program.registration.services.voucher_service import VoucherBulkConfig, generate_voucher_codes

if TYPE_CHECKING:
from django.http import HttpResponse

logger = logging.getLogger(__name__)


class VoucherBulkGenerateView(ManagePermissionMixin, FormView):
"""Bulk-generate a batch of voucher codes for the current conference.

Renders a form for configuring the batch parameters (prefix, count,
discount type, etc.) and delegates to the voucher service for creation.
On success, redirects to the voucher list with a confirmation message.
"""

template_name = "django_program/manage/voucher_bulk_generate.html"
form_class = VoucherBulkGenerateForm

def get_context_data(self, **kwargs: object) -> dict[str, object]:
"""Add ``active_nav`` to the template context."""
context = super().get_context_data(**kwargs)
context["active_nav"] = "vouchers"
return context

def get_form(self, form_class: type[VoucherBulkGenerateForm] | None = None) -> VoucherBulkGenerateForm:
"""Scope the ticket type and add-on querysets to the current conference."""
form = super().get_form(form_class)
form.fields["applicable_ticket_types"].queryset = TicketType.objects.filter(conference=self.conference)
form.fields["applicable_addons"].queryset = AddOn.objects.filter(conference=self.conference)
return form

def form_valid(self, form: VoucherBulkGenerateForm) -> HttpResponse:
"""Generate the voucher batch and redirect to the voucher list."""
data = form.cleaned_data
config = VoucherBulkConfig(
conference=self.conference,
prefix=data["prefix"],
count=data["count"],
voucher_type=data["voucher_type"],
discount_value=data["discount_value"],
max_uses=data["max_uses"],
valid_from=data.get("valid_from"),
valid_until=data.get("valid_until"),
unlocks_hidden_tickets=data.get("unlocks_hidden_tickets", False),
applicable_ticket_types=data.get("applicable_ticket_types"),
applicable_addons=data.get("applicable_addons"),
)
try:
created = generate_voucher_codes(config)
messages.success(
self.request,
f"Successfully generated {len(created)} voucher codes with prefix '{data['prefix']}'.",
)
except (RuntimeError, IntegrityError): # fmt: skip
logger.exception("Voucher bulk generation failed")
messages.error(self.request, "Failed to generate voucher codes. Please try again.")
return self.form_invalid(form)

return super().form_valid(form)

def get_success_url(self) -> str:
"""Redirect to the voucher list after generation."""
return reverse("manage:voucher-list", kwargs={"conference_slug": self.conference.slug})
162 changes: 162 additions & 0 deletions src/django_program/registration/services/voucher_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
"""Voucher bulk generation service.

Provides functions for generating batches of unique, cryptographically
random voucher codes within a single database transaction.
"""

import secrets
import string
from dataclasses import dataclass, field
from typing import TYPE_CHECKING

from django.db import transaction

from django_program.registration.models import Voucher

if TYPE_CHECKING:
import datetime
from decimal import Decimal

from django.db.models import QuerySet

from django_program.conference.models import Conference
from django_program.registration.models import AddOn, TicketType

_CODE_ALPHABET = string.ascii_uppercase + string.digits
_CODE_LENGTH = 8
_MAX_COUNT = 500


@dataclass
class VoucherBulkConfig:
"""Configuration for a bulk voucher generation request.

Bundles all parameters needed to generate a batch of voucher codes
into a single value object.

Attributes:
conference: The conference to create vouchers for.
prefix: Fixed string prepended to each generated code.
count: Number of voucher codes to generate (1-500).
voucher_type: One of the ``Voucher.VoucherType`` values.
discount_value: Percentage (0-100) or fixed amount depending on type.
max_uses: Maximum number of times each voucher can be redeemed.
valid_from: Optional start of the validity window.
valid_until: Optional end of the validity window.
unlocks_hidden_tickets: Whether the vouchers reveal hidden ticket types.
applicable_ticket_types: Optional queryset of ticket types to restrict to.
applicable_addons: Optional queryset of add-ons to restrict to.
"""

conference: Conference
prefix: str
count: int
voucher_type: str
discount_value: Decimal
max_uses: int = 1
valid_from: datetime.datetime | None = None
valid_until: datetime.datetime | None = None
unlocks_hidden_tickets: bool = field(default=False)
applicable_ticket_types: QuerySet[TicketType] | None = None
applicable_addons: QuerySet[AddOn] | None = None


def _generate_unique_code(prefix: str, existing_codes: set[str]) -> str:
"""Generate a single voucher code that does not collide with existing ones.

Produces codes in the format ``{prefix}{8_random_chars}`` where the random
portion uses uppercase alphanumeric characters (A-Z, 0-9) for readability.
Retries up to 100 times if a collision is detected.

Args:
prefix: The fixed prefix prepended to each code.
existing_codes: Set of codes that already exist for uniqueness checks.

Returns:
A unique voucher code string.

Raises:
RuntimeError: If a unique code cannot be generated after 100 attempts.
"""
for _ in range(100):
random_part = "".join(secrets.choice(_CODE_ALPHABET) for _ in range(_CODE_LENGTH))
code = f"{prefix}{random_part}"
if code not in existing_codes:
return code
msg = f"Failed to generate a unique voucher code with prefix '{prefix}' after 100 attempts"
raise RuntimeError(msg)


def generate_voucher_codes(config: VoucherBulkConfig) -> list[Voucher]:
"""Generate a batch of unique voucher codes for a conference.

Creates ``config.count`` vouchers with cryptographically random codes,
all sharing the same configuration (type, discount, validity window, etc.).
The vouchers are inserted in a single ``bulk_create`` call wrapped in a
transaction for atomicity. M2M relations are set via a single
``bulk_create`` on the through tables to avoid N+1 queries.

Args:
config: Bulk generation configuration specifying the conference, prefix,
count, discount parameters, and optional constraints.

Returns:
List of newly created ``Voucher`` instances.

Raises:
ValueError: If ``config.count`` is less than 1 or greater than 500.
RuntimeError: If unique code generation fails after retries.
IntegrityError: If a code collision occurs at the database level despite
the in-memory uniqueness check (race condition safeguard).
"""
if config.count < 1 or config.count > _MAX_COUNT:
msg = f"count must be between 1 and {_MAX_COUNT}, got {config.count}"
raise ValueError(msg)

qs = Voucher.objects.filter(conference=config.conference)
if config.prefix:
qs = qs.filter(code__startswith=config.prefix)
existing_codes: set[str] = set(qs.values_list("code", flat=True))

vouchers_to_create: list[Voucher] = []
for _ in range(config.count):
code = _generate_unique_code(config.prefix, existing_codes)
existing_codes.add(code)
vouchers_to_create.append(
Voucher(
conference=config.conference,
code=code,
voucher_type=config.voucher_type,
discount_value=config.discount_value,
max_uses=config.max_uses,
valid_from=config.valid_from,
valid_until=config.valid_until,
unlocks_hidden_tickets=config.unlocks_hidden_tickets,
)
)

with transaction.atomic():
created = Voucher.objects.bulk_create(vouchers_to_create)
# Re-fetch to guarantee PKs are populated on all database backends
created_codes = [v.code for v in created]
created = list(Voucher.objects.filter(conference=config.conference, code__in=created_codes))

if config.applicable_ticket_types is not None and config.applicable_ticket_types.exists():
ticket_type_ids = list(config.applicable_ticket_types.values_list("pk", flat=True))
ThroughModel = Voucher.applicable_ticket_types.through # noqa: N806
through_objects = [
ThroughModel(voucher_id=voucher.pk, tickettype_id=tt_id)
for voucher in created
for tt_id in ticket_type_ids
]
ThroughModel.objects.bulk_create(through_objects)

if config.applicable_addons is not None and config.applicable_addons.exists():
addon_ids = list(config.applicable_addons.values_list("pk", flat=True))
ThroughModel = Voucher.applicable_addons.through # noqa: N806
through_objects = [
ThroughModel(voucher_id=voucher.pk, addon_id=addon_id) for voucher in created for addon_id in addon_ids
]
ThroughModel.objects.bulk_create(through_objects)

return created
Loading
Loading