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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions conference.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/django_program/conference/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
},
),
(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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] = {
Expand Down
Original file line number Diff line number Diff line change
@@ -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."
),
),
]
5 changes: 5 additions & 0 deletions src/django_program/conference/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions src/django_program/manage/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ class Meta:
"address",
"website_url",
"pretalx_event_slug",
"total_capacity",
"is_active",
]
widgets = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,18 @@ <h1>Conference Settings</h1>
</div>
</div>

<div class="fieldset">
<div class="fieldset-legend">Capacity</div>
<div class="form-row">
<div class="form-group">
<label for="{{ form.total_capacity.id_for_label }}">{{ form.total_capacity.label }}</label>
{{ form.total_capacity }}
{% if form.total_capacity.help_text %}<span class="helptext">{{ form.total_capacity.help_text }}</span>{% endif %}
{% for error in form.total_capacity.errors %}<ul class="errorlist"><li>{{ error }}</li></ul>{% endfor %}
</div>
</div>
</div>

<div class="fieldset">
<div class="fieldset-legend">Integrations &amp; Status</div>
<div class="form-row--tail">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ <h1>Ticket Types</h1>
{% endblock %}

{% block content %}
{% if global_capacity %}
<div class="capacity-banner" style="padding:0.75rem 1rem;margin-bottom:1rem;border-radius:var(--radius-sm);background:var(--color-bg-alt);border:1px solid var(--color-border-light);">
<strong>{{ global_sold }} / {{ global_capacity }}</strong> tickets sold (venue capacity)
</div>
{% endif %}
{% if ticket_types %}
<table class="data-table">
<thead>
Expand Down
6 changes: 5 additions & 1 deletion src/django_program/manage/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]:
Expand Down
94 changes: 94 additions & 0 deletions src/django_program/registration/services/capacity.py
Original file line number Diff line number Diff line change
@@ -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})."
)
27 changes: 27 additions & 0 deletions src/django_program/registration/services/cart.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
TicketType,
Voucher,
)
from django_program.registration.services.capacity import validate_global_capacity
from django_program.settings import get_config


Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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,
Expand Down
31 changes: 29 additions & 2 deletions src/django_program/registration/services/checkout.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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.
"""
Expand All @@ -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."""
Expand Down
47 changes: 47 additions & 0 deletions tests/test_conference/test_bootstrap_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions tests/test_manage/test_forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
}
)
Expand Down
Loading
Loading