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 ;; Events pages — auto-mounted with absolute paths
;; All helpers return data dicts — markup composition in SX.
;; Calendar admin ;; Calendar admin
(defpage calendar-admin (defpage calendar-admin
:path "/<slug>/<calendar_slug>/admin/" :path "/<slug>/<calendar_slug>/admin/"
:auth :admin :auth :admin
:layout :events-calendar-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 ;; Day admin
(defpage day-admin (defpage day-admin
:path "/<slug>/<calendar_slug>/day/<int:year>/<int:month>/<int:day>/admin/" :path "/<slug>/<calendar_slug>/day/<int:year>/<int:month>/<int:day>/admin/"
:auth :admin :auth :admin
:layout :events-day-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 ;; Slots listing
(defpage slots-listing (defpage slots-listing
:path "/<slug>/<calendar_slug>/slots/" :path "/<slug>/<calendar_slug>/slots/"
:auth :public :auth :public
:layout :events-slots :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 ;; Slot detail
(defpage slot-detail (defpage slot-detail
:path "/<slug>/<calendar_slug>/slots/<int:slot_id>/" :path "/<slug>/<calendar_slug>/slots/<int:slot_id>/"
:auth :admin :auth :admin
:layout :events-slot :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 ;; Entry detail
(defpage entry-detail (defpage entry-detail
:path "/<slug>/<calendar_slug>/day/<int:year>/<int:month>/<int:day>/entries/<int:entry_id>/" :path "/<slug>/<calendar_slug>/day/<int:year>/<int:month>/<int:day>/entries/<int:entry_id>/"
:auth :admin :auth :admin
:layout :events-entry :layout :events-entry
:content (entry-content calendar-slug entry-id) :data (entry-data calendar-slug entry-id)
:menu (entry-menu 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 ;; Entry admin
(defpage entry-admin (defpage entry-admin
:path "/<slug>/<calendar_slug>/day/<int:year>/<int:month>/<int:day>/entries/<int:entry_id>/admin/" :path "/<slug>/<calendar_slug>/day/<int:year>/<int:month>/<int:day>/entries/<int:entry_id>/admin/"
:auth :admin :auth :admin
:layout :events-entry-admin :layout :events-entry-admin
:content (entry-admin-content calendar-slug entry-id) :data (entry-admin-data calendar-slug entry-id year month day)
:menu (admin-menu)) :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 ;; Ticket types listing
(defpage ticket-types-listing (defpage ticket-types-listing
:path "/<slug>/<calendar_slug>/day/<int:year>/<int:month>/<int:day>/entries/<int:entry_id>/ticket-types/" :path "/<slug>/<calendar_slug>/day/<int:year>/<int:month>/<int:day>/entries/<int:entry_id>/ticket-types/"
:auth :public :auth :public
:layout :events-ticket-types :layout :events-ticket-types
:content (ticket-types-content calendar-slug entry-id year month day) :data (ticket-types-data calendar-slug entry-id year month day)
:menu (admin-menu)) :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 ;; Ticket type detail
(defpage 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>/" :path "/<slug>/<calendar_slug>/day/<int:year>/<int:month>/<int:day>/entries/<int:entry_id>/ticket-types/<int:ticket_type_id>/"
:auth :admin :auth :admin
:layout :events-ticket-type :layout :events-ticket-type
:content (ticket-type-content calendar-slug entry-id ticket-type-id year month day) :data (ticket-type-data calendar-slug entry-id ticket-type-id year month day)
:menu (admin-menu)) :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 ;; My tickets
(defpage my-tickets (defpage my-tickets
:path "/tickets/" :path "/tickets/"
:auth :public :auth :public
:layout :root :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 ;; Ticket detail
(defpage ticket-detail (defpage ticket-detail
:path "/tickets/<code>/" :path "/tickets/<code>/"
:auth :public :auth :public
:layout :root :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 ;; Ticket admin dashboard
(defpage ticket-admin (defpage ticket-admin
:path "/admin/tickets/" :path "/admin/tickets/"
:auth :admin :auth :admin
:layout :root :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 ;; Markets
(defpage events-markets (defpage events-markets
:path "/<slug>/markets/" :path "/<slug>/markets/"
:auth :public :auth :public
:layout :events-markets :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 __future__ import annotations
from typing import Any from typing import Any
from shared.sx.helpers import sx_call from shared.sx.parser import SxExpr
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
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -261,6 +248,60 @@ def _register_events_layouts() -> None:
"events-markets-layout-full", "events-markets-layout-oob") "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 # Page helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -269,141 +310,468 @@ def _register_events_helpers() -> None:
from shared.sx.pages import register_page_helpers from shared.sx.pages import register_page_helpers
register_page_helpers("events", { register_page_helpers("events", {
"calendar-admin-content": _h_calendar_admin_content, "calendar-admin-data": _h_calendar_admin_data,
"day-admin-content": _h_day_admin_content, "day-admin-data": _h_day_admin_data,
"slots-content": _h_slots_content, "slots-data": _h_slots_data,
"slot-content": _h_slot_content, "slot-data": _h_slot_data,
"entry-content": _h_entry_content, "entry-data": _h_entry_data,
"entry-menu": _h_entry_menu, "entry-admin-data": _h_entry_admin_data,
"entry-admin-content": _h_entry_admin_content, "ticket-types-data": _h_ticket_types_data,
"admin-menu": _h_admin_menu, "ticket-type-data": _h_ticket_type_data,
"ticket-types-content": _h_ticket_types_content, "tickets-data": _h_tickets_data,
"ticket-type-content": _h_ticket_type_content, "ticket-detail-data": _h_ticket_detail_data,
"tickets-content": _h_tickets_content, "ticket-admin-data": _h_ticket_admin_data,
"ticket-detail-content": _h_ticket_detail_content, "markets-data": _h_markets_data,
"ticket-admin-content": _h_ticket_admin_content,
"markets-content": _h_markets_content,
}) })
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_calendar(calendar_slug)
await _ensure_container_nav_defpage_ctx() await _ensure_container_nav_defpage_ctx()
from shared.sx.page import get_template_context
ctx = await get_template_context() from quart import g
return _calendar_admin_main_panel_html(ctx) 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_calendar(calendar_slug)
await _ensure_container_nav_defpage_ctx() await _ensure_container_nav_defpage_ctx()
if year is not None: if year is not None:
await _ensure_day_data(int(year), int(month), int(day)) 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_calendar(calendar_slug)
await _ensure_container_nav_defpage_ctx() await _ensure_container_nav_defpage_ctx()
calendar = getattr(g, "calendar", None) 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 [] slots = await svc_list_slots(g.s, calendar.id) if calendar else []
_add_to_defpage_ctx(slots=slots) _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_calendar(calendar_slug)
await _ensure_container_nav_defpage_ctx() await _ensure_container_nav_defpage_ctx()
from bp.slot.services.slot import get_slot as svc_get_slot 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 slot = await svc_get_slot(g.s, slot_id) if slot_id else None
if not slot: if not slot:
abort(404) abort(404)
g.slot = slot g.slot = slot
_add_to_defpage_ctx(slot=slot) _add_to_defpage_ctx(slot=slot)
calendar = getattr(g, "calendar", None) 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_calendar(calendar_slug)
await _ensure_entry_context(entry_id) await _ensure_entry_context(entry_id)
from shared.sx.page import get_template_context from shared.sx.page import get_template_context
ctx = await 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) # Entry admin
await _ensure_entry_context(entry_id) # ---------------------------------------------------------------------------
from shared.sx.page import get_template_context
ctx = await get_template_context()
return _entry_nav_html(ctx)
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_calendar(calendar_slug)
await _ensure_container_nav_defpage_ctx() await _ensure_container_nav_defpage_ctx()
await _ensure_entry_context(entry_id) 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 from shared.sx.page import get_template_context
ctx = await 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_calendar(calendar_slug)
await _ensure_entry(entry_id) await _ensure_entry(entry_id)
entry = getattr(g, "entry", None) entry = getattr(g, "entry", None)
calendar = getattr(g, "calendar", None) calendar = getattr(g, "calendar", None)
from bp.ticket_types.services.tickets import list_ticket_types as svc_list_ticket_types 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 [] ticket_types = await svc_list_ticket_types(g.s, entry.id) if entry else []
_add_to_defpage_ctx(ticket_types=ticket_types) _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): # Ticket type detail
from quart import g, abort # ---------------------------------------------------------------------------
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_calendar(calendar_slug)
await _ensure_entry(entry_id) await _ensure_entry(entry_id)
from bp.ticket_type.services.ticket import get_ticket_type as svc_get_ticket_type 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 ticket_type = await svc_get_ticket_type(g.s, ticket_type_id) if ticket_type_id else None
if not ticket_type: if not ticket_type:
abort(404) abort(404)
g.ticket_type = ticket_type g.ticket_type = ticket_type
_add_to_defpage_ctx(ticket_type=ticket_type) _add_to_defpage_ctx(ticket_type=ticket_type)
entry = getattr(g, "entry", None) entry = getattr(g, "entry", None)
calendar = getattr(g, "calendar", 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 shared.infrastructure.cart_identity import current_cart_identity
from bp.tickets.services.tickets import get_user_tickets from bp.tickets.services.tickets import get_user_tickets
ident = current_cart_identity() ident = current_cart_identity()
tickets = await get_user_tickets( tickets = await get_user_tickets(
g.s, g.s,
user_id=ident["user_id"], user_id=ident["user_id"],
session_id=ident["session_id"], session_id=ident["session_id"],
) )
from shared.sx.page import get_template_context from shared.sx.page import get_template_context
ctx = await 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 shared.infrastructure.cart_identity import current_cart_identity
from bp.tickets.services.tickets import get_ticket_by_code from bp.tickets.services.tickets import get_ticket_by_code
ticket = await get_ticket_by_code(g.s, code) if code else None ticket = await get_ticket_by_code(g.s, code) if code else None
if not ticket: if not ticket:
abort(404) abort(404)
@@ -417,16 +785,71 @@ async def _h_ticket_detail_content(code=None, **kw):
abort(404) abort(404)
else: else:
abort(404) abort(404)
from shared.sx.page import get_template_context from shared.sx.page import get_template_context
ctx = await 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 import select, func
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from models.calendars import CalendarEntry, Ticket from models.calendars import CalendarEntry, Ticket
from shared.browser.app.csrf import generate_csrf_token
result = await g.s.execute( result = await g.s.execute(
select(Ticket) select(Ticket)
@@ -449,20 +872,118 @@ async def _h_ticket_admin_content(**kw):
reserved = await g.s.scalar( reserved = await g.s.scalar(
select(func.count(Ticket.id)).where(Ticket.state == "reserved") select(func.count(Ticket.id)).where(Ticket.state == "reserved")
) )
stats = {
"total": total or 0, csrf = generate_csrf_token()
"confirmed": confirmed or 0, lookup_url = url_for("ticket_admin.lookup")
"checked_in": checked_in or 0,
"reserved": reserved or 0, 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() _ensure_post_defpage_ctx()
from shared.sx.page import get_template_context from shared.sx.page import get_template_context
ctx = await 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,
}