feat: ticket purchase flow, QR display, and admin check-in
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
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:
181
bp/tickets/routes.py
Normal file
181
bp/tickets/routes.py
Normal file
@@ -0,0 +1,181 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user