diff --git a/src/django_program/manage/forms_vouchers.py b/src/django_program/manage/forms_vouchers.py
new file mode 100644
index 0000000..727f387
--- /dev/null
+++ b/src/django_program/manage/forms_vouchers.py
@@ -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.",
+ )
diff --git a/src/django_program/manage/templates/django_program/manage/voucher_bulk_generate.html b/src/django_program/manage/templates/django_program/manage/voucher_bulk_generate.html
new file mode 100644
index 0000000..47a6227
--- /dev/null
+++ b/src/django_program/manage/templates/django_program/manage/voucher_bulk_generate.html
@@ -0,0 +1,46 @@
+{% extends "django_program/manage/base.html" %}
+
+{% block title %}Bulk Generate Vouchers{% endblock %}
+
+{% block breadcrumb %}
+
+{% endblock %}
+
+{% block page_title %}
+
Bulk Generate Vouchers
+Generate a batch of unique voucher codes with shared configuration
+{% endblock %}
+
+{% block content %}
+
+{% endblock %}
diff --git a/src/django_program/manage/templates/django_program/manage/voucher_list.html b/src/django_program/manage/templates/django_program/manage/voucher_list.html
index 7708782..8acdefa 100644
--- a/src/django_program/manage/templates/django_program/manage/voucher_list.html
+++ b/src/django_program/manage/templates/django_program/manage/voucher_list.html
@@ -9,6 +9,7 @@ Vouchers
{% block page_actions %}
Add Voucher
+Bulk Generate
{% endblock %}
{% block content %}
diff --git a/src/django_program/manage/urls.py b/src/django_program/manage/urls.py
index 99def3d..c8dd32b 100644
--- a/src/django_program/manage/urls.py
+++ b/src/django_program/manage/urls.py
@@ -7,7 +7,7 @@
]
"""
-from django.urls import path
+from django.urls import include, path
from django_program.manage.views import (
ActivityCreateView,
@@ -195,4 +195,6 @@
ManualPaymentView.as_view(),
name="order-manual-payment",
),
+ # --- Voucher Bulk Generation ---
+ path("/vouchers/bulk/", include("django_program.manage.urls_vouchers")),
]
diff --git a/src/django_program/manage/urls_vouchers.py b/src/django_program/manage/urls_vouchers.py
new file mode 100644
index 0000000..0c9bdb9
--- /dev/null
+++ b/src/django_program/manage/urls_vouchers.py
@@ -0,0 +1,13 @@
+"""URL patterns for voucher bulk operations.
+
+Included under ``/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"),
+]
diff --git a/src/django_program/manage/views_vouchers.py b/src/django_program/manage/views_vouchers.py
new file mode 100644
index 0000000..de125ec
--- /dev/null
+++ b/src/django_program/manage/views_vouchers.py
@@ -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})
diff --git a/src/django_program/registration/services/voucher_service.py b/src/django_program/registration/services/voucher_service.py
new file mode 100644
index 0000000..e686c50
--- /dev/null
+++ b/src/django_program/registration/services/voucher_service.py
@@ -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
diff --git a/tests/test_manage/test_programs_views.py b/tests/test_manage/test_programs_views.py
index eb9ca12..ba40098 100644
--- a/tests/test_manage/test_programs_views.py
+++ b/tests/test_manage/test_programs_views.py
@@ -605,6 +605,14 @@ def test_activity_dashboard_denied_for_non_organizer(client: Client, conference,
assert response.status_code == 403
+@pytest.mark.django_db
+def test_activity_dashboard_anonymous_redirects_to_login(client: Client, conference, activity):
+ url = reverse("manage:activity-dashboard", kwargs={"conference_slug": conference.slug, "pk": activity.pk})
+ response = client.get(url)
+ assert response.status_code == 302
+ assert "/accounts/login/" in response.url or "login" in response.url
+
+
# ---- Activity Dashboard CSV Export ----
diff --git a/tests/test_manage/test_voucher_bulk_views.py b/tests/test_manage/test_voucher_bulk_views.py
new file mode 100644
index 0000000..1492054
--- /dev/null
+++ b/tests/test_manage/test_voucher_bulk_views.py
@@ -0,0 +1,319 @@
+"""Tests for voucher bulk generation views."""
+
+from datetime import date
+from decimal import Decimal
+from unittest.mock import patch
+
+import pytest
+from django.contrib.auth.models import User
+from django.db import IntegrityError
+from django.test import Client
+from django.urls import reverse
+
+from django_program.conference.models import Conference
+from django_program.registration.models import AddOn, TicketType, Voucher
+
+# ---------------------------------------------------------------------------
+# Fixtures
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture
+def superuser(db):
+ return User.objects.create_superuser(username="admin", password="password", email="admin@test.com")
+
+
+@pytest.fixture
+def regular_user(db):
+ return User.objects.create_user(username="regular", password="password", email="regular@test.com")
+
+
+@pytest.fixture
+def conference(db):
+ return Conference.objects.create(
+ name="BulkCon",
+ slug="bulkcon",
+ start_date=date(2027, 5, 1),
+ end_date=date(2027, 5, 3),
+ timezone="UTC",
+ is_active=True,
+ )
+
+
+@pytest.fixture
+def ticket_type(conference):
+ return TicketType.objects.create(
+ conference=conference,
+ name="General",
+ slug="general",
+ price=Decimal("100.00"),
+ )
+
+
+@pytest.fixture
+def addon(conference):
+ return AddOn.objects.create(
+ conference=conference,
+ name="T-Shirt",
+ slug="tshirt",
+ price=Decimal("25.00"),
+ )
+
+
+@pytest.fixture
+def client_logged_in_super(superuser):
+ c = Client()
+ c.login(username="admin", password="password")
+ return c
+
+
+@pytest.fixture
+def client_logged_in_regular(regular_user):
+ c = Client()
+ c.login(username="regular", password="password")
+ return c
+
+
+@pytest.fixture
+def bulk_url(conference):
+ return reverse("manage:voucher-bulk-generate", kwargs={"conference_slug": conference.slug})
+
+
+# ---------------------------------------------------------------------------
+# GET tests
+# ---------------------------------------------------------------------------
+
+
+class TestVoucherBulkGenerateViewGet:
+ """Tests for the GET request on the bulk generate view."""
+
+ def test_renders_form(self, client_logged_in_super, bulk_url):
+ resp = client_logged_in_super.get(bulk_url)
+ assert resp.status_code == 200
+ assert "form" in resp.context
+
+ def test_active_nav_is_vouchers(self, client_logged_in_super, bulk_url):
+ resp = client_logged_in_super.get(bulk_url)
+ assert resp.context["active_nav"] == "vouchers"
+
+ def test_form_has_expected_fields(self, client_logged_in_super, bulk_url):
+ resp = client_logged_in_super.get(bulk_url)
+ form = resp.context["form"]
+ expected = {
+ "prefix",
+ "count",
+ "voucher_type",
+ "discount_value",
+ "applicable_ticket_types",
+ "applicable_addons",
+ "max_uses",
+ "valid_from",
+ "valid_until",
+ "unlocks_hidden_tickets",
+ }
+ assert set(form.fields.keys()) == expected
+
+ def test_form_querysets_scoped_to_conference(self, client_logged_in_super, bulk_url, ticket_type, addon):
+ resp = client_logged_in_super.get(bulk_url)
+ form = resp.context["form"]
+ assert ticket_type in form.fields["applicable_ticket_types"].queryset
+ assert addon in form.fields["applicable_addons"].queryset
+
+ def test_anonymous_user_redirected(self, client, bulk_url):
+ resp = client.get(bulk_url)
+ assert resp.status_code == 302
+ assert "/accounts/login/" in resp.url or "login" in resp.url
+
+ def test_non_superuser_denied(self, client_logged_in_regular, bulk_url):
+ resp = client_logged_in_regular.get(bulk_url)
+ assert resp.status_code == 403
+
+
+# ---------------------------------------------------------------------------
+# POST tests
+# ---------------------------------------------------------------------------
+
+
+class TestVoucherBulkGenerateViewPost:
+ """Tests for the POST request on the bulk generate view."""
+
+ def test_creates_vouchers_and_redirects(self, client_logged_in_super, bulk_url, conference):
+ resp = client_logged_in_super.post(
+ bulk_url,
+ {
+ "prefix": "SPEAKER-",
+ "count": 3,
+ "voucher_type": "comp",
+ "discount_value": "0.00",
+ "max_uses": 1,
+ },
+ )
+ assert resp.status_code == 302
+ vouchers = Voucher.objects.filter(conference=conference)
+ assert vouchers.count() == 3
+ for v in vouchers:
+ assert v.code.startswith("SPEAKER-")
+
+ def test_redirects_to_voucher_list(self, client_logged_in_super, bulk_url, conference):
+ resp = client_logged_in_super.post(
+ bulk_url,
+ {
+ "prefix": "REDIR-",
+ "count": 1,
+ "voucher_type": "comp",
+ "discount_value": "0.00",
+ "max_uses": 1,
+ },
+ )
+ expected_url = reverse("manage:voucher-list", kwargs={"conference_slug": conference.slug})
+ assert resp.status_code == 302
+ assert resp.url == expected_url
+
+ def test_success_message_on_creation(self, client_logged_in_super, bulk_url):
+ resp = client_logged_in_super.post(
+ bulk_url,
+ {
+ "prefix": "MSG-",
+ "count": 2,
+ "voucher_type": "comp",
+ "discount_value": "0.00",
+ "max_uses": 1,
+ },
+ follow=True,
+ )
+ messages_list = list(resp.context["messages"])
+ assert len(messages_list) == 1
+ assert "Successfully generated 2 voucher codes" in str(messages_list[0])
+
+ def test_invalid_form_missing_required_field(self, client_logged_in_super, bulk_url):
+ resp = client_logged_in_super.post(
+ bulk_url,
+ {
+ "prefix": "BAD-",
+ "voucher_type": "comp",
+ "discount_value": "0.00",
+ "max_uses": 1,
+ },
+ )
+ assert resp.status_code == 200
+ form = resp.context["form"]
+ assert form.errors
+ assert "count" in form.errors
+
+ def test_invalid_form_count_zero(self, client_logged_in_super, bulk_url):
+ resp = client_logged_in_super.post(
+ bulk_url,
+ {
+ "prefix": "ZERO-",
+ "count": 0,
+ "voucher_type": "comp",
+ "discount_value": "0.00",
+ "max_uses": 1,
+ },
+ )
+ assert resp.status_code == 200
+ form = resp.context["form"]
+ assert "count" in form.errors
+
+ def test_invalid_form_count_over_500(self, client_logged_in_super, bulk_url):
+ resp = client_logged_in_super.post(
+ bulk_url,
+ {
+ "prefix": "OVER-",
+ "count": 501,
+ "voucher_type": "comp",
+ "discount_value": "0.00",
+ "max_uses": 1,
+ },
+ )
+ assert resp.status_code == 200
+ form = resp.context["form"]
+ assert "count" in form.errors
+
+ def test_post_with_ticket_types(self, client_logged_in_super, bulk_url, conference, ticket_type):
+ resp = client_logged_in_super.post(
+ bulk_url,
+ {
+ "prefix": "TT-",
+ "count": 2,
+ "voucher_type": "percentage",
+ "discount_value": "25.00",
+ "max_uses": 1,
+ "applicable_ticket_types": [ticket_type.pk],
+ },
+ )
+ assert resp.status_code == 302
+ for v in Voucher.objects.filter(conference=conference):
+ assert ticket_type in v.applicable_ticket_types.all()
+
+ def test_post_with_addons(self, client_logged_in_super, bulk_url, conference, addon):
+ resp = client_logged_in_super.post(
+ bulk_url,
+ {
+ "prefix": "AO-",
+ "count": 2,
+ "voucher_type": "fixed_amount",
+ "discount_value": "10.00",
+ "max_uses": 1,
+ "applicable_addons": [addon.pk],
+ },
+ )
+ assert resp.status_code == 302
+ for v in Voucher.objects.filter(conference=conference):
+ assert addon in v.applicable_addons.all()
+
+ def test_runtime_error_shows_error_message(self, client_logged_in_super, bulk_url):
+ with patch(
+ "django_program.manage.views_vouchers.generate_voucher_codes",
+ side_effect=RuntimeError("code generation failed"),
+ ):
+ resp = client_logged_in_super.post(
+ bulk_url,
+ {
+ "prefix": "ERR-",
+ "count": 1,
+ "voucher_type": "comp",
+ "discount_value": "0.00",
+ "max_uses": 1,
+ },
+ follow=True,
+ )
+ messages_list = list(resp.context["messages"])
+ assert len(messages_list) == 1
+ assert "Failed to generate voucher codes" in str(messages_list[0])
+
+ def test_integrity_error_shows_error_message(self, client_logged_in_super, bulk_url):
+ with patch(
+ "django_program.manage.views_vouchers.generate_voucher_codes",
+ side_effect=IntegrityError("duplicate key"),
+ ):
+ resp = client_logged_in_super.post(
+ bulk_url,
+ {
+ "prefix": "DUP-",
+ "count": 1,
+ "voucher_type": "comp",
+ "discount_value": "0.00",
+ "max_uses": 1,
+ },
+ follow=True,
+ )
+ messages_list = list(resp.context["messages"])
+ assert len(messages_list) == 1
+ assert "Failed to generate voucher codes" in str(messages_list[0])
+
+ def test_post_with_unlocks_hidden_tickets(self, client_logged_in_super, bulk_url, conference):
+ resp = client_logged_in_super.post(
+ bulk_url,
+ {
+ "prefix": "HIDDEN-",
+ "count": 1,
+ "voucher_type": "comp",
+ "discount_value": "0.00",
+ "max_uses": 1,
+ "unlocks_hidden_tickets": True,
+ },
+ )
+ assert resp.status_code == 302
+ v = Voucher.objects.get(conference=conference)
+ assert v.unlocks_hidden_tickets is True
diff --git a/tests/test_registration/test_voucher_service.py b/tests/test_registration/test_voucher_service.py
new file mode 100644
index 0000000..ecf4297
--- /dev/null
+++ b/tests/test_registration/test_voucher_service.py
@@ -0,0 +1,351 @@
+"""Tests for voucher bulk generation service."""
+
+import string
+from datetime import date
+from decimal import Decimal
+from unittest.mock import patch
+
+import pytest
+from django.utils import timezone
+
+from django_program.conference.models import Conference
+from django_program.registration.models import AddOn, TicketType, Voucher
+from django_program.registration.services.voucher_service import (
+ _CODE_LENGTH,
+ VoucherBulkConfig,
+ _generate_unique_code,
+ generate_voucher_codes,
+)
+
+# ---------------------------------------------------------------------------
+# Fixtures
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture
+def conference(db):
+ return Conference.objects.create(
+ name="VoucherCon",
+ slug="vouchercon",
+ start_date=date(2027, 6, 1),
+ end_date=date(2027, 6, 3),
+ timezone="UTC",
+ )
+
+
+@pytest.fixture
+def ticket_type(conference):
+ return TicketType.objects.create(
+ conference=conference,
+ name="General",
+ slug="general",
+ price=Decimal("100.00"),
+ )
+
+
+@pytest.fixture
+def addon(conference):
+ return AddOn.objects.create(
+ conference=conference,
+ name="T-Shirt",
+ slug="tshirt",
+ price=Decimal("25.00"),
+ )
+
+
+@pytest.fixture
+def base_config(conference):
+ return VoucherBulkConfig(
+ conference=conference,
+ prefix="TEST-",
+ count=5,
+ voucher_type=Voucher.VoucherType.COMP,
+ discount_value=Decimal("0.00"),
+ max_uses=1,
+ )
+
+
+# ---------------------------------------------------------------------------
+# _generate_unique_code tests
+# ---------------------------------------------------------------------------
+
+
+class TestGenerateUniqueCode:
+ """Tests for the internal ``_generate_unique_code`` helper."""
+
+ def test_produces_code_with_prefix(self):
+ code = _generate_unique_code("SPEAKER-", set())
+ assert code.startswith("SPEAKER-")
+
+ def test_code_has_correct_length(self):
+ prefix = "PFX-"
+ code = _generate_unique_code(prefix, set())
+ random_part = code[len(prefix) :]
+ assert len(random_part) == _CODE_LENGTH
+
+ def test_code_is_uppercase_alphanumeric(self):
+ code = _generate_unique_code("", set())
+ allowed = set(string.ascii_uppercase + string.digits)
+ assert all(c in allowed for c in code)
+
+ def test_code_with_prefix_is_uppercase_alphanumeric_in_random_part(self):
+ prefix = "TEST-"
+ code = _generate_unique_code(prefix, set())
+ random_part = code[len(prefix) :]
+ allowed = set(string.ascii_uppercase + string.digits)
+ assert all(c in allowed for c in random_part)
+
+ def test_avoids_existing_codes(self):
+ existing = set()
+ codes = []
+ for _ in range(50):
+ code = _generate_unique_code("", existing)
+ assert code not in existing
+ existing.add(code)
+ codes.append(code)
+ assert len(set(codes)) == 50
+
+ def test_raises_runtime_error_on_exhaustion(self):
+ with patch(
+ "django_program.registration.services.voucher_service.secrets.choice",
+ return_value="A",
+ ):
+ existing = {"A" * _CODE_LENGTH}
+ with pytest.raises(RuntimeError, match="Failed to generate a unique voucher code"):
+ _generate_unique_code("", existing)
+
+ def test_empty_prefix_works(self):
+ code = _generate_unique_code("", set())
+ assert len(code) == _CODE_LENGTH
+
+
+# ---------------------------------------------------------------------------
+# generate_voucher_codes tests
+# ---------------------------------------------------------------------------
+
+
+class TestGenerateVoucherCodes:
+ """Tests for the public ``generate_voucher_codes`` function."""
+
+ def test_creates_correct_number_of_vouchers(self, base_config):
+ created = generate_voucher_codes(base_config)
+ assert len(created) == 5
+ assert Voucher.objects.filter(conference=base_config.conference).count() == 5
+
+ def test_generated_codes_have_correct_prefix(self, base_config):
+ created = generate_voucher_codes(base_config)
+ for voucher in created:
+ assert voucher.code.startswith("TEST-")
+
+ def test_generated_codes_are_uppercase_alphanumeric(self, base_config):
+ created = generate_voucher_codes(base_config)
+ allowed = set(string.ascii_uppercase + string.digits)
+ for voucher in created:
+ random_part = voucher.code[len("TEST-") :]
+ assert all(c in allowed for c in random_part)
+
+ def test_all_codes_are_unique(self, base_config):
+ base_config.count = 50
+ created = generate_voucher_codes(base_config)
+ codes = [v.code for v in created]
+ assert len(set(codes)) == 50
+
+ def test_voucher_fields_match_config(self, conference):
+ config = VoucherBulkConfig(
+ conference=conference,
+ prefix="DISC-",
+ count=2,
+ voucher_type=Voucher.VoucherType.PERCENTAGE,
+ discount_value=Decimal("25.00"),
+ max_uses=3,
+ unlocks_hidden_tickets=True,
+ )
+ created = generate_voucher_codes(config)
+ for v in created:
+ assert v.voucher_type == Voucher.VoucherType.PERCENTAGE
+ assert v.discount_value == Decimal("25.00")
+ assert v.max_uses == 3
+ assert v.unlocks_hidden_tickets is True
+
+ def test_m2m_ticket_types_set_correctly(self, base_config, ticket_type):
+ base_config.applicable_ticket_types = TicketType.objects.filter(pk=ticket_type.pk)
+ created = generate_voucher_codes(base_config)
+ for v in created:
+ assert ticket_type in v.applicable_ticket_types.all()
+
+ def test_m2m_addons_set_correctly(self, base_config, addon):
+ base_config.applicable_addons = AddOn.objects.filter(pk=addon.pk)
+ created = generate_voucher_codes(base_config)
+ for v in created:
+ assert addon in v.applicable_addons.all()
+
+ def test_m2m_both_ticket_types_and_addons(self, base_config, ticket_type, addon):
+ base_config.applicable_ticket_types = TicketType.objects.filter(pk=ticket_type.pk)
+ base_config.applicable_addons = AddOn.objects.filter(pk=addon.pk)
+ created = generate_voucher_codes(base_config)
+ for v in created:
+ assert list(v.applicable_ticket_types.all()) == [ticket_type]
+ assert list(v.applicable_addons.all()) == [addon]
+
+ def test_m2m_empty_querysets_are_not_set(self, base_config):
+ base_config.applicable_ticket_types = TicketType.objects.none()
+ base_config.applicable_addons = AddOn.objects.none()
+ created = generate_voucher_codes(base_config)
+ for v in created:
+ assert v.applicable_ticket_types.count() == 0
+ assert v.applicable_addons.count() == 0
+
+ def test_m2m_none_querysets_are_not_set(self, base_config):
+ base_config.applicable_ticket_types = None
+ base_config.applicable_addons = None
+ created = generate_voucher_codes(base_config)
+ for v in created:
+ assert v.applicable_ticket_types.count() == 0
+ assert v.applicable_addons.count() == 0
+
+ def test_duplicate_code_prevention(self, conference):
+ Voucher.objects.create(
+ conference=conference,
+ code="EXIST-AAAAAAAA",
+ voucher_type=Voucher.VoucherType.COMP,
+ discount_value=Decimal("0.00"),
+ )
+ config = VoucherBulkConfig(
+ conference=conference,
+ prefix="EXIST-",
+ count=3,
+ voucher_type=Voucher.VoucherType.COMP,
+ discount_value=Decimal("0.00"),
+ )
+ created = generate_voucher_codes(config)
+ assert len(created) == 3
+ all_codes = list(Voucher.objects.filter(conference=conference).values_list("code", flat=True))
+ assert len(set(all_codes)) == 4 # 1 existing + 3 new
+
+ def test_generated_codes_use_specified_prefix(self, conference):
+ """Verify generated codes start with the configured prefix."""
+ Voucher.objects.create(
+ conference=conference,
+ code="OTHER-12345678",
+ voucher_type=Voucher.VoucherType.COMP,
+ discount_value=Decimal("0.00"),
+ )
+ config = VoucherBulkConfig(
+ conference=conference,
+ prefix="NEW-",
+ count=2,
+ voucher_type=Voucher.VoucherType.COMP,
+ discount_value=Decimal("0.00"),
+ )
+ created = generate_voucher_codes(config)
+ assert len(created) == 2
+ for v in created:
+ assert v.code.startswith("NEW-")
+
+ def test_count_zero_raises_value_error(self, conference):
+ config = VoucherBulkConfig(
+ conference=conference,
+ prefix="BAD-",
+ count=0,
+ voucher_type=Voucher.VoucherType.COMP,
+ discount_value=Decimal("0.00"),
+ )
+ with pytest.raises(ValueError, match="count must be between 1 and 500"):
+ generate_voucher_codes(config)
+
+ def test_count_negative_raises_value_error(self, conference):
+ config = VoucherBulkConfig(
+ conference=conference,
+ prefix="BAD-",
+ count=-1,
+ voucher_type=Voucher.VoucherType.COMP,
+ discount_value=Decimal("0.00"),
+ )
+ with pytest.raises(ValueError, match="count must be between 1 and 500"):
+ generate_voucher_codes(config)
+
+ def test_count_over_500_raises_value_error(self, conference):
+ config = VoucherBulkConfig(
+ conference=conference,
+ prefix="BAD-",
+ count=501,
+ voucher_type=Voucher.VoucherType.COMP,
+ discount_value=Decimal("0.00"),
+ )
+ with pytest.raises(ValueError, match="count must be between 1 and 500"):
+ generate_voucher_codes(config)
+
+ def test_count_exactly_1_succeeds(self, conference):
+ config = VoucherBulkConfig(
+ conference=conference,
+ prefix="ONE-",
+ count=1,
+ voucher_type=Voucher.VoucherType.COMP,
+ discount_value=Decimal("0.00"),
+ )
+ created = generate_voucher_codes(config)
+ assert len(created) == 1
+
+ def test_count_exactly_500_succeeds(self, conference):
+ config = VoucherBulkConfig(
+ conference=conference,
+ prefix="MAX-",
+ count=500,
+ voucher_type=Voucher.VoucherType.COMP,
+ discount_value=Decimal("0.00"),
+ )
+ created = generate_voucher_codes(config)
+ assert len(created) == 500
+
+ def test_valid_from_and_until_propagated(self, conference):
+ now = timezone.now()
+ config = VoucherBulkConfig(
+ conference=conference,
+ prefix="DATE-",
+ count=2,
+ voucher_type=Voucher.VoucherType.PERCENTAGE,
+ discount_value=Decimal("10.00"),
+ valid_from=now,
+ valid_until=now,
+ )
+ created = generate_voucher_codes(config)
+ for v in created:
+ assert v.valid_from == now
+ assert v.valid_until == now
+
+ def test_empty_prefix(self, conference):
+ config = VoucherBulkConfig(
+ conference=conference,
+ prefix="",
+ count=3,
+ voucher_type=Voucher.VoucherType.COMP,
+ discount_value=Decimal("0.00"),
+ )
+ created = generate_voucher_codes(config)
+ assert len(created) == 3
+ for v in created:
+ assert len(v.code) == _CODE_LENGTH
+
+
+# ---------------------------------------------------------------------------
+# VoucherBulkConfig dataclass tests
+# ---------------------------------------------------------------------------
+
+
+class TestVoucherBulkConfig:
+ """Tests for the ``VoucherBulkConfig`` dataclass defaults."""
+
+ def test_defaults(self, conference):
+ config = VoucherBulkConfig(
+ conference=conference,
+ prefix="X-",
+ count=1,
+ voucher_type="comp",
+ discount_value=Decimal("0.00"),
+ )
+ assert config.max_uses == 1
+ assert config.valid_from is None
+ assert config.valid_until is None
+ assert config.unlocks_hidden_tickets is False
+ assert config.applicable_ticket_types is None
+ assert config.applicable_addons is None