Move events composition from Python to .sx defcomps (Phase 9)

Convert all 14 events page helpers from returning sx_call() strings
to returning data dicts. Defpage expressions compose SX components
with data bindings using map/fn/if/when.

Complex sub-panels (entry tickets config, buy form, posts panel,
options buttons, entry nav menu) returned as SxExpr from existing
render functions which remain for HTMX handler use.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 02:30:46 +00:00
parent 877e776977
commit 1d59023571
2 changed files with 767 additions and 100 deletions

View File

@@ -1,89 +1,235 @@
;; Events pages — auto-mounted with absolute paths
;; All helpers return data dicts — markup composition in SX.
;; Calendar admin
(defpage calendar-admin
:path "/<slug>/<calendar_slug>/admin/"
:auth :admin
:layout :events-calendar-admin
:content (calendar-admin-content calendar-slug))
:data (calendar-admin-data calendar-slug)
:content (~events-calendar-admin-panel
:description-content (~events-calendar-description-display
:description cal-description :edit-url desc-edit-url)
:csrf csrf :description cal-description))
;; Day admin
(defpage day-admin
:path "/<slug>/<calendar_slug>/day/<int:year>/<int:month>/<int:day>/admin/"
:auth :admin
:layout :events-day-admin
:content (day-admin-content calendar-slug year month day))
:data (day-admin-data calendar-slug year month day)
:content (~events-day-admin-panel))
;; Slots listing
(defpage slots-listing
:path "/<slug>/<calendar_slug>/slots/"
:auth :public
:layout :events-slots
:content (slots-content calendar-slug))
:data (slots-data calendar-slug)
:content (~events-slots-table
:list-container list-container
:rows (if has-slots
(<> (map (fn (s)
(~events-slots-row
:tr-cls tr-cls :slot-href (get s "slot-href")
:pill-cls pill-cls :hx-select hx-select
:slot-name (get s "name") :description (get s "description")
:flexible (get s "flexible")
:days (if (get s "has-days")
(~events-slot-days-pills :days-inner
(<> (map (fn (d) (~events-slot-day-pill :day d)) (get s "day-list"))))
(~events-slot-no-days))
:time-str (get s "time-str")
:cost-str (get s "cost-str") :action-btn action-btn
:del-url (get s "del-url")
:csrf-hdr csrf-hdr))
slots-list))
(~events-slots-empty-row))
:pre-action pre-action :add-url add-url))
;; Slot detail
(defpage slot-detail
:path "/<slug>/<calendar_slug>/slots/<int:slot_id>/"
:auth :admin
:layout :events-slot
:content (slot-content calendar-slug slot-id))
:data (slot-data calendar-slug slot-id)
:content (~events-slot-panel
:slot-id slot-id-str
:list-container list-container
:days (if has-days
(~events-slot-days-pills :days-inner
(<> (map (fn (d) (~events-slot-day-pill :day d)) day-list)))
(~events-slot-no-days))
:flexible flexible
:time-str time-str :cost-str cost-str
:pre-action pre-action :edit-url edit-url))
;; Entry detail
(defpage entry-detail
:path "/<slug>/<calendar_slug>/day/<int:year>/<int:month>/<int:day>/entries/<int:entry_id>/"
:auth :admin
:layout :events-entry
:content (entry-content calendar-slug entry-id)
:menu (entry-menu calendar-slug entry-id))
:data (entry-data calendar-slug entry-id)
:content (~events-entry-panel
:entry-id entry-id-str :list-container list-container
:name (~events-entry-field :label "Name"
:content (~events-entry-name-field :name entry-name))
:slot (~events-entry-field :label "Slot"
:content (if has-slot
(~events-entry-slot-assigned :slot-name slot-name :flex-label flex-label)
(~events-entry-slot-none)))
:time (~events-entry-field :label "Time Period"
:content (~events-entry-time-field :time-str time-str))
:state (~events-entry-field :label "State"
:content (~events-entry-state-field :entry-id entry-id-str
:badge (~badge :cls state-badge-cls :label state-badge-label)))
:cost (~events-entry-field :label "Cost"
:content (~events-entry-cost-field :cost cost-str))
:tickets (~events-entry-field :label "Tickets"
:content (~events-entry-tickets-field :entry-id entry-id-str
:tickets-config tickets-config))
:buy buy-form
:date (~events-entry-field :label "Date"
:content (~events-entry-date-field :date-str date-str))
:posts (~events-entry-field :label "Associated Posts"
:content (~events-entry-posts-field :entry-id entry-id-str
:posts-panel posts-panel))
:options options-html
:pre-action pre-action :edit-url edit-url)
:menu entry-menu)
;; Entry admin
(defpage entry-admin
:path "/<slug>/<calendar_slug>/day/<int:year>/<int:month>/<int:day>/entries/<int:entry_id>/admin/"
:auth :admin
:layout :events-entry-admin
:content (entry-admin-content calendar-slug entry-id)
:menu (admin-menu))
:data (entry-admin-data calendar-slug entry-id year month day)
:content (~nav-link :href ticket-types-href :label "ticket_types"
:select-colours select-colours :aclass nav-btn :is-selected false)
:menu (~events-admin-placeholder-nav))
;; Ticket types listing
(defpage ticket-types-listing
:path "/<slug>/<calendar_slug>/day/<int:year>/<int:month>/<int:day>/entries/<int:entry_id>/ticket-types/"
:auth :public
:layout :events-ticket-types
:content (ticket-types-content calendar-slug entry-id year month day)
:menu (admin-menu))
:data (ticket-types-data calendar-slug entry-id year month day)
:content (~events-ticket-types-table
:list-container list-container
:rows (if has-types
(<> (map (fn (tt)
(~events-ticket-types-row
:tr-cls tr-cls :tt-href (get tt "tt-href")
:pill-cls pill-cls :hx-select hx-select
:tt-name (get tt "tt-name") :cost-str (get tt "cost-str")
:count (get tt "count") :action-btn action-btn
:del-url (get tt "del-url")
:csrf-hdr csrf-hdr))
types-list))
(~events-ticket-types-empty-row))
:action-btn action-btn :add-url add-url)
:menu (~events-admin-placeholder-nav))
;; Ticket type detail
(defpage ticket-type-detail
:path "/<slug>/<calendar_slug>/day/<int:year>/<int:month>/<int:day>/entries/<int:entry_id>/ticket-types/<int:ticket_type_id>/"
:auth :admin
:layout :events-ticket-type
:content (ticket-type-content calendar-slug entry-id ticket-type-id year month day)
:menu (admin-menu))
:data (ticket-type-data calendar-slug entry-id ticket-type-id year month day)
:content (~events-ticket-type-panel
:ticket-id ticket-id :list-container list-container
:c1 (~events-ticket-type-col :label "Name" :value tt-name)
:c2 (~events-ticket-type-col :label "Cost" :value cost-str)
:c3 (~events-ticket-type-col :label "Count" :value count-str)
:pre-action pre-action :edit-url edit-url)
:menu (~events-admin-placeholder-nav))
;; My tickets
(defpage my-tickets
:path "/tickets/"
:auth :public
:layout :root
:content (tickets-content))
:data (tickets-data)
:content (~events-tickets-panel
:list-container list-container
:has-tickets has-tickets
:cards (when has-tickets
(<> (map (fn (t)
(~events-ticket-card
:href (get t "href") :entry-name (get t "entry-name")
:type-name (get t "type-name") :time-str (get t "time-str")
:cal-name (get t "cal-name")
:badge (~badge :cls (get t "badge-cls") :label (get t "badge-label"))
:code-prefix (get t "code-prefix")))
tickets-list)))))
;; Ticket detail
(defpage ticket-detail
:path "/tickets/<code>/"
:auth :public
:layout :root
:content (ticket-detail-content code))
:data (ticket-detail-data code)
:content (~events-ticket-detail
:list-container list-container :back-href back-href
:header-bg header-bg :entry-name entry-name
:badge (span :class (str "inline-flex items-center rounded-full px-3 py-1 text-sm font-medium " badge-cls)
badge-label)
:type-name type-name :code ticket-code
:time-date time-date :time-range time-range
:cal-name cal-name :type-desc type-desc :checkin-str checkin-str
:qr-script qr-script))
;; Ticket admin dashboard
(defpage ticket-admin
:path "/admin/tickets/"
:auth :admin
:layout :root
:content (ticket-admin-content))
:data (ticket-admin-data)
:content (~events-ticket-admin-panel
:list-container list-container
:stats (<> (map (fn (s)
(~events-ticket-admin-stat
:border (get s "border") :bg (get s "bg")
:text-cls (get s "text-cls") :label-cls (get s "label-cls")
:value (get s "value") :label (get s "label")))
admin-stats))
:lookup-url lookup-url :has-tickets has-tickets
:rows (when has-tickets
(<> (map (fn (t)
(~events-ticket-admin-row
:code (get t "code") :code-short (get t "code-short")
:entry-name (get t "entry-name")
:date (when (get t "date-str")
(~events-ticket-admin-date :date-str (get t "date-str")))
:type-name (get t "type-name")
:badge (~badge :cls (get t "badge-cls") :label (get t "badge-label"))
:action (if (get t "can-checkin")
(~events-ticket-admin-checkin-form
:checkin-url (get t "checkin-url") :code (get t "code") :csrf csrf)
(when (get t "is-checked-in")
(~events-ticket-admin-checked-in :time-str (get t "checkin-time"))))))
admin-tickets)))))
;; Markets
(defpage events-markets
:path "/<slug>/markets/"
:auth :public
:layout :events-markets
:content (markets-content))
:data (markets-data)
:content (~crud-panel
:list-id "markets-list"
:form (when can-create
(~crud-create-form :create-url create-url :csrf csrf
:errors-id "market-create-errors" :list-id "markets-list"
:placeholder "e.g. Farm Shop, Bakery" :btn-label "Add market"))
:list (if markets-list
(<> (map (fn (m)
(~crud-item :href (get m "href") :name (get m "name")
:slug (get m "slug") :del-url (get m "del-url")
:csrf-hdr (get m "csrf-hdr")
:list-id "markets-list"
:confirm-title "Delete market?"
:confirm-text "Products will be hidden (soft delete)"))
markets-list))
(~empty-state :message "No markets yet. Create one above."
:cls "text-gray-500 mt-4"))))

