Move payments admin from events to cart service
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m40s

Payments config (SumUp credentials per page) is a cart concern since all
checkouts go through the cart service. Moves it from events.rose-ash.com
to cart.rose-ash.com/<page_slug>/admin/payments/ and adds a cart admin
overview page at /<page_slug>/admin/.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 18:15:35 +00:00
parent 5957bd8941
commit ee41e30d5b
12 changed files with 1151 additions and 173 deletions

View File

@@ -16,6 +16,7 @@ from bp import (
register_cart_overview,
register_page_cart,
register_cart_global,
register_page_admin,
register_fragments,
register_actions,
register_data,
@@ -195,6 +196,12 @@ def create_app() -> "Quart":
url_prefix="/",
)
# Page admin at /<page_slug>/admin/ (before page_cart catch-all)
app.register_blueprint(
register_page_admin(),
url_prefix="/<page_slug>/admin",
)
# Page cart at /<page_slug>/ (dynamic, matched last)
app.register_blueprint(
register_page_cart(url_prefix="/"),

View File

@@ -1,6 +1,7 @@
from .cart.overview_routes import register as register_cart_overview
from .cart.page_routes import register as register_page_cart
from .cart.global_routes import register as register_cart_global
from .page_admin.routes import register as register_page_admin
from .fragments import register_fragments
from .actions import register_actions
from .data import register_data

View File

@@ -0,0 +1,83 @@
from __future__ import annotations
from quart import (
make_response, Blueprint, g, request
)
from shared.infrastructure.actions import call_action
from shared.infrastructure.data_client import fetch_data
from shared.browser.app.authz import require_admin
from shared.browser.app.utils.htmx import is_htmx_request
def register():
bp = Blueprint("page_admin", __name__)
@bp.get("/")
@require_admin
async def admin(**kwargs):
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_cart_admin_page, render_cart_admin_oob
ctx = await get_template_context()
page_post = getattr(g, "page_post", None)
if not is_htmx_request():
html = await render_cart_admin_page(ctx, page_post)
else:
html = await render_cart_admin_oob(ctx, page_post)
return await make_response(html)
@bp.get("/payments/")
@require_admin
async def payments(**kwargs):
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_cart_payments_page, render_cart_payments_oob
ctx = await get_template_context()
page_post = getattr(g, "page_post", None)
if not is_htmx_request():
html = await render_cart_payments_page(ctx, page_post)
else:
html = await render_cart_payments_oob(ctx, page_post)
return await make_response(html)
@bp.put("/payments/")
@require_admin
async def update_sumup(**kwargs):
"""Update SumUp credentials for this page (writes to blog's db_blog)."""
page_post = getattr(g, "page_post", None)
if not page_post:
return await make_response("Page not found", 404)
form = await request.form
merchant_code = (form.get("merchant_code") or "").strip()
api_key = (form.get("api_key") or "").strip()
checkout_prefix = (form.get("checkout_prefix") or "").strip()
payload = {
"container_type": "page",
"container_id": page_post.id,
"sumup_merchant_code": merchant_code,
"sumup_checkout_prefix": checkout_prefix,
}
if api_key:
payload["sumup_api_key"] = api_key
await call_action("blog", "update-page-config", payload=payload)
# Re-fetch page config to get fresh data
from types import SimpleNamespace
raw_pc = await fetch_data(
"blog", "page-config",
params={"container_type": "page", "container_id": page_post.id},
required=False,
)
g.page_config = SimpleNamespace(**raw_pc) if raw_pc else None
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_cart_payments_panel
ctx = await get_template_context()
html = render_cart_payments_panel(ctx)
return await make_response(html)
return bp

21
cart/sexp/payments.sexpr Normal file
View File

@@ -0,0 +1,21 @@
;; Cart payments components
(defcomp ~cart-payments-panel (&key update-url csrf merchant-code placeholder input-cls sumup-configured checkout-prefix)
(section :class "p-4 max-w-lg mx-auto"
(div :id "payments-panel" :class "space-y-4 p-4 bg-white rounded-lg border border-stone-200"
(h3 :class "text-lg font-semibold text-stone-800"
(i :class "fa fa-credit-card text-purple-600 mr-1") " SumUp Payment")
(p :class "text-xs text-stone-400" "Configure per-page SumUp credentials. Leave blank to use the global merchant account.")
(form :hx-put update-url :hx-target "#payments-panel" :hx-swap "outerHTML" :hx-select "#payments-panel" :class "space-y-3"
(input :type "hidden" :name "csrf_token" :value csrf)
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "Merchant Code")
(input :type "text" :name "merchant_code" :value merchant-code :placeholder "e.g. ME4J6100" :class input-cls))
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "API Key")
(input :type "password" :name "api_key" :value "" :placeholder placeholder :class input-cls)
(when sumup-configured (p :class "text-xs text-stone-400 mt-0.5" "Key is set. Leave blank to keep current key.")))
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "Checkout Reference Prefix")
(input :type "text" :name "checkout_prefix" :value checkout-prefix :placeholder "e.g. ROSE-" :class input-cls))
(button :type "submit" :class "px-4 py-1.5 text-sm font-medium text-white bg-purple-600 rounded hover:bg-purple-700 focus:ring-2 focus:ring-purple-500"
"Save SumUp Settings")
(when sumup-configured (span :class "ml-2 text-xs text-green-600"
(i :class "fa fa-check-circle") " Connected"))))))

