feat: ticket purchase flow, QR display, and admin check-in
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled

Ticket purchase:
- tickets blueprint with routes for my tickets list, ticket detail with QR
- Buy tickets form on entry detail page (HTMX-powered)
- Ticket services: create, query, availability checking

Admin check-in:
- ticket_admin blueprint with dashboard, lookup, and check-in routes
- QR scanner/lookup interface with real-time search
- Per-entry ticket list view
- Check-in transitions ticket state to checked_in

Internal API:
- GET /internal/events/tickets endpoint for cross-app queries
- POST /internal/events/tickets/<code>/checkin for programmatic check-in

Template fixes:
- All templates updated: blog.post.calendars.* → calendars.*
- Removed slug=post.slug parameters (standalone events service)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
giles
2026-02-10 00:00:35 +00:00
parent 59a69ed320
commit 1bab546dfc
63 changed files with 1421 additions and 125 deletions

View File

166
bp/ticket_admin/routes.py Normal file
View File

@@ -0,0 +1,166 @@
"""
Ticket admin blueprint — check-in interface and ticket management.
Routes:
GET /admin/tickets/ — Ticket dashboard (scan + list)
GET /admin/tickets/entry/<id>/ — Tickets for a specific entry
POST /admin/tickets/<code>/checkin — Check in a ticket
GET /admin/tickets/<code>/ — Ticket admin detail
"""
from __future__ import annotations
import logging
from quart import (
Blueprint, g, request, render_template, make_response, jsonify,
)
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
from models.calendars import CalendarEntry, Ticket, TicketType
from suma_browser.app.authz import require_admin
from suma_browser.app.redis_cacher import clear_cache
from ..tickets.services.tickets import (
get_ticket_by_code,
get_tickets_for_entry,
checkin_ticket,
)
logger = logging.getLogger(__name__)
def register() -> Blueprint:
bp = Blueprint("ticket_admin", __name__, url_prefix="/admin/tickets")
@bp.get("/")
@require_admin
async def dashboard():
"""Ticket admin dashboard with QR scanner and recent tickets."""
from suma_browser.app.utils.htmx import is_htmx_request
# Get recent tickets
result = await g.s.execute(
select(Ticket)
.options(
selectinload(Ticket.entry).selectinload(CalendarEntry.calendar),
selectinload(Ticket.ticket_type),
)
.order_by(Ticket.created_at.desc())
.limit(50)
)
tickets = result.scalars().all()
# Stats
total = await g.s.scalar(select(func.count(Ticket.id)))
confirmed = await g.s.scalar(
select(func.count(Ticket.id)).where(Ticket.state == "confirmed")
)
checked_in = await g.s.scalar(
select(func.count(Ticket.id)).where(Ticket.state == "checked_in")
)
reserved = await g.s.scalar(
select(func.count(Ticket.id)).where(Ticket.state == "reserved")
)
stats = {
"total": total or 0,
"confirmed": confirmed or 0,
"checked_in": checked_in or 0,
"reserved": reserved or 0,
}
if not is_htmx_request():
html = await render_template(
"_types/ticket_admin/index.html",
tickets=tickets,
stats=stats,
)
else:
html = await render_template(
"_types/ticket_admin/_main_panel.html",
tickets=tickets,
stats=stats,
)
return await make_response(html, 200)
@bp.get("/entry/<int:entry_id>/")
@require_admin
async def entry_tickets(entry_id: int):
"""List all tickets for a specific calendar entry."""
from suma_browser.app.utils.htmx import is_htmx_request
entry = await g.s.scalar(
select(CalendarEntry)
.where(
CalendarEntry.id == entry_id,
CalendarEntry.deleted_at.is_(None),
)
.options(selectinload(CalendarEntry.calendar))
)
if not entry:
return await make_response("Entry not found", 404)
tickets = await get_tickets_for_entry(g.s, entry_id)
html = await render_template(
"_types/ticket_admin/_entry_tickets.html",
entry=entry,
tickets=tickets,
)
return await make_response(html, 200)
@bp.get("/lookup/")
@require_admin
async def lookup():
"""Look up a ticket by code (used by scanner)."""
code = request.args.get("code", "").strip()
if not code:
return await make_response(
'<div class="text-sm text-stone-500">Enter a ticket code</div>',
200,
)
ticket = await get_ticket_by_code(g.s, code)
if not ticket:
html = await render_template(
"_types/ticket_admin/_lookup_result.html",
ticket=None,
error="Ticket not found",
)
return await make_response(html, 200)
html = await render_template(
"_types/ticket_admin/_lookup_result.html",
ticket=ticket,
error=None,
)
return await make_response(html, 200)
@bp.post("/<code>/checkin/")
@require_admin
@clear_cache(tag="calendars", tag_scope="all")
async def do_checkin(code: str):
"""Check in a ticket by its code."""
success, error = await checkin_ticket(g.s, code)
if not success:
html = await render_template(
"_types/ticket_admin/_checkin_result.html",
success=False,
error=error,
ticket=None,
)
return await make_response(html, 200)
ticket = await get_ticket_by_code(g.s, code)
html = await render_template(
"_types/ticket_admin/_checkin_result.html",
success=True,
error=None,
ticket=ticket,
)
return await make_response(html, 200)
return bp

View File