Files
mono/events/sxc/pages/tickets.py
giles 51ebf347ba
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 2m33s
Move events/market/blog composition from Python to .sx defcomps (Phase 9)
Continues the pattern of eliminating Python sx_call tree-building in favour
of data-driven .sx defcomps. POST/PUT/DELETE routes now pass plain data
(dicts, lists, scalars) and let .sx handle iteration, conditionals, and
layout via map/let/when/if. Single response components wrap OOB swaps.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 08:17:09 +00:00

599 lines
26 KiB
Python

"""Ticket panels, forms, admin views, buy/adjust controls."""
from __future__ import annotations
from markupsafe import escape
from shared.sx.helpers import sx_call
from shared.sx.parser import SxExpr
from .utils import _list_container, _cart_icon_ctx
# ---------------------------------------------------------------------------
# Ticket widget (inline +/- for entry cards)
# ---------------------------------------------------------------------------
def _ticket_widget_data(entry, qty: int, ticket_url: str) -> dict:
"""Extract ticket widget data for sx composition."""
from shared.browser.app.csrf import generate_csrf_token
eid = entry.id
tp = getattr(entry, "ticket_price", 0) or 0
return {
"entry_id": str(eid),
"price": f"\u00a3{tp:.2f}",
"qty": qty,
"ticket_url": ticket_url,
"csrf": generate_csrf_token(),
}
# ---------------------------------------------------------------------------
# Tickets main panel (my tickets)
# ---------------------------------------------------------------------------
def _tickets_main_panel_html(ctx: dict, tickets: list) -> str:
"""Render my tickets list via data extraction + sx defcomp."""
from quart import url_for
ticket_data = []
if tickets:
for ticket in tickets:
entry = getattr(ticket, "entry", None)
tt = getattr(ticket, "ticket_type", None)
cal = getattr(entry, "calendar", None) if entry else None
time_str = ""
if entry and entry.start_at:
time_str = entry.start_at.strftime("%A, %B %d, %Y at %H:%M")
if entry.end_at:
time_str += f" \u2013 {entry.end_at.strftime('%H:%M')}"
ticket_data.append({
"href": url_for("defpage_ticket_detail", code=ticket.code),
"entry-name": entry.name if entry else "Unknown event",
"type-name": tt.name if tt else None,
"time-str": time_str or None,
"cal-name": cal.name if cal else None,
"state": getattr(ticket, "state", ""),
"code-prefix": ticket.code[:8],
})
return sx_call("events-tickets-panel-from-data",
list_container=_list_container(ctx),
tickets=ticket_data or None)
# ---------------------------------------------------------------------------
# Ticket detail panel
# ---------------------------------------------------------------------------
def _ticket_detail_panel_html(ctx: dict, ticket) -> str:
"""Render a single ticket detail with QR code via data + sx defcomp."""
from quart import url_for
entry = getattr(ticket, "entry", None)
tt = getattr(ticket, "ticket_type", None)
state = getattr(ticket, "state", "")
code = ticket.code
cal = getattr(entry, "calendar", None) if entry else None
checked_in_at = getattr(ticket, "checked_in_at", None)
bg_map = {"confirmed": "bg-emerald-50", "checked_in": "bg-blue-50", "reserved": "bg-amber-50"}
time_date = entry.start_at.strftime("%A, %B %d, %Y") if entry and entry.start_at else None
time_range = entry.start_at.strftime("%H:%M") if entry and entry.start_at else None
if time_range and entry.end_at:
time_range += f" \u2013 {entry.end_at.strftime('%H:%M')}"
qr_script = (
f"(function(){{var c=document.getElementById('ticket-qr-{code}');"
"if(c&&typeof QRCode!=='undefined'){"
"var cv=document.createElement('canvas');"
f"QRCode.toCanvas(cv,'{code}',{{width:200,margin:2,color:{{dark:'#1c1917',light:'#ffffff'}}}},function(e){{if(!e)c.appendChild(cv)}});"
"}})()"
)
return sx_call("events-ticket-detail-from-data",
list_container=_list_container(ctx),
back_href=url_for("defpage_my_tickets"),
header_bg=bg_map.get(state, "bg-stone-50"),
entry_name=entry.name if entry else "Ticket",
state=state, type_name=tt.name if tt else None,
code=code, time_date=time_date, time_range=time_range,
cal_name=cal.name if cal else None,
type_desc=f"{tt.name} \u2014 \u00a3{tt.cost:.2f}" if tt and getattr(tt, "cost", None) else None,
checkin_str=checked_in_at.strftime("Checked in: %B %d, %Y at %H:%M") if checked_in_at else None,
qr_script=qr_script)
# ---------------------------------------------------------------------------
# Ticket admin main panel
# ---------------------------------------------------------------------------
def _ticket_admin_main_panel_html(ctx: dict, tickets: list, stats: dict) -> str:
"""Render ticket admin dashboard via data extraction + sx defcomp."""
from quart import url_for
csrf_token = ctx.get("csrf_token")
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
ticket_data = []
for ticket in tickets:
entry = getattr(ticket, "entry", None)
tt = getattr(ticket, "ticket_type", None)
state = getattr(ticket, "state", "")
code = ticket.code
checked_in_at = getattr(ticket, "checked_in_at", None)
ticket_data.append({
"code": code,
"code-short": code[:12] + "...",
"entry-name": entry.name if entry else "\u2014",
"date-str": entry.start_at.strftime("%d %b %Y, %H:%M") if entry and entry.start_at else None,
"type-name": tt.name if tt else "\u2014",
"state": state,
"checkin-url": url_for("ticket_admin.do_checkin", code=code) if state in ("confirmed", "reserved") else None,
"csrf": csrf,
"checked-in-time": checked_in_at.strftime("%H:%M") if checked_in_at else None,
})
return sx_call("events-ticket-admin-panel-from-data",
list_container=_list_container(ctx),
lookup_url=url_for("ticket_admin.lookup"),
tickets=ticket_data or None,
total=stats.get("total", 0),
confirmed=stats.get("confirmed", 0),
checked_in=stats.get("checked_in", 0),
reserved=stats.get("reserved", 0))
# ---------------------------------------------------------------------------
# Public render: ticket widget
# ---------------------------------------------------------------------------
def render_ticket_widget(entry, qty: int, ticket_url: str) -> str:
"""Render the +/- ticket widget for page_summary / all_events adjust_ticket."""
data = _ticket_widget_data(entry, qty, ticket_url)
return sx_call("events-tw-widget-from-data", **data)
# ---------------------------------------------------------------------------
# Ticket admin: checkin result
# ---------------------------------------------------------------------------
def render_checkin_result(success: bool, error: str | None, ticket) -> str:
"""Render checkin result: table row on success, error div on failure."""
if not success:
return sx_call("events-checkin-error",
message=error or "Check-in failed")
if not ticket:
return ""
code = ticket.code
entry = getattr(ticket, "entry", None)
tt = getattr(ticket, "ticket_type", None)
checked_in_at = getattr(ticket, "checked_in_at", None)
return sx_call("events-checkin-success-row-from-data",
code=code, code_short=code[:12] + "...",
entry_name=entry.name if entry else "\u2014",
date_str=entry.start_at.strftime("%d %b %Y, %H:%M") if entry and entry.start_at else None,
type_name=tt.name if tt else "\u2014",
time_str=checked_in_at.strftime("%H:%M") if checked_in_at else "Just now")
# ---------------------------------------------------------------------------
# Ticket admin: lookup result
# ---------------------------------------------------------------------------
def render_lookup_result(ticket, error: str | None) -> str:
"""Render ticket lookup result via data extraction + sx defcomp."""
from quart import url_for
from shared.browser.app.csrf import generate_csrf_token
if error:
return sx_call("events-lookup-error", message=error)
if not ticket:
return ""
entry = getattr(ticket, "entry", None)
tt = getattr(ticket, "ticket_type", None)
state = getattr(ticket, "state", "")
code = ticket.code
checked_in_at = getattr(ticket, "checked_in_at", None)
cal = getattr(entry, "calendar", None) if entry else None
checkin_url = None
if state in ("confirmed", "reserved"):
checkin_url = url_for("ticket_admin.do_checkin", code=code)
return sx_call("events-lookup-result-from-data",
entry_name=entry.name if entry else "Unknown event",
type_name=tt.name if tt else None,
date_str=entry.start_at.strftime("%A, %B %d, %Y at %H:%M") if entry and entry.start_at else None,
cal_name=cal.name if cal else None,
state=state, code=code,
checked_in_str=checked_in_at.strftime("%B %d, %Y at %H:%M") if checked_in_at else None,
checkin_url=checkin_url,
csrf=generate_csrf_token())
# ---------------------------------------------------------------------------
# Ticket admin: entry tickets table
# ---------------------------------------------------------------------------
def render_entry_tickets_admin(entry, tickets: list) -> str:
"""Render admin ticket table via data extraction + sx defcomp."""
from quart import url_for
from shared.browser.app.csrf import generate_csrf_token
csrf = generate_csrf_token()
count = len(tickets)
suffix = "s" if count != 1 else ""
ticket_data = []
for ticket in tickets:
tt = getattr(ticket, "ticket_type", None)
state = getattr(ticket, "state", "")
code = ticket.code
checked_in_at = getattr(ticket, "checked_in_at", None)
checkin_url = None
if state in ("confirmed", "reserved"):
checkin_url = url_for("ticket_admin.do_checkin", code=code)
ticket_data.append({
"code": code,
"code-short": code[:12] + "...",
"type-name": tt.name if tt else "\u2014",
"state": state,
"checkin-url": checkin_url,
"checked-in-time": checked_in_at.strftime("%H:%M") if checked_in_at else None,
})
return sx_call("events-entry-tickets-admin-from-data",
entry_name=entry.name,
count_label=f"{count} ticket{suffix}",
tickets=ticket_data or None,
csrf=csrf)
# ---------------------------------------------------------------------------
# Ticket type main panel
# ---------------------------------------------------------------------------
def render_ticket_type_main_panel(ticket_type, entry, calendar, day, month, year, *, oob: bool = False) -> str:
"""Render ticket type detail view."""
from quart import url_for, g
styles = getattr(g, "styles", None) or {}
list_container = getattr(styles, "list_container", "") if hasattr(styles, "list_container") else styles.get("list_container", "")
pre_action = getattr(styles, "pre_action_button", "") if hasattr(styles, "pre_action_button") else styles.get("pre_action_button", "")
cal_slug = getattr(calendar, "slug", "")
cost = getattr(ticket_type, "cost", None)
cost_str = f"\u00a3{cost:.2f}" if cost is not None else "\u00a30.00"
count = getattr(ticket_type, "count", 0)
tid = str(ticket_type.id)
edit_url = url_for(
"calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get_edit",
ticket_type_id=ticket_type.id, calendar_slug=cal_slug,
year=year, month=month, day=day, entry_id=entry.id,
)
def _col(label, val):
return sx_call("events-ticket-type-col", label=label, value=val)
return sx_call("events-ticket-type-panel",
ticket_id=tid, list_container=list_container,
c1=_col("Name", ticket_type.name),
c2=_col("Cost", cost_str),
c3=_col("Count", str(count)),
pre_action=pre_action, edit_url=edit_url)
# ---------------------------------------------------------------------------
# Ticket types table
# ---------------------------------------------------------------------------
def render_ticket_types_table(ticket_types, entry, calendar, day, month, year) -> str:
"""Render ticket types table via data extraction + sx defcomp."""
from quart import url_for, g
from shared.browser.app.csrf import generate_csrf_token
csrf = generate_csrf_token()
styles = getattr(g, "styles", None) or {}
list_container = getattr(styles, "list_container", "") if hasattr(styles, "list_container") else styles.get("list_container", "")
tr_cls = getattr(styles, "tr", "") if hasattr(styles, "tr") else styles.get("tr", "")
pill_cls = getattr(styles, "pill", "") if hasattr(styles, "pill") else styles.get("pill", "")
action_btn = getattr(styles, "action_button", "") if hasattr(styles, "action_button") else styles.get("action_button", "")
hx_select = getattr(g, "hx_select_search", "#main-panel")
cal_slug = getattr(calendar, "slug", "")
eid = entry.id
types_data = []
if ticket_types:
for tt in ticket_types:
cost = getattr(tt, "cost", None)
types_data.append({
"tt-href": url_for(
"calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get",
calendar_slug=cal_slug, year=year, month=month, day=day,
entry_id=eid, ticket_type_id=tt.id,
),
"tt-name": tt.name,
"cost-str": f"\u00a3{cost:.2f}" if cost is not None else "\u00a30.00",
"count": str(tt.count),
"del-url": url_for(
"calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.delete",
calendar_slug=cal_slug, year=year, month=month, day=day,
entry_id=eid, ticket_type_id=tt.id,
),
})
add_url = url_for(
"calendar.day.calendar_entries.calendar_entry.ticket_types.add_form",
calendar_slug=cal_slug, entry_id=eid, year=year, month=month, day=day,
)
return sx_call("events-ticket-types-table-from-data",
list_container=list_container,
ticket_types=types_data or None,
action_btn=action_btn, add_url=add_url,
tr_cls=tr_cls, pill_cls=pill_cls,
hx_select=hx_select,
csrf_hdr=f'{{"X-CSRFToken": "{csrf}"}}')
# ---------------------------------------------------------------------------
# Buy result (ticket purchase confirmation)
# ---------------------------------------------------------------------------
def render_buy_result(entry, created_tickets, remaining, cart_count) -> str:
"""Render buy result card with OOB cart icon — single response component."""
from quart import url_for
tickets = [
{"href": url_for("defpage_ticket_detail", code=t.code),
"code_short": t.code[:12] + "..."}
for t in created_tickets
]
cart_ctx = _cart_icon_ctx(cart_count)
return sx_call("events-buy-response",
entry_id=str(entry.id),
tickets=tickets,
remaining=remaining,
my_tickets_href=url_for("defpage_my_tickets"),
**cart_ctx)
# ---------------------------------------------------------------------------
# Buy form (ticket +/- controls)
# ---------------------------------------------------------------------------
def render_buy_form(entry, ticket_remaining, ticket_sold_count,
user_ticket_count, user_ticket_counts_by_type) -> str:
"""Render the ticket buy/adjust form — data only, .sx does layout."""
from quart import url_for
from shared.browser.app.csrf import generate_csrf_token
tp = getattr(entry, "ticket_price", None)
if tp is None:
return ""
ticket_types_orm = getattr(entry, "ticket_types", None) or []
active_types = [tt for tt in ticket_types_orm if getattr(tt, "deleted_at", None) is None]
types_data = [
{"id": tt.id, "name": tt.name,
"cost_str": f"\u00a3{tt.cost:.2f}" if tt.cost is not None else "\u00a30.00"}
for tt in active_types
]
# String keys so .sx can look up via (get counts (str id))
counts_by_type = {}
if user_ticket_counts_by_type:
counts_by_type = {str(k): v for k, v in user_ticket_counts_by_type.items()}
return sx_call("events-buy-form",
entry_id=entry.id,
state=getattr(entry, "state", ""),
price_str=f"\u00a3{tp:.2f}",
adjust_url=url_for("tickets.adjust_quantity"),
csrf=generate_csrf_token(),
my_tickets_href=url_for("defpage_my_tickets"),
info_sold=ticket_sold_count or None,
info_remaining=ticket_remaining,
info_basket=user_ticket_count or None,
ticket_types=types_data if types_data else None,
user_ticket_counts_by_type=counts_by_type if counts_by_type else None,
user_ticket_count=user_ticket_count or 0)
# ---------------------------------------------------------------------------
# Adjust response (OOB cart icon + buy form)
# ---------------------------------------------------------------------------
def render_adjust_response(entry, ticket_remaining, ticket_sold_count,
user_ticket_count, user_ticket_counts_by_type,
cart_count) -> str:
"""Render ticket adjust response — single response component with OOB cart."""
from quart import url_for
from shared.browser.app.csrf import generate_csrf_token
tp = getattr(entry, "ticket_price", None)
if tp is None:
return ""
ticket_types_orm = getattr(entry, "ticket_types", None) or []
active_types = [tt for tt in ticket_types_orm if getattr(tt, "deleted_at", None) is None]
types_data = [
{"id": tt.id, "name": tt.name,
"cost_str": f"\u00a3{tt.cost:.2f}" if tt.cost is not None else "\u00a30.00"}
for tt in active_types
]
# String keys so .sx can look up via (get counts (str id))
counts_by_type = {}
if user_ticket_counts_by_type:
counts_by_type = {str(k): v for k, v in user_ticket_counts_by_type.items()}
cart_ctx = _cart_icon_ctx(cart_count)
return sx_call("events-adjust-response",
entry_id=entry.id,
state=getattr(entry, "state", ""),
price_str=f"\u00a3{tp:.2f}",
adjust_url=url_for("tickets.adjust_quantity"),
csrf=generate_csrf_token(),
my_tickets_href=url_for("defpage_my_tickets"),
info_sold=ticket_sold_count or None,
info_remaining=ticket_remaining,
info_basket=user_ticket_count or None,
ticket_types=types_data if types_data else None,
user_ticket_counts_by_type=counts_by_type if counts_by_type else None,
user_ticket_count=user_ticket_count or 0,
**cart_ctx)
# ---------------------------------------------------------------------------
# Ticket types header rows
# ---------------------------------------------------------------------------
def _ticket_types_header_html(ctx: dict, *, oob: bool = False) -> str:
"""Build the ticket types header row."""
from quart import url_for
calendar = ctx.get("calendar")
entry = ctx.get("entry")
if not calendar or not entry:
return ""
cal_slug = getattr(calendar, "slug", "")
day = ctx.get("day")
month = ctx.get("month")
year = ctx.get("year")
link_href = url_for("calendar.day.calendar_entries.calendar_entry.ticket_types.get",
calendar_slug=cal_slug, entry_id=entry.id,
year=year, month=month, day=day)
label_html = '<i class="fa fa-ticket"></i><div class="shrink-0">ticket types</div>'
nav_html = sx_call("events-admin-placeholder-nav")
return sx_call("menu-row-sx", id="ticket_types-row", level=7,
link_href=link_href, link_label_content=SxExpr(label_html),
nav=nav_html or None, child_id="ticket_type-header-child", oob=oob)
def _ticket_type_header_html(ctx: dict, *, oob: bool = False) -> str:
"""Build the single ticket type header row."""
from quart import url_for
calendar = ctx.get("calendar")
entry = ctx.get("entry")
ticket_type = ctx.get("ticket_type")
if not calendar or not entry or not ticket_type:
return ""
cal_slug = getattr(calendar, "slug", "")
day = ctx.get("day")
month = ctx.get("month")
year = ctx.get("year")
link_href = url_for(
"calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get",
calendar_slug=cal_slug, year=year, month=month, day=day,
entry_id=entry.id, ticket_type_id=ticket_type.id,
)
label_html = (
f'<div class="flex flex-col md:flex-row md:gap-2 items-center">'
f'<div class="flex flex-row items-center gap-2">'
f'<i class="fa fa-ticket"></i>'
f'<div class="shrink-0">{escape(ticket_type.name)}</div>'
f'</div></div>'
)
nav_html = sx_call("events-admin-placeholder-nav")
return sx_call("menu-row-sx", id="ticket_type-row", level=8,
link_href=link_href, link_label_content=SxExpr(label_html),
nav=nav_html or None, child_id="ticket_type-header-child-inner", oob=oob)
# ---------------------------------------------------------------------------
# Ticket type edit form
# ---------------------------------------------------------------------------
def render_ticket_type_edit_form(ticket_type, entry, calendar, day, month, year) -> str:
"""Render ticket type edit form (replaces _types/ticket_type/_edit.html)."""
from quart import url_for, g
from shared.browser.app.csrf import generate_csrf_token
csrf = generate_csrf_token()
styles = getattr(g, "styles", None) or {}
list_container = styles.get("list_container", "") if isinstance(styles, dict) else getattr(styles, "list_container", "")
action_btn = styles.get("action_button", "") if isinstance(styles, dict) else getattr(styles, "action_button", "")
cancel_btn = styles.get("cancel_button", "") if isinstance(styles, dict) else getattr(styles, "cancel_button", "")
cal_slug = getattr(calendar, "slug", "")
tid = ticket_type.id
put_url = url_for("calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.put",
calendar_slug=cal_slug, year=year, month=month, day=day,
entry_id=entry.id, ticket_type_id=tid)
cancel_url = url_for("calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get_view",
calendar_slug=cal_slug, entry_id=entry.id,
year=year, month=month, day=day, ticket_type_id=tid)
cost = getattr(ticket_type, "cost", None)
cost_val = f"{cost:.2f}" if cost is not None else ""
count = getattr(ticket_type, "count", 0)
return sx_call("events-ticket-type-edit-form",
ticket_id=str(tid), list_container=list_container,
put_url=put_url, cancel_url=cancel_url, csrf=csrf,
name_val=ticket_type.name or "",
cost_val=cost_val, count_val=str(count),
action_btn=action_btn, cancel_btn=cancel_btn)
# ---------------------------------------------------------------------------
# Ticket type add form / button
# ---------------------------------------------------------------------------
def render_ticket_type_add_form(entry, calendar, day, month, year) -> str:
"""Render ticket type add form (replaces _types/ticket_types/_add.html)."""
from quart import url_for, g
from shared.browser.app.csrf import generate_csrf_token
csrf = generate_csrf_token()
styles = getattr(g, "styles", None) or {}
action_btn = styles.get("action_button", "") if isinstance(styles, dict) else getattr(styles, "action_button", "")
cancel_btn = styles.get("cancel_button", "") if isinstance(styles, dict) else getattr(styles, "cancel_button", "")
cal_slug = getattr(calendar, "slug", "")
post_url = url_for("calendar.day.calendar_entries.calendar_entry.ticket_types.post",
calendar_slug=cal_slug, entry_id=entry.id,
year=year, month=month, day=day)
cancel_url = url_for("calendar.day.calendar_entries.calendar_entry.ticket_types.add_button",
calendar_slug=cal_slug, entry_id=entry.id,
year=year, month=month, day=day)
csrf_hdr = f'{{"X-CSRFToken": "{csrf}"}}'
return sx_call("events-ticket-type-add-form",
post_url=post_url, csrf=csrf_hdr,
action_btn=action_btn, cancel_btn=cancel_btn,
cancel_url=cancel_url)
def render_ticket_type_add_button(entry, calendar, day, month, year) -> str:
"""Render ticket type add button (replaces _types/ticket_types/_add_button.html)."""
from quart import url_for, g
styles = getattr(g, "styles", None) or {}
action_btn = styles.get("action_button", "") if isinstance(styles, dict) else getattr(styles, "action_button", "")
cal_slug = getattr(calendar, "slug", "")
add_url = url_for("calendar.day.calendar_entries.calendar_entry.ticket_types.add_form",
calendar_slug=cal_slug, entry_id=entry.id,
year=year, month=month, day=day)
return sx_call("events-ticket-type-add-button",
action_btn=action_btn, add_url=add_url)