All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m41s
Replace Python GET page handlers with declarative defpage definitions in .sx files across all 8 apps (sx docs, orders, account, market, cart, federation, events, blog). Each app now has sxc/pages/ with setup functions, layout registrations, page helpers, and .sx defpage declarations. Core infrastructure: add g I/O primitive, PageDef support for auth/layout/ data/content/filter/aside/menu slots, post_author auth level, and custom layout registration. Remove ~1400 lines of render_*_page/render_*_oob boilerplate. Update all endpoint references in routes, sx_components, and templates to defpage_* naming. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
299 lines
11 KiB
Python
299 lines
11 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_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.before_request
|
|
async def _prepare_page_data():
|
|
ep = request.endpoint or ""
|
|
if "defpage_my_tickets" in ep:
|
|
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 _tickets_main_panel_html
|
|
ctx = await get_template_context()
|
|
g.tickets_content = _tickets_main_panel_html(ctx, tickets)
|
|
elif "defpage_ticket_detail" in ep:
|
|
code = (request.view_args or {}).get("code")
|
|
ticket = await get_ticket_by_code(g.s, code) if code else None
|
|
if not ticket:
|
|
from quart import abort
|
|
abort(404)
|
|
# Verify ownership
|
|
ident = current_cart_identity()
|
|
if ident["user_id"] is not None:
|
|
if ticket.user_id != ident["user_id"]:
|
|
from quart import abort
|
|
abort(404)
|
|
elif ident["session_id"] is not None:
|
|
if ticket.session_id != ident["session_id"]:
|
|
from quart import abort
|
|
abort(404)
|
|
else:
|
|
from quart import abort
|
|
abort(404)
|
|
from shared.sx.page import get_template_context
|
|
from sx.sx_components import _ticket_detail_panel_html
|
|
ctx = await get_template_context()
|
|
g.ticket_detail_content = _ticket_detail_panel_html(ctx, ticket)
|
|
|
|
@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
|