diff --git a/conference.example.toml b/conference.example.toml index 6d563ca..72f9cd0 100644 --- a/conference.example.toml +++ b/conference.example.toml @@ -6,6 +6,7 @@ timezone = "America/New_York" venue = "David L. Lawrence Convention Center, Pittsburgh PA" pretalx_event_slug = "pycon-us-2027" website_url = "https://us.pycon.org/2027/" +total_capacity = 2500 [[conference.sections]] name = "Tutorials" diff --git a/src/django_program/conference/admin.py b/src/django_program/conference/admin.py index 1d1d403..7afec2f 100644 --- a/src/django_program/conference/admin.py +++ b/src/django_program/conference/admin.py @@ -106,7 +106,7 @@ class ConferenceAdmin(admin.ModelAdmin): ( None, { - "fields": ("name", "slug", "venue", "address", "website_url"), + "fields": ("name", "slug", "venue", "address", "website_url", "total_capacity"), }, ), ( diff --git a/src/django_program/conference/management/commands/bootstrap_conference.py b/src/django_program/conference/management/commands/bootstrap_conference.py index 793a2cc..d168000 100644 --- a/src/django_program/conference/management/commands/bootstrap_conference.py +++ b/src/django_program/conference/management/commands/bootstrap_conference.py @@ -41,6 +41,7 @@ "venue": "venue", "website_url": "website_url", "pretalx_event_slug": "pretalx_event_slug", + "total_capacity": "total_capacity", } _SECTION_FIELD_MAP: dict[str, str] = { diff --git a/src/django_program/conference/migrations/0004_add_total_capacity.py b/src/django_program/conference/migrations/0004_add_total_capacity.py new file mode 100644 index 0000000..6d5be36 --- /dev/null +++ b/src/django_program/conference/migrations/0004_add_total_capacity.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.11 on 2026-02-14 03:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("program_conference", "0003_conference_address"), + ] + + operations = [ + migrations.AddField( + model_name="conference", + name="total_capacity", + field=models.PositiveIntegerField( + default=0, help_text="Maximum total tickets across all types. 0 means unlimited." + ), + ), + ] diff --git a/src/django_program/conference/models.py b/src/django_program/conference/models.py index c37dbe8..a0b60f3 100644 --- a/src/django_program/conference/models.py +++ b/src/django_program/conference/models.py @@ -26,6 +26,11 @@ class Conference(models.Model): stripe_publishable_key = EncryptedCharField(max_length=200, blank=True, null=True, default=None) stripe_webhook_secret = EncryptedCharField(max_length=200, blank=True, null=True, default=None) + total_capacity = models.PositiveIntegerField( + default=0, + help_text="Maximum total tickets across all types. 0 means unlimited.", + ) + is_active = models.BooleanField(default=True) created_at = models.DateTimeField(auto_now_add=True) diff --git a/src/django_program/manage/forms.py b/src/django_program/manage/forms.py index 686ea76..03e601d 100644 --- a/src/django_program/manage/forms.py +++ b/src/django_program/manage/forms.py @@ -80,6 +80,7 @@ class Meta: "address", "website_url", "pretalx_event_slug", + "total_capacity", "is_active", ] widgets = { diff --git a/src/django_program/manage/templates/django_program/manage/conference_edit.html b/src/django_program/manage/templates/django_program/manage/conference_edit.html index ab7b5ec..63ff1ac 100644 --- a/src/django_program/manage/templates/django_program/manage/conference_edit.html +++ b/src/django_program/manage/templates/django_program/manage/conference_edit.html @@ -136,6 +136,18 @@

Conference Settings

+
+
Capacity
+
+
+ + {{ form.total_capacity }} + {% if form.total_capacity.help_text %}{{ form.total_capacity.help_text }}{% endif %} + {% for error in form.total_capacity.errors %}
  • {{ error }}
{% endfor %} +
+
+
+
Integrations & Status
diff --git a/src/django_program/manage/templates/django_program/manage/ticket_type_list.html b/src/django_program/manage/templates/django_program/manage/ticket_type_list.html index 0926523..974a7ae 100644 --- a/src/django_program/manage/templates/django_program/manage/ticket_type_list.html +++ b/src/django_program/manage/templates/django_program/manage/ticket_type_list.html @@ -12,6 +12,11 @@

Ticket Types

{% endblock %} {% block content %} +{% if global_capacity %} +
+ {{ global_sold }} / {{ global_capacity }} tickets sold (venue capacity) +
+{% endif %} {% if ticket_types %} diff --git a/src/django_program/manage/views.py b/src/django_program/manage/views.py index 47746e4..a976cad 100644 --- a/src/django_program/manage/views.py +++ b/src/django_program/manage/views.py @@ -54,6 +54,7 @@ from django_program.pretalx.sync import PretalxSyncService from django_program.programs.models import Activity, ActivitySignup, Receipt, TravelGrant, TravelGrantMessage from django_program.registration.models import AddOn, Order, Payment, TicketType, Voucher +from django_program.registration.services.capacity import get_global_sold_count from django_program.settings import get_config from django_program.sponsors.models import Sponsor, SponsorLevel from django_program.sponsors.profiles.resolver import resolve_sponsor_profile @@ -2410,9 +2411,12 @@ class TicketTypeListView(ManagePermissionMixin, ListView): paginate_by = 50 def get_context_data(self, **kwargs: object) -> dict[str, object]: - """Add ``active_nav`` to the template context.""" + """Add ``active_nav`` and global capacity info to the template context.""" context = super().get_context_data(**kwargs) context["active_nav"] = "ticket-types" + if self.conference.total_capacity > 0: + context["global_capacity"] = self.conference.total_capacity + context["global_sold"] = get_global_sold_count(self.conference) return context def get_queryset(self) -> QuerySet[TicketType]: diff --git a/src/django_program/registration/services/capacity.py b/src/django_program/registration/services/capacity.py new file mode 100644 index 0000000..78eb2bc --- /dev/null +++ b/src/django_program/registration/services/capacity.py @@ -0,0 +1,94 @@ +"""Global ticket capacity enforcement for conferences. + +Provides functions to count, check, and validate total ticket sales against +a conference-level capacity limit. Add-ons are excluded from the global count +because they do not consume venue seats. +""" + +from django.core.exceptions import ValidationError +from django.db import models +from django.utils import timezone + +from django_program.conference.models import Conference +from django_program.registration.models import Order, OrderLineItem + + +def get_global_sold_count(conference: object) -> int: + """Return the total number of tickets sold across all ticket types. + + Counts OrderLineItem quantities for ticket-type items (not add-ons) in + orders that are PAID, PARTIALLY_REFUNDED, or PENDING with an active + inventory hold. + + Uses ``addon__isnull=True`` rather than ``ticket_type__isnull=False`` so + that line items whose ticket type was deleted (SET_NULL) are still counted + toward the sold total, preventing oversells. + + Args: + conference: The conference to count sales for. + + Returns: + The total number of tickets sold. + """ + now = timezone.now() + return ( + OrderLineItem.objects.filter( + order__conference=conference, + addon__isnull=True, + ) + .filter( + models.Q(order__status__in=[Order.Status.PAID, Order.Status.PARTIALLY_REFUNDED]) + | models.Q(order__status=Order.Status.PENDING, order__hold_expires_at__gt=now), + ) + .aggregate(total=models.Sum("quantity"))["total"] + or 0 + ) + + +def get_global_remaining(conference: object) -> int | None: + """Return the number of tickets still available under the global cap. + + Args: + conference: The conference to check capacity for. + + Returns: + The remaining ticket count, or ``None`` if the conference has no + global capacity limit (``total_capacity == 0``). + """ + if conference.total_capacity == 0: + return None + sold = get_global_sold_count(conference) + return conference.total_capacity - sold + + +def validate_global_capacity(conference: object, desired_total: int) -> None: + """Raise ``ValidationError`` if ``desired_total`` would exceed global capacity. + + Acquires a row-level lock on the conference via ``select_for_update()`` to + prevent race conditions when multiple concurrent requests validate capacity + at the same time. The caller **must** already be inside a + ``transaction.atomic`` block. + + The early-return check for unlimited conferences happens **after** the lock + is acquired so that a stale in-memory instance cannot bypass enforcement. + + Args: + conference: The conference to validate against. + desired_total: The total number of ticket items in the cart + (across all ticket types, excluding add-ons). + + Raises: + ValidationError: If the desired total exceeds the conference's + ``total_capacity``. + """ + locked = Conference.objects.select_for_update().get(pk=conference.pk) + if locked.total_capacity == 0: + return + sold = get_global_sold_count(locked) + remaining = locked.total_capacity - sold + if desired_total > remaining: + if remaining <= 0: + raise ValidationError(f"This conference is sold out (venue capacity: {locked.total_capacity}).") + raise ValidationError( + f"Only {remaining} tickets remaining for this conference (venue capacity: {locked.total_capacity})." + ) diff --git a/src/django_program/registration/services/cart.py b/src/django_program/registration/services/cart.py index a5baeb3..6a97be8 100644 --- a/src/django_program/registration/services/cart.py +++ b/src/django_program/registration/services/cart.py @@ -22,6 +22,7 @@ TicketType, Voucher, ) +from django_program.registration.services.capacity import validate_global_capacity from django_program.settings import get_config @@ -131,6 +132,11 @@ def add_ticket(cart: Cart, ticket_type: TicketType, qty: int = 1) -> CartItem: existing_in_orders=existing_in_orders, ) + validate_global_capacity( + cart.conference, + _get_cart_total_ticket_qty(cart) + qty, + ) + if ticket_type.requires_voucher: voucher = cart.voucher if voucher is None or not voucher.unlocks_hidden_tickets: @@ -666,6 +672,9 @@ def _validate_ticket_quantity(cart: Cart, ticket_type: TicketType, new_qty: int) f"{ticket_type.limit_per_user} for '{ticket_type.name}'." ) + other_ticket_qty = _get_cart_total_ticket_qty(cart, exclude_ticket_type=ticket_type) + validate_global_capacity(cart.conference, other_ticket_qty + new_qty) + def _validate_addon_quantity(addon: AddOn, new_qty: int) -> None: """Validate remaining stock for a new add-on quantity.""" @@ -708,6 +717,24 @@ def _cascade_remove_orphaned_addons(cart: Cart, removing_ticket_type_id: int) -> addon_item.delete() +def _get_cart_total_ticket_qty(cart: Cart, *, exclude_ticket_type: TicketType | None = None) -> int: + """Return the total ticket quantity in the cart (excluding add-ons). + + Args: + cart: The cart to sum ticket items from. + exclude_ticket_type: Optionally exclude items of this ticket type + from the sum (used by ``update_quantity`` to compute the total + of *other* tickets before adding the new quantity). + + Returns: + The total number of ticket items in the cart. + """ + qs = cart.items.filter(ticket_type__isnull=False) + if exclude_ticket_type is not None: + qs = qs.exclude(ticket_type=exclude_ticket_type) + return qs.aggregate(total=models.Sum("quantity"))["total"] or 0 + + def _item_is_voucher_applicable( item: CartItem, applicable_ticket_ids: set[int] | None, diff --git a/src/django_program/registration/services/checkout.py b/src/django_program/registration/services/checkout.py index b81c103..2b95f01 100644 --- a/src/django_program/registration/services/checkout.py +++ b/src/django_program/registration/services/checkout.py @@ -22,6 +22,7 @@ Payment, Voucher, ) +from django_program.registration.services.capacity import validate_global_capacity from django_program.registration.services.cart import get_summary_from_items from django_program.registration.signals import order_paid from django_program.settings import get_config @@ -100,7 +101,7 @@ def checkout( if not items: raise ValidationError("Cannot check out an empty cart.") - _revalidate_stock(items) + _revalidate_stock(items, cart.conference) summary = get_summary_from_items(cart, items) @@ -339,9 +340,13 @@ def _increment_voucher_usage(*, voucher: Voucher | None, now: object) -> None: raise ValidationError(f"Voucher code '{voucher.code}' is no longer valid.") -def _revalidate_stock(items: list[object]) -> None: +def _revalidate_stock(items: list[object], conference: object) -> None: """Re-validate stock availability for all cart items at checkout time. + Args: + items: Pre-fetched cart items to validate. + conference: The conference these items belong to. + Raises: ValidationError: If any item has insufficient stock or missing prerequisites. """ @@ -353,6 +358,28 @@ def _revalidate_stock(items: list[object]) -> None: elif item.addon is not None: _revalidate_addon_stock(item, now, ticket_type_ids) + _revalidate_global_capacity(items, conference) + + +def _revalidate_global_capacity(items: list[object], conference: object) -> None: + """Validate that checkout ticket quantities fit within the global cap. + + Sums ticket quantities from the cart items and validates against the + conference's ``total_capacity``. + + Args: + items: Pre-fetched cart items from the checkout flow. + conference: The conference to validate capacity against. + + Raises: + ValidationError: If the total tickets would exceed the global cap. + """ + ticket_items = [item for item in items if item.ticket_type_id is not None] + if not ticket_items: + return + total_qty = sum(item.quantity for item in ticket_items) + validate_global_capacity(conference, total_qty) + def _revalidate_ticket_stock(item: object) -> None: """Validate a ticket type is still available with sufficient stock.""" diff --git a/tests/test_conference/test_bootstrap_command.py b/tests/test_conference/test_bootstrap_command.py index d7b5a37..15cc72e 100644 --- a/tests/test_conference/test_bootstrap_command.py +++ b/tests/test_conference/test_bootstrap_command.py @@ -448,3 +448,50 @@ def test_bootstrap_deferred_keys_prints_notice(tmp_path): assert "Skipping 'sponsor_levels'" in output assert "sponsors app" in output + + +@pytest.mark.django_db +def test_bootstrap_sets_total_capacity(tmp_path): + config_path = _write_config( + tmp_path / "capacity.toml", + """[conference] +name = "PyCon Capacity Test" +start = 2027-05-01 +end = 2027-05-03 +timezone = "UTC" +total_capacity = 1000 + +[[conference.sections]] +name = "Talks" +start = 2027-05-02 +end = 2027-05-02 +""", + ) + + call_command("bootstrap_conference", config=config_path) + + conference = Conference.objects.get(slug="pycon-capacity-test") + assert conference.total_capacity == 1000 + + +@pytest.mark.django_db +def test_bootstrap_without_total_capacity_defaults_to_zero(tmp_path): + config_path = _write_config( + tmp_path / "no_capacity.toml", + """[conference] +name = "PyCon No Cap" +start = 2027-05-01 +end = 2027-05-03 +timezone = "UTC" + +[[conference.sections]] +name = "Talks" +start = 2027-05-02 +end = 2027-05-02 +""", + ) + + call_command("bootstrap_conference", config=config_path) + + conference = Conference.objects.get(slug="pycon-no-cap") + assert conference.total_capacity == 0 diff --git a/tests/test_manage/test_forms.py b/tests/test_manage/test_forms.py index bcf40b6..f3bfdb2 100644 --- a/tests/test_manage/test_forms.py +++ b/tests/test_manage/test_forms.py @@ -63,6 +63,7 @@ def test_meta_fields(self): "address", "website_url", "pretalx_event_slug", + "total_capacity", "is_active", ] assert list(form.fields.keys()) == expected @@ -74,6 +75,7 @@ def test_valid_data(self): "start_date": "2027-05-01", "end_date": "2027-05-03", "timezone": "UTC", + "total_capacity": 0, "is_active": True, } ) diff --git a/tests/test_manage/test_programs_views.py b/tests/test_manage/test_programs_views.py index eb9ca12..5bc6bb9 100644 --- a/tests/test_manage/test_programs_views.py +++ b/tests/test_manage/test_programs_views.py @@ -710,6 +710,14 @@ def test_activity_promote_non_waitlisted_404(authed_client: Client, conference, assert response.status_code == 404 +@pytest.mark.django_db +def test_activity_dashboard_redirects_unauthenticated_user(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 + + # ---- Activity organizers M2M ---- diff --git a/tests/test_manage/test_views.py b/tests/test_manage/test_views.py index 6717970..62fc76a 100644 --- a/tests/test_manage/test_views.py +++ b/tests/test_manage/test_views.py @@ -400,6 +400,7 @@ def test_post_valid_form(self, client_logged_in_super, conference): "start_date": "2027-05-01", "end_date": "2027-05-03", "timezone": "UTC", + "total_capacity": 0, "is_active": "on", }, ) @@ -416,6 +417,7 @@ def test_get_success_url_redirects_to_dashboard(self, client_logged_in_super, co "start_date": "2027-05-01", "end_date": "2027-05-03", "timezone": "UTC", + "total_capacity": 0, "is_active": "on", }, ) @@ -2070,6 +2072,29 @@ def test_ticket_type_edit_success_url(self, client_logged_in_super, conference, expected = reverse("manage:ticket-type-list", kwargs={"conference_slug": conference.slug}) assert resp.url == expected + def test_ticket_type_list_includes_global_capacity(self, client_logged_in_super, db): + """When total_capacity > 0, context includes capacity info.""" + capped = Conference.objects.create( + name="CappedCon", + slug="cappedcon-views", + start_date=date(2027, 6, 1), + end_date=date(2027, 6, 3), + total_capacity=100, + ) + url = reverse("manage:ticket-type-list", kwargs={"conference_slug": capped.slug}) + resp = client_logged_in_super.get(url) + assert resp.status_code == 200 + assert resp.context["global_capacity"] == 100 + assert resp.context["global_sold"] == 0 + + def test_ticket_type_list_excludes_global_capacity_when_zero(self, client_logged_in_super, conference): + """When total_capacity is 0, context does not include capacity info.""" + url = reverse("manage:ticket-type-list", kwargs={"conference_slug": conference.slug}) + resp = client_logged_in_super.get(url) + assert resp.status_code == 200 + assert "global_capacity" not in resp.context + assert "global_sold" not in resp.context + # --------------------------------------------------------------------------- # _unique_ticket_type_slug diff --git a/tests/test_registration/test_capacity.py b/tests/test_registration/test_capacity.py new file mode 100644 index 0000000..82cc6e8 --- /dev/null +++ b/tests/test_registration/test_capacity.py @@ -0,0 +1,342 @@ +"""Tests for global capacity enforcement in django_program.registration.services.capacity.""" + +from datetime import date, timedelta +from decimal import Decimal +from uuid import uuid4 + +import pytest +from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError +from django.utils import timezone + +from django_program.conference.models import Conference +from django_program.registration.models import ( + AddOn, + Order, + OrderLineItem, + TicketType, +) +from django_program.registration.services.capacity import ( + get_global_remaining, + get_global_sold_count, + validate_global_capacity, +) + +User = get_user_model() + + +def _make_order(*, conference, user, status, reference=None, hold_expires_at=None): + kwargs = { + "conference": conference, + "user": user, + "status": status, + "subtotal": Decimal("0.00"), + "total": Decimal("0.00"), + "reference": reference or f"ORD-{uuid4().hex[:8].upper()}", + } + if hold_expires_at is not None: + kwargs["hold_expires_at"] = hold_expires_at + return Order.objects.create(**kwargs) + + +@pytest.fixture +def conference(): + return Conference.objects.create( + name="CapCon", + slug="capcon", + start_date=date(2027, 6, 1), + end_date=date(2027, 6, 3), + timezone="UTC", + total_capacity=10, + ) + + +@pytest.fixture +def unlimited_conference(): + return Conference.objects.create( + name="UnlimitedCon", + slug="unlimitedcon", + start_date=date(2027, 7, 1), + end_date=date(2027, 7, 3), + timezone="UTC", + total_capacity=0, + ) + + +@pytest.fixture +def user(): + return User.objects.create_user( + username="capuser", + email="cap@example.com", + password="testpass123", + ) + + +@pytest.fixture +def ticket_type(conference): + return TicketType.objects.create( + conference=conference, + name="General", + slug="general", + price=Decimal("100.00"), + total_quantity=0, + limit_per_user=10, + is_active=True, + ) + + +@pytest.fixture +def addon(conference): + return AddOn.objects.create( + conference=conference, + name="T-Shirt", + slug="tshirt", + price=Decimal("25.00"), + total_quantity=0, + is_active=True, + ) + + +@pytest.mark.django_db +class TestGetGlobalSoldCount: + def test_empty_conference_returns_zero(self, conference): + assert get_global_sold_count(conference) == 0 + + def test_counts_paid_orders(self, conference, user, ticket_type): + order = _make_order(conference=conference, user=user, status=Order.Status.PAID) + OrderLineItem.objects.create( + order=order, + description="Ticket", + quantity=3, + unit_price=Decimal("100.00"), + line_total=Decimal("300.00"), + ticket_type=ticket_type, + ) + assert get_global_sold_count(conference) == 3 + + def test_counts_partially_refunded(self, conference, user, ticket_type): + order = _make_order(conference=conference, user=user, status=Order.Status.PARTIALLY_REFUNDED) + OrderLineItem.objects.create( + order=order, + description="Ticket", + quantity=2, + unit_price=Decimal("100.00"), + line_total=Decimal("200.00"), + ticket_type=ticket_type, + ) + assert get_global_sold_count(conference) == 2 + + def test_counts_pending_with_active_hold(self, conference, user, ticket_type): + future = timezone.now() + timedelta(minutes=30) + order = _make_order( + conference=conference, + user=user, + status=Order.Status.PENDING, + hold_expires_at=future, + ) + OrderLineItem.objects.create( + order=order, + description="Ticket", + quantity=1, + unit_price=Decimal("100.00"), + line_total=Decimal("100.00"), + ticket_type=ticket_type, + ) + assert get_global_sold_count(conference) == 1 + + def test_ignores_pending_with_expired_hold(self, conference, user, ticket_type): + past = timezone.now() - timedelta(minutes=30) + order = _make_order( + conference=conference, + user=user, + status=Order.Status.PENDING, + hold_expires_at=past, + ) + OrderLineItem.objects.create( + order=order, + description="Ticket", + quantity=5, + unit_price=Decimal("100.00"), + line_total=Decimal("500.00"), + ticket_type=ticket_type, + ) + assert get_global_sold_count(conference) == 0 + + def test_ignores_cancelled_orders(self, conference, user, ticket_type): + order = _make_order(conference=conference, user=user, status=Order.Status.CANCELLED) + OrderLineItem.objects.create( + order=order, + description="Ticket", + quantity=4, + unit_price=Decimal("100.00"), + line_total=Decimal("400.00"), + ticket_type=ticket_type, + ) + assert get_global_sold_count(conference) == 0 + + def test_ignores_refunded_orders(self, conference, user, ticket_type): + order = _make_order(conference=conference, user=user, status=Order.Status.REFUNDED) + OrderLineItem.objects.create( + order=order, + description="Ticket", + quantity=4, + unit_price=Decimal("100.00"), + line_total=Decimal("400.00"), + ticket_type=ticket_type, + ) + assert get_global_sold_count(conference) == 0 + + def test_excludes_addons(self, conference, user, addon): + order = _make_order(conference=conference, user=user, status=Order.Status.PAID) + OrderLineItem.objects.create( + order=order, + description="T-Shirt", + quantity=10, + unit_price=Decimal("25.00"), + line_total=Decimal("250.00"), + addon=addon, + ) + assert get_global_sold_count(conference) == 0 + + def test_sums_across_ticket_types(self, conference, user): + tt_a = TicketType.objects.create( + conference=conference, + name="A", + slug="a", + price=Decimal("50.00"), + total_quantity=0, + limit_per_user=10, + is_active=True, + ) + tt_b = TicketType.objects.create( + conference=conference, + name="B", + slug="b", + price=Decimal("75.00"), + total_quantity=0, + limit_per_user=10, + is_active=True, + ) + order = _make_order(conference=conference, user=user, status=Order.Status.PAID) + OrderLineItem.objects.create( + order=order, + description="A", + quantity=3, + unit_price=Decimal("50.00"), + line_total=Decimal("150.00"), + ticket_type=tt_a, + ) + OrderLineItem.objects.create( + order=order, + description="B", + quantity=4, + unit_price=Decimal("75.00"), + line_total=Decimal("300.00"), + ticket_type=tt_b, + ) + assert get_global_sold_count(conference) == 7 + + def test_counts_orphaned_line_items_after_ticket_type_deleted(self, conference, user): + """Line items whose ticket_type was deleted (SET_NULL) must still count.""" + tt = TicketType.objects.create( + conference=conference, + name="Ephemeral", + slug="ephemeral", + price=Decimal("50.00"), + total_quantity=0, + limit_per_user=10, + is_active=True, + ) + order = _make_order(conference=conference, user=user, status=Order.Status.PAID) + OrderLineItem.objects.create( + order=order, + description="Ephemeral", + quantity=4, + unit_price=Decimal("50.00"), + line_total=Decimal("200.00"), + ticket_type=tt, + ) + assert get_global_sold_count(conference) == 4 + tt.delete() + assert get_global_sold_count(conference) == 4 + + +@pytest.mark.django_db +class TestGetGlobalRemaining: + def test_no_limit_returns_none(self, unlimited_conference): + assert get_global_remaining(unlimited_conference) is None + + def test_returns_remaining_when_limit_set(self, conference, user, ticket_type): + order = _make_order(conference=conference, user=user, status=Order.Status.PAID) + OrderLineItem.objects.create( + order=order, + description="Ticket", + quantity=3, + unit_price=Decimal("100.00"), + line_total=Decimal("300.00"), + ticket_type=ticket_type, + ) + assert get_global_remaining(conference) == 7 # 10 - 3 + + def test_empty_conference_returns_full_capacity(self, conference): + assert get_global_remaining(conference) == 10 + + +@pytest.mark.django_db +class TestValidateGlobalCapacity: + def test_no_limit_bypasses_validation(self, unlimited_conference): + validate_global_capacity(unlimited_conference, 99999) + + def test_under_capacity_passes(self, conference): + validate_global_capacity(conference, 5) + + def test_at_capacity_passes(self, conference): + validate_global_capacity(conference, 10) + + def test_over_capacity_raises(self, conference): + with pytest.raises(ValidationError, match="Only 10 tickets remaining"): + validate_global_capacity(conference, 11) + + def test_over_capacity_with_existing_sales(self, conference, user, ticket_type): + order = _make_order(conference=conference, user=user, status=Order.Status.PAID) + OrderLineItem.objects.create( + order=order, + description="Ticket", + quantity=8, + unit_price=Decimal("100.00"), + line_total=Decimal("800.00"), + ticket_type=ticket_type, + ) + validate_global_capacity(conference, 2) # 8 sold + 2 new = 10 + + with pytest.raises(ValidationError, match="Only 2 tickets remaining"): + validate_global_capacity(conference, 3) + + def test_sold_out_message_when_at_capacity(self, conference, user, ticket_type): + """When remaining is exactly 0, the error should say 'sold out'.""" + order = _make_order(conference=conference, user=user, status=Order.Status.PAID) + OrderLineItem.objects.create( + order=order, + description="Ticket", + quantity=10, + unit_price=Decimal("100.00"), + line_total=Decimal("1000.00"), + ticket_type=ticket_type, + ) + with pytest.raises(ValidationError, match="sold out"): + validate_global_capacity(conference, 1) + + def test_sold_out_message_when_oversold(self, conference, user, ticket_type): + """When capacity is lowered below current sales, the error should say 'sold out'.""" + order = _make_order(conference=conference, user=user, status=Order.Status.PAID) + OrderLineItem.objects.create( + order=order, + description="Ticket", + quantity=12, + unit_price=Decimal("100.00"), + line_total=Decimal("1200.00"), + ticket_type=ticket_type, + ) + # remaining is negative (-2), should show "sold out" not "-2 tickets remaining" + with pytest.raises(ValidationError, match="sold out"): + validate_global_capacity(conference, 1) diff --git a/tests/test_registration/test_cart_service.py b/tests/test_registration/test_cart_service.py index 97d15f7..6fdf2ba 100644 --- a/tests/test_registration/test_cart_service.py +++ b/tests/test_registration/test_cart_service.py @@ -1297,3 +1297,98 @@ def test_apply_voucher_discounts_unknown_type(self, cart, ticket_type, conferenc ) result = _apply_voucher_discounts(voucher, [line], [(0, Decimal("100.00"))]) assert result == Decimal("0.00") + + +# ============================================================================= +# TestGlobalCapacityCartIntegration +# ============================================================================= + + +@pytest.mark.django_db +class TestGlobalCapacityCartIntegration: + """Tests that add_ticket and update_quantity enforce global capacity.""" + + @pytest.fixture + def capped_conference(self): + return Conference.objects.create( + name="CappedCon", + slug="cappedcon-cart", + start_date=date(2027, 6, 1), + end_date=date(2027, 6, 3), + timezone="UTC", + total_capacity=5, + ) + + @pytest.fixture + def capped_ticket(self, capped_conference): + return TicketType.objects.create( + conference=capped_conference, + name="General", + slug="general", + price=Decimal("100.00"), + total_quantity=0, + limit_per_user=10, + is_active=True, + ) + + @pytest.fixture + def capped_cart(self, capped_conference, user): + return Cart.objects.create( + user=user, + conference=capped_conference, + status=Cart.Status.OPEN, + expires_at=timezone.now() + timedelta(minutes=30), + ) + + def test_add_ticket_respects_global_cap(self, capped_cart, capped_ticket): + add_ticket(capped_cart, capped_ticket, qty=5) + + with pytest.raises(ValidationError, match="tickets remaining for this conference"): + add_ticket(capped_cart, capped_ticket, qty=1) + + def test_add_ticket_bypasses_when_no_cap(self, cart, ticket_type): + # conference fixture has total_capacity=0 (default, no limit) + add_ticket(cart, ticket_type, qty=10) + assert cart.items.first().quantity == 10 + + def test_update_quantity_respects_global_cap(self, capped_cart, capped_ticket): + item = add_ticket(capped_cart, capped_ticket, qty=3) + + with pytest.raises(ValidationError, match="tickets remaining for this conference"): + update_quantity(capped_cart, item.pk, 6) + + def test_update_quantity_within_cap_succeeds(self, capped_cart, capped_ticket): + item = add_ticket(capped_cart, capped_ticket, qty=3) + result = update_quantity(capped_cart, item.pk, 5) + assert result.quantity == 5 + + def test_global_cap_with_multiple_ticket_types(self, capped_conference, user): + tt_a = TicketType.objects.create( + conference=capped_conference, + name="A", + slug="a", + price=Decimal("50.00"), + total_quantity=0, + limit_per_user=10, + is_active=True, + ) + tt_b = TicketType.objects.create( + conference=capped_conference, + name="B", + slug="b", + price=Decimal("75.00"), + total_quantity=0, + limit_per_user=10, + is_active=True, + ) + cart = Cart.objects.create( + user=user, + conference=capped_conference, + status=Cart.Status.OPEN, + expires_at=timezone.now() + timedelta(minutes=30), + ) + add_ticket(cart, tt_a, qty=3) + add_ticket(cart, tt_b, qty=2) # total = 5 = cap + + with pytest.raises(ValidationError, match="tickets remaining for this conference"): + add_ticket(cart, tt_a, qty=1) diff --git a/tests/test_registration/test_checkout_service.py b/tests/test_registration/test_checkout_service.py index 452b331..128d770 100644 --- a/tests/test_registration/test_checkout_service.py +++ b/tests/test_registration/test_checkout_service.py @@ -34,6 +34,7 @@ CheckoutService, _expire_stale_pending_orders, _increment_voucher_usage, + _revalidate_global_capacity, ) from django_program.registration.signals import order_paid @@ -1056,3 +1057,107 @@ def test_expire_decrements_voucher_usage(self, conference, user): voucher.refresh_from_db() assert voucher.times_used == 2 + + +# ============================================================================= +# TestGlobalCapacityCheckoutIntegration +# ============================================================================= + + +@pytest.mark.django_db +class TestGlobalCapacityCheckoutIntegration: + """Tests that checkout revalidation enforces global capacity.""" + + @pytest.fixture + def capped_conference(self): + return Conference.objects.create( + name="CappedCheckout", + slug="capped-checkout", + start_date=date(2027, 6, 1), + end_date=date(2027, 6, 3), + timezone="UTC", + total_capacity=3, + ) + + @pytest.fixture + def capped_ticket(self, capped_conference): + return TicketType.objects.create( + conference=capped_conference, + name="General", + slug="general", + price=Decimal("100.00"), + total_quantity=0, + limit_per_user=10, + is_active=True, + ) + + @pytest.fixture + def capped_cart_with_ticket(self, capped_conference, user, capped_ticket): + cart = Cart.objects.create( + user=user, + conference=capped_conference, + status=Cart.Status.OPEN, + expires_at=timezone.now() + timedelta(minutes=30), + ) + add_ticket(cart, capped_ticket, qty=2) + return cart + + def test_checkout_passes_within_global_cap(self, capped_cart_with_ticket): + order = CheckoutService.checkout(capped_cart_with_ticket) + assert order.status == Order.Status.PENDING + + def test_checkout_revalidation_catches_oversell(self, capped_conference, user, capped_ticket): + """Simulate oversell: another user buys tickets between cart-add and checkout.""" + cart = Cart.objects.create( + user=user, + conference=capped_conference, + status=Cart.Status.OPEN, + expires_at=timezone.now() + timedelta(minutes=30), + ) + add_ticket(cart, capped_ticket, qty=2) + + # Another user buys 2 tickets (total sold = 2, cap = 3) + other_user = User.objects.create_user( + username="oversell-other", + email="oversell@example.com", + password="testpass123", + ) + order = Order.objects.create( + conference=capped_conference, + user=other_user, + status=Order.Status.PAID, + subtotal=Decimal("200.00"), + total=Decimal("200.00"), + reference=f"ORD-{uuid4().hex[:8].upper()}", + ) + OrderLineItem.objects.create( + order=order, + description="Ticket", + quantity=2, + unit_price=Decimal("100.00"), + line_total=Decimal("200.00"), + ticket_type=capped_ticket, + ) + + # Now user tries to check out 2 more (total would be 4 > cap 3) + with pytest.raises(ValidationError, match="tickets remaining for this conference"): + CheckoutService.checkout(cart) + + def test_revalidate_global_capacity_skips_addon_only_items(self, capped_conference): + """When all cart items are add-ons (no ticket_type_id), skip capacity check.""" + addon = AddOn.objects.create( + conference=capped_conference, + name="Swag", + slug="swag-cap", + price=Decimal("15.00"), + is_active=True, + ) + cart = Cart.objects.create( + user=User.objects.create_user(username="addon-only", password="testpass123"), + conference=capped_conference, + status=Cart.Status.OPEN, + expires_at=timezone.now() + timedelta(minutes=30), + ) + add_addon(cart, addon, qty=1) + items = list(cart.items.select_related("ticket_type", "addon")) + _revalidate_global_capacity(items, capped_conference)