Move payments admin from events to cart service
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:
@@ -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="/"),
|
||||
|
||||
@@ -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
|
||||
|
||||
83
cart/bp/page_admin/routes.py
Normal file
83
cart/bp/page_admin/routes.py
Normal 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
21
cart/sexp/payments.sexpr
Normal 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"))))))
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
39
events/sexp/markets.sexpr
Normal 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")))))
|
||||
@@ -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>· {escape(ticket.calendar_name)}</span>'
|
||||
type_name = ""
|
||||
if getattr(ticket, "ticket_type_name", None):
|
||||
type_name = f'<span>· {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>– {escape(booking.end_at.strftime("%H:%M"))}</span>'
|
||||
else:
|
||||
date_str_extra = ""
|
||||
cal_name = ""
|
||||
if getattr(booking, "calendar_name", None):
|
||||
cal_name = f'<span>· {escape(booking.calendar_name)}</span>'
|
||||
cost_str = ""
|
||||
if getattr(booking, "cost", None):
|
||||
cost_str = f'<span>· £{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)
|
||||
|
||||
Reference in New Issue
Block a user