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>
990 lines
35 KiB
Python
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,
|
|
}
|