From c4cea70d15b71234cb7b1ad71a58e7c7bc2ebd39 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 18 Mar 2026 21:26:00 -0500 Subject: [PATCH 1/6] feat: add on-site check-in, door checks, and product redemption system Implements Phase 17: on-site check-in scanner with staff-facing UI, per-product door checks, and line-item redemption with double-use prevention. Modeled after pycon-site's DoorCheck + redemption flow. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../templates/django_program/manage/base.html | 5 + .../manage/checkin_dashboard.html | 152 ++++ .../manage/checkin_scanner.html | 575 +++++++++++++ src/django_program/manage/urls.py | 4 + src/django_program/manage/views_checkin.py | 107 +++ src/django_program/registration/admin.py | 63 ++ src/django_program/registration/checkin.py | 150 ++++ ...016_checkin_doorcheck_productredemption.py | 176 ++++ src/django_program/registration/models.py | 4 + .../registration/services/checkin.py | 299 +++++++ src/django_program/registration/urls.py | 11 + .../registration/views_checkin.py | 378 +++++++++ tests/test_registration/test_checkin.py | 758 ++++++++++++++++++ 13 files changed, 2682 insertions(+) create mode 100644 src/django_program/manage/templates/django_program/manage/checkin_dashboard.html create mode 100644 src/django_program/manage/templates/django_program/manage/checkin_scanner.html create mode 100644 src/django_program/manage/views_checkin.py create mode 100644 src/django_program/registration/checkin.py create mode 100644 src/django_program/registration/migrations/0016_checkin_doorcheck_productredemption.py create mode 100644 src/django_program/registration/services/checkin.py create mode 100644 src/django_program/registration/views_checkin.py create mode 100644 tests/test_registration/test_checkin.py diff --git a/src/django_program/manage/templates/django_program/manage/base.html b/src/django_program/manage/templates/django_program/manage/base.html index a8dcb0d..d79e2bb 100644 --- a/src/django_program/manage/templates/django_program/manage/base.html +++ b/src/django_program/manage/templates/django_program/manage/base.html @@ -1108,6 +1108,11 @@ Bulk Deals +
  • + + Check-in + +
  • '; } metaHtml += '
    ' + '
    Check-in Status
    ' + '
    ' + - (data.checkin_count > 0 - ? 'Re-entry (#' + (data.checkin_count + 1) + ')' + (attendee.checked_in + ? 'Checked in' : 'First check-in') + '
    '; - metaHtml += '
    ' + - '
    Previous Check-ins
    ' + - '
    ' + data.checkin_count + '
    '; - if (data.order_reference) { + if (attendee.checked_in_at) { metaHtml += '
    ' + - '
    Order
    ' + - '
    ' + escapeHtml(data.order_reference) + '
    '; + '
    Checked in at
    ' + + '
    ' + escapeHtml(new Date(attendee.checked_in_at).toLocaleString()) + '
    '; } attendeeMeta.innerHTML = metaHtml; } - function renderProducts(products) { + function renderProducts(redeemable) { productsList.innerHTML = ""; - if (!products || products.length === 0) { + if (!redeemable || redeemable.length === 0) { noProducts.style.display = "block"; return; } noProducts.style.display = "none"; - products.forEach(function(p) { - var remaining = p.quantity - p.redeemed; - var isFullyRedeemed = remaining <= 0; + redeemable.forEach(function(p) { + var isRedeemed = !!p.redeemed; var row = document.createElement("div"); - row.className = "product-row" + (isFullyRedeemed ? " product-row--redeemed" : ""); + row.className = "product-row" + (isRedeemed ? " product-row--redeemed" : ""); var info = document.createElement("div"); info.className = "product-info"; info.innerHTML = '
    ' + escapeHtml(p.description) + '
    ' + - '
    Qty: ' + p.quantity + ' | Redeemed: ' + p.redeemed + - ' | Remaining: ' + remaining + '
    '; + '
    Qty: ' + p.quantity + + (isRedeemed ? ' | Redeemed' : ' | Available') + '
    '; row.appendChild(info); - if (!isFullyRedeemed) { + if (!isRedeemed) { var btn = document.createElement("button"); btn.type = "button"; btn.className = "btn-redeem"; btn.textContent = "Redeem"; - btn.dataset.lineItemId = p.line_item_id; - btn.dataset.attendeeId = p.attendee_id; + btn.dataset.lineItemId = p.id; btn.addEventListener("click", function() { redeemProduct(btn); }); row.appendChild(btn); } @@ -456,6 +457,7 @@

    Redeemable Products

    async function performScan(accessCode) { if (!accessCode.trim()) return; + var trimmed = accessCode.trim(); try { var response = await fetch(SCAN_URL, { @@ -465,7 +467,7 @@

    Redeemable Products

    "X-CSRFToken": getCsrfToken(), }, body: JSON.stringify({ - access_code: accessCode.trim(), + access_code: trimmed, station: stationInput.value.trim(), }), }); @@ -480,16 +482,23 @@

    Redeemable Products

    return; } - if (data.is_reentry) { - showStatus("Re-entry: " + (data.name || accessCode) + " (check-in #" + (data.checkin_count + 1) + ")", "reentry"); + var attendee = data.attendee || {}; + var displayName = attendee.name || trimmed; + + // If the attendee was already checked in before this scan, it is a re-entry + if (attendee.checked_in && attendee.checked_in_at && data.checked_in_at + && attendee.checked_in_at !== data.checked_in_at) { + showStatus("Re-entry: " + displayName, "reentry"); } else { - showStatus("Checked in: " + (data.name || accessCode), "success"); + showStatus("Checked in: " + displayName, "success"); } flashScreen("success"); - renderAttendee(data); - renderProducts(data.products || []); + renderAttendee(attendee, data.badge || null); showResults(); + + // Fetch the full lookup data to get redeemable products + await refreshProducts(trimmed); } catch (err) { showStatus("Network error: " + err.message, "error"); flashScreen("error"); @@ -509,9 +518,8 @@

    Redeemable Products

    "X-CSRFToken": getCsrfToken(), }, body: JSON.stringify({ - attendee_id: parseInt(btn.dataset.attendeeId, 10), + access_code: currentAccessCode, line_item_id: parseInt(btn.dataset.lineItemId, 10), - station: stationInput.value.trim(), }), }); @@ -528,9 +536,9 @@

    Redeemable Products

    showStatus("Redeemed: " + (data.description || "product"), "success"); flashScreen("success"); - // Re-fetch attendee to refresh product list - if (attendeeCode.textContent) { - await refreshAttendee(attendeeCode.textContent); + // Re-fetch to refresh the redeemable product list + if (currentAccessCode) { + await refreshAttendee(currentAccessCode); } } catch (err) { showStatus("Network error: " + err.message, "error"); @@ -541,13 +549,24 @@

    Redeemable Products

    refocusScan(); } + async function refreshProducts(accessCode) { + try { + var response = await fetch(lookupUrl(accessCode)); + if (!response.ok) return; + var data = await response.json(); + renderProducts(data.redeemable || []); + } catch (err) { + // Silently fail on refresh; the previous data remains visible + } + } + async function refreshAttendee(accessCode) { try { var response = await fetch(lookupUrl(accessCode)); if (!response.ok) return; var data = await response.json(); - renderAttendee(data); - renderProducts(data.products || []); + renderAttendee(data.attendee || {}, null); + renderProducts(data.redeemable || []); } catch (err) { // Silently fail on refresh; the previous data remains visible } diff --git a/src/django_program/registration/checkin.py b/src/django_program/registration/checkin.py index eeaf94a..d0c362f 100644 --- a/src/django_program/registration/checkin.py +++ b/src/django_program/registration/checkin.py @@ -108,12 +108,13 @@ def __str__(self) -> str: class ProductRedemption(models.Model): - """Tracks redemption of a purchased order line item to prevent double-use. + """Tracks redemption of a purchased order line item. - Each attendee can redeem a given order line item exactly once, enforced - by a unique constraint on ``(attendee, order_line_item)``. This covers - tutorials, meals, events, and any other purchasable product that should - only be consumed once. + Each attendee can redeem a given order line item up to its purchased + ``quantity`` times. The business-layer limit is enforced by + ``RedemptionService.redeem_product()`` with row-level locking; no + unique constraint exists at the DB level so that quantity > 1 items + can produce multiple redemption rows. """ attendee = models.ForeignKey( @@ -144,7 +145,6 @@ class ProductRedemption(models.Model): class Meta: ordering = ["-redeemed_at"] - unique_together = [("attendee", "order_line_item")] def __str__(self) -> str: return f"Redemption: {self.attendee} → {self.order_line_item}" diff --git a/src/django_program/registration/migrations/0017_alter_productredemption_unique_together.py b/src/django_program/registration/migrations/0017_alter_productredemption_unique_together.py new file mode 100644 index 0000000..f2ced9b --- /dev/null +++ b/src/django_program/registration/migrations/0017_alter_productredemption_unique_together.py @@ -0,0 +1,16 @@ +# Generated by Django 5.2.11 on 2026-03-19 02:41 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("program_registration", "0016_checkin_doorcheck_productredemption"), + ] + + operations = [ + migrations.AlterUniqueTogether( + name="productredemption", + unique_together=set(), + ), + ] diff --git a/src/django_program/registration/services/checkin.py b/src/django_program/registration/services/checkin.py index f8e6aac..d32dfb2 100644 --- a/src/django_program/registration/services/checkin.py +++ b/src/django_program/registration/services/checkin.py @@ -264,32 +264,30 @@ def redeem_product( (redeemed count >= quantity). """ if attendee.order_id != order_line_item.order_id: - msg = ( - f"Line item {order_line_item.pk} belongs to order " - f"{order_line_item.order_id}, not attendee's order " - f"{attendee.order_id}." - ) + msg = "Line item does not belong to the attendee's order." raise ValueError(msg) - redeemed_count = ProductRedemption.objects.filter( - attendee=attendee, - order_line_item=order_line_item, - ).count() - - if redeemed_count >= order_line_item.quantity: - msg = ( - f"Line item {order_line_item.pk} " - f"('{order_line_item.description}') is fully redeemed " - f"({redeemed_count}/{order_line_item.quantity})." + with transaction.atomic(): + locked_item = OrderLineItem.objects.select_for_update().get(pk=order_line_item.pk) + + redeemed_count = ProductRedemption.objects.filter( + attendee=attendee, + order_line_item=locked_item, + ).count() + + if redeemed_count >= locked_item.quantity: + msg = ( + f"Product '{locked_item.description}' is fully redeemed ({redeemed_count}/{locked_item.quantity})." + ) + raise ValueError(msg) + + redemption = ProductRedemption.objects.create( + attendee=attendee, + order_line_item=locked_item, + conference=attendee.conference, + redeemed_by=redeemed_by, ) - raise ValueError(msg) - redemption = ProductRedemption.objects.create( - attendee=attendee, - order_line_item=order_line_item, - conference=attendee.conference, - redeemed_by=redeemed_by, - ) logger.info( "Redeemed line item %s ('%s') for attendee %s", order_line_item.pk, diff --git a/src/django_program/registration/views_checkin.py b/src/django_program/registration/views_checkin.py index 59ef390..106a33a 100644 --- a/src/django_program/registration/views_checkin.py +++ b/src/django_program/registration/views_checkin.py @@ -2,9 +2,8 @@ These views power the scanner UI used by registration desk volunteers. All endpoints require staff or superuser authentication and are scoped -to a conference via the ``conference_slug`` URL kwarg. POST endpoints -are CSRF-exempt since the scanner frontend uses ``fetch()`` with JSON -payloads. +to a conference via the ``conference_slug`` URL kwarg. The scanner UI +is responsible for including the CSRF token in POST requests. """ import json @@ -14,9 +13,7 @@ from django.http import HttpRequest, HttpResponse, JsonResponse from django.shortcuts import get_object_or_404 from django.utils import timezone -from django.utils.decorators import method_decorator from django.views import View -from django.views.decorators.csrf import csrf_exempt from django_program.conference.models import Conference from django_program.registration.attendee import Attendee @@ -110,7 +107,6 @@ def _parse_json_body(request: HttpRequest) -> dict[str, object] | None: return None -@method_decorator(csrf_exempt, name="dispatch") class ScanView(StaffRequiredMixin, View): """Scan an attendee's access code and perform check-in. @@ -225,7 +221,6 @@ def get(self, request: HttpRequest, access_code: str, **kwargs: str) -> JsonResp ) -@method_decorator(csrf_exempt, name="dispatch") class RedeemView(StaffRequiredMixin, View): """Redeem a purchased product (order line item) for an attendee. @@ -248,10 +243,14 @@ def post(self, request: HttpRequest, **kwargs: str) -> JsonResponse: # noqa: AR return JsonResponse({"error": "Invalid JSON body"}, status=400) access_code = str(body.get("access_code", "")).strip() - line_item_id = body.get("line_item_id") + raw_line_item_id = body.get("line_item_id") - if not access_code or line_item_id is None: - return JsonResponse({"error": "access_code and line_item_id are required"}, status=400) + try: + line_item_id = int(raw_line_item_id) # type: ignore[arg-type] + if not access_code: + raise ValueError # noqa: TRY301 + except TypeError, ValueError: + return JsonResponse({"error": "access_code and a valid integer line_item_id are required"}, status=400) try: attendee = CheckInService.lookup_attendee(conference=self.conference, access_code=access_code) @@ -282,9 +281,9 @@ def _do_redeem(self, attendee: Attendee, line_item: OrderLineItem, request: Http order_line_item=line_item, redeemed_by=request.user, ) - except ValueError as exc: + except ValueError: return JsonResponse( - {"error": str(exc), "line_item_id": line_item.pk}, + {"error": "Product already fully redeemed", "line_item_id": line_item.pk}, status=409, ) diff --git a/tests/test_registration/test_checkin.py b/tests/test_registration/test_checkin.py index b1061e1..00c4e8a 100644 --- a/tests/test_registration/test_checkin.py +++ b/tests/test_registration/test_checkin.py @@ -7,7 +7,6 @@ import pytest from django.contrib.auth import get_user_model -from django.db import IntegrityError from django.test import Client from django.urls import reverse @@ -229,25 +228,30 @@ def test_redemption_str(self) -> None: result = str(redemption) assert "Redemption:" in result - def test_unique_together_prevents_duplicate(self) -> None: + def test_allows_multiple_redemptions_up_to_quantity(self) -> None: + """Multiple redemptions of the same line item are allowed (no DB constraint).""" conf = _make_conference() user = _make_user() order = _make_order(conference=conf, user=user) - attendee = _make_attendee(conference=conf, user=user, order=order) - line_item = _make_line_item(order=order, description="Tutorial") - + attendee = Attendee.objects.create(user=user, conference=conf, order=order) + line_item = OrderLineItem.objects.create( + order=order, + description="Workshop", + quantity=2, + unit_price=Decimal("50.00"), + line_total=Decimal("100.00"), + ) ProductRedemption.objects.create( attendee=attendee, order_line_item=line_item, conference=conf, ) - - with pytest.raises(IntegrityError): - ProductRedemption.objects.create( - attendee=attendee, - order_line_item=line_item, - conference=conf, - ) + ProductRedemption.objects.create( + attendee=attendee, + order_line_item=line_item, + conference=conf, + ) + assert ProductRedemption.objects.filter(attendee=attendee, order_line_item=line_item).count() == 2 # -- Service Tests: CheckInService -------------------------------------------- @@ -515,7 +519,7 @@ def test_redeem_raises_for_wrong_order(self) -> None: attendee = _make_attendee(conference=conf, user=user1, order=order1) other_line_item = _make_line_item(order=order2, description="Other Order Item") - with pytest.raises(ValueError, match="not attendee's order"): + with pytest.raises(ValueError, match="does not belong"): RedemptionService.redeem_product( attendee=attendee, order_line_item=other_line_item, @@ -702,7 +706,7 @@ def test_returns_409_for_already_redeemed(self) -> None: ) assert response.status_code == 409 - assert "fully redeemed" in response.json()["error"] + assert response.json()["error"] == "Product already fully redeemed" @pytest.mark.integration From ed99d1178eff2aea490724bdd5b42a247f949b95 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 18 Mar 2026 22:06:13 -0500 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20address=20Codex=20review=20=E2=80=94?= =?UTF-8?q?=20permissions,=20order=20status,=20multi-qty=20redemption?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Align API permission check with ManagePermissionMixin (require change_conference perm, not just is_staff) - Add order status validation on scan/redeem (reject refunded/cancelled), include PARTIALLY_REFUNDED in preload export - Return redeemed_count/remaining instead of boolean for multi-quantity redemption tracking - Filter redeemable products to add-ons only (tickets use check-in) - Add has_delete_permission=False to CheckIn/DoorCheck admin - Add conference validation to record_door_check() - Fix scanner UI: session-based re-entry detection, multi-qty display, error banner persistence, redemption toast key, order status warning Co-Authored-By: Claude Opus 4.6 (1M context) --- .../manage/checkin_scanner.html | 62 ++++++++++++++----- src/django_program/registration/admin.py | 6 ++ .../registration/services/checkin.py | 25 +++++++- .../registration/views_checkin.py | 50 ++++++++++++--- tests/test_registration/test_checkin.py | 8 ++- 5 files changed, 124 insertions(+), 27 deletions(-) diff --git a/src/django_program/manage/templates/django_program/manage/checkin_scanner.html b/src/django_program/manage/templates/django_program/manage/checkin_scanner.html index 02b0127..084e081 100644 --- a/src/django_program/manage/templates/django_program/manage/checkin_scanner.html +++ b/src/django_program/manage/templates/django_program/manage/checkin_scanner.html @@ -184,8 +184,8 @@ margin-top: 0.1rem; } - .product-row--redeemed .product-name, - .product-row--redeemed .product-counts { + .product-row--fully-redeemed .product-name, + .product-row--fully-redeemed .product-counts { opacity: 0.45; text-decoration: line-through; } @@ -214,6 +214,17 @@ color: var(--color-text-muted); } + .order-status-warning { + padding: 0.6rem 1rem; + margin-bottom: 1rem; + border-radius: var(--radius-sm); + background: var(--color-warning-bg); + color: var(--color-warning); + border: 1px solid var(--color-warning-border); + font-size: 0.88rem; + font-weight: 600; + } + .idle-message { text-align: center; padding: 3rem 1rem; @@ -287,6 +298,7 @@

    Check-in Scanner

    +
    @@ -386,7 +398,21 @@

    Redeemable Products

    // Track the current attendee's access code for redemption requests var currentAccessCode = ""; - function renderAttendee(attendee, badge) { + // Track scanned codes this session for re-entry detection + var scannedCodes = new Set(); + + var orderStatusWarning = document.getElementById("orderStatusWarning"); + + function renderOrderStatus(orderStatus) { + if (orderStatus && orderStatus !== "paid") { + orderStatusWarning.textContent = "Order status: " + orderStatus.charAt(0).toUpperCase() + orderStatus.slice(1); + orderStatusWarning.style.display = "block"; + } else { + orderStatusWarning.style.display = "none"; + } + } + + function renderAttendee(attendee, badge, orderStatus) { attendeeName.textContent = attendee.name || "Unknown"; attendeeEmail.textContent = attendee.email || ""; attendeeCode.textContent = attendee.access_code || ""; @@ -413,6 +439,7 @@

    Redeemable Products

    '
    ' + escapeHtml(new Date(attendee.checked_in_at).toLocaleString()) + '
    '; } attendeeMeta.innerHTML = metaHtml; + renderOrderStatus(orderStatus); } function renderProducts(redeemable) { @@ -424,18 +451,21 @@

    Redeemable Products

    noProducts.style.display = "none"; redeemable.forEach(function(p) { - var isRedeemed = !!p.redeemed; + var redeemedCount = p.redeemed_count || 0; + var remaining = (p.remaining != null) ? p.remaining : (p.quantity - redeemedCount); + var fullyRedeemed = remaining <= 0; + var row = document.createElement("div"); - row.className = "product-row" + (isRedeemed ? " product-row--redeemed" : ""); + row.className = "product-row" + (fullyRedeemed ? " product-row--fully-redeemed" : ""); var info = document.createElement("div"); info.className = "product-info"; info.innerHTML = '
    ' + escapeHtml(p.description) + '
    ' + - '
    Qty: ' + p.quantity + - (isRedeemed ? ' | Redeemed' : ' | Available') + '
    '; + '
    Redeemed ' + redeemedCount + '/' + p.quantity + + (remaining > 0 ? ' | ' + remaining + ' remaining' : ' | Fully redeemed') + '
    '; row.appendChild(info); - if (!isRedeemed) { + if (remaining > 0) { var btn = document.createElement("button"); btn.type = "button"; btn.className = "btn-redeem"; @@ -477,7 +507,6 @@

    Redeemable Products

    if (!response.ok) { showStatus(data.error || "Scan failed", "error"); flashScreen("error"); - hideResults(); refocusScan(); return; } @@ -485,16 +514,18 @@

    Redeemable Products

    var attendee = data.attendee || {}; var displayName = attendee.name || trimmed; - // If the attendee was already checked in before this scan, it is a re-entry - if (attendee.checked_in && attendee.checked_in_at && data.checked_in_at - && attendee.checked_in_at !== data.checked_in_at) { + // Detect re-entry: same code scanned again in this session + var isReentry = scannedCodes.has(trimmed); + scannedCodes.add(trimmed); + + if (isReentry) { showStatus("Re-entry: " + displayName, "reentry"); } else { showStatus("Checked in: " + displayName, "success"); } flashScreen("success"); - renderAttendee(attendee, data.badge || null); + renderAttendee(attendee, data.badge || null, data.order_status); showResults(); // Fetch the full lookup data to get redeemable products @@ -533,7 +564,8 @@

    Redeemable Products

    return; } - showStatus("Redeemed: " + (data.description || "product"), "success"); + var redeemDesc = (data.line_item && data.line_item.description) || data.description || "product"; + showStatus("Redeemed: " + redeemDesc, "success"); flashScreen("success"); // Re-fetch to refresh the redeemable product list @@ -565,7 +597,7 @@

    Redeemable Products

    var response = await fetch(lookupUrl(accessCode)); if (!response.ok) return; var data = await response.json(); - renderAttendee(data.attendee || {}, null); + renderAttendee(data.attendee || {}, null, data.order_status); renderProducts(data.redeemable || []); } catch (err) { // Silently fail on refresh; the previous data remains visible diff --git a/src/django_program/registration/admin.py b/src/django_program/registration/admin.py index 0531259..f0bb1de 100644 --- a/src/django_program/registration/admin.py +++ b/src/django_program/registration/admin.py @@ -357,6 +357,9 @@ def has_add_permission(self, request: HttpRequest) -> bool: # noqa: ARG002, D10 def has_change_permission(self, request: HttpRequest, obj: CheckIn | None = None) -> bool: # noqa: ARG002, D102 return False + def has_delete_permission(self, request: HttpRequest, obj: CheckIn | None = None) -> bool: # noqa: ARG002, D102 + return False + @admin.register(DoorCheck) class DoorCheckAdmin(admin.ModelAdmin): @@ -381,6 +384,9 @@ def has_add_permission(self, request: HttpRequest) -> bool: # noqa: ARG002, D10 def has_change_permission(self, request: HttpRequest, obj: DoorCheck | None = None) -> bool: # noqa: ARG002, D102 return False + def has_delete_permission(self, request: HttpRequest, obj: DoorCheck | None = None) -> bool: # noqa: ARG002, D102 + return False + @admin.register(ProductRedemption) class ProductRedemptionAdmin(admin.ModelAdmin): diff --git a/src/django_program/registration/services/checkin.py b/src/django_program/registration/services/checkin.py index d32dfb2..7bde949 100644 --- a/src/django_program/registration/services/checkin.py +++ b/src/django_program/registration/services/checkin.py @@ -14,7 +14,7 @@ from django_program.registration.attendee import Attendee from django_program.registration.checkin import CheckIn, DoorCheck, ProductRedemption -from django_program.registration.models import AddOn, OrderLineItem, TicketType +from django_program.registration.models import AddOn, Order, OrderLineItem, TicketType if TYPE_CHECKING: from django.contrib.auth.models import AbstractUser @@ -27,6 +27,20 @@ class CheckInService: """Service for attendee check-in operations at the conference venue.""" + ACTIVE_ORDER_STATUSES = {Order.Status.PAID, Order.Status.PARTIALLY_REFUNDED} + + @staticmethod + def validate_order_status(attendee: Attendee) -> str | None: + """Check if the attendee has a valid order for check-in. + + Returns None if valid, or an error message string if not. + """ + if attendee.order is None: + return "Attendee has no associated order." + if attendee.order.status not in CheckInService.ACTIVE_ORDER_STATUSES: + return f"Order status is '{attendee.order.get_status_display()}' — check-in not allowed." + return None + @staticmethod def lookup_attendee(*, conference: Conference, access_code: str) -> Attendee: """Look up an attendee by access code within a conference. @@ -164,6 +178,13 @@ def record_door_check( msg = "Exactly one of ticket_type or addon must be provided." raise ValueError(msg) + if ticket_type is not None and ticket_type.conference_id != attendee.conference_id: + msg = "Ticket type does not belong to the attendee's conference." + raise ValueError(msg) + if addon is not None and addon.conference_id != attendee.conference_id: + msg = "Add-on does not belong to the attendee's conference." + raise ValueError(msg) + door_check = DoorCheck.objects.create( attendee=attendee, conference=attendee.conference, @@ -209,7 +230,7 @@ def get_redeemable_products(attendee: Attendee) -> list[dict[str, object]]: return [] line_items = ( - OrderLineItem.objects.filter(order=attendee.order) + OrderLineItem.objects.filter(order=attendee.order, addon__isnull=False) .annotate( redeemed_count=Count( "redemptions", diff --git a/src/django_program/registration/views_checkin.py b/src/django_program/registration/views_checkin.py index 106a33a..741466f 100644 --- a/src/django_program/registration/views_checkin.py +++ b/src/django_program/registration/views_checkin.py @@ -9,7 +9,7 @@ import json import logging -from django.db.models import Prefetch +from django.db.models import Count, Prefetch from django.http import HttpRequest, HttpResponse, JsonResponse from django.shortcuts import get_object_or_404 from django.utils import timezone @@ -46,7 +46,7 @@ def dispatch(self, request: HttpRequest, *args: str, **kwargs: str) -> HttpRespo """ if not request.user.is_authenticated: return JsonResponse({"error": "Authentication required"}, status=401) - if not (request.user.is_staff or request.user.is_superuser): + if not (request.user.is_superuser or request.user.has_perm("program_conference.change_conference")): return JsonResponse({"error": "Staff access required"}, status=403) self.conference = get_object_or_404(Conference, slug=kwargs.get("conference_slug")) return super().dispatch(request, *args, **kwargs) # type: ignore[misc] @@ -143,6 +143,17 @@ def post(self, request: HttpRequest, **kwargs: str) -> JsonResponse: # noqa: AR status=404, ) + status_error = CheckInService.validate_order_status(attendee) + if status_error is not None: + return JsonResponse( + { + "error": status_error, + "access_code": access_code, + "order_status": str(attendee.order.status) if attendee.order else None, + }, + status=409, + ) + checkin = CheckInService.check_in( attendee=attendee, checked_in_by=request.user, @@ -204,9 +215,18 @@ def get(self, request: HttpRequest, access_code: str, **kwargs: str) -> JsonResp line_items = list(order.line_items.select_related("ticket_type", "addon").all()) products = [_serialize_line_item(item) for item in line_items] - redeemed_item_ids = set(attendee.redemptions.values_list("order_line_item_id", flat=True)) + redeemed_counts: dict[int, int] = {} + for r in attendee.redemptions.values("order_line_item_id").annotate(count=Count("id")): + redeemed_counts[r["order_line_item_id"]] = r["count"] + redeemable = [ - {**_serialize_line_item(item), "redeemed": item.pk in redeemed_item_ids} for item in line_items + { + **_serialize_line_item(item), + "redeemed_count": redeemed_counts.get(item.pk, 0), + "remaining": item.quantity - redeemed_counts.get(item.pk, 0), + } + for item in line_items + if item.addon_id is not None ] return JsonResponse( @@ -217,6 +237,7 @@ def get(self, request: HttpRequest, access_code: str, **kwargs: str) -> JsonResp }, "products": products, "redeemable": redeemable, + "order_status": str(attendee.order.status) if attendee.order else None, } ) @@ -260,8 +281,16 @@ def post(self, request: HttpRequest, **kwargs: str) -> JsonResponse: # noqa: AR status=404, ) - if attendee.order is None: - return JsonResponse({"error": "Attendee has no associated order"}, status=400) + status_error = CheckInService.validate_order_status(attendee) + if status_error is not None: + return JsonResponse( + { + "error": status_error, + "access_code": access_code, + "order_status": str(attendee.order.status) if attendee.order else None, + }, + status=409, + ) try: line_item = attendee.order.line_items.get(pk=line_item_id) @@ -320,7 +349,7 @@ def get(self, request: HttpRequest, **kwargs: str) -> JsonResponse: # noqa: ARG Attendee.objects.filter( conference=self.conference, order__isnull=False, - order__status=Order.Status.PAID, + order__status__in=[Order.Status.PAID, Order.Status.PARTIALLY_REFUNDED], ) .select_related("user", "order") .prefetch_related( @@ -357,10 +386,13 @@ def _serialize_preload_attendee(attendee: Attendee) -> dict[str, object]: ticket_type_name = "" if order is not None: - redeemed_item_ids = set(attendee.redemptions.values_list("order_line_item_id", flat=True)) + redeemed_counts: dict[int, int] = {} + for r in attendee.redemptions.values("order_line_item_id").annotate(count=Count("id")): + redeemed_counts[r["order_line_item_id"]] = r["count"] for item in order.line_items.all(): product_data = _serialize_line_item(item) - product_data["redeemed"] = item.pk in redeemed_item_ids + product_data["redeemed_count"] = redeemed_counts.get(item.pk, 0) + product_data["remaining"] = item.quantity - redeemed_counts.get(item.pk, 0) products.append(product_data) if item.ticket_type and not ticket_type_name: diff --git a/tests/test_registration/test_checkin.py b/tests/test_registration/test_checkin.py index 00c4e8a..571f327 100644 --- a/tests/test_registration/test_checkin.py +++ b/tests/test_registration/test_checkin.py @@ -45,6 +45,9 @@ def _make_user(**kwargs: object) -> object: def _make_staff_user(**kwargs: object) -> object: + """Create a staff user with the change_conference permission required by check-in API.""" + from django.contrib.auth.models import Permission + defaults: dict[str, object] = { "username": f"staff-{uuid4().hex[:8]}", "email": f"{uuid4().hex[:8]}@test.com", @@ -52,7 +55,10 @@ def _make_staff_user(**kwargs: object) -> object: "password": "testpass123", } defaults.update(kwargs) - return User.objects.create_user(**defaults) + user = User.objects.create_user(**defaults) + perm = Permission.objects.get(content_type__app_label="program_conference", codename="change_conference") + user.user_permissions.add(perm) + return user def _make_order(*, conference: Conference, user: object, status: str = Order.Status.PAID) -> Order: From 6c69e95c05eaa8a5bad61952c625895552e3cef9 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 18 Mar 2026 22:15:17 -0500 Subject: [PATCH 5/6] fix: N+1 preload query, add manage dashboard/scanner view tests - Use prefetched redemptions in Python instead of per-attendee annotated queries in OfflinePreloadView - Add 6 tests for manage:checkin-dashboard and manage:checkin-scanner (permission checks, 200 responses, context stats) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../registration/views_checkin.py | 5 +- tests/test_registration/test_checkin.py | 67 +++++++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/src/django_program/registration/views_checkin.py b/src/django_program/registration/views_checkin.py index 741466f..86b5a8f 100644 --- a/src/django_program/registration/views_checkin.py +++ b/src/django_program/registration/views_checkin.py @@ -386,9 +386,10 @@ def _serialize_preload_attendee(attendee: Attendee) -> dict[str, object]: ticket_type_name = "" if order is not None: + # Use prefetched redemptions to avoid N+1 queries redeemed_counts: dict[int, int] = {} - for r in attendee.redemptions.values("order_line_item_id").annotate(count=Count("id")): - redeemed_counts[r["order_line_item_id"]] = r["count"] + for redemption in attendee.redemptions.all(): + redeemed_counts[redemption.order_line_item_id] = redeemed_counts.get(redemption.order_line_item_id, 0) + 1 for item in order.line_items.all(): product_data = _serialize_line_item(item) product_data["redeemed_count"] = redeemed_counts.get(item.pk, 0) diff --git a/tests/test_registration/test_checkin.py b/tests/test_registration/test_checkin.py index 571f327..e35e1fc 100644 --- a/tests/test_registration/test_checkin.py +++ b/tests/test_registration/test_checkin.py @@ -766,3 +766,70 @@ def test_filters_by_ticket_type(self) -> None: data = response.json() assert data["count"] == 1 assert data["attendees"][0]["ticket_type"] == "VIP" + + +# -- Manage Dashboard/Scanner View Tests ------------------------------------- + + +@pytest.mark.integration +class TestCheckInDashboardView: + """Tests for the manage check-in dashboard.""" + + def _url(self, conference: Conference) -> str: + return reverse("manage:checkin-dashboard", args=[conference.slug]) + + def test_anonymous_redirected(self) -> None: + conf = _make_conference() + client = Client() + response = client.get(self._url(conf)) + assert response.status_code == 302 + + def test_non_permitted_user_denied(self) -> None: + conf = _make_conference() + user = _make_user(password="testpass123") + client = Client() + client.force_login(user) + response = client.get(self._url(conf)) + assert response.status_code == 403 + + def test_staff_with_permission_gets_200(self) -> None: + conf = _make_conference() + staff = _make_staff_user() + client = Client() + client.force_login(staff) + response = client.get(self._url(conf)) + assert response.status_code == 200 + + def test_context_contains_stats(self) -> None: + conf = _make_conference() + user = _make_user() + Attendee.objects.create(user=user, conference=conf) + staff = _make_staff_user() + client = Client() + client.force_login(staff) + response = client.get(self._url(conf)) + assert response.context["total_attendees"] == 1 + assert response.context["checked_in_count"] == 0 + assert response.context["check_in_rate"] == 0 + + +@pytest.mark.integration +class TestCheckInScannerView: + """Tests for the manage scanner page.""" + + def _url(self, conference: Conference) -> str: + return reverse("manage:checkin-scanner", args=[conference.slug]) + + def test_anonymous_redirected(self) -> None: + conf = _make_conference() + client = Client() + response = client.get(self._url(conf)) + assert response.status_code == 302 + + def test_staff_with_permission_gets_200(self) -> None: + conf = _make_conference() + staff = _make_staff_user() + client = Client() + client.force_login(staff) + response = client.get(self._url(conf)) + assert response.status_code == 200 From 12fd4507ba0464c2b39f4bb67894bcac12dcc9d5 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 18 Mar 2026 22:17:15 -0500 Subject: [PATCH 6/6] fix: use prefetched data, add order_status to scan response, normalize except syntax - Use prefetched line items from lookup_attendee() instead of redundant select_related() queries in ScanView and LookupView - Include order_status in scan success response for UI warning display - Use _get_ticket_type_name() with prefetched iteration instead of filter query - Normalize except syntax to tuple form for tooling compatibility Co-Authored-By: Claude Opus 4.6 (1M context) --- .../registration/views_checkin.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/django_program/registration/views_checkin.py b/src/django_program/registration/views_checkin.py index 86b5a8f..381d7e7 100644 --- a/src/django_program/registration/views_checkin.py +++ b/src/django_program/registration/views_checkin.py @@ -90,12 +90,12 @@ def _serialize_line_item(item: OrderLineItem) -> dict[str, object]: def _get_ticket_type_name(order: Order | None) -> str: - """Extract the ticket type name from an order's line items.""" + """Extract the ticket type name from an order's prefetched line items.""" if order is None: return "" - ticket_item = order.line_items.filter(ticket_type__isnull=False).select_related("ticket_type").first() - if ticket_item and ticket_item.ticket_type: - return str(ticket_item.ticket_type.name) + for item in order.line_items.all(): + if item.ticket_type is not None: + return str(item.ticket_type.name) return "" @@ -103,7 +103,7 @@ def _parse_json_body(request: HttpRequest) -> dict[str, object] | None: """Parse JSON from request body, returning None on failure.""" try: return json.loads(request.body) # type: ignore[no-any-return] - except json.JSONDecodeError, ValueError: + except (json.JSONDecodeError, ValueError): return None @@ -163,9 +163,7 @@ def post(self, request: HttpRequest, **kwargs: str) -> JsonResponse: # noqa: AR order = attendee.order products = [] if order is not None: - products = [ - _serialize_line_item(item) for item in order.line_items.select_related("ticket_type", "addon").all() - ] + products = [_serialize_line_item(item) for item in order.line_items.all()] return JsonResponse( { @@ -176,6 +174,7 @@ def post(self, request: HttpRequest, **kwargs: str) -> JsonResponse: # noqa: AR "ticket_type": _get_ticket_type_name(order), }, "products": products, + "order_status": str(order.status) if order else None, "checkin_id": checkin.pk, "checked_in_at": checkin.checked_in_at.isoformat(), } @@ -212,7 +211,7 @@ def get(self, request: HttpRequest, access_code: str, **kwargs: str) -> JsonResp products: list[dict[str, object]] = [] redeemable: list[dict[str, object]] = [] if order is not None: - line_items = list(order.line_items.select_related("ticket_type", "addon").all()) + line_items = list(order.line_items.all()) products = [_serialize_line_item(item) for item in line_items] redeemed_counts: dict[int, int] = {}