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>
182 lines
5.5 KiB
Python
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
|