From d16910519b3d036045946ed9387934665829ad2c Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Fri, 13 Feb 2026 19:08:03 -0600 Subject: [PATCH 1/5] feat: add voucher bulk code generation --- src/django_program/manage/forms_vouchers.py | 71 +++++++++ .../manage/voucher_bulk_generate.html | 46 ++++++ src/django_program/manage/urls.py | 4 +- src/django_program/manage/urls_vouchers.py | 13 ++ src/django_program/manage/views_vouchers.py | 75 ++++++++++ .../registration/services/voucher_service.py | 136 ++++++++++++++++++ 6 files changed, 344 insertions(+), 1 deletion(-) create mode 100644 src/django_program/manage/forms_vouchers.py create mode 100644 src/django_program/manage/templates/django_program/manage/voucher_bulk_generate.html create mode 100644 src/django_program/manage/urls_vouchers.py create mode 100644 src/django_program/manage/views_vouchers.py create mode 100644 src/django_program/registration/services/voucher_service.py 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 %} +
+
+ {% csrf_token %} + {% for field in form %} +
+ + {{ field }} + {% if field.help_text %} + {{ field.help_text }} + {% endif %} + {% if field.errors %} +
    + {% for error in field.errors %} +
  • {{ error }}
  • + {% endfor %} +
+ {% endif %} +
+ {% endfor %} +
+ + Cancel +
+
+
+{% endblock %} 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..babb31b --- /dev/null +++ b/src/django_program/manage/views_vouchers.py @@ -0,0 +1,75 @@ +"""Views for voucher bulk operations in the management dashboard.""" + +import logging +from typing import TYPE_CHECKING + +from django.contrib import messages +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: + logger.exception("Voucher bulk generation failed") + messages.error(self.request, "Failed to generate voucher codes. Please try again.") + + 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..d67ddbd --- /dev/null +++ b/src/django_program/registration/services/voucher_service.py @@ -0,0 +1,136 @@ +"""Voucher bulk generation service. + +Provides functions for generating batches of unique, cryptographically +random voucher codes within a single database transaction. +""" + +import secrets +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 + + +@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 is derived from ``secrets.token_urlsafe(6)`` (8 URL-safe characters). + 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): + code = f"{prefix}{secrets.token_urlsafe(6)}" + 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. + + Args: + config: Bulk generation configuration specifying the conference, prefix, + count, discount parameters, and optional constraints. + + Returns: + List of newly created ``Voucher`` instances. + + Raises: + 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). + """ + existing_codes: set[str] = set(Voucher.objects.filter(conference=config.conference).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) + + 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)) + for voucher in created: + voucher.applicable_ticket_types.set(ticket_type_ids) + + if config.applicable_addons is not None and config.applicable_addons.exists(): + addon_ids = list(config.applicable_addons.values_list("pk", flat=True)) + for voucher in created: + voucher.applicable_addons.set(addon_ids) + + return created From 89058a98139daeb8a452790f78f16d299773c941 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Fri, 13 Feb 2026 19:18:53 -0600 Subject: [PATCH 2/5] feat: add bulk generate button to voucher list page --- .../manage/templates/django_program/manage/voucher_list.html | 1 + 1 file changed, 1 insertion(+) 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 %} From 1278dd1d2bb3d99c7b84c04293a9418bbcef5503 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Fri, 13 Feb 2026 19:33:44 -0600 Subject: [PATCH 3/5] fix: address PR review feedback and add tests for voucher bulk generation Co-Authored-By: Claude Opus 4.6 --- src/django_program/manage/views_vouchers.py | 3 +- .../registration/services/voucher_service.py | 39 +- tests/test_manage/test_programs_views.py | 8 + tests/test_manage/test_voucher_bulk_views.py | 319 ++++++++++++++++ .../test_registration/test_voucher_service.py | 351 ++++++++++++++++++ 5 files changed, 711 insertions(+), 9 deletions(-) create mode 100644 tests/test_manage/test_voucher_bulk_views.py create mode 100644 tests/test_registration/test_voucher_service.py diff --git a/src/django_program/manage/views_vouchers.py b/src/django_program/manage/views_vouchers.py index babb31b..0ee5bb9 100644 --- a/src/django_program/manage/views_vouchers.py +++ b/src/django_program/manage/views_vouchers.py @@ -4,6 +4,7 @@ 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 @@ -64,7 +65,7 @@ def form_valid(self, form: VoucherBulkGenerateForm) -> HttpResponse: self.request, f"Successfully generated {len(created)} voucher codes with prefix '{data['prefix']}'.", ) - except RuntimeError: + except (RuntimeError, IntegrityError): # fmt: skip logger.exception("Voucher bulk generation failed") messages.error(self.request, "Failed to generate voucher codes. Please try again.") diff --git a/src/django_program/registration/services/voucher_service.py b/src/django_program/registration/services/voucher_service.py index d67ddbd..49ca4ea 100644 --- a/src/django_program/registration/services/voucher_service.py +++ b/src/django_program/registration/services/voucher_service.py @@ -5,6 +5,7 @@ """ import secrets +import string from dataclasses import dataclass, field from typing import TYPE_CHECKING @@ -21,6 +22,10 @@ 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: @@ -60,7 +65,7 @@ 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 is derived from ``secrets.token_urlsafe(6)`` (8 URL-safe characters). + portion uses uppercase alphanumeric characters (A-Z, 0-9) for readability. Retries up to 100 times if a collision is detected. Args: @@ -74,7 +79,8 @@ def _generate_unique_code(prefix: str, existing_codes: set[str]) -> str: RuntimeError: If a unique code cannot be generated after 100 attempts. """ for _ in range(100): - code = f"{prefix}{secrets.token_urlsafe(6)}" + 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" @@ -87,7 +93,8 @@ def generate_voucher_codes(config: VoucherBulkConfig) -> list[Voucher]: 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. + 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, @@ -97,11 +104,19 @@ def generate_voucher_codes(config: VoucherBulkConfig) -> list[Voucher]: 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). """ - existing_codes: set[str] = set(Voucher.objects.filter(conference=config.conference).values_list("code", flat=True)) + 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): @@ -125,12 +140,20 @@ def generate_voucher_codes(config: VoucherBulkConfig) -> list[Voucher]: 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)) - for voucher in created: - voucher.applicable_ticket_types.set(ticket_type_ids) + 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)) - for voucher in created: - voucher.applicable_addons.set(addon_ids) + 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..eee56a0 --- /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_prefix_filter_optimization(self, conference): + """Verify the existing code query filters by prefix when one is given.""" + 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 From 852371d00e2b761707b58ab8b0a5fbd8bc6de44f Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Fri, 13 Feb 2026 20:07:38 -0600 Subject: [PATCH 4/5] Update src/django_program/manage/views_vouchers.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/django_program/manage/views_vouchers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/django_program/manage/views_vouchers.py b/src/django_program/manage/views_vouchers.py index 0ee5bb9..de125ec 100644 --- a/src/django_program/manage/views_vouchers.py +++ b/src/django_program/manage/views_vouchers.py @@ -68,6 +68,7 @@ def form_valid(self, form: VoucherBulkGenerateForm) -> HttpResponse: 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) From 717fbb145ee35b39f05aadf6f63cf361d6a2dafb Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Fri, 13 Feb 2026 21:54:38 -0600 Subject: [PATCH 5/5] fix: address PR #26 review comments (error redirect, PK safety, test rename) - Return form_invalid on voucher generation failure instead of redirecting - Re-fetch vouchers after bulk_create to guarantee PKs on all backends - Rename test_prefix_filter_optimization to match its actual assertion Note: PR description mentioned secrets.token_urlsafe but code correctly uses secrets.choice with uppercase A-Z/0-9 alphabet (from earlier iteration). Co-Authored-By: Claude Opus 4.6 --- src/django_program/registration/services/voucher_service.py | 3 +++ tests/test_registration/test_voucher_service.py | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/django_program/registration/services/voucher_service.py b/src/django_program/registration/services/voucher_service.py index 49ca4ea..e686c50 100644 --- a/src/django_program/registration/services/voucher_service.py +++ b/src/django_program/registration/services/voucher_service.py @@ -137,6 +137,9 @@ def generate_voucher_codes(config: VoucherBulkConfig) -> list[Voucher]: 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)) diff --git a/tests/test_registration/test_voucher_service.py b/tests/test_registration/test_voucher_service.py index eee56a0..ecf4297 100644 --- a/tests/test_registration/test_voucher_service.py +++ b/tests/test_registration/test_voucher_service.py @@ -222,8 +222,8 @@ def test_duplicate_code_prevention(self, conference): 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_prefix_filter_optimization(self, conference): - """Verify the existing code query filters by prefix when one is given.""" + 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",