View File

@@ -721,3 +721,126 @@ async def render_checkout_error_page(ctx: dict, error: str | None = None, order:
filt = _checkout_error_filter_html()
content = _checkout_error_content_html(error, order)
return full_page(ctx, header_rows_html=hdr, filter_html=filt, content_html=content)
# ---------------------------------------------------------------------------
# Page admin (/<page_slug>/admin/)
# ---------------------------------------------------------------------------
def _cart_page_admin_header_html(ctx: dict, page_post: Any, *, oob: bool = False) -> str:
"""Build the page-level admin header row."""
from quart import url_for
link_href = url_for("page_admin.admin")
return render("menu-row", id="page-admin-row", level=2, colour="sky",
link_href=link_href, link_label="admin", icon="fa fa-cog",
child_id="page-admin-header-child", oob=oob)
def _cart_payments_header_html(ctx: dict, *, oob: bool = False) -> str:
"""Build the payments section header row."""
from quart import url_for
link_href = url_for("page_admin.payments")
return render("menu-row", id="payments-row", level=3, colour="sky",
link_href=link_href, link_label="Payments",
icon="fa fa-credit-card",
child_id="payments-header-child", oob=oob)
def _cart_admin_main_panel_html(ctx: dict) -> str:
"""Admin overview panel — links to sub-admin pages."""
from quart import url_for
payments_href = url_for("page_admin.payments")
return (
'<div id="main-panel">'
'<div class="flex items-center justify-between p-3 border-b">'
'<span class="font-medium"><i class="fa fa-credit-card text-purple-600 mr-1"></i> Payments</span>'
f'<a href="{payments_href}" class="text-sm underline">configure</a>'
'</div>'
'</div>'
)
def _cart_payments_main_panel_html(ctx: dict) -> str:
"""Render SumUp payment config form."""
from quart import url_for
csrf_token = ctx.get("csrf_token")
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
page_config = ctx.get("page_config")
sumup_configured = bool(page_config and getattr(page_config, "sumup_api_key", None))
merchant_code = (getattr(page_config, "sumup_merchant_code", None) or "") if page_config else ""
checkout_prefix = (getattr(page_config, "sumup_checkout_prefix", None) or "") if page_config else ""
update_url = url_for("page_admin.update_sumup")
placeholder = "--------" if sumup_configured else "sup_sk_..."
input_cls = "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500"
return render("cart-payments-panel",
update_url=update_url, csrf=csrf,
merchant_code=merchant_code, placeholder=placeholder,
input_cls=input_cls, sumup_configured=sumup_configured,
checkout_prefix=checkout_prefix)
# ---------------------------------------------------------------------------
# Public API: Cart page admin
# ---------------------------------------------------------------------------
async def render_cart_admin_page(ctx: dict, page_post: Any) -> str:
"""Full page: cart page admin overview."""
content = _cart_admin_main_panel_html(ctx)
hdr = root_header_html(ctx)
child = _page_cart_header_html(ctx, page_post) + _cart_page_admin_header_html(ctx, page_post)
hdr += render("cart-header-child-nested",
outer_html=_cart_header_html(ctx), inner_html=child)
return full_page(ctx, header_rows_html=hdr, content_html=content)
async def render_cart_admin_oob(ctx: dict, page_post: Any) -> str:
"""OOB response: cart page admin overview."""
content = _cart_admin_main_panel_html(ctx)
oobs = (
_cart_page_admin_header_html(ctx, page_post, oob=True)
+ render("cart-header-child-oob",
inner_html=_page_cart_header_html(ctx, page_post)
+ _cart_page_admin_header_html(ctx, page_post))
+ _cart_header_html(ctx, oob=True)
+ root_header_html(ctx, oob=True)
)
return oob_page(ctx, oobs_html=oobs, content_html=content)
# ---------------------------------------------------------------------------
# Public API: Cart payments admin
# ---------------------------------------------------------------------------
async def render_cart_payments_page(ctx: dict, page_post: Any) -> str:
"""Full page: payments config."""
content = _cart_payments_main_panel_html(ctx)
hdr = root_header_html(ctx)
admin_hdr = _cart_page_admin_header_html(ctx, page_post)
payments_hdr = _cart_payments_header_html(ctx)
child = _page_cart_header_html(ctx, page_post) + admin_hdr + payments_hdr
hdr += render("cart-header-child-nested",
outer_html=_cart_header_html(ctx), inner_html=child)
return full_page(ctx, header_rows_html=hdr, content_html=content)
async def render_cart_payments_oob(ctx: dict, page_post: Any) -> str:
"""OOB response: payments config."""
content = _cart_payments_main_panel_html(ctx)
admin_hdr = _cart_page_admin_header_html(ctx, page_post)
payments_hdr = _cart_payments_header_html(ctx)
oobs = (
_cart_payments_header_html(ctx, oob=True)
+ render("cart-header-child-oob",
inner_html=_page_cart_header_html(ctx, page_post)
+ admin_hdr + payments_hdr)
+ _cart_header_html(ctx, oob=True)
+ root_header_html(ctx, oob=True)
)
return oob_page(ctx, oobs_html=oobs, content_html=content)
def render_cart_payments_panel(ctx: dict) -> str:
"""Render the payments config panel for PUT response."""
return _cart_payments_main_panel_html(ctx)

View File

@@ -9,7 +9,7 @@ from jinja2 import FileSystemLoader, ChoiceLoader
from shared.infrastructure.factory import create_base_app
from bp import register_all_events, register_calendar, register_calendars, register_markets, register_payments, register_page, register_fragments, register_actions, register_data
from bp import register_all_events, register_calendar, register_calendars, register_markets, register_page, register_fragments, register_actions, register_data
async def events_context() -> dict:
@@ -93,13 +93,13 @@ def create_app() -> "Quart":
# Individual calendars at /<slug>/<calendar_slug>/
app.register_blueprint(
register_calendar(),
url_prefix="/<slug>",
url_prefix="/<slug>/<calendar_slug>",
)
# Calendar admin under post slug: /<slug>/admin/
app.register_blueprint(
register_calendars(),
url_prefix="/<slug>",
url_prefix="/<slug>/admin",
)
# Markets nested under post slug: /<slug>/markets/...
@@ -108,12 +108,6 @@ def create_app() -> "Quart":
url_prefix="/<slug>/markets",
)
# Payments nested under post slug: /<slug>/payments/...
app.register_blueprint(
register_payments(),
url_prefix="/<slug>/payments",
)
app.register_blueprint(register_fragments())
app.register_blueprint(register_actions())
app.register_blueprint(register_data())