View File

@@ -1,26 +1,13 @@
"""Layout registrations, page helpers, and shared hydration helpers."""
"""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.helpers import sx_call
from .calendar import (
_calendar_admin_main_panel_html,
_day_admin_main_panel_html,
_markets_main_panel_html,
)
from .entries import (
_entry_main_panel_html,
_entry_nav_html,
_entry_admin_main_panel_html,
)
from .tickets import (
_tickets_main_panel_html, _ticket_detail_panel_html,
_ticket_admin_main_panel_html,
render_ticket_type_main_panel, render_ticket_types_table,
)
from .slots import render_slot_main_panel, render_slots_table
from shared.sx.parser import SxExpr
# ---------------------------------------------------------------------------
@@ -261,6 +248,60 @@ def _register_events_layouts() -> None:
"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
# ---------------------------------------------------------------------------
@@ -269,141 +310,468 @@ def _register_events_helpers() -> None:
from shared.sx.pages import register_page_helpers
register_page_helpers("events", {
"calendar-admin-content": _h_calendar_admin_content,
"day-admin-content": _h_day_admin_content,
"slots-content": _h_slots_content,
"slot-content": _h_slot_content,
"entry-content": _h_entry_content,
"entry-menu": _h_entry_menu,
"entry-admin-content": _h_entry_admin_content,
"admin-menu": _h_admin_menu,
"ticket-types-content": _h_ticket_types_content,
"ticket-type-content": _h_ticket_type_content,
"tickets-content": _h_tickets_content,
"ticket-detail-content": _h_ticket_detail_content,
"ticket-admin-content": _h_ticket_admin_content,
"markets-content": _h_markets_content,
"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,
})
async def _h_calendar_admin_content(calendar_slug=None, **kw):
# ---------------------------------------------------------------------------
# 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 shared.sx.page import get_template_context
ctx = await get_template_context()
return _calendar_admin_main_panel_html(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,
}
async def _h_day_admin_content(calendar_slug=None, year=None, month=None, day=None, **kw):
# ---------------------------------------------------------------------------
# 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 _day_admin_main_panel_html({})
return {}
async def _h_slots_content(calendar_slug=None, **kw):
from quart import g
# ---------------------------------------------------------------------------
# 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)
from bp.slots.services.slots import list_slots as svc_list_slots
slots = await svc_list_slots(g.s, calendar.id) if calendar else []
_add_to_defpage_ctx(slots=slots)
return render_slots_table(slots, calendar)
styles = _styles_data()
csrf = generate_csrf_token()
cal_slug = getattr(calendar, "slug", "")
hx_select = getattr(g, "hx_select_search", "#main-panel")
csrf_hdr = f'{{"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,
}
async def _h_slot_content(calendar_slug=None, slot_id=None, **kw):
from quart import g, abort
# ---------------------------------------------------------------------------
# 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)
return render_slot_main_panel(slot, calendar)
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,
}
async def _h_entry_content(calendar_slug=None, entry_id=None, **kw):
# ---------------------------------------------------------------------------
# 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()
return _entry_main_panel_html(ctx)
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,
}
async def _h_entry_menu(calendar_slug=None, entry_id=None, **kw):
await _ensure_calendar(calendar_slug)
await _ensure_entry_context(entry_id)
from shared.sx.page import get_template_context
ctx = await get_template_context()
return _entry_nav_html(ctx)
# ---------------------------------------------------------------------------
# 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
async def _h_entry_admin_content(calendar_slug=None, entry_id=None, **kw):
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()
return _entry_admin_main_panel_html(ctx)
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,
}
def _h_admin_menu():
return sx_call("events-admin-placeholder-nav")
# ---------------------------------------------------------------------------
# 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
async def _h_ticket_types_content(calendar_slug=None, entry_id=None,
year=None, month=None, day=None, **kw):
from quart import g
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)
return render_ticket_types_table(ticket_types, entry, calendar, day, month, year)
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 = f'{{"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,
}
async def _h_ticket_type_content(calendar_slug=None, entry_id=None,
ticket_type_id=None, year=None, month=None, day=None, **kw):
from quart import g, abort
# ---------------------------------------------------------------------------
# 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)
return render_ticket_type_main_panel(ticket_type, entry, calendar, day, month, year)
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,
}
async def _h_tickets_content(**kw):
from quart import g
# ---------------------------------------------------------------------------
# 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()
return _tickets_main_panel_html(ctx, tickets)
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,
}
async def _h_ticket_detail_content(code=None, **kw):
from quart import g, abort
# ---------------------------------------------------------------------------
# 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)
@@ -417,16 +785,71 @@ async def _h_ticket_detail_content(code=None, **kw):
abort(404)
else:
abort(404)
from shared.sx.page import get_template_context
ctx = await get_template_context()
return _ticket_detail_panel_html(ctx, ticket)
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,
}
async def _h_ticket_admin_content(**kw):
from quart import g
# ---------------------------------------------------------------------------
# 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)
@@ -449,20 +872,118 @@ async def _h_ticket_admin_content(**kw):
reserved = await g.s.scalar(
select(func.count(Ticket.id)).where(Ticket.state == "reserved")
)
stats = {
"total": total or 0,
"confirmed": confirmed or 0,
"checked_in": checked_in or 0,
"reserved": reserved or 0,
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),
}
from shared.sx.page import get_template_context
ctx = await get_template_context()
return _ticket_admin_main_panel_html(ctx, tickets, stats)
# ---------------------------------------------------------------------------
# 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
async def _h_markets_content(**kw):
_ensure_post_defpage_ctx()
from shared.sx.page import get_template_context
ctx = await get_template_context()
return _markets_main_panel_html(ctx)
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 = f'{{"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,
}