-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add voucher bulk code generation #26
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
d169105
feat: add voucher bulk code generation
JacobCoffee 89058a9
feat: add bulk generate button to voucher list page
JacobCoffee 1278dd1
fix: address PR review feedback and add tests for voucher bulk genera…
JacobCoffee 852371d
Update src/django_program/manage/views_vouchers.py
JacobCoffee 717fbb1
fix: address PR #26 review comments (error redirect, PK safety, test …
JacobCoffee File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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.", | ||
| ) |
46 changes: 46 additions & 0 deletions
46
src/django_program/manage/templates/django_program/manage/voucher_bulk_generate.html
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 %} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"), | ||
| ] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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.") | ||
JacobCoffee marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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
162
src/django_program/registration/services/voucher_service.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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: | ||
JacobCoffee marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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.). | ||
JacobCoffee marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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 | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.