All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 49s
Phase 7 of the zero-Python-rendering plan. All 100 rendering functions move from events/sx/sx_components.py into events/sxc/pages/__init__.py. Route handlers (15 files) import from sxc.pages instead. load_service_components call moves into _load_events_page_files. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
259 lines
9.1 KiB
Python
259 lines
9.1 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
|
|
POST /tickets/adjust/ — Adjust ticket quantity (+/-)
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
from quart import (
|
|
Blueprint, g, request, 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_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.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 sxc.pages import render_buy_result
|
|
return sx_response(await 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 sxc.pages import render_adjust_response
|
|
return sx_response(await render_adjust_response(
|
|
entry, ticket_remaining, ticket_sold_count,
|
|
user_ticket_count, user_ticket_counts_by_type, cart_count,
|
|
))
|
|
|
|
return bp
|