Files
rose-ash/events/sexp/sexp_components.py
giles 8e4c2c139e
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m39s
Fix duplicate menu rows on HTMX navigation between depth levels
When navigating from a deeper page (e.g. day) to a shallower one
(e.g. calendar) via HTMX, orphaned header rows from the deeper page
persisted in the DOM because OOB swaps only replaced specific child
divs, not siblings. Fix by sending empty OOB swaps to clear all
header row IDs not present at the current depth.

Applied to events (calendars/calendar/day/entry/admin/slots) and
market (market_home/browse/product/admin). Also restore app_label
in root header.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 23:09:15 +00:00

3515 lines
146 KiB
Python

"""
Events service s-expression page components.
Renders all events, page summary, calendars, calendar month, day, day admin,
calendar admin, tickets, ticket admin, and markets pages.
Called from route handlers in place of ``render_template()``.
"""
from __future__ import annotations
import os
from typing import Any
from markupsafe import escape
from shared.sexp.jinja_bridge import render, load_service_components
from shared.sexp.helpers import (
call_url, get_asset_url, root_header_html,
post_header_html as _shared_post_header_html,
post_admin_header_html,
oob_header_html,
search_mobile_html, search_desktop_html,
full_page, oob_page,
)
# Load events-specific .sexpr components at import time
load_service_components(os.path.dirname(os.path.dirname(__file__)))
# ---------------------------------------------------------------------------
# OOB header helper — delegates to shared
# ---------------------------------------------------------------------------
_oob_header_html = oob_header_html
# ---------------------------------------------------------------------------
# Post header helpers — thin wrapper over shared post_header_html
# ---------------------------------------------------------------------------
def _clear_oob(*ids: str) -> str:
"""Generate OOB swaps to remove orphaned header rows/children."""
return "".join(f'<div id="{i}" hx-swap-oob="outerHTML"></div>' for i in ids)
# All possible header row/child IDs at each depth (deepest first)
_EVENTS_DEEP_IDS = [
"entry-admin-row", "entry-admin-header-child",
"entry-row", "entry-header-child",
"day-admin-row", "day-admin-header-child",
"day-row", "day-header-child",
"calendar-admin-row", "calendar-admin-header-child",
"calendar-row", "calendar-header-child",
"calendars-row", "calendars-header-child",
"post-admin-row", "post-admin-header-child",
]
def _clear_deeper_oob(*keep_ids: str) -> str:
"""Clear all events header rows/children NOT in keep_ids."""
to_clear = [i for i in _EVENTS_DEEP_IDS if i not in keep_ids]
return _clear_oob(*to_clear)
async def _ensure_container_nav(ctx: dict) -> dict:
"""Fetch container_nav_html if not already present (for post header row)."""
if ctx.get("container_nav_html"):
return ctx
post = ctx.get("post") or {}
post_id = post.get("id")
slug = post.get("slug", "")
if not post_id:
return ctx
from shared.infrastructure.fragments import fetch_fragments
nav_params = {
"container_type": "page",
"container_id": str(post_id),
"post_slug": slug,
}
events_nav, market_nav = await fetch_fragments([
("events", "container-nav", nav_params),
("market", "container-nav", nav_params),
], required=False)
return {**ctx, "container_nav_html": events_nav + market_nav}
def _post_header_html(ctx: dict, *, oob: bool = False) -> str:
"""Build the post-level header row — delegates to shared helper."""
return _shared_post_header_html(ctx, oob=oob)
def _post_nav_html(ctx: dict) -> str:
"""Post desktop nav: calendar links + container nav (markets, etc.)."""
from quart import url_for, g
calendars = ctx.get("calendars") or []
select_colours = ctx.get("select_colours", "")
current_cal_slug = getattr(g, "calendar_slug", None)
parts = []
for cal in calendars:
cal_slug = getattr(cal, "slug", "") if hasattr(cal, "slug") else cal.get("slug", "")
cal_name = getattr(cal, "name", "") if hasattr(cal, "name") else cal.get("name", "")
href = url_for("calendar.get", calendar_slug=cal_slug)
is_sel = (cal_slug == current_cal_slug)
parts.append(render("nav-link", href=href, icon="fa fa-calendar",
label=cal_name, select_colours=select_colours,
is_selected=is_sel))
# Container nav fragments (markets, etc.)
container_nav = ctx.get("container_nav_html", "")
if container_nav:
parts.append(container_nav)
# Admin cog → blog admin for this post (cross-domain, no HTMX)
rights = ctx.get("rights") or {}
has_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False)
if has_admin:
post = ctx.get("post") or {}
slug = post.get("slug", "")
styles = ctx.get("styles") or {}
nav_btn = styles.get("nav_button", "") if isinstance(styles, dict) else getattr(styles, "nav_button", "")
select_colours = ctx.get("select_colours", "")
admin_href = call_url(ctx, "blog_url", f"/{slug}/admin/")
aclass = f"{nav_btn} {select_colours}".strip() or (
"justify-center cursor-pointer flex flex-row items-center gap-2 "
"rounded bg-stone-200 text-black p-3"
)
parts.append(
f'<div class="relative nav-group">'
f'<a href="{admin_href}" class="{aclass}">'
f'<i class="fa fa-cog" aria-hidden="true"></i></a></div>'
)
return "".join(parts)
# ---------------------------------------------------------------------------
# Post admin header
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Calendars header
# ---------------------------------------------------------------------------
def _calendars_header_html(ctx: dict, *, oob: bool = False) -> str:
"""Build the calendars section header row."""
from quart import url_for
link_href = url_for("calendars.home")
return render("menu-row", id="calendars-row", level=3,
link_href=link_href,
link_label_html=render("events-calendars-label"),
child_id="calendars-header-child", oob=oob)
# ---------------------------------------------------------------------------
# Calendar header
# ---------------------------------------------------------------------------
def _calendar_header_html(ctx: dict, *, oob: bool = False) -> str:
"""Build a single calendar's header row."""
from quart import url_for
calendar = ctx.get("calendar")
if not calendar:
return ""
cal_slug = getattr(calendar, "slug", "")
cal_name = getattr(calendar, "name", "")
cal_desc = getattr(calendar, "description", "") or ""
link_href = url_for("calendar.get", calendar_slug=cal_slug)
label_html = render("events-calendar-label",
name=cal_name, description=cal_desc)
# Desktop nav: slots + admin
nav_html = _calendar_nav_html(ctx)
return render("menu-row", id="calendar-row", level=3,
link_href=link_href, link_label_html=label_html,
nav_html=nav_html, child_id="calendar-header-child", oob=oob)
def _calendar_nav_html(ctx: dict) -> str:
"""Calendar desktop nav: Slots + admin link."""
from quart import url_for
calendar = ctx.get("calendar")
if not calendar:
return ""
cal_slug = getattr(calendar, "slug", "")
rights = ctx.get("rights") or {}
is_admin = getattr(rights, "admin", False) if hasattr(rights, "admin") else rights.get("admin", False)
select_colours = ctx.get("select_colours", "")
parts = []
slots_href = url_for("calendar.slots.get", calendar_slug=cal_slug)
parts.append(render("nav-link", href=slots_href, icon="fa fa-clock",
label="Slots", select_colours=select_colours))
if is_admin:
admin_href = url_for("calendar.admin.admin", calendar_slug=cal_slug)
parts.append(render("nav-link", href=admin_href, icon="fa fa-cog",
select_colours=select_colours))
return "".join(parts)
# ---------------------------------------------------------------------------
# Day header
# ---------------------------------------------------------------------------
def _day_header_html(ctx: dict, *, oob: bool = False) -> str:
"""Build day detail header row."""
from quart import url_for
calendar = ctx.get("calendar")
if not calendar:
return ""
cal_slug = getattr(calendar, "slug", "")
day_date = ctx.get("day_date")
if not day_date:
return ""
link_href = url_for(
"calendar.day.show_day",
calendar_slug=cal_slug,
year=day_date.year,
month=day_date.month,
day=day_date.day,
)
label_html = render("events-day-label",
date_str=day_date.strftime("%A %d %B %Y"))
nav_html = _day_nav_html(ctx)
return render("menu-row", id="day-row", level=4,
link_href=link_href, link_label_html=label_html,
nav_html=nav_html, child_id="day-header-child", oob=oob)
def _day_nav_html(ctx: dict) -> str:
"""Day desktop nav: confirmed entries scrolling menu + admin link."""
from quart import url_for
calendar = ctx.get("calendar")
if not calendar:
return ""
cal_slug = getattr(calendar, "slug", "")
day_date = ctx.get("day_date")
confirmed_entries = ctx.get("confirmed_entries") or []
rights = ctx.get("rights") or {}
is_admin = getattr(rights, "admin", False) if hasattr(rights, "admin") else rights.get("admin", False)
parts = []
# Confirmed entries nav (scrolling menu)
if confirmed_entries:
entry_links = []
for entry in confirmed_entries:
href = url_for(
"calendar.day.calendar_entries.calendar_entry.get",
calendar_slug=cal_slug,
year=day_date.year,
month=day_date.month,
day=day_date.day,
entry_id=entry.id,
)
start = entry.start_at.strftime("%H:%M") if entry.start_at else ""
end = f" \u2013 {entry.end_at.strftime('%H:%M')}" if entry.end_at else ""
entry_links.append(render("events-day-entry-link",
href=href, name=entry.name,
time_str=f"{start}{end}"))
inner = "".join(entry_links)
parts.append(render("events-day-entries-nav", inner_html=inner))
if is_admin and day_date:
admin_href = url_for(
"calendar.day.admin.admin",
calendar_slug=cal_slug,
year=day_date.year,
month=day_date.month,
day=day_date.day,
)
parts.append(render("nav-link", href=admin_href, icon="fa fa-cog"))
return "".join(parts)
# ---------------------------------------------------------------------------
# Day admin header
# ---------------------------------------------------------------------------
def _day_admin_header_html(ctx: dict, *, oob: bool = False) -> str:
"""Build day admin header row."""
from quart import url_for
calendar = ctx.get("calendar")
if not calendar:
return ""
cal_slug = getattr(calendar, "slug", "")
day_date = ctx.get("day_date")
if not day_date:
return ""
link_href = url_for(
"calendar.day.admin.admin",
calendar_slug=cal_slug,
year=day_date.year,
month=day_date.month,
day=day_date.day,
)
return render("menu-row", id="day-admin-row", level=5,
link_href=link_href, link_label="admin", icon="fa fa-cog",
child_id="day-admin-header-child", oob=oob)
# ---------------------------------------------------------------------------
# Calendar admin header
# ---------------------------------------------------------------------------
def _calendar_admin_header_html(ctx: dict, *, oob: bool = False) -> str:
"""Build calendar admin header row with nav links."""
from quart import url_for
calendar = ctx.get("calendar")
cal_slug = getattr(calendar, "slug", "") if calendar else ""
select_colours = ctx.get("select_colours", "")
nav_parts = []
if cal_slug:
for endpoint, label in [
("calendar.slots.get", "slots"),
("calendar.admin.calendar_description_edit", "description"),
]:
href = url_for(endpoint, calendar_slug=cal_slug)
nav_parts.append(render("nav-link", href=href, label=label,
select_colours=select_colours))
nav_html = "".join(nav_parts)
return render("menu-row", id="calendar-admin-row", level=4,
link_label="admin", icon="fa fa-cog",
nav_html=nav_html, child_id="calendar-admin-header-child", oob=oob)
# ---------------------------------------------------------------------------
# Markets header
# ---------------------------------------------------------------------------
def _markets_header_html(ctx: dict, *, oob: bool = False) -> str:
"""Build the markets section header row."""
from quart import url_for
link_href = url_for("markets.home")
return render("menu-row", id="markets-row", level=3,
link_href=link_href,
link_label_html=render("events-markets-label"),
child_id="markets-header-child", oob=oob)
# ---------------------------------------------------------------------------
# Calendars main panel
# ---------------------------------------------------------------------------
def _calendars_main_panel_html(ctx: dict) -> str:
"""Render the calendars list + create form panel."""
from quart import url_for
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("calendars.create_calendar") if callable(has_access) else is_admin
csrf_token = ctx.get("csrf_token")
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
calendars = ctx.get("calendars") or []
form_html = ""
if can_create:
create_url = url_for("calendars.create_calendar")
form_html = render("events-calendars-create-form",
create_url=create_url, csrf=csrf)
list_html = _calendars_list_html(ctx, calendars)
return render("events-calendars-panel",
form_html=form_html, list_html=list_html)
def _calendars_list_html(ctx: dict, calendars: list) -> str:
"""Render the calendars list items."""
from quart import url_for
from shared.utils import route_prefix
csrf_token = ctx.get("csrf_token")
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
prefix = route_prefix()
if not calendars:
return render("events-calendars-empty")
parts = []
for cal in calendars:
cal_slug = getattr(cal, "slug", "")
cal_name = getattr(cal, "name", "")
href = prefix + url_for("calendar.get", calendar_slug=cal_slug)
del_url = url_for("calendar.delete", calendar_slug=cal_slug)
csrf_hdr = f'{{"X-CSRFToken":"{csrf}"}}'
parts.append(render("events-calendars-item",
href=href, cal_name=cal_name, cal_slug=cal_slug,
del_url=del_url, csrf_hdr=csrf_hdr))
return "".join(parts)
# ---------------------------------------------------------------------------
# Calendar month grid
# ---------------------------------------------------------------------------
def _calendar_main_panel_html(ctx: dict) -> str:
"""Render the calendar month grid."""
from quart import url_for
from quart import session as qsession
calendar = ctx.get("calendar")
if not calendar:
return ""
cal_slug = getattr(calendar, "slug", "")
hx_select = ctx.get("hx_select_search", "#main-panel")
styles = ctx.get("styles") or {}
pill_cls = getattr(styles, "pill", "") if hasattr(styles, "pill") else styles.get("pill", "")
year = ctx.get("year", 2024)
month = ctx.get("month", 1)
month_name = ctx.get("month_name", "")
weekday_names = ctx.get("weekday_names", [])
weeks = ctx.get("weeks", [])
prev_month = ctx.get("prev_month", 1)
prev_month_year = ctx.get("prev_month_year", year)
next_month = ctx.get("next_month", 1)
next_month_year = ctx.get("next_month_year", year)
prev_year = ctx.get("prev_year", year - 1)
next_year = ctx.get("next_year", year + 1)
month_entries = ctx.get("month_entries") or []
user = ctx.get("user")
qs = qsession if "qsession" not in ctx else ctx["qsession"]
def nav_link(y, m):
return url_for("calendar.get", calendar_slug=cal_slug, year=y, month=m)
# Month navigation arrows
nav_arrows = []
for label, yr, mn in [
("\u00ab", prev_year, month),
("\u2039", prev_month_year, prev_month),
]:
href = nav_link(yr, mn)
nav_arrows.append(render("events-calendar-nav-arrow",
pill_cls=pill_cls, href=href, label=label))
nav_arrows.append(render("events-calendar-month-label",
month_name=month_name, year=str(year)))
for label, yr, mn in [
("\u203a", next_month_year, next_month),
("\u00bb", next_year, month),
]:
href = nav_link(yr, mn)
nav_arrows.append(render("events-calendar-nav-arrow",
pill_cls=pill_cls, href=href, label=label))
# Weekday headers
wd_html = "".join(render("events-calendar-weekday", name=wd) for wd in weekday_names)
# Day cells
cells = []
for week in weeks:
for day_cell in week:
if isinstance(day_cell, dict):
in_month = day_cell.get("in_month", True)
is_today = day_cell.get("is_today", False)
day_date = day_cell.get("date")
else:
in_month = getattr(day_cell, "in_month", True)
is_today = getattr(day_cell, "is_today", False)
day_date = getattr(day_cell, "date", None)
cell_cls = "min-h-20 sm:min-h-24 bg-white px-3 py-2 text-xs"
if not in_month:
cell_cls += " bg-stone-50 text-stone-400"
if is_today:
cell_cls += " ring-2 ring-blue-500 z-10 relative"
# Day number link
day_num_html = ""
day_short_html = ""
if day_date:
day_href = url_for(
"calendar.day.show_day",
calendar_slug=cal_slug,
year=day_date.year, month=day_date.month, day=day_date.day,
)
day_short_html = render("events-calendar-day-short",
day_str=day_date.strftime("%a"))
day_num_html = render("events-calendar-day-num",
pill_cls=pill_cls, href=day_href,
num=str(day_date.day))
# Entry badges for this day
entry_badges = []
if day_date:
for e in month_entries:
if e.start_at and e.start_at.date() == day_date:
is_mine = (
(user and e.user_id == user.id)
or (not user and e.session_id == qs.get("calendar_sid"))
)
if e.state == "confirmed":
bg_cls = "bg-emerald-200 text-emerald-900" if is_mine else "bg-emerald-100 text-emerald-800"
else:
bg_cls = "bg-sky-100 text-sky-800" if is_mine else "bg-stone-100 text-stone-700"
state_label = (e.state or "pending").replace("_", " ")
entry_badges.append(render("events-calendar-entry-badge",
bg_cls=bg_cls, name=e.name,
state_label=state_label))
badges_html = "".join(entry_badges)
cells.append(render("events-calendar-cell",
cell_cls=cell_cls, day_short_html=day_short_html,
day_num_html=day_num_html, badges_html=badges_html))
cells_html = "".join(cells)
arrows_html = "".join(nav_arrows)
return render("events-calendar-grid",
arrows_html=arrows_html, weekdays_html=wd_html,
cells_html=cells_html)
# ---------------------------------------------------------------------------
# Day main panel
# ---------------------------------------------------------------------------
def _day_main_panel_html(ctx: dict) -> str:
"""Render the day entries table + add button."""
from quart import url_for
calendar = ctx.get("calendar")
if not calendar:
return ""
cal_slug = getattr(calendar, "slug", "")
day_entries = ctx.get("day_entries") or []
day = ctx.get("day")
month = ctx.get("month")
year = ctx.get("year")
hx_select = ctx.get("hx_select_search", "#main-panel")
styles = ctx.get("styles") or {}
list_container = getattr(styles, "list_container", "") if hasattr(styles, "list_container") else styles.get("list_container", "")
pill_cls = getattr(styles, "pill", "") if hasattr(styles, "pill") else styles.get("pill", "")
tr_cls = getattr(styles, "tr", "") if hasattr(styles, "tr") else styles.get("tr", "")
pre_action = getattr(styles, "pre_action_button", "") if hasattr(styles, "pre_action_button") else styles.get("pre_action_button", "")
rows_html = ""
if day_entries:
rows_html = "".join(_day_row_html(ctx, entry) for entry in day_entries)
else:
rows_html = render("events-day-empty-row")
add_url = url_for(
"calendar.day.calendar_entries.add_form",
calendar_slug=cal_slug,
day=day, month=month, year=year,
)
return render("events-day-table",
list_container=list_container, rows_html=rows_html,
pre_action=pre_action, add_url=add_url)
def _day_row_html(ctx: dict, entry) -> str:
"""Render a single day table row."""
from quart import url_for
calendar = ctx.get("calendar")
cal_slug = getattr(calendar, "slug", "")
day = ctx.get("day")
month = ctx.get("month")
year = ctx.get("year")
hx_select = ctx.get("hx_select_search", "#main-panel")
styles = ctx.get("styles") or {}
pill_cls = getattr(styles, "pill", "") if hasattr(styles, "pill") else styles.get("pill", "")
tr_cls = getattr(styles, "tr", "") if hasattr(styles, "tr") else styles.get("tr", "")
entry_href = url_for(
"calendar.day.calendar_entries.calendar_entry.get",
calendar_slug=cal_slug, day=day, month=month, year=year, entry_id=entry.id,
)
# Name
name_html = render("events-day-row-name",
href=entry_href, pill_cls=pill_cls, name=entry.name)
# Slot/Time
slot = getattr(entry, "slot", None)
if slot:
slot_href = url_for("calendar.slots.slot.get", calendar_slug=cal_slug, slot_id=slot.id)
time_start = slot.time_start.strftime("%H:%M") if slot.time_start else ""
time_end = f" \u2192 {slot.time_end.strftime('%H:%M')}" if slot.time_end else ""
slot_html = render("events-day-row-slot",
href=slot_href, pill_cls=pill_cls, slot_name=slot.name,
time_str=f"({time_start}{time_end})")
else:
start = entry.start_at.strftime("%H:%M") if entry.start_at else ""
end = f" \u2192 {entry.end_at.strftime('%H:%M')}" if entry.end_at else ""
slot_html = render("events-day-row-time", start=start, end=end)
# State
state = getattr(entry, "state", "pending") or "pending"
state_badge = _entry_state_badge_html(state)
state_td = render("events-day-row-state",
state_id=f"entry-state-{entry.id}", badge_html=state_badge)
# Cost
cost = getattr(entry, "cost", None)
cost_str = f"\u00a3{cost:.2f}" if cost is not None else "\u00a30.00"
cost_td = render("events-day-row-cost", cost_str=cost_str)
# Tickets
tp = getattr(entry, "ticket_price", None)
if tp is not None:
tc = getattr(entry, "ticket_count", None)
tc_str = f"{tc} tickets" if tc is not None else "Unlimited"
tickets_td = render("events-day-row-tickets",
price_str=f"\u00a3{tp:.2f}", count_str=tc_str)
else:
tickets_td = render("events-day-row-no-tickets")
actions_td = render("events-day-row-actions")
return render("events-day-row",
tr_cls=tr_cls, name_html=name_html, slot_html=slot_html,
state_html=state_td, cost_html=cost_td,
tickets_html=tickets_td, actions_html=actions_td)
def _entry_state_badge_html(state: str) -> str:
"""Render an entry state badge."""
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",
}
cls = state_classes.get(state, "bg-stone-100 text-stone-700")
label = state.replace("_", " ").capitalize()
return render("events-state-badge", cls=cls, label=label)
# ---------------------------------------------------------------------------
# Day admin main panel
# ---------------------------------------------------------------------------
def _day_admin_main_panel_html(ctx: dict) -> str:
"""Render day admin panel (placeholder nav)."""
return render("events-day-admin-panel")
# ---------------------------------------------------------------------------
# Calendar admin main panel
# ---------------------------------------------------------------------------
def _calendar_admin_main_panel_html(ctx: dict) -> str:
"""Render calendar admin config panel with description editor."""
from quart import url_for
calendar = ctx.get("calendar")
if not calendar:
return ""
csrf_token = ctx.get("csrf_token")
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
cal_slug = getattr(calendar, "slug", "")
desc = getattr(calendar, "description", "") or ""
hx_select = ctx.get("hx_select_search", "#main-panel")
desc_edit_url = url_for("calendar.admin.calendar_description_edit", calendar_slug=cal_slug)
description_html = _calendar_description_display_html(calendar, desc_edit_url)
return render("events-calendar-admin-panel",
description_html=description_html, csrf=csrf,
description=desc)
def _calendar_description_display_html(calendar, edit_url: str) -> str:
"""Render calendar description display with edit button."""
desc = getattr(calendar, "description", "") or ""
return render("events-calendar-description-display",
description=desc, edit_url=edit_url)
# ---------------------------------------------------------------------------
# Markets main panel
# ---------------------------------------------------------------------------
def _markets_main_panel_html(ctx: dict) -> str:
"""Render markets list + create form panel."""
from quart import url_for
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_token = ctx.get("csrf_token")
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
markets = ctx.get("markets") or []
form_html = ""
if can_create:
create_url = url_for("markets.create_market")
form_html = render("events-markets-create-form",
create_url=create_url, csrf=csrf)
list_html = _markets_list_html(ctx, markets)
return render("events-markets-panel",
form_html=form_html, list_html=list_html)
def _markets_list_html(ctx: dict, markets: list) -> str:
"""Render markets list items."""
from quart import url_for
csrf_token = ctx.get("csrf_token")
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
post = ctx.get("post") or {}
slug = post.get("slug", "")
if not markets:
return render("events-markets-empty")
parts = []
for m in markets:
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)
csrf_hdr = f'{{"X-CSRFToken":"{csrf}"}}'
parts.append(render("events-markets-item",
href=market_href, market_name=m_name,
market_slug=m_slug, del_url=del_url,
csrf_hdr=csrf_hdr))
return "".join(parts)
# ---------------------------------------------------------------------------
# Ticket state badge helper
# ---------------------------------------------------------------------------
def _ticket_state_badge_html(state: str) -> str:
"""Render a ticket state badge."""
cls_map = {
"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",
}
cls = cls_map.get(state, "bg-stone-100 text-stone-700")
label = (state or "").replace("_", " ").capitalize()
return render("events-state-badge", cls=cls, label=label)
# ---------------------------------------------------------------------------
# Tickets main panel (my tickets)
# ---------------------------------------------------------------------------
def _tickets_main_panel_html(ctx: dict, tickets: list) -> str:
"""Render my tickets list."""
from quart import url_for
ticket_cards = []
if tickets:
for ticket in tickets:
href = url_for("tickets.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')}"
ticket_cards.append(render("events-ticket-card",
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_html=_ticket_state_badge_html(state),
code_prefix=ticket.code[:8]))
cards_html = "".join(ticket_cards)
return render("events-tickets-panel",
list_container=_list_container(ctx),
has_tickets=bool(tickets), cards_html=cards_html)
# ---------------------------------------------------------------------------
# Ticket detail panel
# ---------------------------------------------------------------------------
def _ticket_detail_panel_html(ctx: dict, ticket) -> str:
"""Render a single ticket detail with QR code."""
from quart import url_for
entry = getattr(ticket, "entry", None)
tt = getattr(ticket, "ticket_type", None)
state = getattr(ticket, "state", "")
code = ticket.code
cal = getattr(entry, "calendar", None) if entry else None
checked_in_at = getattr(ticket, "checked_in_at", None)
bg_map = {"confirmed": "bg-emerald-50", "checked_in": "bg-blue-50", "reserved": "bg-amber-50"}
header_bg = bg_map.get(state, "bg-stone-50")
entry_name = entry.name if entry else "Ticket"
back_href = url_for("tickets.my_tickets")
# Badge with larger sizing
badge = _ticket_state_badge_html(state).replace('px-2 py-0.5 text-xs', 'px-3 py-1 text-sm')
# Time info
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-{code}');"
"if(c&&typeof QRCode!=='undefined'){"
"var cv=document.createElement('canvas');"
f"QRCode.toCanvas(cv,'{code}',{{width:200,margin:2,color:{{dark:'#1c1917',light:'#ffffff'}}}},function(e){{if(!e)c.appendChild(cv)}});"
"}})()"
)
return render("events-ticket-detail",
list_container=_list_container(ctx), back_href=back_href,
header_bg=header_bg, entry_name=entry_name,
badge_html=badge, type_name=tt.name if tt else None,
code=code, time_date=time_date, time_range=time_range,
cal_name=cal.name if cal else None,
type_desc=tt_desc, checkin_str=checkin_str,
qr_script=qr_script)
# ---------------------------------------------------------------------------
# Ticket admin main panel
# ---------------------------------------------------------------------------
def _ticket_admin_main_panel_html(ctx: dict, tickets: list, stats: dict) -> str:
"""Render ticket admin dashboard."""
from quart import url_for
csrf_token = ctx.get("csrf_token")
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
lookup_url = url_for("ticket_admin.lookup")
# Stats cards
stats_html = ""
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 = stats.get(key, 0)
lbl_cls = text_cls.replace("700", "600").replace("900", "500") if "stone" not in text_cls else "text-stone-500"
stats_html += render("events-ticket-admin-stat",
border=border, bg=bg, text_cls=text_cls,
label_cls=lbl_cls, value=str(val), label=label)
# Ticket rows
rows_html = ""
for ticket in tickets:
entry = getattr(ticket, "entry", None)
tt = getattr(ticket, "ticket_type", None)
state = getattr(ticket, "state", "")
code = ticket.code
date_html = ""
if entry and entry.start_at:
date_html = render("events-ticket-admin-date",
date_str=entry.start_at.strftime("%d %b %Y, %H:%M"))
action_html = ""
if state in ("confirmed", "reserved"):
checkin_url = url_for("ticket_admin.do_checkin", code=code)
action_html = render("events-ticket-admin-checkin-form",
checkin_url=checkin_url, code=code, csrf=csrf)
elif state == "checked_in":
checked_in_at = getattr(ticket, "checked_in_at", None)
t_str = checked_in_at.strftime("%H:%M") if checked_in_at else ""
action_html = render("events-ticket-admin-checked-in",
time_str=t_str)
rows_html += render("events-ticket-admin-row",
code=code, code_short=code[:12] + "...",
entry_name=entry.name if entry else "\u2014",
date_html=date_html,
type_name=tt.name if tt else "\u2014",
badge_html=_ticket_state_badge_html(state),
action_html=action_html)
return render("events-ticket-admin-panel",
list_container=_list_container(ctx), stats_html=stats_html,
lookup_url=lookup_url, has_tickets=bool(tickets),
rows_html=rows_html)
# ---------------------------------------------------------------------------
# All events / page summary entry cards
# ---------------------------------------------------------------------------
def _entry_card_html(entry, page_info: dict, pending_tickets: dict,
ticket_url: str, events_url_fn, *, is_page_scoped: bool = False,
post: dict | None = None) -> str:
"""Render a list card for one event entry."""
pi = page_info.get(getattr(entry, "calendar_container_id", 0), {})
if is_page_scoped and post:
page_slug = pi.get("slug", post.get("slug", ""))
else:
page_slug = pi.get("slug", "")
page_title = pi.get("title")
day_href = ""
if page_slug and entry.start_at:
day_href = events_url_fn(f"/{page_slug}/{entry.calendar_slug}/day/{entry.start_at.strftime('%Y/%-m/%-d')}/")
entry_href = f"{day_href}entries/{entry.id}/" if day_href else ""
# Title (linked or plain)
if entry_href:
title_html = render("events-entry-title-linked",
href=entry_href, name=entry.name)
else:
title_html = render("events-entry-title-plain", name=entry.name)
# Badges
badges_html = ""
if page_title and (not is_page_scoped or page_title != (post or {}).get("title")):
page_href = events_url_fn(f"/{page_slug}/")
badges_html += render("events-entry-page-badge",
href=page_href, title=page_title)
cal_name = getattr(entry, "calendar_name", "")
if cal_name:
badges_html += render("events-entry-cal-badge", name=cal_name)
# Time line
time_parts = ""
if day_href and not is_page_scoped:
time_parts += render("events-entry-time-linked",
href=day_href,
date_str=entry.start_at.strftime("%a %-d %b"))
elif not is_page_scoped:
time_parts += render("events-entry-time-plain",
date_str=entry.start_at.strftime("%a %-d %b"))
time_parts += entry.start_at.strftime("%H:%M")
if entry.end_at:
time_parts += f' \u2013 {entry.end_at.strftime("%H:%M")}'
cost = getattr(entry, "cost", None)
cost_html = render("events-entry-cost",
cost_html=f"&pound;{cost:.2f}") if cost else ""
# Ticket widget
tp = getattr(entry, "ticket_price", None)
widget_html = ""
if tp is not None:
qty = pending_tickets.get(entry.id, 0)
widget_html = render("events-entry-widget-wrapper",
widget_html=_ticket_widget_html(entry, qty, ticket_url, ctx={}))
return render("events-entry-card",
title_html=title_html, badges_html=badges_html,
time_parts=time_parts, cost_html=cost_html,
widget_html=widget_html)
def _entry_card_tile_html(entry, page_info: dict, pending_tickets: dict,
ticket_url: str, events_url_fn, *, is_page_scoped: bool = False,
post: dict | None = None) -> str:
"""Render a tile card for one event entry."""
pi = page_info.get(getattr(entry, "calendar_container_id", 0), {})
if is_page_scoped and post:
page_slug = pi.get("slug", post.get("slug", ""))
else:
page_slug = pi.get("slug", "")
page_title = pi.get("title")
day_href = ""
if page_slug and entry.start_at:
day_href = events_url_fn(f"/{page_slug}/{entry.calendar_slug}/day/{entry.start_at.strftime('%Y/%-m/%-d')}/")
entry_href = f"{day_href}entries/{entry.id}/" if day_href else ""
# Title
if entry_href:
title_html = render("events-entry-title-tile-linked",
href=entry_href, name=entry.name)
else:
title_html = render("events-entry-title-tile-plain", name=entry.name)
# Badges
badges_html = ""
if page_title and (not is_page_scoped or page_title != (post or {}).get("title")):
page_href = events_url_fn(f"/{page_slug}/")
badges_html += render("events-entry-page-badge",
href=page_href, title=page_title)
cal_name = getattr(entry, "calendar_name", "")
if cal_name:
badges_html += render("events-entry-cal-badge", name=cal_name)
# Time
time_html = ""
if day_href:
time_html += render("events-entry-time-linked",
href=day_href,
date_str=entry.start_at.strftime("%a %-d %b")).replace(" &middot; ", "")
else:
time_html += entry.start_at.strftime("%a %-d %b")
time_html += f' \u00b7 {entry.start_at.strftime("%H:%M")}'
if entry.end_at:
time_html += f' \u2013 {entry.end_at.strftime("%H:%M")}'
cost = getattr(entry, "cost", None)
cost_html = render("events-entry-cost",
cost_html=f"&pound;{cost:.2f}") if cost else ""
# Ticket widget
tp = getattr(entry, "ticket_price", None)
widget_html = ""
if tp is not None:
qty = pending_tickets.get(entry.id, 0)
widget_html = render("events-entry-tile-widget-wrapper",
widget_html=_ticket_widget_html(entry, qty, ticket_url, ctx={}))
return render("events-entry-card-tile",
title_html=title_html, badges_html=badges_html,
time_html=time_html, cost_html=cost_html,
widget_html=widget_html)
def _ticket_widget_html(entry, qty: int, ticket_url: str, *, ctx: dict) -> str:
"""Render the inline +/- ticket widget."""
csrf_token_val = ""
if ctx:
ct = ctx.get("csrf_token")
csrf_token_val = ct() if callable(ct) else (ct or "")
else:
try:
from flask_wtf.csrf import generate_csrf
csrf_token_val = generate_csrf()
except Exception:
pass
eid = entry.id
tp = getattr(entry, "ticket_price", 0) or 0
tgt = f"#page-ticket-{eid}"
def _tw_form(count_val, btn_html):
return render("events-tw-form",
ticket_url=ticket_url, target=tgt,
csrf=csrf_token_val, entry_id=str(eid),
count_val=str(count_val), btn_html=btn_html)
if qty == 0:
inner = _tw_form(1, render("events-tw-cart-plus"))
else:
minus = _tw_form(qty - 1, render("events-tw-minus"))
cart_icon = render("events-tw-cart-icon", qty=str(qty))
plus = _tw_form(qty + 1, render("events-tw-plus"))
inner = minus + cart_icon + plus
return render("events-tw-widget",
entry_id=str(eid), price_html=f"&pound;{tp:.2f}",
inner_html=inner)
def _entry_cards_html(entries, page_info, pending_tickets, ticket_url,
events_url_fn, view, page, has_more, next_url,
*, is_page_scoped=False, post=None) -> str:
"""Render entry cards (list or tile) with sentinel."""
parts = []
last_date = None
for entry in entries:
if view == "tile":
parts.append(_entry_card_tile_html(
entry, page_info, pending_tickets, ticket_url, events_url_fn,
is_page_scoped=is_page_scoped, post=post,
))
else:
entry_date = entry.start_at.strftime("%A %-d %B %Y") if entry.start_at else ""
if entry_date != last_date:
parts.append(render("events-date-separator",
date_str=entry_date))
last_date = entry_date
parts.append(_entry_card_html(
entry, page_info, pending_tickets, ticket_url, events_url_fn,
is_page_scoped=is_page_scoped, post=post,
))
if has_more:
parts.append(render("events-sentinel",
page=str(page), next_url=next_url))
return "".join(parts)
# ---------------------------------------------------------------------------
# All events / page summary main panels
# ---------------------------------------------------------------------------
_LIST_SVG = None
_TILE_SVG = None
def _get_list_svg():
global _LIST_SVG
if _LIST_SVG is None:
_LIST_SVG = render("events-list-svg")
return _LIST_SVG
def _get_tile_svg():
global _TILE_SVG
if _TILE_SVG is None:
_TILE_SVG = render("events-tile-svg")
return _TILE_SVG
def _view_toggle_html(ctx: dict, view: str) -> str:
"""Render the list/tile view toggle bar."""
from shared.utils import route_prefix
prefix = route_prefix()
clh = ctx.get("current_local_href", "/")
hx_select = ctx.get("hx_select_search", "#main-panel")
list_href = prefix + str(clh)
tile_href = prefix + str(clh)
if "?" in list_href:
list_href = list_href.split("?")[0]
if "?" in tile_href:
tile_href = tile_href.split("?")[0] + "?view=tile"
else:
tile_href = tile_href + "?view=tile"
list_active = 'bg-stone-200 text-stone-800' if view != 'tile' else 'text-stone-400 hover:text-stone-600'
tile_active = 'bg-stone-200 text-stone-800' if view == 'tile' else 'text-stone-400 hover:text-stone-600'
return render("events-view-toggle",
list_href=list_href, tile_href=tile_href,
hx_select=hx_select, list_active=list_active,
tile_active=tile_active, list_svg=_get_list_svg(),
tile_svg=_get_tile_svg())
def _events_main_panel_html(ctx: dict, entries, has_more, pending_tickets, page_info,
page, view, ticket_url, next_url, events_url_fn,
*, is_page_scoped=False, post=None) -> str:
"""Render the events main panel with view toggle + cards."""
toggle = _view_toggle_html(ctx, view)
if entries:
cards = _entry_cards_html(
entries, page_info, pending_tickets, ticket_url, events_url_fn,
view, page, has_more, next_url,
is_page_scoped=is_page_scoped, post=post,
)
grid_cls = ("max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4"
if view == "tile" else "max-w-full px-3 py-3 space-y-3")
body = render("events-grid", grid_cls=grid_cls, cards_html=cards)
else:
body = render("events-empty")
return render("events-main-panel-body",
toggle_html=toggle, body_html=body)
# ---------------------------------------------------------------------------
# Utility
# ---------------------------------------------------------------------------
def _list_container(ctx: dict) -> str:
styles = ctx.get("styles") or {}
return getattr(styles, "list_container", "") if hasattr(styles, "list_container") else styles.get("list_container", "")
# ===========================================================================
# PUBLIC API
# ===========================================================================
# ---------------------------------------------------------------------------
# All events
# ---------------------------------------------------------------------------
async def render_all_events_page(ctx: dict, entries, has_more, pending_tickets,
page_info, page, view) -> str:
"""Full page: all events listing."""
from quart import url_for
from shared.utils import route_prefix
from shared.infrastructure.urls import events_url
prefix = route_prefix()
view_param = f"&view={view}" if view != "list" else ""
ticket_url = url_for("all_events.adjust_ticket")
next_url = prefix + url_for("all_events.entries_fragment", page=page + 1) + (f"?view={view}" if view != "list" else "")
content = _events_main_panel_html(
ctx, entries, has_more, pending_tickets, page_info,
page, view, ticket_url, next_url, events_url,
)
hdr = root_header_html(ctx)
return full_page(ctx, header_rows_html=hdr, content_html=content)
async def render_all_events_oob(ctx: dict, entries, has_more, pending_tickets,
page_info, page, view) -> str:
"""OOB response: all events listing (htmx nav)."""
from quart import url_for
from shared.utils import route_prefix
from shared.infrastructure.urls import events_url
prefix = route_prefix()
ticket_url = url_for("all_events.adjust_ticket")
next_url = prefix + url_for("all_events.entries_fragment", page=page + 1) + (f"?view={view}" if view != "list" else "")
content = _events_main_panel_html(
ctx, entries, has_more, pending_tickets, page_info,
page, view, ticket_url, next_url, events_url,
)
return oob_page(ctx, content_html=content)
async def render_all_events_cards(entries, has_more, pending_tickets,
page_info, page, view) -> str:
"""Pagination fragment: all events cards only."""
from quart import url_for
from shared.utils import route_prefix
from shared.infrastructure.urls import events_url
prefix = route_prefix()
ticket_url = url_for("all_events.adjust_ticket")
next_url = prefix + url_for("all_events.entries_fragment", page=page + 1) + (f"?view={view}" if view != "list" else "")
return _entry_cards_html(
entries, page_info, pending_tickets, ticket_url, events_url,
view, page, has_more, next_url,
)
# ---------------------------------------------------------------------------
# Page summary
# ---------------------------------------------------------------------------
async def render_page_summary_page(ctx: dict, entries, has_more, pending_tickets,
page_info, page, view) -> str:
"""Full page: page-scoped events listing."""
from quart import url_for
from shared.utils import route_prefix
from shared.infrastructure.urls import events_url
prefix = route_prefix()
post = ctx.get("post") or {}
ticket_url = url_for("page_summary.adjust_ticket")
next_url = prefix + url_for("page_summary.entries_fragment", page=page + 1) + (f"?view={view}" if view != "list" else "")
content = _events_main_panel_html(
ctx, entries, has_more, pending_tickets, page_info,
page, view, ticket_url, next_url, events_url,
is_page_scoped=True, post=post,
)
hdr = root_header_html(ctx)
hdr += render("header-child",
inner_html=_post_header_html(ctx))
return full_page(ctx, header_rows_html=hdr, content_html=content)
async def render_page_summary_oob(ctx: dict, entries, has_more, pending_tickets,
page_info, page, view) -> str:
"""OOB response: page-scoped events (htmx nav)."""
from quart import url_for
from shared.utils import route_prefix
from shared.infrastructure.urls import events_url
prefix = route_prefix()
post = ctx.get("post") or {}
ticket_url = url_for("page_summary.adjust_ticket")
next_url = prefix + url_for("page_summary.entries_fragment", page=page + 1) + (f"?view={view}" if view != "list" else "")
content = _events_main_panel_html(
ctx, entries, has_more, pending_tickets, page_info,
page, view, ticket_url, next_url, events_url,
is_page_scoped=True, post=post,
)
oobs = _post_header_html(ctx, oob=True)
oobs += _clear_deeper_oob("post-row", "post-header-child")
return oob_page(ctx, oobs_html=oobs, content_html=content)
async def render_page_summary_cards(entries, has_more, pending_tickets,
page_info, page, view, post) -> str:
"""Pagination fragment: page-scoped events cards only."""
from quart import url_for
from shared.utils import route_prefix
from shared.infrastructure.urls import events_url
prefix = route_prefix()
ticket_url = url_for("page_summary.adjust_ticket")
next_url = prefix + url_for("page_summary.entries_fragment", page=page + 1) + (f"?view={view}" if view != "list" else "")
return _entry_cards_html(
entries, page_info, pending_tickets, ticket_url, events_url,
view, page, has_more, next_url,
is_page_scoped=True, post=post,
)
# ---------------------------------------------------------------------------
# Calendars home
# ---------------------------------------------------------------------------
async def render_calendars_page(ctx: dict) -> str:
"""Full page: calendars listing."""
content = _calendars_main_panel_html(ctx)
ctx = await _ensure_container_nav(ctx)
slug = (ctx.get("post") or {}).get("slug", "")
root_hdr = root_header_html(ctx)
post_hdr = _post_header_html(ctx)
admin_hdr = post_admin_header_html(ctx, slug, selected="calendars")
return full_page(ctx, header_rows_html=root_hdr + post_hdr + admin_hdr, content_html=content)
async def render_calendars_oob(ctx: dict) -> str:
"""OOB response: calendars listing."""
content = _calendars_main_panel_html(ctx)
ctx = await _ensure_container_nav(ctx)
slug = (ctx.get("post") or {}).get("slug", "")
oobs = post_admin_header_html(ctx, slug, oob=True, selected="calendars")
oobs += _clear_deeper_oob("post-row", "post-header-child",
"post-admin-row", "post-admin-header-child")
return oob_page(ctx, oobs_html=oobs, content_html=content)
# ---------------------------------------------------------------------------
# Calendar month view
# ---------------------------------------------------------------------------
async def render_calendar_page(ctx: dict) -> str:
"""Full page: calendar month view."""
content = _calendar_main_panel_html(ctx)
hdr = root_header_html(ctx)
child = _post_header_html(ctx) + _calendar_header_html(ctx)
hdr += render("header-child", inner_html=child)
return full_page(ctx, header_rows_html=hdr, content_html=content)
async def render_calendar_oob(ctx: dict) -> str:
"""OOB response: calendar month view."""
content = _calendar_main_panel_html(ctx)
oobs = _post_header_html(ctx, oob=True)
oobs += _oob_header_html("post-header-child", "calendar-header-child",
_calendar_header_html(ctx))
oobs += _clear_deeper_oob("post-row", "post-header-child",
"calendar-row", "calendar-header-child")
return oob_page(ctx, oobs_html=oobs, content_html=content)
# ---------------------------------------------------------------------------
# Day detail
# ---------------------------------------------------------------------------
async def render_day_page(ctx: dict) -> str:
"""Full page: day detail."""
content = _day_main_panel_html(ctx)
hdr = root_header_html(ctx)
child = (_post_header_html(ctx)
+ _calendar_header_html(ctx) + _day_header_html(ctx))
hdr += render("header-child", inner_html=child)
return full_page(ctx, header_rows_html=hdr, content_html=content)
async def render_day_oob(ctx: dict) -> str:
"""OOB response: day detail."""
content = _day_main_panel_html(ctx)
oobs = _calendar_header_html(ctx, oob=True)
oobs += _oob_header_html("calendar-header-child", "day-header-child",
_day_header_html(ctx))
oobs += _clear_deeper_oob("post-row", "post-header-child",
"calendar-row", "calendar-header-child",
"day-row", "day-header-child")
return oob_page(ctx, oobs_html=oobs, content_html=content)
# ---------------------------------------------------------------------------
# Day admin
# ---------------------------------------------------------------------------
async def render_day_admin_page(ctx: dict) -> str:
"""Full page: day admin."""
content = _day_admin_main_panel_html(ctx)
ctx = await _ensure_container_nav(ctx)
slug = (ctx.get("post") or {}).get("slug", "")
root_hdr = root_header_html(ctx)
post_hdr = _post_header_html(ctx)
admin_hdr = post_admin_header_html(ctx, slug, selected="calendars")
child = (admin_hdr + _calendar_header_html(ctx) + _day_header_html(ctx)
+ _day_admin_header_html(ctx))
hdr = root_hdr + post_hdr + render("header-child", inner_html=child)
return full_page(ctx, header_rows_html=hdr, content_html=content)
async def render_day_admin_oob(ctx: dict) -> str:
"""OOB response: day admin."""
content = _day_admin_main_panel_html(ctx)
ctx = await _ensure_container_nav(ctx)
slug = (ctx.get("post") or {}).get("slug", "")
oobs = (post_admin_header_html(ctx, slug, oob=True, selected="calendars")
+ _calendar_header_html(ctx, oob=True))
oobs += _oob_header_html("day-header-child", "day-admin-header-child",
_day_admin_header_html(ctx))
oobs += _clear_deeper_oob("post-row", "post-header-child",
"post-admin-row", "post-admin-header-child",
"calendar-row", "calendar-header-child",
"day-row", "day-header-child",
"day-admin-row", "day-admin-header-child")
return oob_page(ctx, oobs_html=oobs, content_html=content)
# ---------------------------------------------------------------------------
# Calendar admin
# ---------------------------------------------------------------------------
def _events_post_admin_header_html(ctx: dict, *, oob: bool = False,
selected: str = "") -> str:
"""Post-level admin row for events — delegates to shared helper."""
slug = (ctx.get("post") or {}).get("slug", "")
return post_admin_header_html(ctx, slug, oob=oob, selected=selected)
async def render_calendar_admin_page(ctx: dict) -> str:
"""Full page: calendar admin."""
content = _calendar_admin_main_panel_html(ctx)
ctx = await _ensure_container_nav(ctx)
slug = (ctx.get("post") or {}).get("slug", "")
root_hdr = root_header_html(ctx)
post_hdr = _post_header_html(ctx)
admin_hdr = post_admin_header_html(ctx, slug, selected="calendars")
child = admin_hdr + _calendar_header_html(ctx) + _calendar_admin_header_html(ctx)
hdr = root_hdr + post_hdr + render("header-child", inner_html=child)
return full_page(ctx, header_rows_html=hdr, content_html=content)
async def render_calendar_admin_oob(ctx: dict) -> str:
"""OOB response: calendar admin."""
content = _calendar_admin_main_panel_html(ctx)
ctx = await _ensure_container_nav(ctx)
slug = (ctx.get("post") or {}).get("slug", "")
oobs = (post_admin_header_html(ctx, slug, oob=True, selected="calendars")
+ _calendar_header_html(ctx, oob=True))
oobs += _oob_header_html("calendar-header-child", "calendar-admin-header-child",
_calendar_admin_header_html(ctx))
oobs += _clear_deeper_oob("post-row", "post-header-child",
"post-admin-row", "post-admin-header-child",
"calendar-row", "calendar-header-child",
"calendar-admin-row", "calendar-admin-header-child")
return oob_page(ctx, oobs_html=oobs, content_html=content)
# ---------------------------------------------------------------------------
# Slots
# ---------------------------------------------------------------------------
async def render_slots_page(ctx: dict) -> str:
"""Full page: slots listing."""
from quart import g
slots = ctx.get("slots") or []
calendar = ctx.get("calendar")
content = render_slots_table(slots, calendar)
ctx = await _ensure_container_nav(ctx)
slug = (ctx.get("post") or {}).get("slug", "")
root_hdr = root_header_html(ctx)
post_hdr = _post_header_html(ctx)
admin_hdr = post_admin_header_html(ctx, slug, selected="calendars")
child = admin_hdr + _calendar_header_html(ctx) + _calendar_admin_header_html(ctx)
hdr = root_hdr + post_hdr + render("header-child", inner_html=child)
return full_page(ctx, header_rows_html=hdr, content_html=content)
async def render_slots_oob(ctx: dict) -> str:
"""OOB response: slots listing."""
slots = ctx.get("slots") or []
calendar = ctx.get("calendar")
content = render_slots_table(slots, calendar)
ctx = await _ensure_container_nav(ctx)
slug = (ctx.get("post") or {}).get("slug", "")
oobs = (post_admin_header_html(ctx, slug, oob=True, selected="calendars")
+ _calendar_admin_header_html(ctx, oob=True))
oobs += _clear_deeper_oob("post-row", "post-header-child",
"post-admin-row", "post-admin-header-child",
"calendar-row", "calendar-header-child",
"calendar-admin-row", "calendar-admin-header-child")
return oob_page(ctx, oobs_html=oobs, content_html=content)
# ---------------------------------------------------------------------------
# Tickets
# ---------------------------------------------------------------------------
async def render_tickets_page(ctx: dict, tickets: list) -> str:
"""Full page: my tickets."""
content = _tickets_main_panel_html(ctx, tickets)
hdr = root_header_html(ctx)
return full_page(ctx, header_rows_html=hdr, content_html=content)
async def render_tickets_oob(ctx: dict, tickets: list) -> str:
"""OOB response: my tickets."""
content = _tickets_main_panel_html(ctx, tickets)
return oob_page(ctx, content_html=content)
async def render_ticket_detail_page(ctx: dict, ticket) -> str:
"""Full page: ticket detail with QR."""
content = _ticket_detail_panel_html(ctx, ticket)
hdr = root_header_html(ctx)
return full_page(ctx, header_rows_html=hdr, content_html=content)
async def render_ticket_detail_oob(ctx: dict, ticket) -> str:
"""OOB response: ticket detail."""
content = _ticket_detail_panel_html(ctx, ticket)
return oob_page(ctx, content_html=content)
# ---------------------------------------------------------------------------
# Ticket admin
# ---------------------------------------------------------------------------
async def render_ticket_admin_page(ctx: dict, tickets: list, stats: dict) -> str:
"""Full page: ticket admin dashboard."""
content = _ticket_admin_main_panel_html(ctx, tickets, stats)
hdr = root_header_html(ctx)
return full_page(ctx, header_rows_html=hdr, content_html=content)
async def render_ticket_admin_oob(ctx: dict, tickets: list, stats: dict) -> str:
"""OOB response: ticket admin dashboard."""
content = _ticket_admin_main_panel_html(ctx, tickets, stats)
return oob_page(ctx, content_html=content)
# ---------------------------------------------------------------------------
# Markets
# ---------------------------------------------------------------------------
async def render_markets_page(ctx: dict) -> str:
"""Full page: markets listing."""
content = _markets_main_panel_html(ctx)
hdr = root_header_html(ctx)
child = _post_header_html(ctx) + _markets_header_html(ctx)
hdr += render("header-child", inner_html=child)
return full_page(ctx, header_rows_html=hdr, content_html=content)
async def render_markets_oob(ctx: dict) -> str:
"""OOB response: markets listing."""
content = _markets_main_panel_html(ctx)
oobs = _post_header_html(ctx, oob=True)
oobs += _oob_header_html("post-header-child", "markets-header-child",
_markets_header_html(ctx))
return oob_page(ctx, oobs_html=oobs, content_html=content)
# ===========================================================================
# POST / PUT / DELETE response components
# ===========================================================================
# ---------------------------------------------------------------------------
# Ticket widget (public wrapper for _ticket_widget_html)
# ---------------------------------------------------------------------------
def render_ticket_widget(entry, qty: int, ticket_url: str) -> str:
"""Render the +/- ticket widget for page_summary / all_events adjust_ticket."""
return _ticket_widget_html(entry, qty, ticket_url, ctx={})
# ---------------------------------------------------------------------------
# Ticket admin: checkin result
# ---------------------------------------------------------------------------
def render_checkin_result(success: bool, error: str | None, ticket) -> str:
"""Render checkin result: table row on success, error div on failure."""
if not success:
return render("events-checkin-error",
message=error or "Check-in failed")
if not ticket:
return ""
code = ticket.code
entry = getattr(ticket, "entry", None)
tt = getattr(ticket, "ticket_type", None)
checked_in_at = getattr(ticket, "checked_in_at", None)
time_str = checked_in_at.strftime("%H:%M") if checked_in_at else "Just now"
date_html = ""
if entry and entry.start_at:
date_html = render("events-ticket-admin-date",
date_str=entry.start_at.strftime("%d %b %Y, %H:%M"))
return render("events-checkin-success-row",
code=code, code_short=code[:12] + "...",
entry_name=entry.name if entry else "\u2014",
date_html=date_html,
type_name=tt.name if tt else "\u2014",
badge_html=_ticket_state_badge_html("checked_in"),
time_str=time_str)
# ---------------------------------------------------------------------------
# Ticket admin: lookup result
# ---------------------------------------------------------------------------
def render_lookup_result(ticket, error: str | None) -> str:
"""Render ticket lookup result: error div or ticket info card."""
from quart import url_for
from shared.browser.app.csrf import generate_csrf_token
if error:
return render("events-lookup-error", message=error)
if not ticket:
return ""
entry = getattr(ticket, "entry", None)
tt = getattr(ticket, "ticket_type", None)
state = getattr(ticket, "state", "")
code = ticket.code
checked_in_at = getattr(ticket, "checked_in_at", None)
csrf = generate_csrf_token()
# Info section
info_html = render("events-lookup-info",
entry_name=entry.name if entry else "Unknown event")
if tt:
info_html += render("events-lookup-type", type_name=tt.name)
if entry and entry.start_at:
info_html += render("events-lookup-date",
date_str=entry.start_at.strftime("%A, %B %d, %Y at %H:%M"))
cal = getattr(entry, "calendar", None) if entry else None
if cal:
info_html += render("events-lookup-cal", cal_name=cal.name)
info_html += render("events-lookup-status",
badge_html=_ticket_state_badge_html(state), code=code)
if checked_in_at:
info_html += render("events-lookup-checkin-time",
date_str=checked_in_at.strftime("%B %d, %Y at %H:%M"))
# Action area
action_html = ""
if state in ("confirmed", "reserved"):
checkin_url = url_for("ticket_admin.do_checkin", code=code)
action_html = render("events-lookup-checkin-btn",
checkin_url=checkin_url, code=code, csrf=csrf)
elif state == "checked_in":
action_html = render("events-lookup-checked-in")
elif state == "cancelled":
action_html = render("events-lookup-cancelled")
return render("events-lookup-card",
info_html=info_html, code=code, action_html=action_html)
# ---------------------------------------------------------------------------
# Ticket admin: entry tickets table
# ---------------------------------------------------------------------------
def render_entry_tickets_admin(entry, tickets: list) -> str:
"""Render admin ticket table for a specific entry."""
from quart import url_for
from shared.browser.app.csrf import generate_csrf_token
csrf = generate_csrf_token()
count = len(tickets)
suffix = "s" if count != 1 else ""
rows_html = ""
for ticket in tickets:
tt = getattr(ticket, "ticket_type", None)
state = getattr(ticket, "state", "")
code = ticket.code
checked_in_at = getattr(ticket, "checked_in_at", None)
action_html = ""
if state in ("confirmed", "reserved"):
checkin_url = url_for("ticket_admin.do_checkin", code=code)
action_html = render("events-entry-tickets-admin-checkin",
checkin_url=checkin_url, code=code, csrf=csrf)
elif state == "checked_in":
t_str = checked_in_at.strftime("%H:%M") if checked_in_at else ""
action_html = render("events-ticket-admin-checked-in",
time_str=t_str)
rows_html += render("events-entry-tickets-admin-row",
code=code, code_short=code[:12] + "...",
type_name=tt.name if tt else "\u2014",
badge_html=_ticket_state_badge_html(state),
action_html=action_html)
if tickets:
body_html = render("events-entry-tickets-admin-table",
rows_html=rows_html)
else:
body_html = render("events-entry-tickets-admin-empty")
return render("events-entry-tickets-admin-panel",
entry_name=entry.name,
count_label=f"{count} ticket{suffix}",
body_html=body_html)
# ---------------------------------------------------------------------------
# Day main panel -- public API
# ---------------------------------------------------------------------------
def render_day_main_panel(ctx: dict) -> str:
"""Public wrapper for day main panel rendering."""
return _day_main_panel_html(ctx)
# ---------------------------------------------------------------------------
# Entry main panel
# ---------------------------------------------------------------------------
def _entry_main_panel_html(ctx: dict) -> str:
"""Render the entry detail panel (name, slot, time, state, cost, tickets,
buy form, date, posts, options + edit button)."""
from quart import url_for
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 = ctx.get("styles") or {}
list_container = getattr(styles, "list_container", "") if hasattr(styles, "list_container") else styles.get("list_container", "")
pre_action = getattr(styles, "pre_action_button", "") if hasattr(styles, "pre_action_button") else styles.get("pre_action_button", "")
eid = entry.id
state = getattr(entry, "state", "pending") or "pending"
def _field(label, content_html):
return render("events-entry-field", label=label, content_html=content_html)
# Name
name_html = _field("Name", render("events-entry-name-field", name=entry.name))
# Slot
slot = getattr(entry, "slot", None)
if slot:
flex_label = "(flexible)" if getattr(slot, "flexible", False) else "(fixed)"
slot_inner = render("events-entry-slot-assigned",
slot_name=slot.name, flex_label=flex_label)
else:
slot_inner = render("events-entry-slot-none")
slot_html = _field("Slot", slot_inner)
# Time Period
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"
time_html = _field("Time Period", render("events-entry-time-field",
time_str=start_str + end_str))
# State
state_html = _field("State", render("events-entry-state-field",
entry_id=str(eid),
badge_html=_entry_state_badge_html(state)))
# Cost
cost = getattr(entry, "cost", None)
cost_str = f"{cost:.2f}" if cost is not None else "0.00"
cost_html = _field("Cost", render("events-entry-cost-field",
cost_html=f"&pound;{cost_str}"))
# Ticket Configuration (admin)
tickets_html = _field("Tickets", render("events-entry-tickets-field",
entry_id=str(eid),
tickets_config_html=render_entry_tickets_config(entry, calendar, day, month, year)))
# Buy Tickets (public-facing)
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 {}
buy_html = render_buy_form(
entry, ticket_remaining, ticket_sold_count,
user_ticket_count, user_ticket_counts_by_type,
)
# Date
date_str = entry.start_at.strftime("%A, %B %d, %Y") if entry.start_at else ""
date_html = _field("Date", render("events-entry-date-field", date_str=date_str))
# Associated Posts
entry_posts = ctx.get("entry_posts") or []
posts_html = _field("Associated Posts", render("events-entry-posts-field",
entry_id=str(eid),
posts_panel_html=render_entry_posts_panel(entry_posts, entry, calendar, day, month, year)))
# Options and Edit Button
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,
)
return render("events-entry-panel",
entry_id=str(eid), list_container=list_container,
name_html=name_html, slot_html=slot_html,
time_html=time_html, state_html=state_html,
cost_html=cost_html, tickets_html=tickets_html,
buy_html=buy_html, date_html=date_html,
posts_html=posts_html,
options_html=_entry_options_html(entry, calendar, day, month, year),
pre_action=pre_action, edit_url=edit_url)
# ---------------------------------------------------------------------------
# Entry header row
# ---------------------------------------------------------------------------
def _entry_header_html(ctx: dict, *, oob: bool = False) -> str:
"""Build entry detail header row."""
from quart import url_for
calendar = ctx.get("calendar")
if not calendar:
return ""
cal_slug = getattr(calendar, "slug", "")
entry = ctx.get("entry")
if not entry:
return ""
day = ctx.get("day")
month = ctx.get("month")
year = ctx.get("year")
link_href = url_for(
"calendar.day.calendar_entries.calendar_entry.get",
calendar_slug=cal_slug,
year=year, month=month, day=day,
entry_id=entry.id,
)
label_html = render("events-entry-label",
entry_id=str(entry.id),
title_html=_entry_title_html(entry),
times_html=_entry_times_html(entry))
nav_html = _entry_nav_html(ctx)
return render("menu-row", id="entry-row", level=5,
link_href=link_href, link_label_html=label_html,
nav_html=nav_html, child_id="entry-header-child", oob=oob)
def _entry_times_html(entry) -> str:
"""Render entry times label."""
start = entry.start_at
end = entry.end_at
if not start:
return ""
start_str = start.strftime("%H:%M")
end_str = f" \u2192 {end.strftime('%H:%M')}" if end else ""
return render("events-entry-times", time_str=start_str + end_str)
# ---------------------------------------------------------------------------
# Entry nav (desktop + admin link)
# ---------------------------------------------------------------------------
def _entry_nav_html(ctx: dict) -> str:
"""Entry desktop nav: associated posts scrolling menu + admin link."""
from quart import url_for
calendar = ctx.get("calendar")
if not calendar:
return ""
cal_slug = getattr(calendar, "slug", "")
entry = ctx.get("entry")
if not entry:
return ""
day = ctx.get("day")
month = ctx.get("month")
year = ctx.get("year")
entry_posts = ctx.get("entry_posts") or []
rights = ctx.get("rights") or {}
is_admin = getattr(rights, "admin", False) if hasattr(rights, "admin") else rights.get("admin", False)
blog_url_fn = ctx.get("blog_url")
parts = []
# Associated Posts scrolling menu
if entry_posts:
post_links = ""
for ep in entry_posts:
slug = getattr(ep, "slug", "")
title = getattr(ep, "title", "")
feat = getattr(ep, "feature_image", None)
href = blog_url_fn(f"/{slug}/") if blog_url_fn else f"/{slug}/"
if feat:
img_html = render("events-post-img", src=feat, alt=title)
else:
img_html = render("events-post-img-placeholder")
post_links += render("events-entry-nav-post-link",
href=href, img_html=img_html, title=title)
parts.append(render("events-entry-posts-nav-oob",
items_html=post_links).replace(' :hx-swap-oob "true"', ''))
# Admin link
if is_admin:
admin_url = url_for(
"calendar.day.calendar_entries.calendar_entry.admin.admin",
calendar_slug=cal_slug,
day=day, month=month, year=year,
entry_id=entry.id,
)
parts.append(render("events-entry-admin-link", href=admin_url))
return "".join(parts)
# ---------------------------------------------------------------------------
# Entry page / OOB rendering
# ---------------------------------------------------------------------------
async def render_entry_page(ctx: dict) -> str:
"""Full page: entry detail."""
content = _entry_main_panel_html(ctx)
hdr = root_header_html(ctx)
child = (_post_header_html(ctx)
+ _calendar_header_html(ctx) + _day_header_html(ctx)
+ _entry_header_html(ctx))
hdr += render("header-child", inner_html=child)
nav_html = _entry_nav_html(ctx)
return full_page(ctx, header_rows_html=hdr, content_html=content, menu_html=nav_html)
async def render_entry_oob(ctx: dict) -> str:
"""OOB response: entry detail."""
content = _entry_main_panel_html(ctx)
oobs = _day_header_html(ctx, oob=True)
oobs += _oob_header_html("day-header-child", "entry-header-child",
_entry_header_html(ctx))
oobs += _clear_deeper_oob("post-row", "post-header-child",
"calendar-row", "calendar-header-child",
"day-row", "day-header-child",
"entry-row", "entry-header-child")
nav_html = _entry_nav_html(ctx)
return oob_page(ctx, oobs_html=oobs, content_html=content, menu_html=nav_html)
# ---------------------------------------------------------------------------
# Entry optioned (confirm/decline/provisional response)
# ---------------------------------------------------------------------------
def render_entry_optioned(entry, calendar, day, month, year) -> str:
"""Render entry options buttons + OOB title & state swaps."""
options = _entry_options_html(entry, calendar, day, month, year)
title = _entry_title_html(entry)
state = _entry_state_badge_html(getattr(entry, "state", "pending") or "pending")
return options + render("events-entry-optioned-oob",
entry_id=str(entry.id),
title_html=title, state_html=state)
def _entry_title_html(entry) -> str:
"""Render entry title (icon + name + state badge)."""
state = getattr(entry, "state", "pending") or "pending"
return render("events-entry-title",
name=entry.name,
badge_html=_entry_state_badge_html(state))
def _entry_options_html(entry, calendar, day, month, year) -> str:
"""Render confirm/decline/provisional buttons based on entry state."""
from quart import url_for, g
from shared.browser.app.csrf import generate_csrf_token
csrf = generate_csrf_token()
styles = getattr(g, "styles", None) or {}
action_btn = getattr(styles, "action_button", "") if hasattr(styles, "action_button") else styles.get("action_button", "")
cal_slug = getattr(calendar, "slug", "")
eid = entry.id
state = getattr(entry, "state", "pending") or "pending"
target = f"#calendar_entry_options_{eid}"
def _make_button(action_name, label, confirm_title, confirm_text, *, trigger_type="submit"):
url = url_for(
f"calendar.day.calendar_entries.calendar_entry.{action_name}",
calendar_slug=cal_slug, day=day, month=month, year=year, entry_id=eid,
)
btn_type = "button" if trigger_type == "button" else "submit"
return render("events-entry-option-button",
url=url, target=target, csrf=csrf, btn_type=btn_type,
action_btn=action_btn, confirm_title=confirm_title,
confirm_text=confirm_text, label=label,
is_btn=trigger_type == "button")
buttons_html = ""
if state == "provisional":
buttons_html += _make_button(
"confirm_entry", "confirm",
"Confirm entry?", "Are you sure you want to confirm this entry?",
)
buttons_html += _make_button(
"decline_entry", "decline",
"Decline entry?", "Are you sure you want to decline this entry?",
)
elif state == "confirmed":
buttons_html += _make_button(
"provisional_entry", "provisional",
"Provisional entry?", "Are you sure you want to provisional this entry?",
trigger_type="button",
)
return render("events-entry-options",
entry_id=str(eid), buttons_html=buttons_html)
# ---------------------------------------------------------------------------
# Entry tickets config (display + form)
# ---------------------------------------------------------------------------
def render_entry_tickets_config(entry, calendar, day, month, year) -> str:
"""Render ticket config display + edit form for admin entry view."""
from quart import url_for
from shared.browser.app.csrf import generate_csrf_token
csrf = generate_csrf_token()
cal_slug = getattr(calendar, "slug", "")
eid = entry.id
tp = getattr(entry, "ticket_price", None)
tc = getattr(entry, "ticket_count", None)
eid_s = str(eid)
show_js = f"document.getElementById('ticket-form-{eid}').classList.remove('hidden'); this.classList.add('hidden');"
hide_js = (f"document.getElementById('ticket-form-{eid}').classList.add('hidden'); "
f"document.getElementById('entry-tickets-{eid}').querySelectorAll('button:not([type=submit])').forEach(btn => btn.classList.remove('hidden'));")
if tp is not None:
tc_str = f"{tc} tickets" if tc is not None else "Unlimited"
display_html = render("events-ticket-config-display",
price_str=f"&pound;{tp:.2f}",
count_str=tc_str, show_js=show_js)
else:
display_html = render("events-ticket-config-none", show_js=show_js)
update_url = url_for(
"calendar.day.calendar_entries.calendar_entry.update_tickets",
entry_id=eid, calendar_slug=cal_slug, day=day, month=month, year=year,
)
hidden_cls = "" if tp is None else "hidden"
tp_val = f"{tp:.2f}" if tp is not None else ""
tc_val = str(tc) if tc is not None else ""
form_html = render("events-ticket-config-form",
entry_id=eid_s, hidden_cls=hidden_cls,
update_url=update_url, csrf=csrf,
price_val=tp_val, count_val=tc_val, hide_js=hide_js)
return display_html + form_html
# ---------------------------------------------------------------------------
# Entry posts panel
# ---------------------------------------------------------------------------
def render_entry_posts_panel(entry_posts, entry, calendar, day, month, year) -> str:
"""Render associated posts list with remove buttons and search input."""
from quart import url_for
from shared.browser.app.csrf import generate_csrf_token
csrf = generate_csrf_token()
cal_slug = getattr(calendar, "slug", "")
eid = entry.id
eid_s = str(eid)
posts_html = ""
if entry_posts:
items = ""
for ep in entry_posts:
ep_title = getattr(ep, "title", "")
ep_id = getattr(ep, "id", 0)
feat = getattr(ep, "feature_image", None)
img_html = (render("events-post-img", src=feat, alt=ep_title)
if feat else render("events-post-img-placeholder"))
del_url = url_for(
"calendar.day.calendar_entries.calendar_entry.remove_post",
calendar_slug=cal_slug, day=day, month=month, year=year,
entry_id=eid, post_id=ep_id,
)
items += render("events-entry-post-item",
img_html=img_html, title=ep_title,
del_url=del_url, entry_id=eid_s,
csrf_hdr=f'{{"X-CSRFToken": "{csrf}"}}')
posts_html = render("events-entry-posts-list", items_html=items)
else:
posts_html = render("events-entry-posts-none")
search_url = url_for(
"calendar.day.calendar_entries.calendar_entry.search_posts",
calendar_slug=cal_slug, day=day, month=month, year=year, entry_id=eid,
)
return render("events-entry-posts-panel",
posts_html=posts_html, search_url=search_url,
entry_id=eid_s)
# ---------------------------------------------------------------------------
# Entry posts nav OOB
# ---------------------------------------------------------------------------
def render_entry_posts_nav_oob(entry_posts) -> str:
"""Render OOB nav for entry posts (scrolling menu)."""
from quart import g
styles = getattr(g, "styles", None) or {}
nav_btn = getattr(styles, "nav_button", "") if hasattr(styles, "nav_button") else styles.get("nav_button", "")
blog_url_fn = getattr(g, "blog_url", None)
if not entry_posts:
return render("events-entry-posts-nav-oob-empty")
items = ""
for ep in entry_posts:
slug = getattr(ep, "slug", "")
title = getattr(ep, "title", "")
feat = getattr(ep, "feature_image", None)
href = blog_url_fn(f"/{slug}/") if blog_url_fn else f"/{slug}/"
img_html = (render("events-post-img", src=feat, alt=title)
if feat else render("events-post-img-placeholder"))
items += render("events-entry-nav-post",
href=href, nav_btn=nav_btn,
img_html=img_html, title=title)
return render("events-entry-posts-nav-oob", items_html=items)
# ---------------------------------------------------------------------------
# Day entries nav OOB
# ---------------------------------------------------------------------------
def render_day_entries_nav_oob(confirmed_entries, calendar, day_date) -> str:
"""Render OOB nav for confirmed entries in a day."""
from quart import url_for, g
styles = getattr(g, "styles", None) or {}
nav_btn = getattr(styles, "nav_button", "") if hasattr(styles, "nav_button") else styles.get("nav_button", "")
cal_slug = getattr(calendar, "slug", "")
if not confirmed_entries:
return render("events-day-entries-nav-oob-empty")
items = ""
for entry in confirmed_entries:
href = url_for(
"calendar.day.calendar_entries.calendar_entry.get",
calendar_slug=cal_slug,
year=day_date.year, month=day_date.month, day=day_date.day,
entry_id=entry.id,
)
start = entry.start_at.strftime("%H:%M") if entry.start_at else ""
end = f" \u2013 {entry.end_at.strftime('%H:%M')}" if entry.end_at else ""
items += render("events-day-nav-entry",
href=href, nav_btn=nav_btn,
name=entry.name, time_str=start + end)
return render("events-day-entries-nav-oob", items_html=items)
# ---------------------------------------------------------------------------
# Post nav entries OOB
# ---------------------------------------------------------------------------
def render_post_nav_entries_oob(associated_entries, calendars, post) -> str:
"""Render OOB nav for associated entries and calendars of a post."""
from quart import g
from shared.infrastructure.urls import events_url
styles = getattr(g, "styles", None) or {}
nav_btn = getattr(styles, "nav_button_less_pad", "") if hasattr(styles, "nav_button_less_pad") else styles.get("nav_button_less_pad", "")
has_entries = associated_entries and getattr(associated_entries, "entries", None)
has_items = has_entries or calendars
if not has_items:
return render("events-post-nav-oob-empty")
slug = post.get("slug", "") if isinstance(post, dict) else getattr(post, "slug", "")
items = ""
if has_entries:
for entry in associated_entries.entries:
entry_path = (
f"/{slug}/{entry.calendar_slug}/"
f"{entry.start_at.year}/{entry.start_at.month}/{entry.start_at.day}/"
f"entries/{entry.id}/"
)
href = events_url(entry_path)
time_str = entry.start_at.strftime("%b %d, %Y at %H:%M")
end_str = f" \u2013 {entry.end_at.strftime('%H:%M')}" if entry.end_at else ""
items += render("events-post-nav-entry",
href=href, nav_btn=nav_btn,
name=entry.name, time_str=time_str + end_str)
if calendars:
for cal in calendars:
cs = getattr(cal, "slug", "")
local_href = events_url(f"/{slug}/{cs}/")
items += render("events-post-nav-calendar",
href=local_href, nav_btn=nav_btn, name=cal.name)
hs = ("on load or scroll "
"if window.innerWidth >= 640 and my.scrollWidth > my.clientWidth "
"remove .hidden from .entries-nav-arrow add .flex to .entries-nav-arrow "
"else add .hidden to .entries-nav-arrow remove .flex from .entries-nav-arrow end")
return render("events-post-nav-wrapper",
items_html=items, hyperscript=hs)
# ---------------------------------------------------------------------------
# Calendar description display + edit form
# ---------------------------------------------------------------------------
def render_calendar_description(calendar, *, oob: bool = False) -> str:
"""Render calendar description display with edit button, optionally with OOB title."""
from quart import url_for
cal_slug = getattr(calendar, "slug", "")
edit_url = url_for("calendar.admin.calendar_description_edit", calendar_slug=cal_slug)
html = _calendar_description_display_html(calendar, edit_url)
if oob:
desc = getattr(calendar, "description", "") or ""
html += render("events-calendar-description-title-oob",
description=desc)
return html
def render_calendar_description_edit(calendar) -> str:
"""Render calendar description edit form."""
from quart import url_for
from shared.browser.app.csrf import generate_csrf_token
csrf = generate_csrf_token()
cal_slug = getattr(calendar, "slug", "")
desc = getattr(calendar, "description", "") or ""
save_url = url_for("calendar.admin.calendar_description_save", calendar_slug=cal_slug)
cancel_url = url_for("calendar.admin.calendar_description_view", calendar_slug=cal_slug)
return render("events-calendar-description-edit-form",
save_url=save_url, cancel_url=cancel_url,
csrf=csrf, description=desc)
# ---------------------------------------------------------------------------
# Calendars list panel (for POST create / DELETE)
# ---------------------------------------------------------------------------
def render_calendars_list_panel(ctx: dict) -> str:
"""Render the calendars main panel HTML for POST/DELETE response."""
return _calendars_main_panel_html(ctx)
# ---------------------------------------------------------------------------
# Markets list panel (for POST create / DELETE)
# ---------------------------------------------------------------------------
def render_markets_list_panel(ctx: dict) -> str:
"""Render the markets main panel HTML for POST/DELETE response."""
return _markets_main_panel_html(ctx)
# ---------------------------------------------------------------------------
# Slot main panel
# ---------------------------------------------------------------------------
def render_slot_main_panel(slot, calendar, *, oob: bool = False) -> str:
"""Render slot detail view."""
from quart import url_for, g
styles = getattr(g, "styles", None) or {}
list_container = getattr(styles, "list_container", "") if hasattr(styles, "list_container") else styles.get("list_container", "")
pre_action = getattr(styles, "pre_action_button", "") if hasattr(styles, "pre_action_button") else styles.get("pre_action_button", "")
cal_slug = getattr(calendar, "slug", "")
days_display = getattr(slot, "days_display", "\u2014")
days = days_display.split(", ")
flexible = getattr(slot, "flexible", False)
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 ""
desc = getattr(slot, "description", "") or ""
edit_url = url_for("calendar.slots.slot.get_edit", slot_id=slot.id, calendar_slug=cal_slug)
# Days pills
if days and days[0] != "\u2014":
days_inner = "".join(
render("events-slot-day-pill", day=d) for d in days
)
days_html = render("events-slot-days-pills", days_inner_html=days_inner)
else:
days_html = render("events-slot-no-days")
sid = str(slot.id)
result = render("events-slot-panel",
slot_id=sid, list_container=list_container,
days_html=days_html,
flexible="yes" if flexible else "no",
time_str=f"{time_start} \u2014 {time_end}",
cost_str=cost_str,
pre_action=pre_action, edit_url=edit_url)
if oob:
result += render("events-slot-description-oob", description=desc)
return result
# ---------------------------------------------------------------------------
# Slots table
# ---------------------------------------------------------------------------
def render_slots_table(slots, calendar) -> str:
"""Render slots table with rows and add button."""
from quart import url_for, g
from shared.browser.app.csrf import generate_csrf_token
csrf = generate_csrf_token()
styles = getattr(g, "styles", None) or {}
list_container = getattr(styles, "list_container", "") if hasattr(styles, "list_container") else styles.get("list_container", "")
tr_cls = getattr(styles, "tr", "") if hasattr(styles, "tr") else styles.get("tr", "")
pill_cls = getattr(styles, "pill", "") if hasattr(styles, "pill") else styles.get("pill", "")
action_btn = getattr(styles, "action_button", "") if hasattr(styles, "action_button") else styles.get("action_button", "")
pre_action = getattr(styles, "pre_action_button", "") if hasattr(styles, "pre_action_button") else styles.get("pre_action_button", "")
hx_select = getattr(g, "hx_select_search", "#main-panel")
cal_slug = getattr(calendar, "slug", "")
rows_html = ""
if slots:
for s in slots:
slot_href = url_for("calendar.slots.slot.get", 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(", ")
if day_list and day_list[0] != "\u2014":
days_inner = "".join(
render("events-slot-day-pill", day=d) for d in day_list
)
days_html = render("events-slot-days-pills", days_inner_html=days_inner)
else:
days_html = render("events-slot-no-days")
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 ""
rows_html += render("events-slots-row",
tr_cls=tr_cls, slot_href=slot_href,
pill_cls=pill_cls, hx_select=hx_select,
slot_name=s.name, description=desc,
flexible="yes" if s.flexible else "no",
days_html=days_html,
time_str=f"{time_start} - {time_end}",
cost_str=cost_str, action_btn=action_btn,
del_url=del_url,
csrf_hdr=f'{{"X-CSRFToken": "{csrf}"}}')
else:
rows_html = render("events-slots-empty-row")
add_url = url_for("calendar.slots.add_form", calendar_slug=cal_slug)
return render("events-slots-table",
list_container=list_container, rows_html=rows_html,
pre_action=pre_action, add_url=add_url)
# ---------------------------------------------------------------------------
# Ticket type main panel
# ---------------------------------------------------------------------------
def render_ticket_type_main_panel(ticket_type, entry, calendar, day, month, year, *, oob: bool = False) -> str:
"""Render ticket type detail view."""
from quart import url_for, g
styles = getattr(g, "styles", None) or {}
list_container = getattr(styles, "list_container", "") if hasattr(styles, "list_container") else styles.get("list_container", "")
pre_action = getattr(styles, "pre_action_button", "") if hasattr(styles, "pre_action_button") else styles.get("pre_action_button", "")
cal_slug = getattr(calendar, "slug", "")
cost = getattr(ticket_type, "cost", None)
cost_str = f"\u00a3{cost:.2f}" if cost is not None else "\u00a30.00"
count = getattr(ticket_type, "count", 0)
tid = str(ticket_type.id)
edit_url = url_for(
"calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get_edit",
ticket_type_id=ticket_type.id, calendar_slug=cal_slug,
year=year, month=month, day=day, entry_id=entry.id,
)
def _col(label, val):
return render("events-ticket-type-col", label=label, value=val)
return render("events-ticket-type-panel",
ticket_id=tid, list_container=list_container,
c1=_col("Name", ticket_type.name),
c2=_col("Cost", cost_str),
c3=_col("Count", str(count)),
pre_action=pre_action, edit_url=edit_url)
# ---------------------------------------------------------------------------
# Ticket types table
# ---------------------------------------------------------------------------
def render_ticket_types_table(ticket_types, entry, calendar, day, month, year) -> str:
"""Render ticket types table with rows and add button."""
from quart import url_for, g
from shared.browser.app.csrf import generate_csrf_token
csrf = generate_csrf_token()
styles = getattr(g, "styles", None) or {}
list_container = getattr(styles, "list_container", "") if hasattr(styles, "list_container") else styles.get("list_container", "")
tr_cls = getattr(styles, "tr", "") if hasattr(styles, "tr") else styles.get("tr", "")
pill_cls = getattr(styles, "pill", "") if hasattr(styles, "pill") else styles.get("pill", "")
action_btn = getattr(styles, "action_button", "") if hasattr(styles, "action_button") else styles.get("action_button", "")
hx_select = getattr(g, "hx_select_search", "#main-panel")
cal_slug = getattr(calendar, "slug", "")
eid = entry.id
rows_html = ""
if ticket_types:
for tt in ticket_types:
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"
rows_html += render("events-ticket-types-row",
tr_cls=tr_cls, tt_href=tt_href,
pill_cls=pill_cls, hx_select=hx_select,
tt_name=tt.name, cost_str=cost_str,
count=str(tt.count), action_btn=action_btn,
del_url=del_url,
csrf_hdr=f'{{"X-CSRFToken": "{csrf}"}}')
else:
rows_html = render("events-ticket-types-empty-row")
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 render("events-ticket-types-table",
list_container=list_container, rows_html=rows_html,
action_btn=action_btn, add_url=add_url)
# ---------------------------------------------------------------------------
# Buy result (ticket purchase confirmation)
# ---------------------------------------------------------------------------
def render_buy_result(entry, created_tickets, remaining, cart_count) -> str:
"""Render buy result card with created tickets + OOB cart icon."""
from quart import url_for
cart_html = _cart_icon_oob(cart_count)
count = len(created_tickets)
suffix = "s" if count != 1 else ""
tickets_html = ""
for ticket in created_tickets:
href = url_for("tickets.ticket_detail", code=ticket.code)
tickets_html += render("events-buy-result-ticket",
href=href, code_short=ticket.code[:12] + "...")
remaining_html = ""
if remaining is not None:
r_suffix = "s" if remaining != 1 else ""
remaining_html = render("events-buy-result-remaining",
text=f"{remaining} ticket{r_suffix} remaining")
my_href = url_for("tickets.my_tickets")
return cart_html + render("events-buy-result",
entry_id=str(entry.id),
count_label=f"{count} ticket{suffix} reserved",
tickets_html=tickets_html,
remaining_html=remaining_html,
my_tickets_href=my_href)
# ---------------------------------------------------------------------------
# Buy form (ticket +/- controls)
# ---------------------------------------------------------------------------
def render_buy_form(entry, ticket_remaining, ticket_sold_count,
user_ticket_count, user_ticket_counts_by_type) -> str:
"""Render the ticket buy/adjust form with +/- controls."""
from quart import url_for
from shared.browser.app.csrf import generate_csrf_token
csrf = generate_csrf_token()
eid = entry.id
eid_s = str(eid)
tp = getattr(entry, "ticket_price", None)
state = getattr(entry, "state", "")
ticket_types = getattr(entry, "ticket_types", None) or []
if tp is None:
return ""
if state != "confirmed":
return render("events-buy-not-confirmed", entry_id=eid_s)
adjust_url = url_for("tickets.adjust_quantity")
target = f"#ticket-buy-{eid}"
# Info line
info_html = ""
info_items = ""
if ticket_sold_count:
info_items += render("events-buy-info-sold",
count=str(ticket_sold_count))
if ticket_remaining is not None:
info_items += render("events-buy-info-remaining",
count=str(ticket_remaining))
if user_ticket_count:
info_items += render("events-buy-info-basket",
count=str(user_ticket_count))
if info_items:
info_html = render("events-buy-info-bar", items_html=info_items)
active_types = [tt for tt in ticket_types if getattr(tt, "deleted_at", None) is None]
body_html = ""
if active_types:
type_items = ""
for tt in active_types:
type_count = user_ticket_counts_by_type.get(tt.id, 0) if user_ticket_counts_by_type else 0
cost_str = f"\u00a3{tt.cost:.2f}" if tt.cost is not None else "\u00a30.00"
type_items += render("events-buy-type-item",
type_name=tt.name, cost_str=cost_str,
adjust_controls_html=_ticket_adjust_controls(csrf, adjust_url, target, eid, type_count, ticket_type_id=tt.id))
body_html = render("events-buy-types-wrapper", items_html=type_items)
else:
qty = user_ticket_count or 0
body_html = render("events-buy-default",
price_str=f"\u00a3{tp:.2f}",
adjust_controls_html=_ticket_adjust_controls(csrf, adjust_url, target, eid, qty))
return render("events-buy-panel",
entry_id=eid_s, info_html=info_html, body_html=body_html)
def _ticket_adjust_controls(csrf, adjust_url, target, entry_id, count, *, ticket_type_id=None):
"""Render +/- ticket controls for buy form."""
from quart import url_for
tt_html = render("events-adjust-tt-hidden",
ticket_type_id=str(ticket_type_id)) if ticket_type_id else ""
eid_s = str(entry_id)
def _adj_form(count_val, btn_html, *, extra_cls=""):
return render("events-adjust-form",
adjust_url=adjust_url, target=target,
extra_cls=extra_cls, csrf=csrf,
entry_id=eid_s, tt_html=tt_html,
count_val=str(count_val), btn_html=btn_html)
if count == 0:
return _adj_form(1, render("events-adjust-cart-plus"),
extra_cls="flex items-center")
my_tickets_href = url_for("tickets.my_tickets")
minus = _adj_form(count - 1, render("events-adjust-minus"))
cart_icon = render("events-adjust-cart-icon",
href=my_tickets_href, count=str(count))
plus = _adj_form(count + 1, render("events-adjust-plus"))
return render("events-adjust-controls",
minus_html=minus, cart_icon_html=cart_icon, plus_html=plus)
# ---------------------------------------------------------------------------
# Adjust response (OOB cart icon + buy form)
# ---------------------------------------------------------------------------
def render_adjust_response(entry, ticket_remaining, ticket_sold_count,
user_ticket_count, user_ticket_counts_by_type,
cart_count) -> str:
"""Render ticket adjust response: OOB cart icon + buy form."""
cart_html = _cart_icon_oob(cart_count)
form_html = render_buy_form(
entry, ticket_remaining, ticket_sold_count,
user_ticket_count, user_ticket_counts_by_type,
)
return cart_html + form_html
def _cart_icon_oob(count: int) -> str:
"""Render the OOB cart icon/badge swap."""
from quart import g
blog_url_fn = getattr(g, "blog_url", None)
cart_url_fn = getattr(g, "cart_url", None)
site_fn = getattr(g, "site", None)
logo = ""
if site_fn:
site_obj = site_fn() if callable(site_fn) else site_fn
logo = getattr(site_obj, "logo", "") if site_obj else ""
if count == 0:
blog_href = blog_url_fn("/") if blog_url_fn else "/"
return render("events-cart-icon-logo",
blog_href=blog_href, logo=logo)
cart_href = cart_url_fn("/") if cart_url_fn else "/"
return render("events-cart-icon-badge",
cart_href=cart_href, count=str(count))
# ===========================================================================
# SLOT PICKER JS — shared by entry edit + entry add forms
# ===========================================================================
_SLOT_PICKER_JS = """\
<script>
(function () {
function timeToMinutes(timeStr) {
if (!timeStr) return 0;
const [hours, minutes] = timeStr.split(':').map(Number);
return hours * 60 + minutes;
}
function calculateCost(slotCost, slotStart, slotEnd, actualStart, actualEnd, flexible) {
if (!flexible) {
return parseFloat(slotCost);
}
if (!actualStart || !actualEnd) return 0;
const slotStartMin = timeToMinutes(slotStart);
const slotEndMin = timeToMinutes(slotEnd);
const actualStartMin = timeToMinutes(actualStart);
const actualEndMin = timeToMinutes(actualEnd);
const slotDuration = slotEndMin - slotStartMin;
const actualDuration = actualEndMin - actualStartMin;
if (slotDuration <= 0 || actualDuration <= 0) return 0;
const ratio = actualDuration / slotDuration;
return parseFloat(slotCost) * ratio;
}
function initEntrySlotPicker(root, applyInitial) {
if (applyInitial === undefined) applyInitial = false;
const select = root.querySelector('[data-slot-picker]');
if (!select) return;
const timeFields = root.querySelector('[data-time-fields]');
const startInput = root.querySelector('[data-entry-start]');
const endInput = root.querySelector('[data-entry-end]');
const helper = root.querySelector('[data-slot-boundary]');
const costDisplay = root.querySelector('[data-cost-display]');
const costRow = root.querySelector('[data-cost-row]');
const fixedSummary = root.querySelector('[data-fixed-summary]');
if (!startInput || !endInput) return;
function updateCost() {
const opt = select.selectedOptions[0];
if (!opt || !opt.value) {
if (costDisplay) costDisplay.textContent = '\\u00a30.00';
return;
}
const cost = opt.dataset.cost || '0';
const s = opt.dataset.start || '';
const e = opt.dataset.end || '';
const flexible = opt.dataset.flexible === '1';
const calculatedCost = calculateCost(cost, s, e, startInput.value, endInput.value, flexible);
if (costDisplay) costDisplay.textContent = '\\u00a3' + calculatedCost.toFixed(2);
}
function applyFromOption(opt) {
if (!opt || !opt.value) {
if (timeFields) timeFields.classList.add('hidden');
if (costRow) costRow.classList.add('hidden');
if (fixedSummary) fixedSummary.classList.add('hidden');
return;
}
const s = opt.dataset.start || '';
const e = opt.dataset.end || '';
const flexible = opt.dataset.flexible === '1';
if (!flexible) {
if (s) startInput.value = s;
if (e) endInput.value = e;
if (timeFields) timeFields.classList.add('hidden');
if (fixedSummary) {
fixedSummary.classList.remove('hidden');
fixedSummary.textContent = e ? s + ' \\u2013 ' + e : 'From ' + s + ' (open-ended)';
}
if (costRow) costRow.classList.remove('hidden');
} else {
if (timeFields) timeFields.classList.remove('hidden');
if (fixedSummary) fixedSummary.classList.add('hidden');
if (costRow) costRow.classList.remove('hidden');
if (helper) {
helper.textContent = e ? 'Times must be between ' + s + ' and ' + e + '.' : 'Start at or after ' + s + '.';
}
}
updateCost();
}
if (applyInitial) applyFromOption(select.selectedOptions[0]);
if (select._slotChangeHandler) select.removeEventListener('change', select._slotChangeHandler);
select._slotChangeHandler = () => applyFromOption(select.selectedOptions[0]);
select.addEventListener('change', select._slotChangeHandler);
startInput.addEventListener('input', updateCost);
endInput.addEventListener('input', updateCost);
}
document.addEventListener('DOMContentLoaded', () => initEntrySlotPicker(document, true));
if (window.htmx) htmx.onLoad((content) => initEntrySlotPicker(content, true));
})();
</script>"""
# ===========================================================================
# Entry edit form
# ===========================================================================
def _slot_options_html(day_slots, selected_slot_id=None) -> str:
"""Build slot <option> elements."""
parts = []
for slot in day_slots:
start = slot.time_start.strftime("%H:%M") if slot.time_start else ""
end = slot.time_end.strftime("%H:%M") if slot.time_end else ""
flexible = getattr(slot, "flexible", False)
cost = getattr(slot, "cost", None)
cost_str = str(cost) if cost is not None else "0"
label_parts = [slot.name, f"({start}"]
if end:
label_parts.append(f"\u2013{end})")
else:
label_parts.append("\u2013open-ended)")
if flexible:
label_parts.append("[flexible]")
label = " ".join(label_parts)
parts.append(render("events-slot-option",
value=str(slot.id),
data_start=start, data_end=end,
data_flexible="1" if flexible else "0",
data_cost=cost_str,
selected="selected" if selected_slot_id == slot.id else None,
label=label))
return "".join(parts)
def render_entry_edit_form(entry, calendar, day, month, year, day_slots) -> str:
"""Render entry edit form (replaces _types/entry/_edit.html)."""
from quart import url_for, g
from shared.browser.app.csrf import generate_csrf_token
csrf = generate_csrf_token()
styles = getattr(g, "styles", None) or {}
list_container = styles.get("list_container", "") if isinstance(styles, dict) else getattr(styles, "list_container", "")
action_btn = styles.get("action_button", "") if isinstance(styles, dict) else getattr(styles, "action_button", "")
cancel_btn = styles.get("cancel_button", "") if isinstance(styles, dict) else getattr(styles, "cancel_button", "")
cal_slug = getattr(calendar, "slug", "")
eid = entry.id
put_url = url_for("calendar.day.calendar_entries.calendar_entry.put",
calendar_slug=cal_slug, day=day, month=month, year=year, entry_id=eid)
cancel_url = url_for("calendar.day.calendar_entries.calendar_entry.get",
calendar_slug=cal_slug, day=day, month=month, year=year, entry_id=eid)
# Slot picker
if day_slots:
options_html = _slot_options_html(day_slots, selected_slot_id=getattr(entry, "slot_id", None))
slot_picker_html = render("events-slot-picker",
id=f"entry-slot-{eid}", options_html=options_html)
else:
slot_picker_html = render("events-no-slots")
# Values
start_val = entry.start_at.strftime("%H:%M") if entry.start_at else ""
end_val = entry.end_at.strftime("%H:%M") if entry.end_at else ""
cost = getattr(entry, "cost", None)
cost_display = f"\u00a3{cost:.2f}" if cost is not None else "\u00a30.00"
tp = getattr(entry, "ticket_price", None)
tc = getattr(entry, "ticket_count", None)
tp_val = f"{tp:.2f}" if tp is not None else ""
tc_val = str(tc) if tc is not None else ""
html = render("events-entry-edit-form",
entry_id=str(eid), list_container=list_container,
put_url=put_url, cancel_url=cancel_url, csrf=csrf,
name_val=entry.name or "", slot_picker_html=slot_picker_html,
start_val=start_val, end_val=end_val, cost_display=cost_display,
ticket_price_val=tp_val, ticket_count_val=tc_val,
action_btn=action_btn, cancel_btn=cancel_btn)
return html + _SLOT_PICKER_JS
# ===========================================================================
# Post search results
# ===========================================================================
def render_post_search_results(search_posts, search_query, page, total_pages,
entry, calendar, day, month, year) -> str:
"""Render post search results (replaces _types/entry/_post_search_results.html)."""
from quart import url_for
from shared.browser.app.csrf import generate_csrf_token
csrf = generate_csrf_token()
cal_slug = getattr(calendar, "slug", "")
eid = entry.id
parts = []
for sp in search_posts:
post_url = url_for("calendar.day.calendar_entries.calendar_entry.add_post",
calendar_slug=cal_slug, day=day, month=month, year=year,
entry_id=eid)
feat = getattr(sp, "feature_image", None)
title = getattr(sp, "title", "")
if feat:
img_html = render("events-post-img", src=feat, alt=title)
else:
img_html = render("events-post-img-placeholder")
parts.append(render("events-post-search-item",
post_url=post_url, entry_id=str(eid), csrf=csrf,
post_id=str(sp.id), img_html=img_html, title=title))
result = "".join(parts)
if page < int(total_pages):
next_url = url_for("calendar.day.calendar_entries.calendar_entry.search_posts",
calendar_slug=cal_slug, day=day, month=month, year=year,
entry_id=eid, q=search_query, page=page + 1)
result += render("events-post-search-sentinel",
page=str(page), next_url=next_url)
elif search_posts:
result += render("events-post-search-end")
return result
# ===========================================================================
# Entry admin page / OOB
# ===========================================================================
def _entry_admin_header_html(ctx: dict, *, oob: bool = False) -> str:
"""Build the entry admin header row."""
from quart import url_for
calendar = ctx.get("calendar")
if not calendar:
return ""
cal_slug = getattr(calendar, "slug", "")
entry = ctx.get("entry")
if not entry:
return ""
day = ctx.get("day")
month = ctx.get("month")
year = ctx.get("year")
link_href = url_for(
"calendar.day.calendar_entries.calendar_entry.admin.admin",
calendar_slug=cal_slug, day=day, month=month, year=year, entry_id=entry.id,
)
# Nav: ticket_types link
nav_html = _entry_admin_nav_html(ctx)
return render("menu-row", id="entry-admin-row", level=6,
link_href=link_href, link_label="admin", icon="fa fa-cog",
nav_html=nav_html, child_id="entry-admin-header-child", oob=oob)
def _entry_admin_nav_html(ctx: dict) -> str:
"""Entry admin nav: ticket_types link."""
from quart import url_for
calendar = ctx.get("calendar")
if not calendar:
return ""
cal_slug = getattr(calendar, "slug", "")
entry = ctx.get("entry")
if not entry:
return ""
day = ctx.get("day")
month = ctx.get("month")
year = ctx.get("year")
select_colours = ctx.get("select_colours", "")
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 render("nav-link", href=href, label="ticket_types",
select_colours=select_colours)
def _entry_admin_main_panel_html(ctx: dict) -> str:
"""Entry admin main panel: just a ticket_types link."""
from quart import url_for
calendar = ctx.get("calendar")
entry = ctx.get("entry")
if not calendar or not entry:
return ""
cal_slug = getattr(calendar, "slug", "")
day = ctx.get("day")
month = ctx.get("month")
year = ctx.get("year")
styles = ctx.get("styles") or {}
nav_btn = styles.get("nav_button", "") if isinstance(styles, dict) else getattr(styles, "nav_button", "")
hx_select = ctx.get("hx_select_search", "#main-panel")
select_colours = ctx.get("select_colours", "")
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 render("nav-link", href=href, label="ticket_types",
select_colours=select_colours, aclass=nav_btn,
is_selected=False)
async def render_entry_admin_page(ctx: dict) -> str:
"""Full page: entry admin."""
content = _entry_admin_main_panel_html(ctx)
ctx = await _ensure_container_nav(ctx)
slug = (ctx.get("post") or {}).get("slug", "")
root_hdr = root_header_html(ctx)
post_hdr = _post_header_html(ctx)
admin_hdr = post_admin_header_html(ctx, slug, selected="calendars")
child = (admin_hdr + _calendar_header_html(ctx) + _day_header_html(ctx)
+ _entry_header_html(ctx) + _entry_admin_header_html(ctx))
hdr = root_hdr + post_hdr + render("header-child", inner_html=child)
nav_html = render("events-admin-placeholder-nav")
return full_page(ctx, header_rows_html=hdr, content_html=content, menu_html=nav_html)
async def render_entry_admin_oob(ctx: dict) -> str:
"""OOB response: entry admin."""
content = _entry_admin_main_panel_html(ctx)
ctx = await _ensure_container_nav(ctx)
slug = (ctx.get("post") or {}).get("slug", "")
oobs = (post_admin_header_html(ctx, slug, oob=True, selected="calendars")
+ _entry_header_html(ctx, oob=True))
oobs += _oob_header_html("entry-header-child", "entry-admin-header-child",
_entry_admin_header_html(ctx))
oobs += _clear_deeper_oob("post-row", "post-header-child",
"post-admin-row", "post-admin-header-child",
"calendar-row", "calendar-header-child",
"day-row", "day-header-child",
"entry-row", "entry-header-child",
"entry-admin-row", "entry-admin-header-child")
nav_html = render("events-admin-placeholder-nav")
return oob_page(ctx, oobs_html=oobs, content_html=content, menu_html=nav_html)
# ===========================================================================
# Slot page / OOB (extends slots)
# ===========================================================================
def _slot_header_html(ctx: dict, *, oob: bool = False) -> str:
"""Build the slot detail header row."""
from quart import url_for
calendar = ctx.get("calendar")
if not calendar:
return ""
cal_slug = getattr(calendar, "slug", "")
slot = ctx.get("slot")
if not slot:
return ""
# Label: icon + name + description
desc = getattr(slot, "description", "") or ""
label_inner = (
f'<div class="flex flex-col md:flex-row md:gap-2 items-center">'
f'<div class="flex flex-row items-center gap-2">'
f'<i class="fa fa-clock"></i>'
f'<div class="shrink-0">{escape(slot.name)}</div>'
f'</div>'
f'<p class="text-stone-500 whitespace-pre-line break-all w-full">{escape(desc)}</p>'
f'</div>'
)
return render("menu-row", id="slot-row", level=5,
link_label_html=label_inner,
child_id="slot-header-child", oob=oob)
async def render_slot_page(ctx: dict) -> str:
"""Full page: slot detail (extends slots page)."""
slot = ctx.get("slot")
calendar = ctx.get("calendar")
if not slot or not calendar:
return ""
content = render_slot_main_panel(slot, calendar)
ctx = await _ensure_container_nav(ctx)
slug = (ctx.get("post") or {}).get("slug", "")
root_hdr = root_header_html(ctx)
post_hdr = _post_header_html(ctx)
admin_hdr = post_admin_header_html(ctx, slug, selected="calendars")
child = (admin_hdr + _calendar_header_html(ctx) + _calendar_admin_header_html(ctx)
+ _slot_header_html(ctx))
hdr = root_hdr + post_hdr + render("header-child", inner_html=child)
return full_page(ctx, header_rows_html=hdr, content_html=content)
async def render_slot_oob(ctx: dict) -> str:
"""OOB response: slot detail."""
slot = ctx.get("slot")
calendar = ctx.get("calendar")
if not slot or not calendar:
return ""
content = render_slot_main_panel(slot, calendar)
ctx = await _ensure_container_nav(ctx)
slug = (ctx.get("post") or {}).get("slug", "")
oobs = (post_admin_header_html(ctx, slug, oob=True, selected="calendars")
+ _calendar_admin_header_html(ctx, oob=True))
oobs += _oob_header_html("calendar-admin-header-child", "slot-header-child",
_slot_header_html(ctx))
oobs += _clear_deeper_oob("post-row", "post-header-child",
"post-admin-row", "post-admin-header-child",
"calendar-row", "calendar-header-child",
"calendar-admin-row", "calendar-admin-header-child",
"slot-row", "slot-header-child")
return oob_page(ctx, oobs_html=oobs, content_html=content)
# ===========================================================================
# Slot edit form
# ===========================================================================
def render_slot_edit_form(slot, calendar) -> str:
"""Render slot edit form (replaces _types/slot/_edit.html)."""
from quart import url_for, g
from shared.browser.app.csrf import generate_csrf_token
csrf = generate_csrf_token()
styles = getattr(g, "styles", None) or {}
list_container = styles.get("list_container", "") if isinstance(styles, dict) else getattr(styles, "list_container", "")
action_btn = styles.get("action_button", "") if isinstance(styles, dict) else getattr(styles, "action_button", "")
cancel_btn = styles.get("cancel_button", "") if isinstance(styles, dict) else getattr(styles, "cancel_button", "")
cal_slug = getattr(calendar, "slug", "")
sid = slot.id
put_url = url_for("calendar.slots.slot.put", calendar_slug=cal_slug, slot_id=sid)
cancel_url = url_for("calendar.slots.slot.get_view", calendar_slug=cal_slug, slot_id=sid)
cost = getattr(slot, "cost", None)
cost_val = f"{cost:.2f}" if cost is not None else ""
start_val = slot.time_start.strftime("%H:%M") if slot.time_start else ""
end_val = slot.time_end.strftime("%H:%M") if slot.time_end else ""
desc_val = getattr(slot, "description", "") or ""
# Days checkboxes
day_keys = [("mon", "Mon"), ("tue", "Tue"), ("wed", "Wed"), ("thu", "Thu"),
("fri", "Fri"), ("sat", "Sat"), ("sun", "Sun")]
all_checked = all(getattr(slot, k, False) for k, _ in day_keys)
days_parts = [render("events-day-all-checkbox",
checked="checked" if all_checked else None)]
for key, label in day_keys:
checked = getattr(slot, key, False)
days_parts.append(render("events-day-checkbox",
name=key, label=label,
checked="checked" if checked else None))
days_html = "".join(days_parts)
flexible = getattr(slot, "flexible", False)
return render("events-slot-edit-form",
slot_id=str(sid), list_container=list_container,
put_url=put_url, cancel_url=cancel_url, csrf=csrf,
name_val=slot.name or "", cost_val=cost_val,
start_val=start_val, end_val=end_val,
desc_val=desc_val, days_html=days_html,
flexible_checked="checked" if flexible else None,
action_btn=action_btn, cancel_btn=cancel_btn)
# ===========================================================================
# Slot add form / button
# ===========================================================================
def render_slot_add_form(calendar) -> str:
"""Render slot add form (replaces _types/slots/_add.html)."""
from quart import url_for, g
from shared.browser.app.csrf import generate_csrf_token
csrf = generate_csrf_token()
styles = getattr(g, "styles", None) or {}
action_btn = styles.get("action_button", "") if isinstance(styles, dict) else getattr(styles, "action_button", "")
cancel_btn = styles.get("cancel_button", "") if isinstance(styles, dict) else getattr(styles, "cancel_button", "")
cal_slug = getattr(calendar, "slug", "")
post_url = url_for("calendar.slots.post", calendar_slug=cal_slug)
cancel_url = url_for("calendar.slots.add_button", calendar_slug=cal_slug)
csrf_hdr = f'{{"X-CSRFToken": "{csrf}"}}'
# Days checkboxes (all unchecked for add)
day_keys = [("mon", "Mon"), ("tue", "Tue"), ("wed", "Wed"), ("thu", "Thu"),
("fri", "Fri"), ("sat", "Sat"), ("sun", "Sun")]
days_parts = [render("events-day-all-checkbox", checked=None)]
for key, label in day_keys:
days_parts.append(render("events-day-checkbox", name=key, label=label, checked=None))
days_html = "".join(days_parts)
return render("events-slot-add-form",
post_url=post_url, csrf=csrf_hdr,
days_html=days_html,
action_btn=action_btn, cancel_btn=cancel_btn,
cancel_url=cancel_url)
def render_slot_add_button(calendar) -> str:
"""Render slot add button (replaces _types/slots/_add_button.html)."""
from quart import url_for, g
styles = getattr(g, "styles", None) or {}
pre_action = styles.get("pre_action_button", "") if isinstance(styles, dict) else getattr(styles, "pre_action_button", "")
cal_slug = getattr(calendar, "slug", "")
add_url = url_for("calendar.slots.add_form", calendar_slug=cal_slug)
return render("events-slot-add-button", pre_action=pre_action, add_url=add_url)
# ===========================================================================
# Entry add form / button
# ===========================================================================
def render_entry_add_form(calendar, day, month, year, day_slots) -> str:
"""Render entry add form (replaces _types/day/_add.html)."""
from quart import url_for, g
from shared.browser.app.csrf import generate_csrf_token
csrf = generate_csrf_token()
styles = getattr(g, "styles", None) or {}
action_btn = styles.get("action_button", "") if isinstance(styles, dict) else getattr(styles, "action_button", "")
cancel_btn = styles.get("cancel_button", "") if isinstance(styles, dict) else getattr(styles, "cancel_button", "")
cal_slug = getattr(calendar, "slug", "")
post_url = url_for("calendar.day.calendar_entries.add_entry",
calendar_slug=cal_slug, day=day, month=month, year=year)
cancel_url = url_for("calendar.day.calendar_entries.add_button",
calendar_slug=cal_slug, day=day, month=month, year=year)
# Slot picker
if day_slots:
options_html = _slot_options_html(day_slots)
slot_picker_html = render("events-slot-picker",
id="entry-slot-new", options_html=options_html)
else:
slot_picker_html = render("events-no-slots")
html = render("events-entry-add-form",
post_url=post_url, csrf=csrf,
slot_picker_html=slot_picker_html,
action_btn=action_btn, cancel_btn=cancel_btn,
cancel_url=cancel_url)
return html + _SLOT_PICKER_JS
def render_entry_add_button(calendar, day, month, year) -> str:
"""Render entry add button (replaces _types/day/_add_button.html)."""
from quart import url_for, g
styles = getattr(g, "styles", None) or {}
pre_action = styles.get("pre_action_button", "") if isinstance(styles, dict) else getattr(styles, "pre_action_button", "")
cal_slug = getattr(calendar, "slug", "")
add_url = url_for("calendar.day.calendar_entries.add_form",
calendar_slug=cal_slug, day=day, month=month, year=year)
return render("events-entry-add-button", pre_action=pre_action, add_url=add_url)
# ===========================================================================
# Ticket types page / OOB
# ===========================================================================
def _ticket_types_header_html(ctx: dict, *, oob: bool = False) -> str:
"""Build the ticket types header row."""
from quart import url_for
calendar = ctx.get("calendar")
entry = ctx.get("entry")
if not calendar or not entry:
return ""
cal_slug = getattr(calendar, "slug", "")
day = ctx.get("day")
month = ctx.get("month")
year = ctx.get("year")
link_href = url_for("calendar.day.calendar_entries.calendar_entry.ticket_types.get",
calendar_slug=cal_slug, entry_id=entry.id,
year=year, month=month, day=day)
label_html = '<i class="fa fa-ticket"></i><div class="shrink-0">ticket types</div>'
nav_html = render("events-admin-placeholder-nav")
return render("menu-row", id="ticket_types-row", level=7,
link_href=link_href, link_label_html=label_html,
nav_html=nav_html, child_id="ticket_type-header-child", oob=oob)
async def render_ticket_types_page(ctx: dict) -> str:
"""Full page: ticket types listing (extends entry admin)."""
ticket_types = ctx.get("ticket_types") or []
entry = ctx.get("entry")
calendar = ctx.get("calendar")
day = ctx.get("day")
month = ctx.get("month")
year = ctx.get("year")
content = render_ticket_types_table(ticket_types, entry, calendar, day, month, year)
hdr = root_header_html(ctx)
child = (_post_header_html(ctx)
+ _calendar_header_html(ctx) + _day_header_html(ctx)
+ _entry_header_html(ctx) + _entry_admin_header_html(ctx)
+ _ticket_types_header_html(ctx))
hdr += render("header-child", inner_html=child)
nav_html = render("events-admin-placeholder-nav")
return full_page(ctx, header_rows_html=hdr, content_html=content, menu_html=nav_html)
async def render_ticket_types_oob(ctx: dict) -> str:
"""OOB response: ticket types listing."""
ticket_types = ctx.get("ticket_types") or []
entry = ctx.get("entry")
calendar = ctx.get("calendar")
day = ctx.get("day")
month = ctx.get("month")
year = ctx.get("year")
content = render_ticket_types_table(ticket_types, entry, calendar, day, month, year)
oobs = _entry_admin_header_html(ctx, oob=True)
oobs += _oob_header_html("entry-admin-header-child", "ticket_types-header-child",
_ticket_types_header_html(ctx))
nav_html = render("events-admin-placeholder-nav")
return oob_page(ctx, oobs_html=oobs, content_html=content, menu_html=nav_html)
# ===========================================================================
# Ticket type page / OOB
# ===========================================================================
def _ticket_type_header_html(ctx: dict, *, oob: bool = False) -> str:
"""Build the single ticket type header row."""
from quart import url_for
calendar = ctx.get("calendar")
entry = ctx.get("entry")
ticket_type = ctx.get("ticket_type")
if not calendar or not entry or not ticket_type:
return ""
cal_slug = getattr(calendar, "slug", "")
day = ctx.get("day")
month = ctx.get("month")
year = ctx.get("year")
link_href = url_for(
"calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get",
calendar_slug=cal_slug, year=year, month=month, day=day,
entry_id=entry.id, ticket_type_id=ticket_type.id,
)
label_html = (
f'<div class="flex flex-col md:flex-row md:gap-2 items-center">'
f'<div class="flex flex-row items-center gap-2">'
f'<i class="fa fa-ticket"></i>'
f'<div class="shrink-0">{escape(ticket_type.name)}</div>'
f'</div></div>'
)
nav_html = render("events-admin-placeholder-nav")
return render("menu-row", id="ticket_type-row", level=8,
link_href=link_href, link_label_html=label_html,
nav_html=nav_html, child_id="ticket_type-header-child-inner", oob=oob)
async def render_ticket_type_page(ctx: dict) -> str:
"""Full page: single ticket type detail (extends ticket types)."""
ticket_type = ctx.get("ticket_type")
entry = ctx.get("entry")
calendar = ctx.get("calendar")
day = ctx.get("day")
month = ctx.get("month")
year = ctx.get("year")
content = render_ticket_type_main_panel(ticket_type, entry, calendar, day, month, year)
hdr = root_header_html(ctx)
child = (_post_header_html(ctx)
+ _calendar_header_html(ctx) + _day_header_html(ctx)
+ _entry_header_html(ctx) + _entry_admin_header_html(ctx)
+ _ticket_types_header_html(ctx) + _ticket_type_header_html(ctx))
hdr += render("header-child", inner_html=child)
nav_html = render("events-admin-placeholder-nav")
return full_page(ctx, header_rows_html=hdr, content_html=content, menu_html=nav_html)
async def render_ticket_type_oob(ctx: dict) -> str:
"""OOB response: single ticket type detail."""
ticket_type = ctx.get("ticket_type")
entry = ctx.get("entry")
calendar = ctx.get("calendar")
day = ctx.get("day")
month = ctx.get("month")
year = ctx.get("year")
content = render_ticket_type_main_panel(ticket_type, entry, calendar, day, month, year)
oobs = _ticket_types_header_html(ctx, oob=True)
oobs += _oob_header_html("ticket_types-header-child", "ticket_type-header-child",
_ticket_type_header_html(ctx))
nav_html = render("events-admin-placeholder-nav")
return oob_page(ctx, oobs_html=oobs, content_html=content, menu_html=nav_html)
# ===========================================================================
# Ticket type edit form
# ===========================================================================
def render_ticket_type_edit_form(ticket_type, entry, calendar, day, month, year) -> str:
"""Render ticket type edit form (replaces _types/ticket_type/_edit.html)."""
from quart import url_for, g
from shared.browser.app.csrf import generate_csrf_token
csrf = generate_csrf_token()
styles = getattr(g, "styles", None) or {}
list_container = styles.get("list_container", "") if isinstance(styles, dict) else getattr(styles, "list_container", "")
action_btn = styles.get("action_button", "") if isinstance(styles, dict) else getattr(styles, "action_button", "")
cancel_btn = styles.get("cancel_button", "") if isinstance(styles, dict) else getattr(styles, "cancel_button", "")
cal_slug = getattr(calendar, "slug", "")
tid = ticket_type.id
put_url = url_for("calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.put",
calendar_slug=cal_slug, year=year, month=month, day=day,
entry_id=entry.id, ticket_type_id=tid)
cancel_url = url_for("calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get_view",
calendar_slug=cal_slug, entry_id=entry.id,
year=year, month=month, day=day, ticket_type_id=tid)
cost = getattr(ticket_type, "cost", None)
cost_val = f"{cost:.2f}" if cost is not None else ""
count = getattr(ticket_type, "count", 0)
return render("events-ticket-type-edit-form",
ticket_id=str(tid), list_container=list_container,
put_url=put_url, cancel_url=cancel_url, csrf=csrf,
name_val=ticket_type.name or "",
cost_val=cost_val, count_val=str(count),
action_btn=action_btn, cancel_btn=cancel_btn)
# ===========================================================================
# Ticket type add form / button
# ===========================================================================
def render_ticket_type_add_form(entry, calendar, day, month, year) -> str:
"""Render ticket type add form (replaces _types/ticket_types/_add.html)."""
from quart import url_for, g
from shared.browser.app.csrf import generate_csrf_token
csrf = generate_csrf_token()
styles = getattr(g, "styles", None) or {}
action_btn = styles.get("action_button", "") if isinstance(styles, dict) else getattr(styles, "action_button", "")
cancel_btn = styles.get("cancel_button", "") if isinstance(styles, dict) else getattr(styles, "cancel_button", "")
cal_slug = getattr(calendar, "slug", "")
post_url = url_for("calendar.day.calendar_entries.calendar_entry.ticket_types.post",
calendar_slug=cal_slug, entry_id=entry.id,
year=year, month=month, day=day)
cancel_url = url_for("calendar.day.calendar_entries.calendar_entry.ticket_types.add_button",
calendar_slug=cal_slug, entry_id=entry.id,
year=year, month=month, day=day)
csrf_hdr = f'{{"X-CSRFToken": "{csrf}"}}'
return render("events-ticket-type-add-form",
post_url=post_url, csrf=csrf_hdr,
action_btn=action_btn, cancel_btn=cancel_btn,
cancel_url=cancel_url)
def render_ticket_type_add_button(entry, calendar, day, month, year) -> str:
"""Render ticket type add button (replaces _types/ticket_types/_add_button.html)."""
from quart import url_for, g
styles = getattr(g, "styles", None) or {}
action_btn = styles.get("action_button", "") if isinstance(styles, dict) else getattr(styles, "action_button", "")
cal_slug = getattr(calendar, "slug", "")
add_url = url_for("calendar.day.calendar_entries.calendar_entry.ticket_types.add_form",
calendar_slug=cal_slug, entry_id=entry.id,
year=year, month=month, day=day)
return render("events-ticket-type-add-button",
action_btn=action_btn, add_url=add_url)
# ===========================================================================
# Fragment: container cards entries
# ===========================================================================
def render_fragment_container_cards(batch, post_ids, slug_map) -> str:
"""Render container cards entries (replaces fragments/container_cards_entries.html)."""
from shared.infrastructure.urls import events_url
parts = []
for post_id in post_ids:
parts.append(f"<!-- card-widget:{post_id} -->")
widget_entries = batch.get(post_id, [])
if widget_entries:
cards_html = ""
for entry in widget_entries:
_post_slug = slug_map.get(post_id, "")
_entry_path = (
f"/{_post_slug}/{entry.calendar_slug}/"
f"{entry.start_at.year}/{entry.start_at.month}/"
f"{entry.start_at.day}/entries/{entry.id}/"
)
time_str = entry.start_at.strftime("%H:%M")
if entry.end_at:
time_str += f" \u2013 {entry.end_at.strftime('%H:%M')}"
cards_html += render("events-frag-entry-card",
href=events_url(_entry_path),
name=entry.name,
date_str=entry.start_at.strftime("%a, %b %d"),
time_str=time_str)
parts.append(render("events-frag-entries-widget", cards_html=cards_html))
parts.append(f"<!-- /card-widget:{post_id} -->")
return "\n".join(parts)
# ===========================================================================
# Fragment: account page tickets
# ===========================================================================
def render_fragment_account_tickets(tickets) -> str:
"""Render account page tickets (replaces fragments/account_page_tickets.html)."""
from shared.infrastructure.urls import events_url
if tickets:
items_html = ""
for ticket in tickets:
href = events_url(f"/tickets/{ticket.code}/")
date_str = ticket.entry_start_at.strftime("%d %b %Y, %H:%M")
cal_name = ""
if getattr(ticket, "calendar_name", None):
cal_name = f'<span>&middot; {escape(ticket.calendar_name)}</span>'
type_name = ""
if getattr(ticket, "ticket_type_name", None):
type_name = f'<span>&middot; {escape(ticket.ticket_type_name)}</span>'
badge_html = render("events-frag-ticket-badge",
state=getattr(ticket, "state", ""))
items_html += render("events-frag-ticket-item",
href=href, entry_name=ticket.entry_name,
date_str=date_str, calendar_name=cal_name,
type_name=type_name, badge_html=badge_html)
body = render("events-frag-tickets-list", items_html=items_html)
else:
body = render("events-frag-tickets-empty")
return render("events-frag-tickets-panel", items_html=body)
# ===========================================================================
# Fragment: account page bookings
# ===========================================================================
def render_fragment_account_bookings(bookings) -> str:
"""Render account page bookings (replaces fragments/account_page_bookings.html)."""
if bookings:
items_html = ""
for booking in bookings:
date_str = booking.start_at.strftime("%d %b %Y, %H:%M")
if getattr(booking, "end_at", None):
date_str_extra = f'<span>&ndash; {escape(booking.end_at.strftime("%H:%M"))}</span>'
else:
date_str_extra = ""
cal_name = ""
if getattr(booking, "calendar_name", None):
cal_name = f'<span>&middot; {escape(booking.calendar_name)}</span>'
cost_str = ""
if getattr(booking, "cost", None):
cost_str = f'<span>&middot; &pound;{escape(str(booking.cost))}</span>'
badge_html = render("events-frag-booking-badge",
state=getattr(booking, "state", ""))
items_html += render("events-frag-booking-item",
name=booking.name,
date_str=date_str + date_str_extra,
calendar_name=cal_name, cost_str=cost_str,
badge_html=badge_html)
body = render("events-frag-bookings-list", items_html=items_html)
else:
body = render("events-frag-bookings-empty")
return render("events-frag-bookings-panel", items_html=body)