View File

@@ -2,7 +2,6 @@ from .all_events.routes import register as register_all_events
from .calendar.routes import register as register_calendar
from .calendars.routes import register as register_calendars
from .markets.routes import register as register_markets
from .payments.routes import register as register_payments
from .page.routes import register as register_page
from .fragments import register_fragments
from .actions import register_actions

View File

@@ -1,90 +0,0 @@
from __future__ import annotations
from types import SimpleNamespace
from quart import (
render_template, make_response, Blueprint, g, request
)
from shared.infrastructure.data_client import fetch_data
from shared.infrastructure.actions import call_action
from shared.browser.app.authz import require_admin
from shared.browser.app.utils.htmx import is_htmx_request
def register():
bp = Blueprint("payments", __name__, url_prefix='/payments')
@bp.context_processor
async def inject_root():
return {}
async def _load_payment_ctx():
"""Load PageConfig SumUp data for the current page (from blog)."""
post = (getattr(g, "post_data", None) or {}).get("post", {})
post_id = post.get("id")
if not post_id:
return {}
raw_pc = await fetch_data(
"blog", "page-config",
params={"container_type": "page", "container_id": post_id},
required=False,
)
pc = SimpleNamespace(**raw_pc) if raw_pc else None
return {
"sumup_configured": bool(pc and getattr(pc, "sumup_api_key", None)),
"sumup_merchant_code": (getattr(pc, "sumup_merchant_code", None) or "") if pc else "",
"sumup_checkout_prefix": (getattr(pc, "sumup_checkout_prefix", None) or "") if pc else "",
}
@bp.get("/")
@require_admin
async def home(**kwargs):
pay_ctx = await _load_payment_ctx()
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_payments_page, render_payments_oob
ctx = await get_template_context()
ctx.update(pay_ctx)
if not is_htmx_request():
html = await render_payments_page(ctx)
else:
html = await render_payments_oob(ctx)
return await make_response(html)
@bp.put("/")
@require_admin
async def update_sumup(**kwargs):
"""Update SumUp credentials for this page (writes to blog's db_blog)."""
post = (getattr(g, "post_data", None) or {}).get("post", {})
post_id = post.get("id")
if not post_id:
return await make_response("Post not found", 404)
form = await request.form
merchant_code = (form.get("merchant_code") or "").strip()
api_key = (form.get("api_key") or "").strip()
checkout_prefix = (form.get("checkout_prefix") or "").strip()
payload = {
"container_type": "page",
"container_id": post_id,
"sumup_merchant_code": merchant_code,
"sumup_checkout_prefix": checkout_prefix,
}
if api_key:
payload["sumup_api_key"] = api_key
await call_action("blog", "update-page-config", payload=payload)
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_payments_panel
ctx = await get_template_context()
ctx.update(await _load_payment_ctx())
html = render_payments_panel(ctx)
return await make_response(html)
return bp

