This repository has been archived on 2026-02-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
events/bp/tickets/routes.py
giles 1bab546dfc
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
feat: ticket purchase flow, QR display, and admin check-in
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>
2026-02-10 00:00:35 +00:00

182 lines
5.5 KiB
Python

"""
Tickets blueprint — user-facing ticket views and QR codes.
Routes:
GET /tickets/ — My tickets list
GET /tickets/<code>/ — Ticket detail with QR code
POST /tickets/buy/ — Purchase tickets for an entry
"""
from __future__ import annotations
import logging
from quart import (
Blueprint, g, request, render_template, make_response,
)
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from models.calendars import CalendarEntry
from shared.cart_identity import current_cart_identity
from suma_browser.app.redis_cacher import clear_cache
from .services.tickets import (
create_ticket,
get_ticket_by_code,
get_user_tickets,
get_available_ticket_count,
get_tickets_for_entry,
)
logger = logging.getLogger(__name__)
def register() -> Blueprint:
bp = Blueprint("tickets", __name__, url_prefix="/tickets")
@bp.get("/")
async def my_tickets():
"""List all tickets for the current user/session."""
from suma_browser.app.utils.htmx import is_htmx_request
ident = current_cart_identity()
tickets = await get_user_tickets(
g.s,
user_id=ident["user_id"],
session_id=ident["session_id"],
)
if not is_htmx_request():
html = await render_template(
"_types/tickets/index.html",
tickets=tickets,
)
else:
html = await render_template(
"_types/tickets/_main_panel.html",
tickets=tickets,
)
return await make_response(html, 200)
@bp.get("/<code>/")
async def ticket_detail(code: str):
"""View a single ticket with QR code."""
from suma_browser.app.utils.htmx import is_htmx_request
ticket = await get_ticket_by_code(g.s, code)
if not ticket:
return await make_response("Ticket not found", 404)
# Verify ownership
ident = current_cart_identity()
if ident["user_id"] is not None:
if ticket.user_id != ident["user_id"]:
return await make_response("Ticket not found", 404)
elif ident["session_id"] is not None:
if ticket.session_id != ident["session_id"]:
return await make_response("Ticket not found", 404)
else:
return await make_response("Ticket not found", 404)
if not is_htmx_request():
html = await render_template(
"_types/tickets/detail.html",
ticket=ticket,
)
else:
html = await render_template(
"_types/tickets/_detail_panel.html",
ticket=ticket,
)
return await make_response(html, 200)
@bp.post("/buy/")
@clear_cache(tag="calendars", tag_scope="all")
async def buy_tickets():
"""
Purchase tickets for a calendar entry.
Creates ticket records with state='reserved' (awaiting payment).
Form fields:
entry_id — the calendar entry ID
ticket_type_id (optional) — specific ticket type
quantity — number of tickets (default 1)
"""
form = await request.form
entry_id_raw = form.get("entry_id", "").strip()
if not entry_id_raw:
return await make_response("Entry ID required", 400)
try:
entry_id = int(entry_id_raw)
except ValueError:
return await make_response("Invalid entry ID", 400)
# Load entry
entry = await g.s.scalar(
select(CalendarEntry)
.where(
CalendarEntry.id == entry_id,
CalendarEntry.deleted_at.is_(None),
)
.options(selectinload(CalendarEntry.ticket_types))
)
if not entry:
return await make_response("Entry not found", 404)
if entry.ticket_price is None:
return await make_response("Tickets not available for this entry", 400)
# Check availability
available = await get_available_ticket_count(g.s, entry_id)
quantity = int(form.get("quantity", 1))
if quantity < 1:
quantity = 1
if available is not None and quantity > available:
return await make_response(
f"Only {available} ticket(s) remaining", 400
)
# Ticket type (optional)
ticket_type_id = None
tt_raw = form.get("ticket_type_id", "").strip()
if tt_raw:
try:
ticket_type_id = int(tt_raw)
except ValueError:
pass
ident = current_cart_identity()
# Create tickets
created = []
for _ in range(quantity):
ticket = await create_ticket(
g.s,
entry_id=entry_id,
ticket_type_id=ticket_type_id,
user_id=ident["user_id"],
session_id=ident["session_id"],
state="reserved",
)
created.append(ticket)
# Re-check availability for display
remaining = await get_available_ticket_count(g.s, entry_id)
all_tickets = await get_tickets_for_entry(g.s, entry_id)
html = await render_template(
"_types/tickets/_buy_result.html",
entry=entry,
created_tickets=created,
remaining=remaining,
all_tickets=all_tickets,
)
return await make_response(html, 200)
return bp