""" Tickets blueprint — user-facing ticket views and QR codes. Routes: GET /tickets/ — My tickets list GET /tickets// — Ticket detail with QR code POST /tickets/buy/ — Purchase tickets for an entry POST /tickets/adjust/ — Adjust ticket quantity (+/-) """ 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.infrastructure.cart_identity import current_cart_identity from shared.browser.app.redis_cacher import clear_cache from shared.sx.helpers import sx_response from .services.tickets import ( create_ticket, get_ticket_by_code, get_user_tickets, get_available_ticket_count, get_tickets_for_entry, get_sold_ticket_count, get_user_reserved_count, cancel_latest_reserved_ticket, ) 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 shared.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"], ) from shared.sx.page import get_template_context from sx.sx_components import render_tickets_page, render_tickets_oob ctx = await get_template_context() if not is_htmx_request(): html = await render_tickets_page(ctx, tickets) return await make_response(html, 200) else: sx_src = await render_tickets_oob(ctx, tickets) return sx_response(sx_src) @bp.get("//") async def ticket_detail(code: str): """View a single ticket with QR code.""" from shared.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) from shared.sx.page import get_template_context from sx.sx_components import render_ticket_detail_page, render_ticket_detail_oob ctx = await get_template_context() if not is_htmx_request(): html = await render_ticket_detail_page(ctx, ticket) return await make_response(html, 200) else: sx_src = await render_ticket_detail_oob(ctx, ticket) return sx_response(sx_src) @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) # Compute cart count for OOB mini-cart update from shared.infrastructure.data_client import fetch_data from shared.contracts.dtos import CartSummaryDTO, dto_from_dict summary_params = {} if ident["user_id"] is not None: summary_params["user_id"] = ident["user_id"] if ident["session_id"] is not None: summary_params["session_id"] = ident["session_id"] raw_summary = await fetch_data("cart", "cart-summary", params=summary_params, required=False) summary = dto_from_dict(CartSummaryDTO, raw_summary) if raw_summary else CartSummaryDTO() cart_count = summary.count + summary.calendar_count + summary.ticket_count from sx.sx_components import render_buy_result return sx_response(render_buy_result(entry, created, remaining, cart_count)) @bp.post("/adjust/") @clear_cache(tag="calendars", tag_scope="all") async def adjust_quantity(): """ Adjust ticket quantity for a calendar entry (+/- pattern). Creates or cancels tickets to reach the target count. Form fields: entry_id — the calendar entry ID ticket_type_id — (optional) specific ticket type count — target quantity of reserved tickets """ 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) # 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 target = max(int(form.get("count", 0)), 0) ident = current_cart_identity() current = await get_user_reserved_count( g.s, entry_id, user_id=ident["user_id"], session_id=ident["session_id"], ticket_type_id=ticket_type_id, ) if target > current: # Need to add tickets to_add = target - current available = await get_available_ticket_count(g.s, entry_id) if available is not None and to_add > available: return await make_response( f"Only {available} ticket(s) remaining", 400 ) for _ in range(to_add): 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", ) elif target < current: # Need to remove tickets to_remove = current - target for _ in range(to_remove): await cancel_latest_reserved_ticket( g.s, entry_id, user_id=ident["user_id"], session_id=ident["session_id"], ticket_type_id=ticket_type_id, ) # Build context for re-rendering the buy form ticket_remaining = await get_available_ticket_count(g.s, entry_id) ticket_sold_count = await get_sold_ticket_count(g.s, entry_id) user_ticket_count = await get_user_reserved_count( g.s, entry_id, user_id=ident["user_id"], session_id=ident["session_id"], ) # Per-type counts for multi-type entries user_ticket_counts_by_type = {} if entry.ticket_types: for tt in entry.ticket_types: if tt.deleted_at is None: user_ticket_counts_by_type[tt.id] = await get_user_reserved_count( g.s, entry_id, user_id=ident["user_id"], session_id=ident["session_id"], ticket_type_id=tt.id, ) # Commit so cart's callback to events sees the updated tickets await g.tx.commit() g.tx = await g.s.begin() # Compute cart count for OOB mini-cart update from shared.infrastructure.data_client import fetch_data from shared.contracts.dtos import CartSummaryDTO, dto_from_dict summary_params = {} if ident["user_id"] is not None: summary_params["user_id"] = ident["user_id"] if ident["session_id"] is not None: summary_params["session_id"] = ident["session_id"] raw_summary = await fetch_data("cart", "cart-summary", params=summary_params, required=False) summary = dto_from_dict(CartSummaryDTO, raw_summary) if raw_summary else CartSummaryDTO() cart_count = summary.count + summary.calendar_count + summary.ticket_count from sx.sx_components import render_adjust_response return sx_response(render_adjust_response( entry, ticket_remaining, ticket_sold_count, user_ticket_count, user_ticket_counts_by_type, cart_count, )) return bp