View File

@@ -21,9 +21,6 @@
(defcomp ~events-markets-label ()
(<> (i :class "fa fa-shopping-bag" :aria-hidden "true") (div "Markets")))
(defcomp ~events-payments-label ()
(<> (i :class "fa fa-credit-card" :aria-hidden "true") (div "Payments")))
(defcomp ~events-calendar-label (&key name description)
(div :class "flex flex-col md:flex-row md:gap-2 items-center min-w-0"
(div :class "flex flex-row items-center gap-2"

39
events/sexp/markets.sexpr Normal file
View File

@@ -0,0 +1,39 @@
;; Events markets components
(defcomp ~events-markets-create-form (&key create-url csrf)
(<>
(div :id "market-create-errors" :class "mt-2 text-sm text-red-600")
(form :class "mt-4 flex gap-2 items-end" :hx-post create-url
:hx-target "#markets-list" :hx-select "#markets-list" :hx-swap "outerHTML"
:hx-on::before-request "document.querySelector('#market-create-errors').textContent='';"
:hx-on::response-error "document.querySelector('#market-create-errors').innerHTML = event.detail.xhr.responseText;"
(input :type "hidden" :name "csrf_token" :value csrf)
(div :class "flex-1"
(label :class "block text-sm text-gray-600" "Name")
(input :name "name" :type "text" :required true :class "w-full border rounded px-3 py-2"
:placeholder "e.g. Farm Shop, Bakery"))
(button :type "submit" :class "border rounded px-3 py-2" "Add market"))))
(defcomp ~events-markets-panel (&key form-html list-html)
(section :class "p-4"
(raw! form-html)
(div :id "markets-list" :class "mt-6" (raw! list-html))))
(defcomp ~events-markets-empty ()
(p :class "text-gray-500 mt-4" "No markets yet. Create one above."))
(defcomp ~events-markets-item (&key href market-name market-slug del-url csrf-hdr)
(div :class "mt-6 border rounded-lg p-4"
(div :class "flex items-center justify-between gap-3"
(a :class "flex items-baseline gap-3" :href href
(h3 :class "font-semibold" market-name)
(h4 :class "text-gray-500" (str "/" market-slug "/")))
(button :class "text-sm border rounded px-3 py-1 hover:bg-red-50 hover:border-red-400"
:data-confirm true :data-confirm-title "Delete market?"
:data-confirm-text "Products will be hidden (soft delete)"
:data-confirm-icon "warning" :data-confirm-confirm-text "Yes, delete it"
:data-confirm-cancel-text "Cancel" :data-confirm-event "confirmed"
:hx-delete del-url :hx-trigger "confirmed"
:hx-target "#markets-list" :hx-select "#markets-list" :hx-swap "outerHTML"
:hx-headers csrf-hdr
(i :class "fa-solid fa-trash")))))

View File

@@ -2,7 +2,7 @@
Events service s-expression page components.
Renders all events, page summary, calendars, calendar month, day, day admin,
calendar admin, tickets, ticket admin, markets, and payments pages.
calendar admin, tickets, ticket admin, and markets pages.
Called from route handlers in place of ``render_template()``.
"""
from __future__ import annotations
@@ -87,6 +87,26 @@ def _post_nav_html(ctx: dict) -> str:
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)
@@ -301,20 +321,6 @@ def _markets_header_html(ctx: dict, *, oob: bool = False) -> str:
child_id="markets-header-child", oob=oob)
# ---------------------------------------------------------------------------
# Payments header
# ---------------------------------------------------------------------------
def _payments_header_html(ctx: dict, *, oob: bool = False) -> str:
"""Build the payments section header row."""
from quart import url_for
link_href = url_for("payments.home")
return render("menu-row", id="payments-row", level=3,
link_href=link_href,
link_label_html=render("events-payments-label"),
child_id="payments-header-child", oob=oob)
# ---------------------------------------------------------------------------
# Calendars main panel
# ---------------------------------------------------------------------------
@@ -699,30 +705,6 @@ def _markets_list_html(ctx: dict, markets: list) -> str:
return "".join(parts)
# ---------------------------------------------------------------------------
# Payments main panel
# ---------------------------------------------------------------------------
def _payments_main_panel_html(ctx: dict) -> str:
"""Render SumUp payment config form."""
from quart import url_for
csrf_token = ctx.get("csrf_token")
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
sumup_configured = ctx.get("sumup_configured", False)
merchant_code = ctx.get("sumup_merchant_code", "")
checkout_prefix = ctx.get("sumup_checkout_prefix", "")
update_url = url_for("payments.update_sumup")
placeholder = "--------" if sumup_configured else "sup_sk_..."
input_cls = "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500"
return render("events-payments-panel",
update_url=update_url, csrf=csrf,
merchant_code=merchant_code, placeholder=placeholder,
input_cls=input_cls, sumup_configured=sumup_configured,
checkout_prefix=checkout_prefix)
# ---------------------------------------------------------------------------
# Ticket state badge helper
# ---------------------------------------------------------------------------
@@ -1507,28 +1489,6 @@ async def render_markets_oob(ctx: dict) -> str:
return oob_page(ctx, oobs_html=oobs, content_html=content)
# ---------------------------------------------------------------------------
# Payments
# ---------------------------------------------------------------------------
async def render_payments_page(ctx: dict) -> str:
"""Full page: payments admin."""
content = _payments_main_panel_html(ctx)
hdr = root_header_html(ctx)
child = _post_header_html(ctx) + _payments_header_html(ctx)
hdr += render("events-header-child", inner_html=child)
return full_page(ctx, header_rows_html=hdr, content_html=content)
async def render_payments_oob(ctx: dict) -> str:
"""OOB response: payments admin."""
content = _payments_main_panel_html(ctx)
oobs = _post_header_html(ctx, oob=True)
oobs += _oob_header_html("post-header-child", "payments-header-child",
_payments_header_html(ctx))
return oob_page(ctx, oobs_html=oobs, content_html=content)
# ===========================================================================
# POST / PUT / DELETE response components
# ===========================================================================
@@ -2221,15 +2181,6 @@ def render_calendar_description_edit(calendar) -> str:
csrf=csrf, description=desc)
# ---------------------------------------------------------------------------
# Payments panel (public wrapper)
# ---------------------------------------------------------------------------
def render_payments_panel(ctx: dict) -> str:
"""Render the payments config panel for PUT response."""
return _payments_main_panel_html(ctx)
# ---------------------------------------------------------------------------
# Calendars list panel (for POST create / DELETE)
# ---------------------------------------------------------------------------
@@ -2611,3 +2562,856 @@ def _cart_icon_oob(count: int) -> str:
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)
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))
hdr += render("events-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)
oobs = _entry_header_html(ctx, oob=True)
oobs += _oob_header_html("entry-header-child", "entry-admin-header-child",
_entry_admin_header_html(ctx))
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)
hdr = root_header_html(ctx)
child = (_post_header_html(ctx)
+ _calendar_header_html(ctx) + _calendar_admin_header_html(ctx)
+ _slot_header_html(ctx))
hdr += render("events-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)
oobs = _calendar_admin_header_html(ctx, oob=True)
oobs += _oob_header_html("calendar-admin-header-child", "slot-header-child",
_slot_header_html(ctx))
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("events-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("events-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)