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

@@ -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)