Files
mono/events/sxc/pages/helpers.py
giles 64aa417d63 Replace JSON sx-headers with SX dict expressions, fix blog like component
sx-headers attributes now use native SX dict format {:key val} instead of
JSON strings. Eliminates manual JSON string construction in both .sx files
and Python callers.

- sx.js: parse sx-headers/sx-vals as SX dict ({: prefix) with JSON fallback,
  add _serializeDict for dict→attribute serialization, fix verbInfo scope in
  _doFetch error handler
- html.py: serialize dict attribute values via SX serialize() not str()
- All .sx files: {:X-CSRFToken csrf} replaces (str "{\"X-CSRFToken\": ...}")
- All Python callers: {"X-CSRFToken": csrf} dict replaces f-string JSON
- Blog like: extract ~blog-like-toggle, fix POST returning wrong component,
  fix emoji escapes in .sx (parser has no \U support), fix card :hx-headers
  keyword mismatch, wrap sx_content in SxExpr for evaluation

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

990 lines
35 KiB
Python

"""Layout registrations, page helpers, and shared hydration helpers.
All helpers return data dicts — no sx_call().
Markup composition lives entirely in .sx defpage and .sx defcomp files.
"""
from __future__ import annotations
from typing import Any
from shared.sx.parser import SxExpr
# ---------------------------------------------------------------------------
# Shared hydration helpers
# ---------------------------------------------------------------------------
def _add_to_defpage_ctx(**kwargs: Any) -> None:
"""Add data to g._defpage_ctx for the app-level context_processor."""
from quart import g
if not hasattr(g, '_defpage_ctx'):
g._defpage_ctx = {}
g._defpage_ctx.update(kwargs)
def _ensure_post_defpage_ctx() -> None:
"""Copy g.post_data["post"] into g._defpage_ctx for layout IO primitives."""
from quart import g
post_data = getattr(g, "post_data", None)
if post_data and post_data.get("post"):
_add_to_defpage_ctx(post=post_data["post"])
async def _ensure_container_nav_defpage_ctx() -> None:
"""Fetch container_nav and add to g._defpage_ctx for layout IO primitives."""
from quart import g
dctx = getattr(g, "_defpage_ctx", None) or {}
if dctx.get("container_nav"):
return
post = dctx.get("post") or {}
post_id = post.get("id")
slug = post.get("slug", "")
if not post_id:
return
from shared.infrastructure.fragments import fetch_fragments
current_cal = getattr(g, "calendar_slug", "") or ""
nav_params = {
"container_type": "page",
"container_id": str(post_id),
"post_slug": slug,
"current_calendar": current_cal,
}
events_nav, market_nav = await fetch_fragments([
("events", "container-nav", nav_params),
("market", "container-nav", nav_params),
], required=False)
_add_to_defpage_ctx(container_nav=events_nav + market_nav)
async def _ensure_calendar(calendar_slug: str | None) -> None:
"""Load calendar into g.calendar if not already present."""
from quart import g, abort
if hasattr(g, 'calendar'):
_add_to_defpage_ctx(calendar=g.calendar)
return
from bp.calendar.services.calendar_view import (
get_calendar_by_post_and_slug, get_calendar_by_slug,
)
post_data = getattr(g, "post_data", None)
if post_data:
post_id = (post_data.get("post") or {}).get("id")
cal = await get_calendar_by_post_and_slug(g.s, post_id, calendar_slug)
else:
cal = await get_calendar_by_slug(g.s, calendar_slug)
if not cal:
abort(404)
g.calendar = cal
g.calendar_slug = calendar_slug
_add_to_defpage_ctx(calendar=cal)
_ensure_post_defpage_ctx()
async def _ensure_entry(entry_id: int | None) -> None:
"""Load calendar entry into g.entry if not already present."""
from quart import g, abort
if hasattr(g, 'entry'):
_add_to_defpage_ctx(entry=g.entry)
return
from sqlalchemy import select
from models.calendars import CalendarEntry
result = await g.s.execute(
select(CalendarEntry).where(
CalendarEntry.id == entry_id,
CalendarEntry.deleted_at.is_(None),
)
)
entry = result.scalar_one_or_none()
if entry is None:
abort(404)
g.entry = entry
_add_to_defpage_ctx(entry=entry)
async def _ensure_entry_context(entry_id: int | None) -> None:
"""Load full entry context (ticket data, posts) into g.* and _defpage_ctx."""
from quart import g
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from models.calendars import CalendarEntry
from bp.tickets.services.tickets import (
get_available_ticket_count,
get_sold_ticket_count,
get_user_reserved_count,
)
from shared.infrastructure.cart_identity import current_cart_identity
from bp.calendar_entry.services.post_associations import get_entry_posts
await _ensure_entry(entry_id)
# Reload with ticket_types eagerly loaded
stmt = (
select(CalendarEntry)
.where(CalendarEntry.id == entry_id, CalendarEntry.deleted_at.is_(None))
.options(selectinload(CalendarEntry.ticket_types))
)
result = await g.s.execute(stmt)
calendar_entry = result.scalar_one_or_none()
if calendar_entry and getattr(g, "calendar", None):
if calendar_entry.calendar_id != g.calendar.id:
calendar_entry = None
if calendar_entry:
await g.s.refresh(calendar_entry, ['slot'])
g.entry = calendar_entry
entry_posts = await get_entry_posts(g.s, calendar_entry.id)
ticket_remaining = await get_available_ticket_count(g.s, calendar_entry.id)
ticket_sold_count = await get_sold_ticket_count(g.s, calendar_entry.id)
ident = current_cart_identity()
user_ticket_count = await get_user_reserved_count(
g.s, calendar_entry.id,
user_id=ident["user_id"],
session_id=ident["session_id"],
)
user_ticket_counts_by_type = {}
if calendar_entry.ticket_types:
for tt in calendar_entry.ticket_types:
if tt.deleted_at is None:
user_ticket_counts_by_type[tt.id] = await get_user_reserved_count(
g.s, calendar_entry.id,
user_id=ident["user_id"],
session_id=ident["session_id"],
ticket_type_id=tt.id,
)
_add_to_defpage_ctx(
entry=calendar_entry,
entry_posts=entry_posts,
ticket_remaining=ticket_remaining,
ticket_sold_count=ticket_sold_count,
user_ticket_count=user_ticket_count,
user_ticket_counts_by_type=user_ticket_counts_by_type,
)
async def _ensure_day_data(year: int, month: int, day: int) -> None:
"""Load day-specific data for layout header functions."""
from quart import g, session as qsession
if hasattr(g, 'day_date'):
return
from datetime import date as date_cls, datetime, timezone, timedelta
from sqlalchemy import select
from bp.calendar.services import get_visible_entries_for_period
from models.calendars import CalendarSlot
calendar = getattr(g, "calendar", None)
if not calendar:
return
try:
day_date = date_cls(year, month, day)
except (ValueError, TypeError):
return
period_start = datetime(year, month, day, tzinfo=timezone.utc)
period_end = period_start + timedelta(days=1)
user = getattr(g, "user", None)
session_id = qsession.get("calendar_sid")
visible = await get_visible_entries_for_period(
sess=g.s,
calendar_id=calendar.id,
period_start=period_start,
period_end=period_end,
user=user,
session_id=session_id,
)
weekday_attr = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"][day_date.weekday()]
stmt = (
select(CalendarSlot)
.where(
CalendarSlot.calendar_id == calendar.id,
getattr(CalendarSlot, weekday_attr) == True, # noqa: E712
CalendarSlot.deleted_at.is_(None),
)
.order_by(CalendarSlot.time_start.asc(), CalendarSlot.id.asc())
)
result = await g.s.execute(stmt)
day_slots = list(result.scalars())
g.day_date = day_date
_add_to_defpage_ctx(
qsession=qsession,
day_date=day_date,
day=day,
year=year,
month=month,
day_entries=visible.merged_entries,
user_entries=visible.user_entries,
confirmed_entries=visible.confirmed_entries,
day_slots=day_slots,
)
# ---------------------------------------------------------------------------
# Layouts — all layouts delegate to .sx defcomps via register_sx_layout
# ---------------------------------------------------------------------------
def _register_events_layouts() -> None:
from shared.sx.layouts import register_sx_layout
register_sx_layout("events-calendar-admin",
"events-cal-admin-layout-full", "events-cal-admin-layout-oob")
register_sx_layout("events-slots",
"events-slots-layout-full", "events-slots-layout-oob")
register_sx_layout("events-slot",
"events-slot-layout-full", "events-slot-layout-oob")
register_sx_layout("events-day-admin",
"events-day-admin-layout-full", "events-day-admin-layout-oob")
register_sx_layout("events-entry",
"events-entry-layout-full", "events-entry-layout-oob")
register_sx_layout("events-entry-admin",
"events-entry-admin-layout-full", "events-entry-admin-layout-oob")
register_sx_layout("events-ticket-types",
"events-ticket-types-layout-full", "events-ticket-types-layout-oob")
register_sx_layout("events-ticket-type",
"events-ticket-type-layout-full", "events-ticket-type-layout-oob")
register_sx_layout("events-markets",
"events-markets-layout-full", "events-markets-layout-oob")
# ---------------------------------------------------------------------------
# Badge data helpers
# ---------------------------------------------------------------------------
_ENTRY_STATE_CLASSES = {
"confirmed": "bg-emerald-100 text-emerald-800",
"provisional": "bg-amber-100 text-amber-800",
"ordered": "bg-sky-100 text-sky-800",
"pending": "bg-stone-100 text-stone-700",
"declined": "bg-red-100 text-red-800",
}
_TICKET_STATE_CLASSES = {
"confirmed": "bg-emerald-100 text-emerald-800",
"checked_in": "bg-blue-100 text-blue-800",
"reserved": "bg-amber-100 text-amber-800",
"cancelled": "bg-red-100 text-red-800",
}
def _entry_badge_data(state: str) -> dict:
cls = _ENTRY_STATE_CLASSES.get(state, "bg-stone-100 text-stone-700")
label = state.replace("_", " ").capitalize()
return {"cls": cls, "label": label}
def _ticket_badge_data(state: str) -> dict:
cls = _TICKET_STATE_CLASSES.get(state, "bg-stone-100 text-stone-700")
label = (state or "").replace("_", " ").capitalize()
return {"cls": cls, "label": label}
# ---------------------------------------------------------------------------
# Styles helper
# ---------------------------------------------------------------------------
def _styles_data() -> dict:
"""Extract common style classes from g.styles."""
from quart import g
styles = getattr(g, "styles", None) or {}
def _gs(attr):
return getattr(styles, attr, "") if hasattr(styles, attr) else styles.get(attr, "")
return {
"list-container": _gs("list_container"),
"pre-action": _gs("pre_action_button"),
"action-btn": _gs("action_button"),
"tr-cls": _gs("tr"),
"pill-cls": _gs("pill"),
"nav-btn": _gs("nav_button"),
}
# ---------------------------------------------------------------------------
# Page helpers
# ---------------------------------------------------------------------------
def _register_events_helpers() -> None:
from shared.sx.pages import register_page_helpers
register_page_helpers("events", {
"calendar-admin-data": _h_calendar_admin_data,
"day-admin-data": _h_day_admin_data,
"slots-data": _h_slots_data,
"slot-data": _h_slot_data,
"entry-data": _h_entry_data,
"entry-admin-data": _h_entry_admin_data,
"ticket-types-data": _h_ticket_types_data,
"ticket-type-data": _h_ticket_type_data,
"tickets-data": _h_tickets_data,
"ticket-detail-data": _h_ticket_detail_data,
"ticket-admin-data": _h_ticket_admin_data,
"markets-data": _h_markets_data,
})
# ---------------------------------------------------------------------------
# Calendar admin
# ---------------------------------------------------------------------------
async def _h_calendar_admin_data(calendar_slug=None, **kw) -> dict:
from quart import url_for
from shared.browser.app.csrf import generate_csrf_token
await _ensure_calendar(calendar_slug)
await _ensure_container_nav_defpage_ctx()
from quart import g
calendar = getattr(g, "calendar", None)
if not calendar:
return {}
csrf = generate_csrf_token()
cal_slug = getattr(calendar, "slug", "")
desc = getattr(calendar, "description", "") or ""
desc_edit_url = url_for("calendar.admin.calendar_description_edit",
calendar_slug=cal_slug)
return {
"cal-description": desc,
"csrf": csrf,
"desc-edit-url": desc_edit_url,
}
# ---------------------------------------------------------------------------
# Day admin
# ---------------------------------------------------------------------------
async def _h_day_admin_data(calendar_slug=None, year=None, month=None,
day=None, **kw) -> dict:
await _ensure_calendar(calendar_slug)
await _ensure_container_nav_defpage_ctx()
if year is not None:
await _ensure_day_data(int(year), int(month), int(day))
return {}
# ---------------------------------------------------------------------------
# Slots listing
# ---------------------------------------------------------------------------
async def _h_slots_data(calendar_slug=None, **kw) -> dict:
from quart import g, url_for
from shared.browser.app.csrf import generate_csrf_token
from bp.slots.services.slots import list_slots as svc_list_slots
await _ensure_calendar(calendar_slug)
await _ensure_container_nav_defpage_ctx()
calendar = getattr(g, "calendar", None)
slots = await svc_list_slots(g.s, calendar.id) if calendar else []
_add_to_defpage_ctx(slots=slots)
styles = _styles_data()
csrf = generate_csrf_token()
cal_slug = getattr(calendar, "slug", "")
hx_select = getattr(g, "hx_select_search", "#main-panel")
csrf_hdr = {"X-CSRFToken": csrf}
add_url = url_for("calendar.slots.add_form", calendar_slug=cal_slug)
slots_list = []
for s in slots:
slot_href = url_for("defpage_slot_detail",
calendar_slug=cal_slug, slot_id=s.id)
del_url = url_for("calendar.slots.slot.slot_delete",
calendar_slug=cal_slug, slot_id=s.id)
desc = getattr(s, "description", "") or ""
days_display = getattr(s, "days_display", "\u2014")
day_list = days_display.split(", ")
has_days = bool(day_list and day_list[0] != "\u2014")
time_start = s.time_start.strftime("%H:%M") if s.time_start else ""
time_end = s.time_end.strftime("%H:%M") if s.time_end else ""
cost = getattr(s, "cost", None)
cost_str = f"{cost:.2f}" if cost is not None else ""
slots_list.append({
"name": s.name,
"description": desc,
"day-list": day_list if has_days else [],
"has-days": has_days,
"flexible": "yes" if s.flexible else "no",
"time-str": f"{time_start} - {time_end}",
"cost-str": cost_str,
"slot-href": slot_href,
"del-url": del_url,
})
return {
"has-slots": bool(slots),
"slots-list": slots_list,
"add-url": add_url,
"csrf-hdr": csrf_hdr,
"hx-select": hx_select,
**styles,
}
# ---------------------------------------------------------------------------
# Slot detail
# ---------------------------------------------------------------------------
async def _h_slot_data(calendar_slug=None, slot_id=None, **kw) -> dict:
from quart import g, abort, url_for
await _ensure_calendar(calendar_slug)
await _ensure_container_nav_defpage_ctx()
from bp.slot.services.slot import get_slot as svc_get_slot
slot = await svc_get_slot(g.s, slot_id) if slot_id else None
if not slot:
abort(404)
g.slot = slot
_add_to_defpage_ctx(slot=slot)
calendar = getattr(g, "calendar", None)
styles = _styles_data()
cal_slug = getattr(calendar, "slug", "")
days_display = getattr(slot, "days_display", "\u2014")
day_list = days_display.split(", ")
has_days = bool(day_list and day_list[0] != "\u2014")
time_start = slot.time_start.strftime("%H:%M") if slot.time_start else ""
time_end = slot.time_end.strftime("%H:%M") if slot.time_end else ""
cost = getattr(slot, "cost", None)
cost_str = f"{cost:.2f}" if cost is not None else ""
edit_url = url_for("calendar.slots.slot.get_edit",
slot_id=slot.id, calendar_slug=cal_slug)
return {
"slot-id-str": str(slot.id),
"day-list": day_list if has_days else [],
"has-days": has_days,
"flexible": "yes" if getattr(slot, "flexible", False) else "no",
"time-str": f"{time_start} \u2014 {time_end}",
"cost-str": cost_str,
"edit-url": edit_url,
**styles,
}
# ---------------------------------------------------------------------------
# Entry detail (complex — sub-panels returned as SxExpr)
# ---------------------------------------------------------------------------
async def _h_entry_data(calendar_slug=None, entry_id=None, **kw) -> dict:
from quart import url_for, g
from .entries import (
_entry_nav_html,
_entry_options_html,
render_entry_tickets_config,
render_entry_posts_panel,
)
from .tickets import render_buy_form
await _ensure_calendar(calendar_slug)
await _ensure_entry_context(entry_id)
from shared.sx.page import get_template_context
ctx = await get_template_context()
entry = ctx.get("entry")
if not entry:
return {}
calendar = ctx.get("calendar")
cal_slug = getattr(calendar, "slug", "") if calendar else ""
day = ctx.get("day")
month = ctx.get("month")
year = ctx.get("year")
styles = _styles_data()
eid = entry.id
state = getattr(entry, "state", "pending") or "pending"
# Simple field data
slot = getattr(entry, "slot", None)
has_slot = slot is not None
slot_name = slot.name if slot else ""
flex_label = "(flexible)" if slot and getattr(slot, "flexible", False) else "(fixed)"
start_str = entry.start_at.strftime("%H:%M") if entry.start_at else ""
end_str = f" \u2013 {entry.end_at.strftime('%H:%M')}" if entry.end_at else " \u2013 open-ended"
cost = getattr(entry, "cost", None)
cost_str = f"\u00a3{cost:.2f}" if cost is not None else "\u00a30.00"
date_str = entry.start_at.strftime("%A, %B %d, %Y") if entry.start_at else ""
badge = _entry_badge_data(state)
edit_url = url_for(
"calendar.day.calendar_entries.calendar_entry.get_edit",
entry_id=eid, calendar_slug=cal_slug,
day=day, month=month, year=year,
)
# Complex sub-panels (pre-composed as SxExpr)
ticket_remaining = ctx.get("ticket_remaining")
ticket_sold_count = ctx.get("ticket_sold_count", 0)
user_ticket_count = ctx.get("user_ticket_count", 0)
user_ticket_counts_by_type = ctx.get("user_ticket_counts_by_type") or {}
entry_posts = ctx.get("entry_posts") or []
tickets_config = render_entry_tickets_config(entry, calendar, day, month, year)
buy_form = render_buy_form(
entry, ticket_remaining, ticket_sold_count,
user_ticket_count, user_ticket_counts_by_type,
)
posts_panel = render_entry_posts_panel(
entry_posts, entry, calendar, day, month, year,
)
options_html = _entry_options_html(entry, calendar, day, month, year)
# Entry menu (pre-composed for :menu slot)
entry_menu = _entry_nav_html(ctx)
return {
"entry-id-str": str(eid),
"entry-name": entry.name,
"has-slot": has_slot,
"slot-name": slot_name,
"flex-label": flex_label,
"time-str": start_str + end_str,
"state-badge-cls": badge["cls"],
"state-badge-label": badge["label"],
"cost-str": cost_str,
"date-str": date_str,
"edit-url": edit_url,
"tickets-config": SxExpr(tickets_config),
"buy-form": SxExpr(buy_form) if buy_form else None,
"posts-panel": SxExpr(posts_panel),
"options-html": SxExpr(options_html),
"entry-menu": SxExpr(entry_menu) if entry_menu else None,
**styles,
}
# ---------------------------------------------------------------------------
# Entry admin
# ---------------------------------------------------------------------------
async def _h_entry_admin_data(calendar_slug=None, entry_id=None,
year=None, month=None, day=None, **kw) -> dict:
from quart import url_for, g
await _ensure_calendar(calendar_slug)
await _ensure_container_nav_defpage_ctx()
await _ensure_entry_context(entry_id)
calendar = getattr(g, "calendar", None)
entry = getattr(g, "entry", None)
if not calendar or not entry:
return {}
cal_slug = getattr(calendar, "slug", "")
styles = _styles_data()
from shared.sx.page import get_template_context
ctx = await get_template_context()
select_colours = ctx.get("select_colours", "")
ticket_types_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,
)
return {
"ticket-types-href": ticket_types_href,
"select-colours": select_colours,
**styles,
}
# ---------------------------------------------------------------------------
# Ticket types listing
# ---------------------------------------------------------------------------
async def _h_ticket_types_data(calendar_slug=None, entry_id=None,
year=None, month=None, day=None, **kw) -> dict:
from quart import g, url_for
from shared.browser.app.csrf import generate_csrf_token
await _ensure_calendar(calendar_slug)
await _ensure_entry(entry_id)
entry = getattr(g, "entry", None)
calendar = getattr(g, "calendar", None)
from bp.ticket_types.services.tickets import list_ticket_types as svc_list_ticket_types
ticket_types = await svc_list_ticket_types(g.s, entry.id) if entry else []
_add_to_defpage_ctx(ticket_types=ticket_types)
styles = _styles_data()
csrf = generate_csrf_token()
cal_slug = getattr(calendar, "slug", "")
hx_select = getattr(g, "hx_select_search", "#main-panel")
eid = entry.id if entry else 0
csrf_hdr = {"X-CSRFToken": csrf}
types_list = []
for tt in (ticket_types or []):
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,
)
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,
)
cost = getattr(tt, "cost", None)
cost_str = f"\u00a3{cost:.2f}" if cost is not None else "\u00a30.00"
types_list.append({
"tt-href": tt_href,
"tt-name": tt.name,
"cost-str": cost_str,
"count": str(tt.count),
"del-url": del_url,
})
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 {
"has-types": bool(ticket_types),
"types-list": types_list,
"add-url": add_url,
"csrf-hdr": csrf_hdr,
"hx-select": hx_select,
**styles,
}
# ---------------------------------------------------------------------------
# Ticket type detail
# ---------------------------------------------------------------------------
async def _h_ticket_type_data(calendar_slug=None, entry_id=None,
ticket_type_id=None,
year=None, month=None, day=None, **kw) -> dict:
from quart import g, abort, url_for
await _ensure_calendar(calendar_slug)
await _ensure_entry(entry_id)
from bp.ticket_type.services.ticket import get_ticket_type as svc_get_ticket_type
ticket_type = await svc_get_ticket_type(g.s, ticket_type_id) if ticket_type_id else None
if not ticket_type:
abort(404)
g.ticket_type = ticket_type
_add_to_defpage_ctx(ticket_type=ticket_type)
entry = getattr(g, "entry", None)
calendar = getattr(g, "calendar", None)
styles = _styles_data()
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)
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 if entry else 0,
)
return {
"ticket-id": str(ticket_type.id),
"tt-name": ticket_type.name,
"cost-str": cost_str,
"count-str": str(count),
"edit-url": edit_url,
**styles,
}
# ---------------------------------------------------------------------------
# My tickets
# ---------------------------------------------------------------------------
async def _h_tickets_data(**kw) -> dict:
from quart import g, url_for
from shared.infrastructure.cart_identity import current_cart_identity
from bp.tickets.services.tickets import get_user_tickets
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
ctx = await get_template_context()
styles = ctx.get("styles") or {}
list_container = getattr(styles, "list_container", "") if hasattr(styles, "list_container") else styles.get("list_container", "")
tickets_list = []
for ticket in (tickets or []):
href = url_for("defpage_ticket_detail", code=ticket.code)
entry = getattr(ticket, "entry", None)
entry_name = entry.name if entry else "Unknown event"
tt = getattr(ticket, "ticket_type", None)
state = getattr(ticket, "state", "")
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')}"
badge = _ticket_badge_data(state)
tickets_list.append({
"href": href,
"entry-name": entry_name,
"type-name": tt.name if tt else None,
"time-str": time_str or None,
"cal-name": cal.name if cal else None,
"badge-cls": badge["cls"],
"badge-label": badge["label"],
"code-prefix": ticket.code[:8],
})
return {
"has-tickets": bool(tickets),
"tickets-list": tickets_list,
"list-container": list_container,
}
# ---------------------------------------------------------------------------
# Ticket detail
# ---------------------------------------------------------------------------
async def _h_ticket_detail_data(code=None, **kw) -> dict:
from quart import g, abort, url_for
from shared.infrastructure.cart_identity import current_cart_identity
from bp.tickets.services.tickets import get_ticket_by_code
ticket = await get_ticket_by_code(g.s, code) if code else None
if not ticket:
abort(404)
# Verify ownership
ident = current_cart_identity()
if ident["user_id"] is not None:
if ticket.user_id != ident["user_id"]:
abort(404)
elif ident["session_id"] is not None:
if ticket.session_id != ident["session_id"]:
abort(404)
else:
abort(404)
from shared.sx.page import get_template_context
ctx = await get_template_context()
styles = ctx.get("styles") or {}
list_container = getattr(styles, "list_container", "") if hasattr(styles, "list_container") else styles.get("list_container", "")
entry = getattr(ticket, "entry", None)
tt = getattr(ticket, "ticket_type", None)
state = getattr(ticket, "state", "")
ticket_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"}
header_bg = bg_map.get(state, "bg-stone-50")
entry_name = entry.name if entry else "Ticket"
back_href = url_for("defpage_my_tickets")
badge = _ticket_badge_data(state)
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')}"
tt_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 = (
f"(function(){{var c=document.getElementById('ticket-qr-{ticket_code}');"
"if(c&&typeof QRCode!=='undefined'){"
"var cv=document.createElement('canvas');"
f"QRCode.toCanvas(cv,'{ticket_code}',{{width:200,margin:2,color:{{dark:'#1c1917',light:'#ffffff'}}}},function(e){{if(!e)c.appendChild(cv)}});"
"}})()"
)
return {
"list-container": list_container,
"back-href": back_href,
"header-bg": header_bg,
"entry-name": entry_name,
"badge-cls": badge["cls"],
"badge-label": badge["label"],
"type-name": tt.name if tt else None,
"ticket-code": ticket_code,
"time-date": time_date,
"time-range": time_range,
"cal-name": cal.name if cal else None,
"type-desc": tt_desc,
"checkin-str": checkin_str,
"qr-script": qr_script,
}
# ---------------------------------------------------------------------------
# Ticket admin dashboard
# ---------------------------------------------------------------------------
async def _h_ticket_admin_data(**kw) -> dict:
from quart import g, url_for
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
from models.calendars import CalendarEntry, Ticket
from shared.browser.app.csrf import generate_csrf_token
result = await g.s.execute(
select(Ticket)
.options(
selectinload(Ticket.entry).selectinload(CalendarEntry.calendar),
selectinload(Ticket.ticket_type),
)
.order_by(Ticket.created_at.desc())
.limit(50)
)
tickets = result.scalars().all()
total = await g.s.scalar(select(func.count(Ticket.id)))
confirmed = await g.s.scalar(
select(func.count(Ticket.id)).where(Ticket.state == "confirmed")
)
checked_in = await g.s.scalar(
select(func.count(Ticket.id)).where(Ticket.state == "checked_in")
)
reserved = await g.s.scalar(
select(func.count(Ticket.id)).where(Ticket.state == "reserved")
)
csrf = generate_csrf_token()
lookup_url = url_for("ticket_admin.lookup")
from shared.sx.page import get_template_context
ctx = await get_template_context()
styles = ctx.get("styles") or {}
list_container = getattr(styles, "list_container", "") if hasattr(styles, "list_container") else styles.get("list_container", "")
# Stats cards data
admin_stats = []
for label, key, border, bg, text_cls in [
("Total", "total", "border-stone-200", "", "text-stone-900"),
("Confirmed", "confirmed", "border-emerald-200", "bg-emerald-50", "text-emerald-700"),
("Checked In", "checked_in", "border-blue-200", "bg-blue-50", "text-blue-700"),
("Reserved", "reserved", "border-amber-200", "bg-amber-50", "text-amber-700"),
]:
val_map = {"total": total, "confirmed": confirmed,
"checked_in": checked_in, "reserved": reserved}
val = val_map.get(key, 0) or 0
lbl_cls = text_cls.replace("700", "600").replace("900", "500") if "stone" not in text_cls else "text-stone-500"
admin_stats.append({
"border": border, "bg": bg, "text-cls": text_cls,
"label-cls": lbl_cls, "value": str(val), "label": label,
})
# Ticket rows data
admin_tickets = []
for ticket in tickets:
entry = getattr(ticket, "entry", None)
tt = getattr(ticket, "ticket_type", None)
state = getattr(ticket, "state", "")
tcode = ticket.code
checked_in_at = getattr(ticket, "checked_in_at", None)
date_str = None
if entry and entry.start_at:
date_str = entry.start_at.strftime("%d %b %Y, %H:%M")
badge = _ticket_badge_data(state)
can_checkin = state in ("confirmed", "reserved")
is_checked_in = state == "checked_in"
checkin_url = url_for("ticket_admin.do_checkin", code=tcode) if can_checkin else None
checkin_time = checked_in_at.strftime("%H:%M") if checked_in_at else ""
admin_tickets.append({
"code": tcode,
"code-short": tcode[:12] + "...",
"entry-name": entry.name if entry else "\u2014",
"date-str": date_str,
"type-name": tt.name if tt else "\u2014",
"badge-cls": badge["cls"],
"badge-label": badge["label"],
"can-checkin": can_checkin,
"is-checked-in": is_checked_in,
"checkin-url": checkin_url,
"checkin-time": checkin_time,
})
return {
"admin-stats": admin_stats,
"admin-tickets": admin_tickets,
"list-container": list_container,
"lookup-url": lookup_url,
"csrf": csrf,
"has-tickets": bool(tickets),
}
# ---------------------------------------------------------------------------
# Markets
# ---------------------------------------------------------------------------
async def _h_markets_data(**kw) -> dict:
from quart import url_for
from shared.browser.app.csrf import generate_csrf_token
from shared.sx.helpers import call_url
_ensure_post_defpage_ctx()
from shared.sx.page import get_template_context
ctx = await get_template_context()
rights = ctx.get("rights") or {}
is_admin = getattr(rights, "admin", False) if hasattr(rights, "admin") else rights.get("admin", False)
has_access = ctx.get("has_access")
can_create = has_access("markets.create_market") if callable(has_access) else is_admin
csrf = generate_csrf_token()
markets_raw = ctx.get("markets") or []
post = ctx.get("post") or {}
slug = post.get("slug", "")
csrf_hdr = {"X-CSRFToken": csrf}
markets_list = []
for m in markets_raw:
m_slug = getattr(m, "slug", "") if hasattr(m, "slug") else m.get("slug", "")
m_name = getattr(m, "name", "") if hasattr(m, "name") else m.get("name", "")
market_href = call_url(ctx, "market_url", f"/{slug}/{m_slug}/")
del_url = url_for("markets.delete_market", market_slug=m_slug)
markets_list.append({
"href": market_href,
"name": m_name,
"slug": m_slug,
"del-url": del_url,
"csrf-hdr": csrf_hdr,
})
return {
"can-create": can_create,
"create-url": url_for("markets.create_market") if can_create else None,
"csrf": csrf,
"markets-list": markets_list,
}