Merge branch 'macros'

# Conflicts:
#	blog/bp/post/admin/routes.py
#	events/sxc/pages/calendar.py
#	events/sxc/pages/entries.py
#	events/sxc/pages/slots.py
#	events/sxc/pages/tickets.py
This commit is contained in:
2026-03-05 16:40:06 +00:00
69 changed files with 18073 additions and 977 deletions

View File

@@ -8,8 +8,8 @@ from shared.sx.helpers import (
from shared.sx.parser import SxExpr
from .utils import (
_ensure_container_nav,
_list_container,
_clear_deeper_oob, _ensure_container_nav,
_entry_state_badge_html, _list_container,
)
@@ -27,41 +27,45 @@ def _post_nav_sx(ctx: dict) -> str:
"""Post desktop nav: calendar links + container nav (markets, etc.)."""
from quart import url_for, g
calendars_orm = ctx.get("calendars") or []
calendars = ctx.get("calendars") or []
select_colours = ctx.get("select_colours", "")
current_cal_slug = getattr(g, "calendar_slug", None)
calendars_data = []
for cal in calendars_orm:
parts = []
for cal in calendars:
cal_slug = getattr(cal, "slug", "") if hasattr(cal, "slug") else cal.get("slug", "")
cal_name = getattr(cal, "name", "") if hasattr(cal, "name") else cal.get("name", "")
href = url_for("calendar.get", calendar_slug=cal_slug)
calendars_data.append({
"href": href, "name": cal_name,
"is-selected": True if cal_slug == current_cal_slug else None,
})
container_nav = ctx.get("container_nav", "") or None
is_sel = (cal_slug == current_cal_slug)
parts.append(sx_call("nav-link", href=href, icon="fa fa-calendar",
label=cal_name, select_colours=select_colours,
is_selected=is_sel))
# Container nav fragments (markets, etc.)
container_nav = ctx.get("container_nav", "")
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)
admin_href = None
aclass = None
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 sx_call("events-post-nav-from-data",
calendars=calendars_data or None, container_nav=container_nav,
select_colours=select_colours,
has_admin=has_admin or None, admin_href=admin_href, aclass=aclass)
return "".join(parts)
# ---------------------------------------------------------------------------
@@ -115,13 +119,15 @@ def _calendar_nav_sx(ctx: dict) -> str:
is_admin = getattr(rights, "admin", False) if hasattr(rights, "admin") else rights.get("admin", False)
select_colours = ctx.get("select_colours", "")
parts = []
slots_href = url_for("defpage_slots_listing", calendar_slug=cal_slug)
admin_href = url_for("defpage_calendar_admin", calendar_slug=cal_slug) if is_admin else None
return sx_call("events-calendar-nav-from-data",
slots_href=slots_href, admin_href=admin_href,
select_colours=select_colours,
is_admin=is_admin or None)
parts.append(sx_call("nav-link", href=slots_href, icon="fa fa-clock",
label="Slots", select_colours=select_colours))
if is_admin:
admin_href = url_for("defpage_calendar_admin", calendar_slug=cal_slug)
parts.append(sx_call("nav-link", href=admin_href, icon="fa fa-cog",
select_colours=select_colours))
return "(<> " + " ".join(parts) + ")" if parts else ""
# ---------------------------------------------------------------------------
@@ -168,21 +174,27 @@ def _day_nav_sx(ctx: dict) -> str:
rights = ctx.get("rights") or {}
is_admin = getattr(rights, "admin", False) if hasattr(rights, "admin") else rights.get("admin", False)
entries_data = []
for entry in confirmed_entries:
href = url_for(
"defpage_entry_detail",
calendar_slug=cal_slug,
year=day_date.year,
month=day_date.month,
day=day_date.day,
entry_id=entry.id,
)
start = entry.start_at.strftime("%H:%M") if entry.start_at else ""
end = f" \u2013 {entry.end_at.strftime('%H:%M')}" if entry.end_at else ""
entries_data.append({"href": href, "name": entry.name, "time-str": f"{start}{end}"})
parts = []
# Confirmed entries nav (scrolling menu)
if confirmed_entries:
entry_links = []
for entry in confirmed_entries:
href = url_for(
"defpage_entry_detail",
calendar_slug=cal_slug,
year=day_date.year,
month=day_date.month,
day=day_date.day,
entry_id=entry.id,
)
start = entry.start_at.strftime("%H:%M") if entry.start_at else ""
end = f" \u2013 {entry.end_at.strftime('%H:%M')}" if entry.end_at else ""
entry_links.append(sx_call("events-day-entry-link",
href=href, name=entry.name,
time_str=f"{start}{end}"))
inner = "".join(entry_links)
parts.append(sx_call("events-day-entries-nav", inner=SxExpr(inner)))
admin_href = None
if is_admin and day_date:
admin_href = url_for(
"defpage_day_admin",
@@ -191,10 +203,8 @@ def _day_nav_sx(ctx: dict) -> str:
month=day_date.month,
day=day_date.day,
)
return sx_call("events-day-nav-from-data",
entries=entries_data or None,
is_admin=is_admin or None, admin_href=admin_href)
parts.append(sx_call("nav-link", href=admin_href, icon="fa fa-cog"))
return "".join(parts)
# ---------------------------------------------------------------------------
@@ -235,16 +245,17 @@ def _calendar_admin_header_sx(ctx: dict, *, oob: bool = False) -> str:
cal_slug = getattr(calendar, "slug", "") if calendar else ""
select_colours = ctx.get("select_colours", "")
links_data = []
nav_parts = []
if cal_slug:
for endpoint, label in [
("defpage_slots_listing", "slots"),
("calendar.admin.calendar_description_edit", "description"),
]:
links_data.append({"href": url_for(endpoint, calendar_slug=cal_slug), "label": label})
href = url_for(endpoint, calendar_slug=cal_slug)
nav_parts.append(sx_call("nav-link", href=href, label=label,
select_colours=select_colours))
nav_html = sx_call("events-calendar-admin-nav-from-data",
links=links_data or None, select_colours=select_colours) if links_data else ""
nav_html = "".join(nav_parts)
return sx_call("menu-row-sx", id="calendar-admin-row", level=4,
link_label="admin", icon="fa fa-cog",
nav=SxExpr(nav_html) if nav_html else None, child_id="calendar-admin-header-child", oob=oob)
@@ -271,36 +282,55 @@ def _markets_header_sx(ctx: dict, *, oob: bool = False) -> str:
def _calendars_main_panel_sx(ctx: dict) -> str:
"""Render the calendars list + create form panel."""
from quart import url_for
from shared.utils import route_prefix
rights = ctx.get("rights") or {}
is_admin = getattr(rights, "admin", False) if hasattr(rights, "admin") else rights.get("admin", False)
has_access = ctx.get("has_access")
can_create = has_access("calendars.create_calendar") if callable(has_access) else is_admin
csrf_token = ctx.get("csrf_token")
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
prefix = route_prefix()
calendars = ctx.get("calendars") or []
items_data = []
form_html = ""
if can_create:
create_url = url_for("calendars.create_calendar")
form_html = sx_call("crud-create-form",
create_url=create_url, csrf=csrf,
errors_id="cal-create-errors", list_id="calendars-list",
placeholder="e.g. Events, Gigs, Meetings", btn_label="Add calendar")
list_html = _calendars_list_sx(ctx, calendars)
return sx_call("crud-panel",
form=SxExpr(form_html), list=SxExpr(list_html),
list_id="calendars-list")
def _calendars_list_sx(ctx: dict, calendars: list) -> str:
"""Render the calendars list items."""
from quart import url_for
from shared.utils import route_prefix
csrf_token = ctx.get("csrf_token")
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
prefix = route_prefix()
if not calendars:
return sx_call("empty-state", message="No calendars yet. Create one above.",
cls="text-gray-500 mt-4")
parts = []
for cal in calendars:
cal_slug = getattr(cal, "slug", "")
cal_name = getattr(cal, "name", "")
items_data.append({
"href": prefix + url_for("calendar.get", calendar_slug=cal_slug),
"name": cal_name, "slug": cal_slug,
"del-url": url_for("calendar.delete", calendar_slug=cal_slug),
"csrf-hdr": f'{{"X-CSRFToken":"{csrf}"}}',
"confirm-title": "Delete calendar?",
"confirm-text": "Entries will be hidden (soft delete)",
})
return sx_call("events-crud-panel-from-data",
can_create=can_create or None,
create_url=url_for("calendars.create_calendar") if can_create else None,
csrf=csrf, errors_id="cal-create-errors", list_id="calendars-list",
placeholder="e.g. Events, Gigs, Meetings", btn_label="Add calendar",
items=items_data or None,
empty_msg="No calendars yet. Create one above.")
href = prefix + url_for("calendar.get", calendar_slug=cal_slug)
del_url = url_for("calendar.delete", calendar_slug=cal_slug)
csrf_hdr = {"X-CSRFToken": csrf}
parts.append(sx_call("crud-item",
href=href, name=cal_name, slug=cal_slug,
del_url=del_url, csrf_hdr=csrf_hdr,
list_id="calendars-list",
confirm_title="Delete calendar?",
confirm_text="Entries will be hidden (soft delete)"))
return "".join(parts)
# ---------------------------------------------------------------------------
@@ -308,7 +338,7 @@ def _calendars_main_panel_sx(ctx: dict) -> str:
# ---------------------------------------------------------------------------
def _calendar_main_panel_html(ctx: dict) -> str:
"""Render the calendar month grid via data extraction + sx defcomp."""
"""Render the calendar month grid."""
from quart import url_for
from quart import session as qsession
@@ -316,6 +346,7 @@ def _calendar_main_panel_html(ctx: dict) -> str:
if not calendar:
return ""
cal_slug = getattr(calendar, "slug", "")
hx_select = ctx.get("hx_select_search", "#main-panel")
styles = ctx.get("styles") or {}
pill_cls = getattr(styles, "pill", "") if hasattr(styles, "pill") else styles.get("pill", "")
@@ -337,8 +368,35 @@ def _calendar_main_panel_html(ctx: dict) -> str:
def nav_link(y, m):
return url_for("calendar.get", calendar_slug=cal_slug, year=y, month=m)
# Day cells data
cells_data = []
# Month navigation arrows
nav_arrows = []
for label, yr, mn in [
("\u00ab", prev_year, month),
("\u2039", prev_month_year, prev_month),
]:
href = nav_link(yr, mn)
nav_arrows.append(sx_call("events-calendar-nav-arrow",
pill_cls=pill_cls, href=href, label=label))
nav_arrows.append(sx_call("events-calendar-month-label",
month_name=month_name, year=str(year)))
for label, yr, mn in [
("\u203a", next_month_year, next_month),
("\u00bb", next_year, month),
]:
href = nav_link(yr, mn)
nav_arrows.append(sx_call("events-calendar-nav-arrow",
pill_cls=pill_cls, href=href, label=label))
# Weekday headers
wd_parts = []
for wd in weekday_names:
wd_parts.append(sx_call("events-calendar-weekday", name=wd))
wd_html = "".join(wd_parts)
# Day cells
cells = []
for week in weeks:
for day_cell in week:
if isinstance(day_cell, dict):
@@ -356,18 +414,24 @@ def _calendar_main_panel_html(ctx: dict) -> str:
if is_today:
cell_cls += " ring-2 ring-blue-500 z-10 relative"
cell = {"cell-cls": cell_cls}
# Day number link
day_num_html = ""
day_short_html = ""
if day_date:
cell["day-str"] = day_date.strftime("%a")
cell["day-href"] = url_for(
day_href = url_for(
"calendar.day.show_day",
calendar_slug=cal_slug,
year=day_date.year, month=day_date.month, day=day_date.day,
)
cell["day-num"] = str(day_date.day)
day_short_html = sx_call("events-calendar-day-short",
day_str=day_date.strftime("%a"))
day_num_html = sx_call("events-calendar-day-num",
pill_cls=pill_cls, href=day_href,
num=str(day_date.day))
# Entry badges for this day
badges = []
# Entry badges for this day
entry_badges = []
if day_date:
for e in month_entries:
if e.start_at and e.start_at.date() == day_date:
is_mine = (
@@ -378,23 +442,23 @@ def _calendar_main_panel_html(ctx: dict) -> str:
bg_cls = "bg-emerald-200 text-emerald-900" if is_mine else "bg-emerald-100 text-emerald-800"
else:
bg_cls = "bg-sky-100 text-sky-800" if is_mine else "bg-stone-100 text-stone-700"
badges.append({
"bg-cls": bg_cls, "name": e.name,
"state-label": (e.state or "pending").replace("_", " "),
})
if badges:
cell["badges"] = badges
state_label = (e.state or "pending").replace("_", " ")
entry_badges.append(sx_call("events-calendar-entry-badge",
bg_cls=bg_cls, name=e.name,
state_label=state_label))
cells_data.append(cell)
badges_html = "(<> " + "".join(entry_badges) + ")" if entry_badges else ""
cells.append(sx_call("events-calendar-cell",
cell_cls=cell_cls, day_short=SxExpr(day_short_html),
day_num=SxExpr(day_num_html),
badges=SxExpr(badges_html) if badges_html else None))
return sx_call("events-calendar-grid-from-data",
pill_cls=pill_cls, month_name=month_name, year=str(year),
prev_year_href=nav_link(prev_year, month),
prev_month_href=nav_link(prev_month_year, prev_month),
next_month_href=nav_link(next_month_year, next_month),
next_year_href=nav_link(next_year, month),
weekday_names=weekday_names or None,
cells=cells_data or None)
cells_html = "(<> " + "".join(cells) + ")"
arrows_html = "(<> " + "".join(nav_arrows) + ")"
wd_html = "(<> " + wd_html + ")"
return sx_call("events-calendar-grid",
arrows=SxExpr(arrows_html), weekdays=SxExpr(wd_html),
cells=SxExpr(cells_html))
# ---------------------------------------------------------------------------
@@ -402,7 +466,7 @@ def _calendar_main_panel_html(ctx: dict) -> str:
# ---------------------------------------------------------------------------
def _day_main_panel_html(ctx: dict) -> str:
"""Render the day entries table via data extraction + sx defcomp."""
"""Render the day entries table + add button."""
from quart import url_for
calendar = ctx.get("calendar")
@@ -413,49 +477,21 @@ def _day_main_panel_html(ctx: dict) -> str:
day = ctx.get("day")
month = ctx.get("month")
year = ctx.get("year")
hx_select = ctx.get("hx_select_search", "#main-panel")
styles = ctx.get("styles") or {}
list_container = getattr(styles, "list_container", "") if hasattr(styles, "list_container") else styles.get("list_container", "")
pill_cls = getattr(styles, "pill", "") if hasattr(styles, "pill") else styles.get("pill", "")
tr_cls = getattr(styles, "tr", "") if hasattr(styles, "tr") else styles.get("tr", "")
pre_action = getattr(styles, "pre_action_button", "") if hasattr(styles, "pre_action_button") else styles.get("pre_action_button", "")
rows_data = []
for entry in day_entries:
entry_href = url_for(
"defpage_entry_detail",
calendar_slug=cal_slug, day=day, month=month, year=year, entry_id=entry.id,
)
row = {
"href": entry_href, "name": entry.name,
"state-id": f"entry-state-{entry.id}",
"state": getattr(entry, "state", "pending") or "pending",
}
# Slot/Time
slot = getattr(entry, "slot", None)
if slot:
row["slot-name"] = slot.name
row["slot-href"] = url_for("defpage_slot_detail", calendar_slug=cal_slug, slot_id=slot.id)
time_start = slot.time_start.strftime("%H:%M") if slot.time_start else ""
time_end = f" \u2192 {slot.time_end.strftime('%H:%M')}" if slot.time_end else ""
row["slot-time"] = f"({time_start}{time_end})"
else:
row["start"] = entry.start_at.strftime("%H:%M") if entry.start_at else ""
row["end"] = f" \u2192 {entry.end_at.strftime('%H:%M')}" if entry.end_at else ""
# Cost
cost = getattr(entry, "cost", None)
row["cost-str"] = f"\u00a3{cost:.2f}" if cost is not None else "\u00a30.00"
# Tickets
tp = getattr(entry, "ticket_price", None)
if tp is not None:
tc = getattr(entry, "ticket_count", None)
row["has-tickets"] = True
row["price-str"] = f"\u00a3{tp:.2f}"
row["count-str"] = f"{tc} tickets" if tc is not None else "Unlimited"
rows_data.append(row)
rows_html = ""
if day_entries:
row_parts = []
for entry in day_entries:
row_parts.append(_day_row_html(ctx, entry))
rows_html = "".join(row_parts)
else:
rows_html = sx_call("events-day-empty-row")
add_url = url_for(
"calendar.day.calendar_entries.add_form",
@@ -463,10 +499,74 @@ def _day_main_panel_html(ctx: dict) -> str:
day=day, month=month, year=year,
)
return sx_call("events-day-table-from-data",
list_container=list_container, pre_action=pre_action,
add_url=add_url, tr_cls=tr_cls, pill_cls=pill_cls,
rows=rows_data or None)
return sx_call("events-day-table",
list_container=list_container, rows=SxExpr(rows_html),
pre_action=pre_action, add_url=add_url)
def _day_row_html(ctx: dict, entry) -> str:
"""Render a single day table row."""
from quart import url_for
calendar = ctx.get("calendar")
cal_slug = getattr(calendar, "slug", "")
day = ctx.get("day")
month = ctx.get("month")
year = ctx.get("year")
hx_select = ctx.get("hx_select_search", "#main-panel")
styles = ctx.get("styles") or {}
pill_cls = getattr(styles, "pill", "") if hasattr(styles, "pill") else styles.get("pill", "")
tr_cls = getattr(styles, "tr", "") if hasattr(styles, "tr") else styles.get("tr", "")
entry_href = url_for(
"defpage_entry_detail",
calendar_slug=cal_slug, day=day, month=month, year=year, entry_id=entry.id,
)
# Name
name_html = sx_call("events-day-row-name",
href=entry_href, pill_cls=pill_cls, name=entry.name)
# Slot/Time
slot = getattr(entry, "slot", None)
if slot:
slot_href = url_for("defpage_slot_detail", calendar_slug=cal_slug, slot_id=slot.id)
time_start = slot.time_start.strftime("%H:%M") if slot.time_start else ""
time_end = f" \u2192 {slot.time_end.strftime('%H:%M')}" if slot.time_end else ""
slot_html = sx_call("events-day-row-slot",
href=slot_href, pill_cls=pill_cls, slot_name=slot.name,
time_str=f"({time_start}{time_end})")
else:
start = entry.start_at.strftime("%H:%M") if entry.start_at else ""
end = f" \u2192 {entry.end_at.strftime('%H:%M')}" if entry.end_at else ""
slot_html = sx_call("events-day-row-time", start=start, end=end)
# State
state = getattr(entry, "state", "pending") or "pending"
state_badge = _entry_state_badge_html(state)
state_td = sx_call("events-day-row-state",
state_id=f"entry-state-{entry.id}", badge=state_badge)
# Cost
cost = getattr(entry, "cost", None)
cost_str = f"\u00a3{cost:.2f}" if cost is not None else "\u00a30.00"
cost_td = sx_call("events-day-row-cost", cost_str=cost_str)
# Tickets
tp = getattr(entry, "ticket_price", None)
if tp is not None:
tc = getattr(entry, "ticket_count", None)
tc_str = f"{tc} tickets" if tc is not None else "Unlimited"
tickets_td = sx_call("events-day-row-tickets",
price_str=f"\u00a3{tp:.2f}", count_str=tc_str)
else:
tickets_td = sx_call("events-day-row-no-tickets")
actions_td = sx_call("events-day-row-actions")
return sx_call("events-day-row",
tr_cls=tr_cls, name=name_html, slot=slot_html,
state=state_td, cost=cost_td,
tickets=tickets_td, actions=actions_td)
# ---------------------------------------------------------------------------
@@ -514,7 +614,7 @@ def _calendar_description_display_html(calendar, edit_url: str) -> str:
# ---------------------------------------------------------------------------
def _markets_main_panel_html(ctx: dict) -> str:
"""Render markets list + create form panel via data extraction + sx defcomp."""
"""Render markets list + create form panel."""
from quart import url_for
rights = ctx.get("rights") or {}
is_admin = getattr(rights, "admin", False) if hasattr(rights, "admin") else rights.get("admin", False)
@@ -523,29 +623,48 @@ def _markets_main_panel_html(ctx: dict) -> str:
csrf_token = ctx.get("csrf_token")
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
markets = ctx.get("markets") or []
form_html = ""
if can_create:
create_url = url_for("markets.create_market")
form_html = sx_call("crud-create-form",
create_url=create_url, csrf=csrf,
errors_id="market-create-errors", list_id="markets-list",
placeholder="e.g. Farm Shop, Bakery", btn_label="Add market")
list_html = _markets_list_html(ctx, markets)
return sx_call("crud-panel",
form=SxExpr(form_html), list=SxExpr(list_html),
list_id="markets-list")
def _markets_list_html(ctx: dict, markets: list) -> str:
"""Render markets list items."""
from quart import url_for
csrf_token = ctx.get("csrf_token")
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
post = ctx.get("post") or {}
slug = post.get("slug", "")
items_data = []
if not markets:
return sx_call("empty-state", message="No markets yet. Create one above.",
cls="text-gray-500 mt-4")
parts = []
for m in markets:
m_slug = getattr(m, "slug", "") if hasattr(m, "slug") else m.get("slug", "")
m_name = getattr(m, "name", "") if hasattr(m, "name") else m.get("name", "")
items_data.append({
"href": call_url(ctx, "market_url", f"/{slug}/{m_slug}/"),
"name": m_name, "slug": m_slug,
"del-url": url_for("markets.delete_market", market_slug=m_slug),
"csrf-hdr": f'{{"X-CSRFToken":"{csrf}"}}',
"confirm-title": "Delete market?",
"confirm-text": "Products will be hidden (soft delete)",
})
return sx_call("events-crud-panel-from-data",
can_create=can_create or None,
create_url=url_for("markets.create_market") if can_create else None,
csrf=csrf, errors_id="market-create-errors", list_id="markets-list",
placeholder="e.g. Farm Shop, Bakery", btn_label="Add market",
items=items_data or None,
empty_msg="No markets yet. Create one above.")
market_href = call_url(ctx, "market_url", f"/{slug}/{m_slug}/")
del_url = url_for("markets.delete_market", market_slug=m_slug)
csrf_hdr = {"X-CSRFToken": csrf}
parts.append(sx_call("crud-item",
href=market_href, name=m_name,
slug=m_slug, del_url=del_url,
csrf_hdr=csrf_hdr,
list_id="markets-list",
confirm_title="Delete market?",
confirm_text="Products will be hidden (soft delete)"))
return "".join(parts)
# ---------------------------------------------------------------------------

View File

@@ -1,23 +1,26 @@
"""Entry panels, cards, forms, edit/add."""
from __future__ import annotations
from markupsafe import escape
from shared.sx.helpers import sx_call
from shared.sx.parser import SxExpr
from .utils import (
_entry_state_badge_html,
_entry_state_badge_html, _ticket_state_badge_html,
_list_container, _view_toggle_html,
)
# ---------------------------------------------------------------------------
# All events / page summary entry cards — data extraction
# All events / page summary entry cards
# ---------------------------------------------------------------------------
def _entry_card_data(entry, page_info: dict, pending_tickets: dict,
def _entry_card_html(entry, page_info: dict, pending_tickets: dict,
ticket_url: str, events_url_fn, *, is_page_scoped: bool = False,
post: dict | None = None) -> dict:
"""Extract data for a single entry card (list or tile)."""
post: dict | None = None) -> str:
"""Render a list card for one event entry."""
from .tickets import _ticket_widget_html
pi = page_info.get(getattr(entry, "calendar_container_id", 0), {})
if is_page_scoped and post:
page_slug = pi.get("slug", post.get("slug", ""))
@@ -30,103 +33,145 @@ def _entry_card_data(entry, page_info: dict, pending_tickets: dict,
day_href = events_url_fn(f"/{page_slug}/{entry.calendar_slug}/day/{entry.start_at.strftime('%Y/%-m/%-d')}/")
entry_href = f"{day_href}entries/{entry.id}/" if day_href else ""
# Page badge (only show if different from current page title)
page_badge_href = ""
page_badge_title = ""
# Title (linked or plain)
if entry_href:
title_html = sx_call("events-entry-title-linked",
href=entry_href, name=entry.name)
else:
title_html = sx_call("events-entry-title-plain", name=entry.name)
# Badges
badges_html = ""
if page_title and (not is_page_scoped or page_title != (post or {}).get("title")):
page_badge_href = events_url_fn(f"/{page_slug}/")
page_badge_title = page_title
page_href = events_url_fn(f"/{page_slug}/")
badges_html += sx_call("events-entry-page-badge",
href=page_href, title=page_title)
cal_name = getattr(entry, "calendar_name", "")
if cal_name:
badges_html += sx_call("events-entry-cal-badge", name=cal_name)
cal_name = getattr(entry, "calendar_name", "") or ""
# Time parts
date_str = entry.start_at.strftime("%a %-d %b") if entry.start_at else ""
start_time = entry.start_at.strftime("%H:%M") if entry.start_at else ""
end_time = entry.end_at.strftime("%H:%M") if entry.end_at else ""
# Tile time string (combined)
time_str_parts = []
if date_str:
time_str_parts.append(date_str)
if start_time:
time_str_parts.append(start_time)
time_str = " \u00b7 ".join(time_str_parts)
if end_time:
time_str += f" \u2013 {end_time}"
# Time line
time_parts = ""
if day_href and not is_page_scoped:
time_parts += sx_call("events-entry-time-linked",
href=day_href,
date_str=entry.start_at.strftime("%a %-d %b"))
elif not is_page_scoped:
time_parts += sx_call("events-entry-time-plain",
date_str=entry.start_at.strftime("%a %-d %b"))
time_parts += entry.start_at.strftime("%H:%M")
if entry.end_at:
time_parts += f' \u2013 {entry.end_at.strftime("%H:%M")}'
cost = getattr(entry, "cost", None)
cost_str = f"\u00a3{cost:.2f}" if cost else None
cost_html = sx_call("events-entry-cost",
cost=f"\u00a3{cost:.2f}") if cost else ""
# Ticket widget data
# Ticket widget
tp = getattr(entry, "ticket_price", None)
has_ticket = tp is not None
ticket_data = None
if has_ticket:
widget_html = ""
if tp is not None:
qty = pending_tickets.get(entry.id, 0)
ticket_data = {
"entry-id": str(entry.id),
"price": f"\u00a3{tp:.2f}",
"qty": qty,
"ticket-url": ticket_url,
"csrf": _get_csrf(),
}
widget_html = sx_call("events-entry-widget-wrapper",
widget=_ticket_widget_html(entry, qty, ticket_url, ctx={}))
return {
"entry-href": entry_href or None,
"name": entry.name,
"day-href": day_href or None,
"page-badge-href": page_badge_href or None,
"page-badge-title": page_badge_title or None,
"cal-name": cal_name or None,
"date-str": date_str,
"start-time": start_time,
"end-time": end_time or None,
"time-str": time_str,
"is-page-scoped": is_page_scoped or None,
"cost": cost_str,
"has-ticket": has_ticket or None,
"ticket-data": ticket_data,
}
return sx_call("events-entry-card",
title=title_html, badges=SxExpr(badges_html),
time_parts=SxExpr(time_parts), cost=SxExpr(cost_html),
widget=SxExpr(widget_html))
def _get_csrf() -> str:
"""Get CSRF token (lazy import)."""
try:
from flask_wtf.csrf import generate_csrf
return generate_csrf()
except Exception:
return ""
def _entry_card_tile_html(entry, page_info: dict, pending_tickets: dict,
ticket_url: str, events_url_fn, *, is_page_scoped: bool = False,
post: dict | None = None) -> str:
"""Render a tile card for one event entry."""
from .tickets import _ticket_widget_html
pi = page_info.get(getattr(entry, "calendar_container_id", 0), {})
if is_page_scoped and post:
page_slug = pi.get("slug", post.get("slug", ""))
else:
page_slug = pi.get("slug", "")
page_title = pi.get("title")
day_href = ""
if page_slug and entry.start_at:
day_href = events_url_fn(f"/{page_slug}/{entry.calendar_slug}/day/{entry.start_at.strftime('%Y/%-m/%-d')}/")
entry_href = f"{day_href}entries/{entry.id}/" if day_href else ""
def _entry_cards_data(entries, page_info, pending_tickets, ticket_url,
events_url_fn, view, *, is_page_scoped=False, post=None) -> list:
"""Extract data list for entry cards with date separators."""
items = []
last_date = None
for entry in entries:
if view != "tile":
entry_date = entry.start_at.strftime("%A %-d %B %Y") if entry.start_at else ""
if entry_date != last_date:
items.append({"is-separator": True, "date-str": entry_date})
last_date = entry_date
items.append(_entry_card_data(
entry, page_info, pending_tickets, ticket_url, events_url_fn,
is_page_scoped=is_page_scoped, post=post,
))
return items
# Title
if entry_href:
title_html = sx_call("events-entry-title-tile-linked",
href=entry_href, name=entry.name)
else:
title_html = sx_call("events-entry-title-tile-plain", name=entry.name)
# Badges
badges_html = ""
if page_title and (not is_page_scoped or page_title != (post or {}).get("title")):
page_href = events_url_fn(f"/{page_slug}/")
badges_html += sx_call("events-entry-page-badge",
href=page_href, title=page_title)
cal_name = getattr(entry, "calendar_name", "")
if cal_name:
badges_html += sx_call("events-entry-cal-badge", name=cal_name)
# Time
time_html = ""
if day_href:
time_html += (sx_call("events-entry-time-linked",
href=day_href,
date_str=entry.start_at.strftime("%a %-d %b"))).replace(" &middot; ", "")
else:
time_html += entry.start_at.strftime("%a %-d %b")
time_html += f' \u00b7 {entry.start_at.strftime("%H:%M")}'
if entry.end_at:
time_html += f' \u2013 {entry.end_at.strftime("%H:%M")}'
cost = getattr(entry, "cost", None)
cost_html = sx_call("events-entry-cost",
cost=f"\u00a3{cost:.2f}") if cost else ""
# Ticket widget
tp = getattr(entry, "ticket_price", None)
widget_html = ""
if tp is not None:
qty = pending_tickets.get(entry.id, 0)
widget_html = sx_call("events-entry-tile-widget-wrapper",
widget=_ticket_widget_html(entry, qty, ticket_url, ctx={}))
return sx_call("events-entry-card-tile",
title=title_html, badges=SxExpr(badges_html),
time=SxExpr(time_html), cost=SxExpr(cost_html),
widget=SxExpr(widget_html))
def _entry_cards_html(entries, page_info, pending_tickets, ticket_url,
events_url_fn, view, page, has_more, next_url,
*, is_page_scoped=False, post=None) -> str:
"""Render entry cards via sx defcomp with data extraction."""
items = _entry_cards_data(
entries, page_info, pending_tickets, ticket_url, events_url_fn,
view, is_page_scoped=is_page_scoped, post=post,
)
return sx_call("events-entry-cards-from-data",
items=items, view=view, page=page,
has_more=has_more, next_url=next_url)
"""Render entry cards (list or tile) with sentinel."""
parts = []
last_date = None
for entry in entries:
if view == "tile":
parts.append(_entry_card_tile_html(
entry, page_info, pending_tickets, ticket_url, events_url_fn,
is_page_scoped=is_page_scoped, post=post,
))
else:
entry_date = entry.start_at.strftime("%A %-d %B %Y") if entry.start_at else ""
if entry_date != last_date:
parts.append(sx_call("events-date-separator",
date_str=entry_date))
last_date = entry_date
parts.append(_entry_card_html(
entry, page_info, pending_tickets, ticket_url, events_url_fn,
is_page_scoped=is_page_scoped, post=post,
))
if has_more:
parts.append(sx_call("sentinel-simple",
id=f"sentinel-{page}", next_url=next_url))
return "".join(parts)
# ---------------------------------------------------------------------------
@@ -138,15 +183,23 @@ def _events_main_panel_html(ctx: dict, entries, has_more, pending_tickets, page_
*, is_page_scoped=False, post=None) -> str:
"""Render the events main panel with view toggle + cards."""
toggle = _view_toggle_html(ctx, view)
items = None
if entries:
items = _entry_cards_data(
cards = _entry_cards_html(
entries, page_info, pending_tickets, ticket_url, events_url_fn,
view, is_page_scoped=is_page_scoped, post=post,
view, page, has_more, next_url,
is_page_scoped=is_page_scoped, post=post,
)
return sx_call("events-main-panel-from-data",
toggle=toggle, items=items, view=view, page=page,
has_more=has_more, next_url=next_url)
grid_cls = ("max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4"
if view == "tile" else "max-w-full px-3 py-3 space-y-3")
body = sx_call("events-grid", grid_cls=grid_cls, cards=SxExpr(cards))
else:
body = sx_call("empty-state", icon="fa fa-calendar-xmark",
message="No upcoming events",
cls="px-3 py-12 text-center text-stone-400")
return sx_call("events-main-panel-body",
toggle=toggle, body=body)
# ---------------------------------------------------------------------------
@@ -320,16 +373,27 @@ def _entry_nav_html(ctx: dict) -> str:
entry_posts = ctx.get("entry_posts") or []
rights = ctx.get("rights") or {}
is_admin = getattr(rights, "admin", False) if hasattr(rights, "admin") else rights.get("admin", False)
blog_url_fn = ctx.get("blog_url")
parts = []
# Associated Posts scrolling menu (strip OOB attr for inline embedding)
# Associated Posts scrolling menu
if entry_posts:
posts_data = _entry_posts_nav_data(entry_posts, blog_url_fn)
nav_html = sx_call("events-entry-posts-nav-inner-from-data", posts=posts_data or None)
if nav_html:
parts.append(nav_html.replace(' :hx-swap-oob "true"', ''))
post_links = ""
for ep in entry_posts:
slug = getattr(ep, "slug", "")
title = getattr(ep, "title", "")
feat = getattr(ep, "feature_image", None)
href = blog_url_fn(f"/{slug}/") if blog_url_fn else f"/{slug}/"
if feat:
img_html = sx_call("events-post-img", src=feat, alt=title)
else:
img_html = sx_call("events-post-img-placeholder")
post_links += sx_call("events-entry-nav-post-link",
href=href, img=img_html, title=title)
parts.append((sx_call("events-entry-posts-nav-oob",
items=SxExpr(post_links))).replace(' :hx-swap-oob "true"', ''))
# Admin link
if is_admin:
@@ -368,7 +432,7 @@ def _entry_title_html(entry) -> str:
def _entry_options_html(entry, calendar, day, month, year) -> str:
"""Render confirm/decline/provisional buttons via data extraction + sx defcomp."""
"""Render confirm/decline/provisional buttons based on entry state."""
from quart import url_for, g
from shared.browser.app.csrf import generate_csrf_token
csrf = generate_csrf_token()
@@ -379,30 +443,39 @@ def _entry_options_html(entry, calendar, day, month, year) -> str:
cal_slug = getattr(calendar, "slug", "")
eid = entry.id
state = getattr(entry, "state", "pending") or "pending"
target = f"#calendar_entry_options_{eid}"
def _btn_data(action_name, label, confirm_title, confirm_text, *, trigger_type="submit"):
return {
"url": url_for(f"calendar.day.calendar_entries.calendar_entry.{action_name}",
calendar_slug=cal_slug, day=day, month=month, year=year, entry_id=eid),
"csrf": csrf, "btn-type": "button" if trigger_type == "button" else "submit",
"action-btn": action_btn, "confirm-title": confirm_title,
"confirm-text": confirm_text, "label": label,
"is-btn": True if trigger_type == "button" else None,
}
def _make_button(action_name, label, confirm_title, confirm_text, *, trigger_type="submit"):
url = url_for(
f"calendar.day.calendar_entries.calendar_entry.{action_name}",
calendar_slug=cal_slug, day=day, month=month, year=year, entry_id=eid,
)
btn_type = "button" if trigger_type == "button" else "submit"
return sx_call("events-entry-option-button",
url=url, target=target, csrf=csrf, btn_type=btn_type,
action_btn=action_btn, confirm_title=confirm_title,
confirm_text=confirm_text, label=label,
is_btn=trigger_type == "button")
buttons = []
buttons_html = ""
if state == "provisional":
buttons.append(_btn_data("confirm_entry", "confirm",
"Confirm entry?", "Are you sure you want to confirm this entry?"))
buttons.append(_btn_data("decline_entry", "decline",
"Decline entry?", "Are you sure you want to decline this entry?"))
buttons_html += _make_button(
"confirm_entry", "confirm",
"Confirm entry?", "Are you sure you want to confirm this entry?",
)
buttons_html += _make_button(
"decline_entry", "decline",
"Decline entry?", "Are you sure you want to decline this entry?",
)
elif state == "confirmed":
buttons.append(_btn_data("provisional_entry", "provisional",
"Provisional entry?", "Are you sure you want to provisional this entry?",
trigger_type="button"))
buttons_html += _make_button(
"provisional_entry", "provisional",
"Provisional entry?", "Are you sure you want to provisional this entry?",
trigger_type="button",
)
return sx_call("events-entry-options-from-data",
entry_id=str(eid), buttons=buttons or None)
return sx_call("events-entry-options",
entry_id=str(eid), buttons=SxExpr(buttons_html))
# ---------------------------------------------------------------------------
@@ -452,7 +525,7 @@ def render_entry_tickets_config(entry, calendar, day, month, year) -> str:
# ---------------------------------------------------------------------------
def render_entry_posts_panel(entry_posts, entry, calendar, day, month, year) -> str:
"""Render associated posts list via data extraction + sx defcomp."""
"""Render associated posts list with remove buttons and search input."""
from quart import url_for
from shared.browser.app.csrf import generate_csrf_token
csrf = generate_csrf_token()
@@ -460,46 +533,38 @@ def render_entry_posts_panel(entry_posts, entry, calendar, day, month, year) ->
cal_slug = getattr(calendar, "slug", "")
eid = entry.id
eid_s = str(eid)
csrf_hdr = f'{{"X-CSRFToken": "{csrf}"}}'
posts_data = []
posts_html = ""
if entry_posts:
items = ""
for ep in entry_posts:
posts_data.append({
"title": getattr(ep, "title", ""),
"img": getattr(ep, "feature_image", None),
"del-url": url_for(
"calendar.day.calendar_entries.calendar_entry.remove_post",
calendar_slug=cal_slug, day=day, month=month, year=year,
entry_id=eid, post_id=getattr(ep, "id", 0),
),
"csrf-hdr": csrf_hdr,
})
ep_title = getattr(ep, "title", "")
ep_id = getattr(ep, "id", 0)
feat = getattr(ep, "feature_image", None)
img_html = (sx_call("events-post-img", src=feat, alt=ep_title)
if feat else sx_call("events-post-img-placeholder"))
del_url = url_for(
"calendar.day.calendar_entries.calendar_entry.remove_post",
calendar_slug=cal_slug, day=day, month=month, year=year,
entry_id=eid, post_id=ep_id,
)
items += sx_call("events-entry-post-item",
img=img_html, title=ep_title,
del_url=del_url, entry_id=eid_s,
csrf_hdr={"X-CSRFToken": csrf})
posts_html = sx_call("events-entry-posts-list", items=SxExpr(items))
else:
posts_html = sx_call("events-entry-posts-none")
search_url = url_for(
"calendar.day.calendar_entries.calendar_entry.search_posts",
calendar_slug=cal_slug, day=day, month=month, year=year, entry_id=eid,
)
return sx_call("events-entry-posts-panel-from-data",
entry_id=eid_s, posts=posts_data or None,
search_url=search_url)
# ---------------------------------------------------------------------------
# Entry posts nav data helper (shared by nav OOB + entry nav)
# ---------------------------------------------------------------------------
def _entry_posts_nav_data(entry_posts, blog_url_fn) -> list:
"""Extract post nav data from ORM entry posts."""
if not entry_posts:
return []
return [
{"href": blog_url_fn(f"/{getattr(ep, 'slug', '')}/") if blog_url_fn else f"/{getattr(ep, 'slug', '')}/",
"title": getattr(ep, "title", ""),
"img": getattr(ep, "feature_image", None)}
for ep in entry_posts
]
return sx_call("events-entry-posts-panel",
posts=posts_html, search_url=search_url,
entry_id=eid_s)
# ---------------------------------------------------------------------------
@@ -507,15 +572,28 @@ def _entry_posts_nav_data(entry_posts, blog_url_fn) -> list:
# ---------------------------------------------------------------------------
def render_entry_posts_nav_oob(entry_posts) -> str:
"""Render OOB nav for entry posts via data extraction + sx defcomp."""
"""Render OOB nav for entry posts (scrolling menu)."""
from quart import g
styles = getattr(g, "styles", None) or {}
nav_btn = getattr(styles, "nav_button", "") if hasattr(styles, "nav_button") else styles.get("nav_button", "")
blog_url_fn = getattr(g, "blog_url", None)
posts_data = _entry_posts_nav_data(entry_posts, blog_url_fn)
return sx_call("events-entry-posts-nav-oob-from-data",
nav_btn=nav_btn, posts=posts_data or None)
if not entry_posts:
return sx_call("events-entry-posts-nav-oob-empty")
items = ""
for ep in entry_posts:
slug = getattr(ep, "slug", "")
title = getattr(ep, "title", "")
feat = getattr(ep, "feature_image", None)
href = blog_url_fn(f"/{slug}/") if blog_url_fn else f"/{slug}/"
img_html = (sx_call("events-post-img", src=feat, alt=title)
if feat else sx_call("events-post-img-placeholder"))
items += sx_call("events-entry-nav-post",
href=href, nav_btn=nav_btn,
img=img_html, title=title)
return sx_call("events-entry-posts-nav-oob", items=SxExpr(items))
# ---------------------------------------------------------------------------
@@ -523,28 +601,31 @@ def render_entry_posts_nav_oob(entry_posts) -> str:
# ---------------------------------------------------------------------------
def render_day_entries_nav_oob(confirmed_entries, calendar, day_date) -> str:
"""Render OOB nav for confirmed entries via data extraction + sx defcomp."""
"""Render OOB nav for confirmed entries in a day."""
from quart import url_for, g
styles = getattr(g, "styles", None) or {}
nav_btn = getattr(styles, "nav_button", "") if hasattr(styles, "nav_button") else styles.get("nav_button", "")
cal_slug = getattr(calendar, "slug", "")
entries_data = []
if confirmed_entries:
for entry in confirmed_entries:
start = entry.start_at.strftime("%H:%M") if entry.start_at else ""
end = f" \u2013 {entry.end_at.strftime('%H:%M')}" if entry.end_at else ""
entries_data.append({
"href": url_for("defpage_entry_detail", calendar_slug=cal_slug,
year=day_date.year, month=day_date.month, day=day_date.day,
entry_id=entry.id),
"name": entry.name,
"time-str": start + end,
})
if not confirmed_entries:
return sx_call("events-day-entries-nav-oob-empty")
return sx_call("events-day-entries-nav-oob-from-data",
nav_btn=nav_btn, entries=entries_data or None)
items = ""
for entry in confirmed_entries:
href = url_for(
"defpage_entry_detail",
calendar_slug=cal_slug,
year=day_date.year, month=day_date.month, day=day_date.day,
entry_id=entry.id,
)
start = entry.start_at.strftime("%H:%M") if entry.start_at else ""
end = f" \u2013 {entry.end_at.strftime('%H:%M')}" if entry.end_at else ""
items += sx_call("events-day-nav-entry",
href=href, nav_btn=nav_btn,
name=entry.name, time_str=start + end)
return sx_call("events-day-entries-nav-oob", items=SxExpr(items))
# ---------------------------------------------------------------------------
@@ -552,7 +633,7 @@ def render_day_entries_nav_oob(confirmed_entries, calendar, day_date) -> str:
# ---------------------------------------------------------------------------
def render_post_nav_entries_oob(associated_entries, calendars, post) -> str:
"""Render OOB nav for associated entries and calendars via data + sx defcomp."""
"""Render OOB nav for associated entries and calendars of a post."""
from quart import g
from shared.infrastructure.urls import events_url
@@ -560,9 +641,14 @@ def render_post_nav_entries_oob(associated_entries, calendars, post) -> str:
nav_btn = getattr(styles, "nav_button_less_pad", "") if hasattr(styles, "nav_button_less_pad") else styles.get("nav_button_less_pad", "")
has_entries = associated_entries and getattr(associated_entries, "entries", None)
has_items = has_entries or calendars
if not has_items:
return sx_call("events-post-nav-oob-empty")
slug = post.get("slug", "") if isinstance(post, dict) else getattr(post, "slug", "")
entries_data = []
items = ""
if has_entries:
for entry in associated_entries.entries:
entry_path = (
@@ -570,31 +656,27 @@ def render_post_nav_entries_oob(associated_entries, calendars, post) -> str:
f"{entry.start_at.year}/{entry.start_at.month}/{entry.start_at.day}/"
f"entries/{entry.id}/"
)
href = events_url(entry_path)
time_str = entry.start_at.strftime("%b %d, %Y at %H:%M")
end_str = f" \u2013 {entry.end_at.strftime('%H:%M')}" if entry.end_at else ""
entries_data.append({
"href": events_url(entry_path),
"name": entry.name,
"time-str": time_str + end_str,
})
items += sx_call("events-post-nav-entry",
href=href, nav_btn=nav_btn,
name=entry.name, time_str=time_str + end_str)
calendars_data = []
if calendars:
for cal in calendars:
cs = getattr(cal, "slug", "")
calendars_data.append({
"href": events_url(f"/{slug}/{cs}/"),
"name": cal.name,
})
local_href = events_url(f"/{slug}/{cs}/")
items += sx_call("events-post-nav-calendar",
href=local_href, nav_btn=nav_btn, name=cal.name)
hs = ("on load or scroll "
"if window.innerWidth >= 640 and my.scrollWidth > my.clientWidth "
"remove .hidden from .entries-nav-arrow add .flex to .entries-nav-arrow "
"else add .hidden to .entries-nav-arrow remove .flex from .entries-nav-arrow end")
return sx_call("events-post-nav-wrapper-from-data",
nav_btn=nav_btn, entries=entries_data or None,
calendars=calendars_data or None, hyperscript=hs)
return sx_call("events-post-nav-wrapper",
items=SxExpr(items), hyperscript=hs)
# ---------------------------------------------------------------------------
@@ -718,36 +800,42 @@ def _entry_admin_main_panel_html(ctx: dict) -> str:
def render_post_search_results(search_posts, search_query, page, total_pages,
entry, calendar, day, month, year) -> str:
"""Render post search results via data extraction + sx defcomp."""
"""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
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)
items_data = []
parts = []
for sp in search_posts:
items_data.append({
"post-url": post_url, "entry-id": str(eid),
"csrf": csrf, "post-id": str(sp.id),
"img": getattr(sp, "feature_image", None),
"title": getattr(sp, "title", ""),
})
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 = sx_call("events-post-img", src=feat, alt=title)
else:
img_html = sx_call("events-post-img-placeholder")
has_more = page < int(total_pages)
next_url = None
if has_more:
parts.append(sx_call("events-post-search-item",
post_url=post_url, entry_id=str(eid), csrf=csrf,
post_id=str(sp.id), img=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 += sx_call("events-post-search-sentinel",
page=str(page), next_url=next_url)
elif search_posts:
result += sx_call("events-post-search-end")
return sx_call("events-post-search-results-from-data",
items=items_data or None, page=str(page),
next_url=next_url, has_more=has_more or None)
return result
# ---------------------------------------------------------------------------
@@ -758,7 +846,7 @@ 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
from .slots import _slot_options_data, _SLOT_PICKER_JS
from .slots import _slot_options_html, _SLOT_PICKER_JS
csrf = generate_csrf_token()
styles = getattr(g, "styles", None) or {}
@@ -774,9 +862,12 @@ def render_entry_edit_form(entry, calendar, day, month, year, day_slots) -> str:
calendar_slug=cal_slug, day=day, month=month, year=year, entry_id=eid)
# Slot picker
slots_data = _slot_options_data(day_slots, selected_slot_id=getattr(entry, "slot_id", None)) if day_slots else []
slot_picker_html = sx_call("events-slot-picker-from-data",
id=f"entry-slot-{eid}", slots=slots_data or None)
if day_slots:
options_html = _slot_options_html(day_slots, selected_slot_id=getattr(entry, "slot_id", None))
slot_picker_html = sx_call("events-slot-picker",
id=f"entry-slot-{eid}", options=SxExpr(options_html))
else:
slot_picker_html = sx_call("events-no-slots")
# Values
start_val = entry.start_at.strftime("%H:%M") if entry.start_at else ""
@@ -806,7 +897,7 @@ 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
from .slots import _slot_options_data, _SLOT_PICKER_JS
from .slots import _slot_options_html, _SLOT_PICKER_JS
csrf = generate_csrf_token()
styles = getattr(g, "styles", None) or {}
@@ -820,9 +911,12 @@ def render_entry_add_form(calendar, day, month, year, day_slots) -> str:
calendar_slug=cal_slug, day=day, month=month, year=year)
# Slot picker
slots_data = _slot_options_data(day_slots) if day_slots else []
slot_picker_html = sx_call("events-slot-picker-from-data",
id="entry-slot-new", slots=slots_data or None)
if day_slots:
options_html = _slot_options_html(day_slots)
slot_picker_html = sx_call("events-slot-picker",
id="entry-slot-new", options=SxExpr(options_html))
else:
slot_picker_html = sx_call("events-no-slots")
html = sx_call("events-entry-add-form",
post_url=post_url, csrf=csrf,
@@ -850,33 +944,34 @@ def render_entry_add_button(calendar, day, month, year) -> str:
# ---------------------------------------------------------------------------
def render_fragment_container_cards(batch, post_ids, slug_map) -> str:
"""Render container cards entries via data extraction + sx defcomp."""
"""Render container cards entries (replaces fragments/container_cards_entries.html)."""
from shared.infrastructure.urls import events_url
widgets_data = []
parts = []
for post_id in post_ids:
parts.append(f"<!-- card-widget:{post_id} -->")
widget_entries = batch.get(post_id, [])
entries_data = []
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')}"
entries_data.append({
"href": events_url(_entry_path),
"name": entry.name,
"date-str": entry.start_at.strftime("%a, %b %d"),
"time-str": time_str,
})
widgets_data.append({"entries": entries_data or None})
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 += sx_call("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(sx_call("events-frag-entries-widget", cards=SxExpr(cards_html)))
parts.append(f"<!-- /card-widget:{post_id} -->")
return sx_call("events-frag-container-cards-from-data",
widgets=widgets_data or None)
return "\n".join(parts)
# ---------------------------------------------------------------------------
@@ -884,23 +979,32 @@ def render_fragment_container_cards(batch, post_ids, slug_map) -> str:
# ---------------------------------------------------------------------------
def render_fragment_account_tickets(tickets) -> str:
"""Render account page tickets via data extraction + sx defcomp."""
"""Render account page tickets (replaces fragments/account_page_tickets.html)."""
from shared.infrastructure.urls import events_url
tickets_data = []
if tickets:
items_html = ""
for ticket in tickets:
tickets_data.append({
"href": events_url(f"/tickets/{ticket.code}/"),
"entry-name": ticket.entry_name,
"date-str": ticket.entry_start_at.strftime("%d %b %Y, %H:%M"),
"calendar-name": getattr(ticket, "calendar_name", None) or None,
"type-name": getattr(ticket, "ticket_type_name", None) or None,
"state": getattr(ticket, "state", ""),
})
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 = sx_call("status-pill",
status=getattr(ticket, "state", ""))
items_html += sx_call("events-frag-ticket-item",
href=href, entry_name=ticket.entry_name,
date_str=date_str, calendar_name=cal_name,
type_name=type_name, badge=badge_html)
body = sx_call("events-frag-tickets-list", items=SxExpr(items_html))
else:
body = sx_call("empty-state", message="No tickets yet.",
cls="text-sm text-stone-500")
return sx_call("events-frag-tickets-panel-from-data",
tickets=tickets_data or None)
return sx_call("events-frag-tickets-panel", items=body)
# ---------------------------------------------------------------------------
@@ -908,18 +1012,31 @@ def render_fragment_account_tickets(tickets) -> str:
# ---------------------------------------------------------------------------
def render_fragment_account_bookings(bookings) -> str:
"""Render account page bookings via data extraction + sx defcomp."""
bookings_data = []
"""Render account page bookings (replaces fragments/account_page_bookings.html)."""
if bookings:
items_html = ""
for booking in bookings:
bookings_data.append({
"name": booking.name,
"date-str": booking.start_at.strftime("%d %b %Y, %H:%M"),
"end-time": booking.end_at.strftime("%H:%M") if getattr(booking, "end_at", None) else None,
"calendar-name": getattr(booking, "calendar_name", None) or None,
"cost-str": str(booking.cost) if getattr(booking, "cost", None) else None,
"state": getattr(booking, "state", ""),
})
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 = sx_call("status-pill",
status=getattr(booking, "state", ""))
items_html += sx_call("events-frag-booking-item",
name=booking.name,
date_str=date_str + date_str_extra,
calendar_name=cal_name, cost_str=cost_str,
badge=badge_html)
body = sx_call("events-frag-bookings-list", items=SxExpr(items_html))
else:
body = sx_call("empty-state", message="No bookings yet.",
cls="text-sm text-stone-500")
return sx_call("events-frag-bookings-panel-from-data",
bookings=bookings_data or None)
return sx_call("events-frag-bookings-panel", items=body)

View File

@@ -1,89 +1,235 @@
;; Events pages — auto-mounted with absolute paths
;; All helpers return data dicts — markup composition in SX.
;; Calendar admin
(defpage calendar-admin
:path "/<slug>/<calendar_slug>/admin/"
:auth :admin
:layout :events-calendar-admin
:content (calendar-admin-content calendar-slug))
:data (calendar-admin-data calendar-slug)
:content (~events-calendar-admin-panel
:description-content (~events-calendar-description-display
:description cal-description :edit-url desc-edit-url)
:csrf csrf :description cal-description))
;; Day admin
(defpage day-admin
:path "/<slug>/<calendar_slug>/day/<int:year>/<int:month>/<int:day>/admin/"
:auth :admin
:layout :events-day-admin
:content (day-admin-content calendar-slug year month day))
:data (day-admin-data calendar-slug year month day)
:content (~events-day-admin-panel))
;; Slots listing
(defpage slots-listing
:path "/<slug>/<calendar_slug>/slots/"
:auth :public
:layout :events-slots
:content (slots-content calendar-slug))
:data (slots-data calendar-slug)
:content (~events-slots-table
:list-container list-container
:rows (if has-slots
(<> (map (fn (s)
(~events-slots-row
:tr-cls tr-cls :slot-href (get s "slot-href")
:pill-cls pill-cls :hx-select hx-select
:slot-name (get s "name") :description (get s "description")
:flexible (get s "flexible")
:days (if (get s "has-days")
(~events-slot-days-pills :days-inner
(<> (map (fn (d) (~events-slot-day-pill :day d)) (get s "day-list"))))
(~events-slot-no-days))
:time-str (get s "time-str")
:cost-str (get s "cost-str") :action-btn action-btn
:del-url (get s "del-url")
:csrf-hdr csrf-hdr))
slots-list))
(~events-slots-empty-row))
:pre-action pre-action :add-url add-url))
;; Slot detail
(defpage slot-detail
:path "/<slug>/<calendar_slug>/slots/<int:slot_id>/"
:auth :admin
:layout :events-slot
:content (slot-content calendar-slug slot-id))
:data (slot-data calendar-slug slot-id)
:content (~events-slot-panel
:slot-id slot-id-str
:list-container list-container
:days (if has-days
(~events-slot-days-pills :days-inner
(<> (map (fn (d) (~events-slot-day-pill :day d)) day-list)))
(~events-slot-no-days))
:flexible flexible
:time-str time-str :cost-str cost-str
:pre-action pre-action :edit-url edit-url))
;; Entry detail
(defpage entry-detail
:path "/<slug>/<calendar_slug>/day/<int:year>/<int:month>/<int:day>/entries/<int:entry_id>/"
:auth :admin
:layout :events-entry
:content (entry-content calendar-slug entry-id)
:menu (entry-menu calendar-slug entry-id))
:data (entry-data calendar-slug entry-id)
:content (~events-entry-panel
:entry-id entry-id-str :list-container list-container
:name (~events-entry-field :label "Name"
:content (~events-entry-name-field :name entry-name))
:slot (~events-entry-field :label "Slot"
:content (if has-slot
(~events-entry-slot-assigned :slot-name slot-name :flex-label flex-label)
(~events-entry-slot-none)))
:time (~events-entry-field :label "Time Period"
:content (~events-entry-time-field :time-str time-str))
:state (~events-entry-field :label "State"
:content (~events-entry-state-field :entry-id entry-id-str
:badge (~badge :cls state-badge-cls :label state-badge-label)))
:cost (~events-entry-field :label "Cost"
:content (~events-entry-cost-field :cost cost-str))
:tickets (~events-entry-field :label "Tickets"
:content (~events-entry-tickets-field :entry-id entry-id-str
:tickets-config tickets-config))
:buy buy-form
:date (~events-entry-field :label "Date"
:content (~events-entry-date-field :date-str date-str))
:posts (~events-entry-field :label "Associated Posts"
:content (~events-entry-posts-field :entry-id entry-id-str
:posts-panel posts-panel))
:options options-html
:pre-action pre-action :edit-url edit-url)
:menu entry-menu)
;; Entry admin
(defpage entry-admin
:path "/<slug>/<calendar_slug>/day/<int:year>/<int:month>/<int:day>/entries/<int:entry_id>/admin/"
:auth :admin
:layout :events-entry-admin
:content (entry-admin-content calendar-slug entry-id)
:menu (admin-menu))
:data (entry-admin-data calendar-slug entry-id year month day)
:content (~nav-link :href ticket-types-href :label "ticket_types"
:select-colours select-colours :aclass nav-btn :is-selected false)
:menu (~events-admin-placeholder-nav))
;; Ticket types listing
(defpage ticket-types-listing
:path "/<slug>/<calendar_slug>/day/<int:year>/<int:month>/<int:day>/entries/<int:entry_id>/ticket-types/"
:auth :public
:layout :events-ticket-types
:content (ticket-types-content calendar-slug entry-id year month day)
:menu (admin-menu))
:data (ticket-types-data calendar-slug entry-id year month day)
:content (~events-ticket-types-table
:list-container list-container
:rows (if has-types
(<> (map (fn (tt)
(~events-ticket-types-row
:tr-cls tr-cls :tt-href (get tt "tt-href")
:pill-cls pill-cls :hx-select hx-select
:tt-name (get tt "tt-name") :cost-str (get tt "cost-str")
:count (get tt "count") :action-btn action-btn
:del-url (get tt "del-url")
:csrf-hdr csrf-hdr))
types-list))
(~events-ticket-types-empty-row))
:action-btn action-btn :add-url add-url)
:menu (~events-admin-placeholder-nav))
;; Ticket type detail
(defpage ticket-type-detail
:path "/<slug>/<calendar_slug>/day/<int:year>/<int:month>/<int:day>/entries/<int:entry_id>/ticket-types/<int:ticket_type_id>/"
:auth :admin
:layout :events-ticket-type
:content (ticket-type-content calendar-slug entry-id ticket-type-id year month day)
:menu (admin-menu))
:data (ticket-type-data calendar-slug entry-id ticket-type-id year month day)
:content (~events-ticket-type-panel
:ticket-id ticket-id :list-container list-container
:c1 (~events-ticket-type-col :label "Name" :value tt-name)
:c2 (~events-ticket-type-col :label "Cost" :value cost-str)
:c3 (~events-ticket-type-col :label "Count" :value count-str)
:pre-action pre-action :edit-url edit-url)
:menu (~events-admin-placeholder-nav))
;; My tickets
(defpage my-tickets
:path "/tickets/"
:auth :public
:layout :root
:content (tickets-content))
:data (tickets-data)
:content (~events-tickets-panel
:list-container list-container
:has-tickets has-tickets
:cards (when has-tickets
(<> (map (fn (t)
(~events-ticket-card
:href (get t "href") :entry-name (get t "entry-name")
:type-name (get t "type-name") :time-str (get t "time-str")
:cal-name (get t "cal-name")
:badge (~badge :cls (get t "badge-cls") :label (get t "badge-label"))
:code-prefix (get t "code-prefix")))
tickets-list)))))
;; Ticket detail
(defpage ticket-detail
:path "/tickets/<code>/"
:auth :public
:layout :root
:content (ticket-detail-content code))
:data (ticket-detail-data code)
:content (~events-ticket-detail
:list-container list-container :back-href back-href
:header-bg header-bg :entry-name entry-name
:badge (span :class (str "inline-flex items-center rounded-full px-3 py-1 text-sm font-medium " badge-cls)
badge-label)
:type-name type-name :code ticket-code
:time-date time-date :time-range time-range
:cal-name cal-name :type-desc type-desc :checkin-str checkin-str
:qr-script qr-script))
;; Ticket admin dashboard
(defpage ticket-admin
:path "/admin/tickets/"
:auth :admin
:layout :root
:content (ticket-admin-content))
:data (ticket-admin-data)
:content (~events-ticket-admin-panel
:list-container list-container
:stats (<> (map (fn (s)
(~events-ticket-admin-stat
:border (get s "border") :bg (get s "bg")
:text-cls (get s "text-cls") :label-cls (get s "label-cls")
:value (get s "value") :label (get s "label")))
admin-stats))
:lookup-url lookup-url :has-tickets has-tickets
:rows (when has-tickets
(<> (map (fn (t)
(~events-ticket-admin-row
:code (get t "code") :code-short (get t "code-short")
:entry-name (get t "entry-name")
:date (when (get t "date-str")
(~events-ticket-admin-date :date-str (get t "date-str")))
:type-name (get t "type-name")
:badge (~badge :cls (get t "badge-cls") :label (get t "badge-label"))
:action (if (get t "can-checkin")
(~events-ticket-admin-checkin-form
:checkin-url (get t "checkin-url") :code (get t "code") :csrf csrf)
(when (get t "is-checked-in")
(~events-ticket-admin-checked-in :time-str (get t "checkin-time"))))))
admin-tickets)))))
;; Markets
(defpage events-markets
:path "/<slug>/markets/"
:auth :public
:layout :events-markets
:content (markets-content))
:data (markets-data)
:content (~crud-panel
:list-id "markets-list"
:form (when can-create
(~crud-create-form :create-url create-url :csrf csrf
:errors-id "market-create-errors" :list-id "markets-list"
:placeholder "e.g. Farm Shop, Bakery" :btn-label "Add market"))
:list (if markets-list
(<> (map (fn (m)
(~crud-item :href (get m "href") :name (get m "name")
:slug (get m "slug") :del-url (get m "del-url")
:csrf-hdr (get m "csrf-hdr")
:list-id "markets-list"
:confirm-title "Delete market?"
:confirm-text "Products will be hidden (soft delete)"))
markets-list))
(~empty-state :message "No markets yet. Create one above."
:cls "text-gray-500 mt-4"))))

View File

@@ -1,26 +1,13 @@
"""Layout registrations, page helpers, and shared hydration helpers."""
"""Layout registrations, page helpers, and shared hydration helpers.
All helpers return data dicts — no sx_call().
Markup composition lives entirely in .sx defpage and .sx defcomp files.
"""
from __future__ import annotations
from typing import Any
from shared.sx.helpers import sx_call
from .calendar import (
_calendar_admin_main_panel_html,
_day_admin_main_panel_html,
_markets_main_panel_html,
)
from .entries import (
_entry_main_panel_html,
_entry_nav_html,
_entry_admin_main_panel_html,
)
from .tickets import (
_tickets_main_panel_html, _ticket_detail_panel_html,
_ticket_admin_main_panel_html,
render_ticket_type_main_panel, render_ticket_types_table,
)
from .slots import render_slot_main_panel, render_slots_table
from shared.sx.parser import SxExpr
# ---------------------------------------------------------------------------
@@ -261,6 +248,60 @@ def _register_events_layouts() -> None:
"events-markets-layout-full", "events-markets-layout-oob")
# ---------------------------------------------------------------------------
# Badge data helpers
# ---------------------------------------------------------------------------
_ENTRY_STATE_CLASSES = {
"confirmed": "bg-emerald-100 text-emerald-800",
"provisional": "bg-amber-100 text-amber-800",
"ordered": "bg-sky-100 text-sky-800",
"pending": "bg-stone-100 text-stone-700",
"declined": "bg-red-100 text-red-800",
}
_TICKET_STATE_CLASSES = {
"confirmed": "bg-emerald-100 text-emerald-800",
"checked_in": "bg-blue-100 text-blue-800",
"reserved": "bg-amber-100 text-amber-800",
"cancelled": "bg-red-100 text-red-800",
}
def _entry_badge_data(state: str) -> dict:
cls = _ENTRY_STATE_CLASSES.get(state, "bg-stone-100 text-stone-700")
label = state.replace("_", " ").capitalize()
return {"cls": cls, "label": label}
def _ticket_badge_data(state: str) -> dict:
cls = _TICKET_STATE_CLASSES.get(state, "bg-stone-100 text-stone-700")
label = (state or "").replace("_", " ").capitalize()
return {"cls": cls, "label": label}
# ---------------------------------------------------------------------------
# Styles helper
# ---------------------------------------------------------------------------
def _styles_data() -> dict:
"""Extract common style classes from g.styles."""
from quart import g
styles = getattr(g, "styles", None) or {}
def _gs(attr):
return getattr(styles, attr, "") if hasattr(styles, attr) else styles.get(attr, "")
return {
"list-container": _gs("list_container"),
"pre-action": _gs("pre_action_button"),
"action-btn": _gs("action_button"),
"tr-cls": _gs("tr"),
"pill-cls": _gs("pill"),
"nav-btn": _gs("nav_button"),
}
# ---------------------------------------------------------------------------
# Page helpers
# ---------------------------------------------------------------------------
@@ -269,141 +310,468 @@ def _register_events_helpers() -> None:
from shared.sx.pages import register_page_helpers
register_page_helpers("events", {
"calendar-admin-content": _h_calendar_admin_content,
"day-admin-content": _h_day_admin_content,
"slots-content": _h_slots_content,
"slot-content": _h_slot_content,
"entry-content": _h_entry_content,
"entry-menu": _h_entry_menu,
"entry-admin-content": _h_entry_admin_content,
"admin-menu": _h_admin_menu,
"ticket-types-content": _h_ticket_types_content,
"ticket-type-content": _h_ticket_type_content,
"tickets-content": _h_tickets_content,
"ticket-detail-content": _h_ticket_detail_content,
"ticket-admin-content": _h_ticket_admin_content,
"markets-content": _h_markets_content,
"calendar-admin-data": _h_calendar_admin_data,
"day-admin-data": _h_day_admin_data,
"slots-data": _h_slots_data,
"slot-data": _h_slot_data,
"entry-data": _h_entry_data,
"entry-admin-data": _h_entry_admin_data,
"ticket-types-data": _h_ticket_types_data,
"ticket-type-data": _h_ticket_type_data,
"tickets-data": _h_tickets_data,
"ticket-detail-data": _h_ticket_detail_data,
"ticket-admin-data": _h_ticket_admin_data,
"markets-data": _h_markets_data,
})
async def _h_calendar_admin_content(calendar_slug=None, **kw):
# ---------------------------------------------------------------------------
# Calendar admin
# ---------------------------------------------------------------------------
async def _h_calendar_admin_data(calendar_slug=None, **kw) -> dict:
from quart import url_for
from shared.browser.app.csrf import generate_csrf_token
await _ensure_calendar(calendar_slug)
await _ensure_container_nav_defpage_ctx()
from shared.sx.page import get_template_context
ctx = await get_template_context()
return _calendar_admin_main_panel_html(ctx)
from quart import g
calendar = getattr(g, "calendar", None)
if not calendar:
return {}
csrf = generate_csrf_token()
cal_slug = getattr(calendar, "slug", "")
desc = getattr(calendar, "description", "") or ""
desc_edit_url = url_for("calendar.admin.calendar_description_edit",
calendar_slug=cal_slug)
return {
"cal-description": desc,
"csrf": csrf,
"desc-edit-url": desc_edit_url,
}
async def _h_day_admin_content(calendar_slug=None, year=None, month=None, day=None, **kw):
# ---------------------------------------------------------------------------
# Day admin
# ---------------------------------------------------------------------------
async def _h_day_admin_data(calendar_slug=None, year=None, month=None,
day=None, **kw) -> dict:
await _ensure_calendar(calendar_slug)
await _ensure_container_nav_defpage_ctx()
if year is not None:
await _ensure_day_data(int(year), int(month), int(day))
return _day_admin_main_panel_html({})
return {}
async def _h_slots_content(calendar_slug=None, **kw):
from quart import g
# ---------------------------------------------------------------------------
# Slots listing
# ---------------------------------------------------------------------------
async def _h_slots_data(calendar_slug=None, **kw) -> dict:
from quart import g, url_for
from shared.browser.app.csrf import generate_csrf_token
from bp.slots.services.slots import list_slots as svc_list_slots
await _ensure_calendar(calendar_slug)
await _ensure_container_nav_defpage_ctx()
calendar = getattr(g, "calendar", None)
from bp.slots.services.slots import list_slots as svc_list_slots
slots = await svc_list_slots(g.s, calendar.id) if calendar else []
_add_to_defpage_ctx(slots=slots)
return render_slots_table(slots, calendar)
styles = _styles_data()
csrf = generate_csrf_token()
cal_slug = getattr(calendar, "slug", "")
hx_select = getattr(g, "hx_select_search", "#main-panel")
csrf_hdr = {"X-CSRFToken": csrf}
add_url = url_for("calendar.slots.add_form", calendar_slug=cal_slug)
slots_list = []
for s in slots:
slot_href = url_for("defpage_slot_detail",
calendar_slug=cal_slug, slot_id=s.id)
del_url = url_for("calendar.slots.slot.slot_delete",
calendar_slug=cal_slug, slot_id=s.id)
desc = getattr(s, "description", "") or ""
days_display = getattr(s, "days_display", "\u2014")
day_list = days_display.split(", ")
has_days = bool(day_list and day_list[0] != "\u2014")
time_start = s.time_start.strftime("%H:%M") if s.time_start else ""
time_end = s.time_end.strftime("%H:%M") if s.time_end else ""
cost = getattr(s, "cost", None)
cost_str = f"{cost:.2f}" if cost is not None else ""
slots_list.append({
"name": s.name,
"description": desc,
"day-list": day_list if has_days else [],
"has-days": has_days,
"flexible": "yes" if s.flexible else "no",
"time-str": f"{time_start} - {time_end}",
"cost-str": cost_str,
"slot-href": slot_href,
"del-url": del_url,
})
return {
"has-slots": bool(slots),
"slots-list": slots_list,
"add-url": add_url,
"csrf-hdr": csrf_hdr,
"hx-select": hx_select,
**styles,
}
async def _h_slot_content(calendar_slug=None, slot_id=None, **kw):
from quart import g, abort
# ---------------------------------------------------------------------------
# Slot detail
# ---------------------------------------------------------------------------
async def _h_slot_data(calendar_slug=None, slot_id=None, **kw) -> dict:
from quart import g, abort, url_for
await _ensure_calendar(calendar_slug)
await _ensure_container_nav_defpage_ctx()
from bp.slot.services.slot import get_slot as svc_get_slot
slot = await svc_get_slot(g.s, slot_id) if slot_id else None
if not slot:
abort(404)
g.slot = slot
_add_to_defpage_ctx(slot=slot)
calendar = getattr(g, "calendar", None)
return render_slot_main_panel(slot, calendar)
styles = _styles_data()
cal_slug = getattr(calendar, "slug", "")
days_display = getattr(slot, "days_display", "\u2014")
day_list = days_display.split(", ")
has_days = bool(day_list and day_list[0] != "\u2014")
time_start = slot.time_start.strftime("%H:%M") if slot.time_start else ""
time_end = slot.time_end.strftime("%H:%M") if slot.time_end else ""
cost = getattr(slot, "cost", None)
cost_str = f"{cost:.2f}" if cost is not None else ""
edit_url = url_for("calendar.slots.slot.get_edit",
slot_id=slot.id, calendar_slug=cal_slug)
return {
"slot-id-str": str(slot.id),
"day-list": day_list if has_days else [],
"has-days": has_days,
"flexible": "yes" if getattr(slot, "flexible", False) else "no",
"time-str": f"{time_start} \u2014 {time_end}",
"cost-str": cost_str,
"edit-url": edit_url,
**styles,
}
async def _h_entry_content(calendar_slug=None, entry_id=None, **kw):
# ---------------------------------------------------------------------------
# Entry detail (complex — sub-panels returned as SxExpr)
# ---------------------------------------------------------------------------
async def _h_entry_data(calendar_slug=None, entry_id=None, **kw) -> dict:
from quart import url_for, g
from .entries import (
_entry_nav_html,
_entry_options_html,
render_entry_tickets_config,
render_entry_posts_panel,
)
from .tickets import render_buy_form
await _ensure_calendar(calendar_slug)
await _ensure_entry_context(entry_id)
from shared.sx.page import get_template_context
ctx = await get_template_context()
return _entry_main_panel_html(ctx)
entry = ctx.get("entry")
if not entry:
return {}
calendar = ctx.get("calendar")
cal_slug = getattr(calendar, "slug", "") if calendar else ""
day = ctx.get("day")
month = ctx.get("month")
year = ctx.get("year")
styles = _styles_data()
eid = entry.id
state = getattr(entry, "state", "pending") or "pending"
# Simple field data
slot = getattr(entry, "slot", None)
has_slot = slot is not None
slot_name = slot.name if slot else ""
flex_label = "(flexible)" if slot and getattr(slot, "flexible", False) else "(fixed)"
start_str = entry.start_at.strftime("%H:%M") if entry.start_at else ""
end_str = f" \u2013 {entry.end_at.strftime('%H:%M')}" if entry.end_at else " \u2013 open-ended"
cost = getattr(entry, "cost", None)
cost_str = f"\u00a3{cost:.2f}" if cost is not None else "\u00a30.00"
date_str = entry.start_at.strftime("%A, %B %d, %Y") if entry.start_at else ""
badge = _entry_badge_data(state)
edit_url = url_for(
"calendar.day.calendar_entries.calendar_entry.get_edit",
entry_id=eid, calendar_slug=cal_slug,
day=day, month=month, year=year,
)
# Complex sub-panels (pre-composed as SxExpr)
ticket_remaining = ctx.get("ticket_remaining")
ticket_sold_count = ctx.get("ticket_sold_count", 0)
user_ticket_count = ctx.get("user_ticket_count", 0)
user_ticket_counts_by_type = ctx.get("user_ticket_counts_by_type") or {}
entry_posts = ctx.get("entry_posts") or []
tickets_config = render_entry_tickets_config(entry, calendar, day, month, year)
buy_form = render_buy_form(
entry, ticket_remaining, ticket_sold_count,
user_ticket_count, user_ticket_counts_by_type,
)
posts_panel = render_entry_posts_panel(
entry_posts, entry, calendar, day, month, year,
)
options_html = _entry_options_html(entry, calendar, day, month, year)
# Entry menu (pre-composed for :menu slot)
entry_menu = _entry_nav_html(ctx)
return {
"entry-id-str": str(eid),
"entry-name": entry.name,
"has-slot": has_slot,
"slot-name": slot_name,
"flex-label": flex_label,
"time-str": start_str + end_str,
"state-badge-cls": badge["cls"],
"state-badge-label": badge["label"],
"cost-str": cost_str,
"date-str": date_str,
"edit-url": edit_url,
"tickets-config": SxExpr(tickets_config),
"buy-form": SxExpr(buy_form) if buy_form else None,
"posts-panel": SxExpr(posts_panel),
"options-html": SxExpr(options_html),
"entry-menu": SxExpr(entry_menu) if entry_menu else None,
**styles,
}
async def _h_entry_menu(calendar_slug=None, entry_id=None, **kw):
await _ensure_calendar(calendar_slug)
await _ensure_entry_context(entry_id)
from shared.sx.page import get_template_context
ctx = await get_template_context()
return _entry_nav_html(ctx)
# ---------------------------------------------------------------------------
# Entry admin
# ---------------------------------------------------------------------------
async def _h_entry_admin_data(calendar_slug=None, entry_id=None,
year=None, month=None, day=None, **kw) -> dict:
from quart import url_for, g
async def _h_entry_admin_content(calendar_slug=None, entry_id=None, **kw):
await _ensure_calendar(calendar_slug)
await _ensure_container_nav_defpage_ctx()
await _ensure_entry_context(entry_id)
calendar = getattr(g, "calendar", None)
entry = getattr(g, "entry", None)
if not calendar or not entry:
return {}
cal_slug = getattr(calendar, "slug", "")
styles = _styles_data()
from shared.sx.page import get_template_context
ctx = await get_template_context()
return _entry_admin_main_panel_html(ctx)
select_colours = ctx.get("select_colours", "")
ticket_types_href = url_for(
"calendar.day.calendar_entries.calendar_entry.ticket_types.get",
calendar_slug=cal_slug, entry_id=entry.id,
year=year, month=month, day=day,
)
return {
"ticket-types-href": ticket_types_href,
"select-colours": select_colours,
**styles,
}
def _h_admin_menu():
return sx_call("events-admin-placeholder-nav")
# ---------------------------------------------------------------------------
# Ticket types listing
# ---------------------------------------------------------------------------
async def _h_ticket_types_data(calendar_slug=None, entry_id=None,
year=None, month=None, day=None, **kw) -> dict:
from quart import g, url_for
from shared.browser.app.csrf import generate_csrf_token
async def _h_ticket_types_content(calendar_slug=None, entry_id=None,
year=None, month=None, day=None, **kw):
from quart import g
await _ensure_calendar(calendar_slug)
await _ensure_entry(entry_id)
entry = getattr(g, "entry", None)
calendar = getattr(g, "calendar", None)
from bp.ticket_types.services.tickets import list_ticket_types as svc_list_ticket_types
ticket_types = await svc_list_ticket_types(g.s, entry.id) if entry else []
_add_to_defpage_ctx(ticket_types=ticket_types)
return render_ticket_types_table(ticket_types, entry, calendar, day, month, year)
styles = _styles_data()
csrf = generate_csrf_token()
cal_slug = getattr(calendar, "slug", "")
hx_select = getattr(g, "hx_select_search", "#main-panel")
eid = entry.id if entry else 0
csrf_hdr = {"X-CSRFToken": csrf}
types_list = []
for tt in (ticket_types or []):
tt_href = url_for(
"calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get",
calendar_slug=cal_slug, year=year, month=month, day=day,
entry_id=eid, ticket_type_id=tt.id,
)
del_url = url_for(
"calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.delete",
calendar_slug=cal_slug, year=year, month=month, day=day,
entry_id=eid, ticket_type_id=tt.id,
)
cost = getattr(tt, "cost", None)
cost_str = f"\u00a3{cost:.2f}" if cost is not None else "\u00a30.00"
types_list.append({
"tt-href": tt_href,
"tt-name": tt.name,
"cost-str": cost_str,
"count": str(tt.count),
"del-url": del_url,
})
add_url = url_for(
"calendar.day.calendar_entries.calendar_entry.ticket_types.add_form",
calendar_slug=cal_slug, entry_id=eid, year=year, month=month, day=day,
)
return {
"has-types": bool(ticket_types),
"types-list": types_list,
"add-url": add_url,
"csrf-hdr": csrf_hdr,
"hx-select": hx_select,
**styles,
}
async def _h_ticket_type_content(calendar_slug=None, entry_id=None,
ticket_type_id=None, year=None, month=None, day=None, **kw):
from quart import g, abort
# ---------------------------------------------------------------------------
# Ticket type detail
# ---------------------------------------------------------------------------
async def _h_ticket_type_data(calendar_slug=None, entry_id=None,
ticket_type_id=None,
year=None, month=None, day=None, **kw) -> dict:
from quart import g, abort, url_for
await _ensure_calendar(calendar_slug)
await _ensure_entry(entry_id)
from bp.ticket_type.services.ticket import get_ticket_type as svc_get_ticket_type
ticket_type = await svc_get_ticket_type(g.s, ticket_type_id) if ticket_type_id else None
if not ticket_type:
abort(404)
g.ticket_type = ticket_type
_add_to_defpage_ctx(ticket_type=ticket_type)
entry = getattr(g, "entry", None)
calendar = getattr(g, "calendar", None)
return render_ticket_type_main_panel(ticket_type, entry, calendar, day, month, year)
styles = _styles_data()
cal_slug = getattr(calendar, "slug", "")
cost = getattr(ticket_type, "cost", None)
cost_str = f"\u00a3{cost:.2f}" if cost is not None else "\u00a30.00"
count = getattr(ticket_type, "count", 0)
edit_url = url_for(
"calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get_edit",
ticket_type_id=ticket_type.id, calendar_slug=cal_slug,
year=year, month=month, day=day,
entry_id=entry.id if entry else 0,
)
return {
"ticket-id": str(ticket_type.id),
"tt-name": ticket_type.name,
"cost-str": cost_str,
"count-str": str(count),
"edit-url": edit_url,
**styles,
}
async def _h_tickets_content(**kw):
from quart import g
# ---------------------------------------------------------------------------
# My tickets
# ---------------------------------------------------------------------------
async def _h_tickets_data(**kw) -> dict:
from quart import g, url_for
from shared.infrastructure.cart_identity import current_cart_identity
from bp.tickets.services.tickets import get_user_tickets
ident = current_cart_identity()
tickets = await get_user_tickets(
g.s,
user_id=ident["user_id"],
session_id=ident["session_id"],
)
from shared.sx.page import get_template_context
ctx = await get_template_context()
return _tickets_main_panel_html(ctx, tickets)
styles = ctx.get("styles") or {}
list_container = getattr(styles, "list_container", "") if hasattr(styles, "list_container") else styles.get("list_container", "")
tickets_list = []
for ticket in (tickets or []):
href = url_for("defpage_ticket_detail", code=ticket.code)
entry = getattr(ticket, "entry", None)
entry_name = entry.name if entry else "Unknown event"
tt = getattr(ticket, "ticket_type", None)
state = getattr(ticket, "state", "")
cal = getattr(entry, "calendar", None) if entry else None
time_str = ""
if entry and entry.start_at:
time_str = entry.start_at.strftime("%A, %B %d, %Y at %H:%M")
if entry.end_at:
time_str += f" \u2013 {entry.end_at.strftime('%H:%M')}"
badge = _ticket_badge_data(state)
tickets_list.append({
"href": href,
"entry-name": entry_name,
"type-name": tt.name if tt else None,
"time-str": time_str or None,
"cal-name": cal.name if cal else None,
"badge-cls": badge["cls"],
"badge-label": badge["label"],
"code-prefix": ticket.code[:8],
})
return {
"has-tickets": bool(tickets),
"tickets-list": tickets_list,
"list-container": list_container,
}
async def _h_ticket_detail_content(code=None, **kw):
from quart import g, abort
# ---------------------------------------------------------------------------
# Ticket detail
# ---------------------------------------------------------------------------
async def _h_ticket_detail_data(code=None, **kw) -> dict:
from quart import g, abort, url_for
from shared.infrastructure.cart_identity import current_cart_identity
from bp.tickets.services.tickets import get_ticket_by_code
ticket = await get_ticket_by_code(g.s, code) if code else None
if not ticket:
abort(404)
@@ -417,16 +785,71 @@ async def _h_ticket_detail_content(code=None, **kw):
abort(404)
else:
abort(404)
from shared.sx.page import get_template_context
ctx = await get_template_context()
return _ticket_detail_panel_html(ctx, ticket)
styles = ctx.get("styles") or {}
list_container = getattr(styles, "list_container", "") if hasattr(styles, "list_container") else styles.get("list_container", "")
entry = getattr(ticket, "entry", None)
tt = getattr(ticket, "ticket_type", None)
state = getattr(ticket, "state", "")
ticket_code = ticket.code
cal = getattr(entry, "calendar", None) if entry else None
checked_in_at = getattr(ticket, "checked_in_at", None)
bg_map = {"confirmed": "bg-emerald-50", "checked_in": "bg-blue-50",
"reserved": "bg-amber-50"}
header_bg = bg_map.get(state, "bg-stone-50")
entry_name = entry.name if entry else "Ticket"
back_href = url_for("defpage_my_tickets")
badge = _ticket_badge_data(state)
time_date = entry.start_at.strftime("%A, %B %d, %Y") if entry and entry.start_at else None
time_range = entry.start_at.strftime("%H:%M") if entry and entry.start_at else None
if time_range and entry.end_at:
time_range += f" \u2013 {entry.end_at.strftime('%H:%M')}"
tt_desc = f"{tt.name} \u2014 \u00a3{tt.cost:.2f}" if tt and getattr(tt, "cost", None) else None
checkin_str = checked_in_at.strftime("Checked in: %B %d, %Y at %H:%M") if checked_in_at else None
qr_script = (
f"(function(){{var c=document.getElementById('ticket-qr-{ticket_code}');"
"if(c&&typeof QRCode!=='undefined'){"
"var cv=document.createElement('canvas');"
f"QRCode.toCanvas(cv,'{ticket_code}',{{width:200,margin:2,color:{{dark:'#1c1917',light:'#ffffff'}}}},function(e){{if(!e)c.appendChild(cv)}});"
"}})()"
)
return {
"list-container": list_container,
"back-href": back_href,
"header-bg": header_bg,
"entry-name": entry_name,
"badge-cls": badge["cls"],
"badge-label": badge["label"],
"type-name": tt.name if tt else None,
"ticket-code": ticket_code,
"time-date": time_date,
"time-range": time_range,
"cal-name": cal.name if cal else None,
"type-desc": tt_desc,
"checkin-str": checkin_str,
"qr-script": qr_script,
}
async def _h_ticket_admin_content(**kw):
from quart import g
# ---------------------------------------------------------------------------
# Ticket admin dashboard
# ---------------------------------------------------------------------------
async def _h_ticket_admin_data(**kw) -> dict:
from quart import g, url_for
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
from models.calendars import CalendarEntry, Ticket
from shared.browser.app.csrf import generate_csrf_token
result = await g.s.execute(
select(Ticket)
@@ -449,20 +872,118 @@ async def _h_ticket_admin_content(**kw):
reserved = await g.s.scalar(
select(func.count(Ticket.id)).where(Ticket.state == "reserved")
)
stats = {
"total": total or 0,
"confirmed": confirmed or 0,
"checked_in": checked_in or 0,
"reserved": reserved or 0,
csrf = generate_csrf_token()
lookup_url = url_for("ticket_admin.lookup")
from shared.sx.page import get_template_context
ctx = await get_template_context()
styles = ctx.get("styles") or {}
list_container = getattr(styles, "list_container", "") if hasattr(styles, "list_container") else styles.get("list_container", "")
# Stats cards data
admin_stats = []
for label, key, border, bg, text_cls in [
("Total", "total", "border-stone-200", "", "text-stone-900"),
("Confirmed", "confirmed", "border-emerald-200", "bg-emerald-50", "text-emerald-700"),
("Checked In", "checked_in", "border-blue-200", "bg-blue-50", "text-blue-700"),
("Reserved", "reserved", "border-amber-200", "bg-amber-50", "text-amber-700"),
]:
val_map = {"total": total, "confirmed": confirmed,
"checked_in": checked_in, "reserved": reserved}
val = val_map.get(key, 0) or 0
lbl_cls = text_cls.replace("700", "600").replace("900", "500") if "stone" not in text_cls else "text-stone-500"
admin_stats.append({
"border": border, "bg": bg, "text-cls": text_cls,
"label-cls": lbl_cls, "value": str(val), "label": label,
})
# Ticket rows data
admin_tickets = []
for ticket in tickets:
entry = getattr(ticket, "entry", None)
tt = getattr(ticket, "ticket_type", None)
state = getattr(ticket, "state", "")
tcode = ticket.code
checked_in_at = getattr(ticket, "checked_in_at", None)
date_str = None
if entry and entry.start_at:
date_str = entry.start_at.strftime("%d %b %Y, %H:%M")
badge = _ticket_badge_data(state)
can_checkin = state in ("confirmed", "reserved")
is_checked_in = state == "checked_in"
checkin_url = url_for("ticket_admin.do_checkin", code=tcode) if can_checkin else None
checkin_time = checked_in_at.strftime("%H:%M") if checked_in_at else ""
admin_tickets.append({
"code": tcode,
"code-short": tcode[:12] + "...",
"entry-name": entry.name if entry else "\u2014",
"date-str": date_str,
"type-name": tt.name if tt else "\u2014",
"badge-cls": badge["cls"],
"badge-label": badge["label"],
"can-checkin": can_checkin,
"is-checked-in": is_checked_in,
"checkin-url": checkin_url,
"checkin-time": checkin_time,
})
return {
"admin-stats": admin_stats,
"admin-tickets": admin_tickets,
"list-container": list_container,
"lookup-url": lookup_url,
"csrf": csrf,
"has-tickets": bool(tickets),
}
from shared.sx.page import get_template_context
ctx = await get_template_context()
return _ticket_admin_main_panel_html(ctx, tickets, stats)
# ---------------------------------------------------------------------------
# Markets
# ---------------------------------------------------------------------------
async def _h_markets_data(**kw) -> dict:
from quart import url_for
from shared.browser.app.csrf import generate_csrf_token
from shared.sx.helpers import call_url
async def _h_markets_content(**kw):
_ensure_post_defpage_ctx()
from shared.sx.page import get_template_context
ctx = await get_template_context()
return _markets_main_panel_html(ctx)
rights = ctx.get("rights") or {}
is_admin = getattr(rights, "admin", False) if hasattr(rights, "admin") else rights.get("admin", False)
has_access = ctx.get("has_access")
can_create = has_access("markets.create_market") if callable(has_access) else is_admin
csrf = generate_csrf_token()
markets_raw = ctx.get("markets") or []
post = ctx.get("post") or {}
slug = post.get("slug", "")
csrf_hdr = {"X-CSRFToken": csrf}
markets_list = []
for m in markets_raw:
m_slug = getattr(m, "slug", "") if hasattr(m, "slug") else m.get("slug", "")
m_name = getattr(m, "name", "") if hasattr(m, "name") else m.get("name", "")
market_href = call_url(ctx, "market_url", f"/{slug}/{m_slug}/")
del_url = url_for("markets.delete_market", market_slug=m_slug)
markets_list.append({
"href": market_href,
"name": m_name,
"slug": m_slug,
"del-url": del_url,
"csrf-hdr": csrf_hdr,
})
return {
"can-create": can_create,
"create-url": url_for("markets.create_market") if can_create else None,
"csrf": csrf,
"markets-list": markets_list,
}

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
from shared.sx.helpers import sx_call
from shared.sx.parser import SxExpr
# ===========================================================================
@@ -110,9 +111,9 @@ _SLOT_PICKER_JS = """\
# Slot options (shared by entry edit + add forms)
# ---------------------------------------------------------------------------
def _slot_options_data(day_slots, selected_slot_id=None) -> list:
"""Extract slot option data for sx composition."""
result = []
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 ""
@@ -126,15 +127,16 @@ def _slot_options_data(day_slots, selected_slot_id=None) -> list:
label_parts.append("\u2013open-ended)")
if flexible:
label_parts.append("[flexible]")
result.append({
"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": " ".join(label_parts),
})
return result
label = " ".join(label_parts)
parts.append(sx_call("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)
# ---------------------------------------------------------------------------
@@ -167,7 +169,7 @@ def _slot_header_html(ctx: dict, *, oob: bool = False) -> str:
# ---------------------------------------------------------------------------
def render_slot_main_panel(slot, calendar, *, oob: bool = False) -> str:
"""Render slot detail view via data extraction + sx defcomp."""
"""Render slot detail view."""
from quart import url_for, g
styles = getattr(g, "styles", None) or {}
@@ -177,23 +179,38 @@ def render_slot_main_panel(slot, calendar, *, oob: bool = False) -> str:
days_display = getattr(slot, "days_display", "\u2014")
days = days_display.split(", ")
if days and days[0] == "\u2014":
days = []
flexible = getattr(slot, "flexible", False)
time_start = slot.time_start.strftime("%H:%M") if slot.time_start else ""
time_end = slot.time_end.strftime("%H:%M") if slot.time_end else ""
cost = getattr(slot, "cost", None)
cost_str = f"{cost:.2f}" if cost is not None else ""
desc = getattr(slot, "description", "") or ""
return sx_call("events-slot-panel-from-data",
slot_id=str(slot.id), list_container=list_container,
days=days or None,
flexible="yes" if getattr(slot, "flexible", False) else "no",
time_str=f"{time_start} \u2014 {time_end}",
cost_str=f"{cost:.2f}" if cost is not None else "",
pre_action=pre_action,
edit_url=url_for("calendar.slots.slot.get_edit", slot_id=slot.id, calendar_slug=cal_slug),
description=getattr(slot, "description", "") or "",
oob=oob or None)
edit_url = url_for("calendar.slots.slot.get_edit", slot_id=slot.id, calendar_slug=cal_slug)
# Days pills
if days and days[0] != "\u2014":
days_inner = "".join(
sx_call("events-slot-day-pill", day=d) for d in days
)
days_html = sx_call("events-slot-days-pills", days_inner=SxExpr(days_inner))
else:
days_html = sx_call("events-slot-no-days")
sid = str(slot.id)
result = sx_call("events-slot-panel",
slot_id=sid, list_container=list_container,
days=days_html,
flexible="yes" if flexible else "no",
time_str=f"{time_start} \u2014 {time_end}",
cost_str=cost_str,
pre_action=pre_action, edit_url=edit_url)
if oob:
result += sx_call("events-slot-description-oob", description=desc)
return result
# ---------------------------------------------------------------------------
@@ -201,7 +218,7 @@ def render_slot_main_panel(slot, calendar, *, oob: bool = False) -> str:
# ---------------------------------------------------------------------------
def render_slots_table(slots, calendar) -> str:
"""Render slots table via data extraction + sx defcomp."""
"""Render slots table with rows and add button."""
from quart import url_for, g
from shared.browser.app.csrf import generate_csrf_token
csrf = generate_csrf_token()
@@ -215,34 +232,46 @@ def render_slots_table(slots, calendar) -> str:
hx_select = getattr(g, "hx_select_search", "#main-panel")
cal_slug = getattr(calendar, "slug", "")
slots_data = []
rows_html = ""
if slots:
for s in slots:
slot_href = url_for("defpage_slot_detail", calendar_slug=cal_slug, slot_id=s.id)
del_url = url_for("calendar.slots.slot.slot_delete", calendar_slug=cal_slug, slot_id=s.id)
desc = getattr(s, "description", "") or ""
days_display = getattr(s, "days_display", "\u2014")
day_list = days_display.split(", ")
if day_list and day_list[0] == "\u2014":
day_list = []
if day_list and day_list[0] != "\u2014":
days_inner = "".join(
sx_call("events-slot-day-pill", day=d) for d in day_list
)
days_html = sx_call("events-slot-days-pills", days_inner=SxExpr(days_inner))
else:
days_html = sx_call("events-slot-no-days")
time_start = s.time_start.strftime("%H:%M") if s.time_start else ""
time_end = s.time_end.strftime("%H:%M") if s.time_end else ""
cost = getattr(s, "cost", None)
slots_data.append({
"slot-href": url_for("defpage_slot_detail", calendar_slug=cal_slug, slot_id=s.id),
"slot-name": s.name,
"description": getattr(s, "description", "") or "",
"flexible": "yes" if s.flexible else "no",
"days": day_list or None,
"time-str": f"{time_start} - {time_end}",
"cost-str": f"{cost:.2f}" if cost is not None else "",
"del-url": url_for("calendar.slots.slot.slot_delete", calendar_slug=cal_slug, slot_id=s.id),
})
cost_str = f"{cost:.2f}" if cost is not None else ""
return sx_call("events-slots-table-from-data",
list_container=list_container, slots=slots_data or None,
pre_action=pre_action,
add_url=url_for("calendar.slots.add_form", calendar_slug=cal_slug),
tr_cls=tr_cls, pill_cls=pill_cls, action_btn=action_btn,
hx_select=hx_select,
csrf_hdr=f'{{"X-CSRFToken": "{csrf}"}}')
rows_html += sx_call("events-slots-row",
tr_cls=tr_cls, slot_href=slot_href,
pill_cls=pill_cls, hx_select=hx_select,
slot_name=s.name, description=desc,
flexible="yes" if s.flexible else "no",
days=days_html,
time_str=f"{time_start} - {time_end}",
cost_str=cost_str, action_btn=action_btn,
del_url=del_url,
csrf_hdr={"X-CSRFToken": csrf})
else:
rows_html = sx_call("events-slots-empty-row")
add_url = url_for("calendar.slots.add_form", calendar_slug=cal_slug)
return sx_call("events-slots-table",
list_container=list_container, rows=SxExpr(rows_html),
pre_action=pre_action, add_url=add_url)
# ---------------------------------------------------------------------------
@@ -250,7 +279,7 @@ def render_slots_table(slots, calendar) -> str:
# ---------------------------------------------------------------------------
def render_slot_edit_form(slot, calendar) -> str:
"""Render slot edit form via data extraction + sx defcomp."""
"""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()
@@ -262,25 +291,38 @@ def render_slot_edit_form(slot, calendar) -> str:
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")]
days_data = [{"name": k, "label": lbl, "checked": getattr(slot, k, False) or None}
for k, lbl in day_keys]
all_checked = all(getattr(slot, k, False) for k, _ in day_keys)
return sx_call("events-slot-edit-form-from-data",
days_parts = [sx_call("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(sx_call("events-day-checkbox",
name=key, label=label,
checked="checked" if checked else None))
days_html = "".join(days_parts)
flexible = getattr(slot, "flexible", False)
return sx_call("events-slot-edit-form",
slot_id=str(sid), list_container=list_container,
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),
csrf=csrf,
name_val=slot.name or "",
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_data=days_data, all_checked=all_checked or None,
flexible_checked="checked" if getattr(slot, "flexible", False) else None,
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=SxExpr(days_html),
flexible_checked="checked" if flexible else None,
action_btn=action_btn, cancel_btn=cancel_btn)
@@ -289,7 +331,7 @@ def render_slot_edit_form(slot, calendar) -> str:
# ---------------------------------------------------------------------------
def render_slot_add_form(calendar) -> str:
"""Render slot add form via data extraction + sx defcomp."""
"""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()
@@ -299,16 +341,23 @@ def render_slot_add_form(calendar) -> str:
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 = {"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_data = [{"name": k, "label": lbl} for k, lbl in day_keys]
days_parts = [sx_call("events-day-all-checkbox", checked=None)]
for key, label in day_keys:
days_parts.append(sx_call("events-day-checkbox", name=key, label=label, checked=None))
days_html = "".join(days_parts)
return sx_call("events-slot-add-form-from-data",
post_url=url_for("calendar.slots.post", calendar_slug=cal_slug),
csrf=f'{{"X-CSRFToken": "{csrf}"}}',
days_data=days_data,
return sx_call("events-slot-add-form",
post_url=post_url, csrf=csrf_hdr,
days=SxExpr(days_html),
action_btn=action_btn, cancel_btn=cancel_btn,
cancel_url=url_for("calendar.slots.add_button", calendar_slug=cal_slug))
cancel_url=cancel_url)
def render_slot_add_button(calendar) -> str:

View File

@@ -6,25 +6,49 @@ from markupsafe import escape
from shared.sx.helpers import sx_call
from shared.sx.parser import SxExpr
from .utils import _list_container, _cart_icon_ctx
from .utils import (
_ticket_state_badge_html, _list_container, _cart_icon_oob,
)
# ---------------------------------------------------------------------------
# Ticket widget (inline +/- for entry cards)
# ---------------------------------------------------------------------------
def _ticket_widget_data(entry, qty: int, ticket_url: str) -> dict:
"""Extract ticket widget data for sx composition."""
from shared.browser.app.csrf import generate_csrf_token
def _ticket_widget_html(entry, qty: int, ticket_url: str, *, ctx: dict) -> str:
"""Render the inline +/- ticket widget."""
csrf_token_val = ""
if ctx:
ct = ctx.get("csrf_token")
csrf_token_val = ct() if callable(ct) else (ct or "")
else:
try:
from flask_wtf.csrf import generate_csrf
csrf_token_val = generate_csrf()
except Exception:
pass
eid = entry.id
tp = getattr(entry, "ticket_price", 0) or 0
return {
"entry_id": str(eid),
"price": f"\u00a3{tp:.2f}",
"qty": qty,
"ticket_url": ticket_url,
"csrf": generate_csrf_token(),
}
tgt = f"#page-ticket-{eid}"
def _tw_form(count_val, btn_html):
return sx_call("events-tw-form",
ticket_url=ticket_url, target=tgt,
csrf=csrf_token_val, entry_id=str(eid),
count_val=str(count_val), btn=btn_html)
if qty == 0:
inner = _tw_form(1, sx_call("events-tw-cart-plus"))
else:
minus = _tw_form(qty - 1, sx_call("events-tw-minus"))
cart_icon = sx_call("events-tw-cart-icon", qty=str(qty))
plus = _tw_form(qty + 1, sx_call("events-tw-plus"))
inner = minus + cart_icon + plus
return sx_call("events-tw-widget",
entry_id=str(eid), price=f"\u00a3{tp:.2f}",
inner=SxExpr(inner))
# ---------------------------------------------------------------------------
@@ -32,33 +56,37 @@ def _ticket_widget_data(entry, qty: int, ticket_url: str) -> dict:
# ---------------------------------------------------------------------------
def _tickets_main_panel_html(ctx: dict, tickets: list) -> str:
"""Render my tickets list via data extraction + sx defcomp."""
"""Render my tickets list."""
from quart import url_for
ticket_data = []
ticket_cards = []
if tickets:
for ticket in tickets:
href = url_for("defpage_ticket_detail", code=ticket.code)
entry = getattr(ticket, "entry", None)
entry_name = entry.name if entry else "Unknown event"
tt = getattr(ticket, "ticket_type", None)
state = getattr(ticket, "state", "")
cal = getattr(entry, "calendar", None) if entry else None
time_str = ""
if entry and entry.start_at:
time_str = entry.start_at.strftime("%A, %B %d, %Y at %H:%M")
if entry.end_at:
time_str += f" \u2013 {entry.end_at.strftime('%H:%M')}"
ticket_data.append({
"href": url_for("defpage_ticket_detail", code=ticket.code),
"entry-name": entry.name if entry else "Unknown event",
"type-name": tt.name if tt else None,
"time-str": time_str or None,
"cal-name": cal.name if cal else None,
"state": getattr(ticket, "state", ""),
"code-prefix": ticket.code[:8],
})
return sx_call("events-tickets-panel-from-data",
ticket_cards.append(sx_call("events-ticket-card",
href=href, entry_name=entry_name,
type_name=tt.name if tt else None,
time_str=time_str or None,
cal_name=cal.name if cal else None,
badge=_ticket_state_badge_html(state),
code_prefix=ticket.code[:8]))
cards_html = "".join(ticket_cards)
return sx_call("events-tickets-panel",
list_container=_list_container(ctx),
tickets=ticket_data or None)
has_tickets=bool(tickets), cards=SxExpr(cards_html))
# ---------------------------------------------------------------------------
@@ -66,7 +94,7 @@ def _tickets_main_panel_html(ctx: dict, tickets: list) -> str:
# ---------------------------------------------------------------------------
def _ticket_detail_panel_html(ctx: dict, ticket) -> str:
"""Render a single ticket detail with QR code via data + sx defcomp."""
"""Render a single ticket detail with QR code."""
from quart import url_for
entry = getattr(ticket, "entry", None)
@@ -77,11 +105,22 @@ def _ticket_detail_panel_html(ctx: dict, ticket) -> str:
checked_in_at = getattr(ticket, "checked_in_at", None)
bg_map = {"confirmed": "bg-emerald-50", "checked_in": "bg-blue-50", "reserved": "bg-amber-50"}
header_bg = bg_map.get(state, "bg-stone-50")
entry_name = entry.name if entry else "Ticket"
back_href = url_for("defpage_my_tickets")
# Badge with larger sizing
badge = (_ticket_state_badge_html(state)).replace('px-2 py-0.5 text-xs', 'px-3 py-1 text-sm')
# Time info
time_date = entry.start_at.strftime("%A, %B %d, %Y") if entry and entry.start_at else None
time_range = entry.start_at.strftime("%H:%M") if entry and entry.start_at else None
if time_range and entry.end_at:
time_range += f" \u2013 {entry.end_at.strftime('%H:%M')}"
tt_desc = f"{tt.name} \u2014 \u00a3{tt.cost:.2f}" if tt and getattr(tt, "cost", None) else None
checkin_str = checked_in_at.strftime("Checked in: %B %d, %Y at %H:%M") if checked_in_at else None
qr_script = (
f"(function(){{var c=document.getElementById('ticket-qr-{code}');"
"if(c&&typeof QRCode!=='undefined'){"
@@ -90,16 +129,13 @@ def _ticket_detail_panel_html(ctx: dict, ticket) -> str:
"}})()"
)
return sx_call("events-ticket-detail-from-data",
list_container=_list_container(ctx),
back_href=url_for("defpage_my_tickets"),
header_bg=bg_map.get(state, "bg-stone-50"),
entry_name=entry.name if entry else "Ticket",
state=state, type_name=tt.name if tt else None,
return sx_call("events-ticket-detail",
list_container=_list_container(ctx), back_href=back_href,
header_bg=header_bg, entry_name=entry_name,
badge=SxExpr(badge), type_name=tt.name if tt else None,
code=code, time_date=time_date, time_range=time_range,
cal_name=cal.name if cal else None,
type_desc=f"{tt.name} \u2014 \u00a3{tt.cost:.2f}" if tt and getattr(tt, "cost", None) else None,
checkin_str=checked_in_at.strftime("Checked in: %B %d, %Y at %H:%M") if checked_in_at else None,
type_desc=tt_desc, checkin_str=checkin_str,
qr_script=qr_script)
@@ -108,38 +144,62 @@ def _ticket_detail_panel_html(ctx: dict, ticket) -> str:
# ---------------------------------------------------------------------------
def _ticket_admin_main_panel_html(ctx: dict, tickets: list, stats: dict) -> str:
"""Render ticket admin dashboard via data extraction + sx defcomp."""
"""Render ticket admin dashboard."""
from quart import url_for
csrf_token = ctx.get("csrf_token")
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
lookup_url = url_for("ticket_admin.lookup")
ticket_data = []
# Stats cards
stats_html = ""
for label, key, border, bg, text_cls in [
("Total", "total", "border-stone-200", "", "text-stone-900"),
("Confirmed", "confirmed", "border-emerald-200", "bg-emerald-50", "text-emerald-700"),
("Checked In", "checked_in", "border-blue-200", "bg-blue-50", "text-blue-700"),
("Reserved", "reserved", "border-amber-200", "bg-amber-50", "text-amber-700"),
]:
val = stats.get(key, 0)
lbl_cls = text_cls.replace("700", "600").replace("900", "500") if "stone" not in text_cls else "text-stone-500"
stats_html += sx_call("events-ticket-admin-stat",
border=border, bg=bg, text_cls=text_cls,
label_cls=lbl_cls, value=str(val), label=label)
# Ticket rows
rows_html = ""
for ticket in tickets:
entry = getattr(ticket, "entry", None)
tt = getattr(ticket, "ticket_type", None)
state = getattr(ticket, "state", "")
code = ticket.code
checked_in_at = getattr(ticket, "checked_in_at", None)
ticket_data.append({
"code": code,
"code-short": code[:12] + "...",
"entry-name": entry.name if entry else "\u2014",
"date-str": entry.start_at.strftime("%d %b %Y, %H:%M") if entry and entry.start_at else None,
"type-name": tt.name if tt else "\u2014",
"state": state,
"checkin-url": url_for("ticket_admin.do_checkin", code=code) if state in ("confirmed", "reserved") else None,
"csrf": csrf,
"checked-in-time": checked_in_at.strftime("%H:%M") if checked_in_at else None,
})
return sx_call("events-ticket-admin-panel-from-data",
list_container=_list_container(ctx),
lookup_url=url_for("ticket_admin.lookup"),
tickets=ticket_data or None,
total=stats.get("total", 0),
confirmed=stats.get("confirmed", 0),
checked_in=stats.get("checked_in", 0),
reserved=stats.get("reserved", 0))
date_html = ""
if entry and entry.start_at:
date_html = sx_call("events-ticket-admin-date",
date_str=entry.start_at.strftime("%d %b %Y, %H:%M"))
action_html = ""
if state in ("confirmed", "reserved"):
checkin_url = url_for("ticket_admin.do_checkin", code=code)
action_html = sx_call("events-ticket-admin-checkin-form",
checkin_url=checkin_url, code=code, csrf=csrf)
elif state == "checked_in":
checked_in_at = getattr(ticket, "checked_in_at", None)
t_str = checked_in_at.strftime("%H:%M") if checked_in_at else ""
action_html = sx_call("events-ticket-admin-checked-in",
time_str=t_str)
rows_html += sx_call("events-ticket-admin-row",
code=code, code_short=code[:12] + "...",
entry_name=entry.name if entry else "\u2014",
date=SxExpr(date_html),
type_name=tt.name if tt else "\u2014",
badge=_ticket_state_badge_html(state),
action=SxExpr(action_html))
return sx_call("events-ticket-admin-panel",
list_container=_list_container(ctx), stats=SxExpr(stats_html),
lookup_url=lookup_url, has_tickets=bool(tickets),
rows=SxExpr(rows_html))
# ---------------------------------------------------------------------------
@@ -148,8 +208,7 @@ def _ticket_admin_main_panel_html(ctx: dict, tickets: list, stats: dict) -> str:
def render_ticket_widget(entry, qty: int, ticket_url: str) -> str:
"""Render the +/- ticket widget for page_summary / all_events adjust_ticket."""
data = _ticket_widget_data(entry, qty, ticket_url)
return sx_call("events-tw-widget-from-data", **data)
return _ticket_widget_html(entry, qty, ticket_url, ctx={})
# ---------------------------------------------------------------------------
@@ -167,13 +226,20 @@ def render_checkin_result(success: bool, error: str | None, ticket) -> str:
entry = getattr(ticket, "entry", None)
tt = getattr(ticket, "ticket_type", None)
checked_in_at = getattr(ticket, "checked_in_at", None)
time_str = checked_in_at.strftime("%H:%M") if checked_in_at else "Just now"
return sx_call("events-checkin-success-row-from-data",
date_html = ""
if entry and entry.start_at:
date_html = sx_call("events-ticket-admin-date",
date_str=entry.start_at.strftime("%d %b %Y, %H:%M"))
return sx_call("events-checkin-success-row",
code=code, code_short=code[:12] + "...",
entry_name=entry.name if entry else "\u2014",
date_str=entry.start_at.strftime("%d %b %Y, %H:%M") if entry and entry.start_at else None,
date=SxExpr(date_html),
type_name=tt.name if tt else "\u2014",
time_str=checked_in_at.strftime("%H:%M") if checked_in_at else "Just now")
badge=_ticket_state_badge_html("checked_in"),
time_str=time_str)
# ---------------------------------------------------------------------------
@@ -181,7 +247,7 @@ def render_checkin_result(success: bool, error: str | None, ticket) -> str:
# ---------------------------------------------------------------------------
def render_lookup_result(ticket, error: str | None) -> str:
"""Render ticket lookup result via data extraction + sx defcomp."""
"""Render ticket lookup result: error div or ticket info card."""
from quart import url_for
from shared.browser.app.csrf import generate_csrf_token
@@ -195,21 +261,38 @@ def render_lookup_result(ticket, error: str | None) -> str:
state = getattr(ticket, "state", "")
code = ticket.code
checked_in_at = getattr(ticket, "checked_in_at", None)
cal = getattr(entry, "calendar", None) if entry else None
csrf = generate_csrf_token()
checkin_url = None
# Info section
info_html = sx_call("events-lookup-info",
entry_name=entry.name if entry else "Unknown event")
if tt:
info_html += sx_call("events-lookup-type", type_name=tt.name)
if entry and entry.start_at:
info_html += sx_call("events-lookup-date",
date_str=entry.start_at.strftime("%A, %B %d, %Y at %H:%M"))
cal = getattr(entry, "calendar", None) if entry else None
if cal:
info_html += sx_call("events-lookup-cal", cal_name=cal.name)
info_html += sx_call("events-lookup-status",
badge=_ticket_state_badge_html(state), code=code)
if checked_in_at:
info_html += sx_call("events-lookup-checkin-time",
date_str=checked_in_at.strftime("%B %d, %Y at %H:%M"))
# Action area
action_html = ""
if state in ("confirmed", "reserved"):
checkin_url = url_for("ticket_admin.do_checkin", code=code)
action_html = sx_call("events-lookup-checkin-btn",
checkin_url=checkin_url, code=code, csrf=csrf)
elif state == "checked_in":
action_html = sx_call("events-lookup-checked-in")
elif state == "cancelled":
action_html = sx_call("events-lookup-cancelled")
return sx_call("events-lookup-result-from-data",
entry_name=entry.name if entry else "Unknown event",
type_name=tt.name if tt else None,
date_str=entry.start_at.strftime("%A, %B %d, %Y at %H:%M") if entry and entry.start_at else None,
cal_name=cal.name if cal else None,
state=state, code=code,
checked_in_str=checked_in_at.strftime("%B %d, %Y at %H:%M") if checked_in_at else None,
checkin_url=checkin_url,
csrf=generate_csrf_token())
return sx_call("events-lookup-card",
info=SxExpr(info_html), code=code, action=SxExpr(action_html))
# ---------------------------------------------------------------------------
@@ -217,7 +300,7 @@ def render_lookup_result(ticket, error: str | None) -> str:
# ---------------------------------------------------------------------------
def render_entry_tickets_admin(entry, tickets: list) -> str:
"""Render admin ticket table via data extraction + sx defcomp."""
"""Render admin ticket table for a specific entry."""
from quart import url_for
from shared.browser.app.csrf import generate_csrf_token
csrf = generate_csrf_token()
@@ -225,29 +308,39 @@ def render_entry_tickets_admin(entry, tickets: list) -> str:
count = len(tickets)
suffix = "s" if count != 1 else ""
ticket_data = []
rows_html = ""
for ticket in tickets:
tt = getattr(ticket, "ticket_type", None)
state = getattr(ticket, "state", "")
code = ticket.code
checked_in_at = getattr(ticket, "checked_in_at", None)
checkin_url = None
action_html = ""
if state in ("confirmed", "reserved"):
checkin_url = url_for("ticket_admin.do_checkin", code=code)
ticket_data.append({
"code": code,
"code-short": code[:12] + "...",
"type-name": tt.name if tt else "\u2014",
"state": state,
"checkin-url": checkin_url,
"checked-in-time": checked_in_at.strftime("%H:%M") if checked_in_at else None,
})
action_html = sx_call("events-entry-tickets-admin-checkin",
checkin_url=checkin_url, code=code, csrf=csrf)
elif state == "checked_in":
t_str = checked_in_at.strftime("%H:%M") if checked_in_at else ""
action_html = sx_call("events-ticket-admin-checked-in",
time_str=t_str)
return sx_call("events-entry-tickets-admin-from-data",
rows_html += sx_call("events-entry-tickets-admin-row",
code=code, code_short=code[:12] + "...",
type_name=tt.name if tt else "\u2014",
badge=_ticket_state_badge_html(state),
action=SxExpr(action_html))
if tickets:
body_html = sx_call("events-entry-tickets-admin-table",
rows=SxExpr(rows_html))
else:
body_html = sx_call("events-entry-tickets-admin-empty")
return sx_call("events-entry-tickets-admin-panel",
entry_name=entry.name,
count_label=f"{count} ticket{suffix}",
tickets=ticket_data or None,
csrf=csrf)
body=body_html)
# ---------------------------------------------------------------------------
@@ -290,7 +383,7 @@ def render_ticket_type_main_panel(ticket_type, entry, calendar, day, month, year
# ---------------------------------------------------------------------------
def render_ticket_types_table(ticket_types, entry, calendar, day, month, year) -> str:
"""Render ticket types table via data extraction + sx defcomp."""
"""Render ticket types table with rows and add button."""
from quart import url_for, g
from shared.browser.app.csrf import generate_csrf_token
csrf = generate_csrf_token()
@@ -304,38 +397,40 @@ def render_ticket_types_table(ticket_types, entry, calendar, day, month, year) -
cal_slug = getattr(calendar, "slug", "")
eid = entry.id
types_data = []
rows_html = ""
if ticket_types:
for tt in ticket_types:
tt_href = url_for(
"calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get",
calendar_slug=cal_slug, year=year, month=month, day=day,
entry_id=eid, ticket_type_id=tt.id,
)
del_url = url_for(
"calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.delete",
calendar_slug=cal_slug, year=year, month=month, day=day,
entry_id=eid, ticket_type_id=tt.id,
)
cost = getattr(tt, "cost", None)
types_data.append({
"tt-href": url_for(
"calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get",
calendar_slug=cal_slug, year=year, month=month, day=day,
entry_id=eid, ticket_type_id=tt.id,
),
"tt-name": tt.name,
"cost-str": f"\u00a3{cost:.2f}" if cost is not None else "\u00a30.00",
"count": str(tt.count),
"del-url": url_for(
"calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.delete",
calendar_slug=cal_slug, year=year, month=month, day=day,
entry_id=eid, ticket_type_id=tt.id,
),
})
cost_str = f"\u00a3{cost:.2f}" if cost is not None else "\u00a30.00"
rows_html += sx_call("events-ticket-types-row",
tr_cls=tr_cls, tt_href=tt_href,
pill_cls=pill_cls, hx_select=hx_select,
tt_name=tt.name, cost_str=cost_str,
count=str(tt.count), action_btn=action_btn,
del_url=del_url,
csrf_hdr={"X-CSRFToken": csrf})
else:
rows_html = sx_call("events-ticket-types-empty-row")
add_url = url_for(
"calendar.day.calendar_entries.calendar_entry.ticket_types.add_form",
calendar_slug=cal_slug, entry_id=eid, year=year, month=month, day=day,
)
return sx_call("events-ticket-types-table-from-data",
list_container=list_container,
ticket_types=types_data or None,
action_btn=action_btn, add_url=add_url,
tr_cls=tr_cls, pill_cls=pill_cls,
hx_select=hx_select,
csrf_hdr=f'{{"X-CSRFToken": "{csrf}"}}')
return sx_call("events-ticket-types-table",
list_container=list_container, rows=SxExpr(rows_html),
action_btn=action_btn, add_url=add_url)
# ---------------------------------------------------------------------------
@@ -343,22 +438,34 @@ def render_ticket_types_table(ticket_types, entry, calendar, day, month, year) -
# ---------------------------------------------------------------------------
def render_buy_result(entry, created_tickets, remaining, cart_count) -> str:
"""Render buy result card with OOB cart icon — single response component."""
"""Render buy result card with created tickets + OOB cart icon."""
from quart import url_for
tickets = [
{"href": url_for("defpage_ticket_detail", code=t.code),
"code_short": t.code[:12] + "..."}
for t in created_tickets
]
cart_ctx = _cart_icon_ctx(cart_count)
cart_html = _cart_icon_oob(cart_count)
return sx_call("events-buy-response",
entry_id=str(entry.id),
tickets=tickets,
remaining=remaining,
my_tickets_href=url_for("defpage_my_tickets"),
**cart_ctx)
count = len(created_tickets)
suffix = "s" if count != 1 else ""
tickets_html = ""
for ticket in created_tickets:
href = url_for("defpage_ticket_detail", code=ticket.code)
tickets_html += sx_call("events-buy-result-ticket",
href=href, code_short=ticket.code[:12] + "...")
remaining_html = ""
if remaining is not None:
r_suffix = "s" if remaining != 1 else ""
remaining_html = sx_call("events-buy-result-remaining",
text=f"{remaining} ticket{r_suffix} remaining")
my_href = url_for("defpage_my_tickets")
return cart_html + sx_call("events-buy-result",
entry_id=str(entry.id),
count_label=f"{count} ticket{suffix} reserved",
tickets=SxExpr(tickets_html),
remaining=SxExpr(remaining_html),
my_tickets_href=my_href)
# ---------------------------------------------------------------------------
@@ -367,41 +474,90 @@ def render_buy_result(entry, created_tickets, remaining, cart_count) -> str:
def render_buy_form(entry, ticket_remaining, ticket_sold_count,
user_ticket_count, user_ticket_counts_by_type) -> str:
"""Render the ticket buy/adjust form — data only, .sx does layout."""
"""Render the ticket buy/adjust form with +/- controls."""
from quart import url_for
from shared.browser.app.csrf import generate_csrf_token
csrf = generate_csrf_token()
eid = entry.id
eid_s = str(eid)
tp = getattr(entry, "ticket_price", None)
state = getattr(entry, "state", "")
ticket_types = getattr(entry, "ticket_types", None) or []
if tp is None:
return ""
ticket_types_orm = getattr(entry, "ticket_types", None) or []
active_types = [tt for tt in ticket_types_orm if getattr(tt, "deleted_at", None) is None]
if state != "confirmed":
return sx_call("events-buy-not-confirmed", entry_id=eid_s)
types_data = [
{"id": tt.id, "name": tt.name,
"cost_str": f"\u00a3{tt.cost:.2f}" if tt.cost is not None else "\u00a30.00"}
for tt in active_types
]
adjust_url = url_for("tickets.adjust_quantity")
target = f"#ticket-buy-{eid}"
# String keys so .sx can look up via (get counts (str id))
counts_by_type = {}
if user_ticket_counts_by_type:
counts_by_type = {str(k): v for k, v in user_ticket_counts_by_type.items()}
# Info line
info_html = ""
info_items = ""
if ticket_sold_count:
info_items += sx_call("events-buy-info-sold",
count=str(ticket_sold_count))
if ticket_remaining is not None:
info_items += sx_call("events-buy-info-remaining",
count=str(ticket_remaining))
if user_ticket_count:
info_items += sx_call("events-buy-info-basket",
count=str(user_ticket_count))
if info_items:
info_html = sx_call("events-buy-info-bar", items=SxExpr(info_items))
return sx_call("events-buy-form",
entry_id=entry.id,
state=getattr(entry, "state", ""),
price_str=f"\u00a3{tp:.2f}",
adjust_url=url_for("tickets.adjust_quantity"),
csrf=generate_csrf_token(),
my_tickets_href=url_for("defpage_my_tickets"),
info_sold=ticket_sold_count or None,
info_remaining=ticket_remaining,
info_basket=user_ticket_count or None,
ticket_types=types_data if types_data else None,
user_ticket_counts_by_type=counts_by_type if counts_by_type else None,
user_ticket_count=user_ticket_count or 0)
active_types = [tt for tt in ticket_types if getattr(tt, "deleted_at", None) is None]
body_html = ""
if active_types:
type_items = ""
for tt in active_types:
type_count = user_ticket_counts_by_type.get(tt.id, 0) if user_ticket_counts_by_type else 0
cost_str = f"\u00a3{tt.cost:.2f}" if tt.cost is not None else "\u00a30.00"
type_items += sx_call("events-buy-type-item",
type_name=tt.name, cost_str=cost_str,
adjust_controls=_ticket_adjust_controls(csrf, adjust_url, target, eid, type_count, ticket_type_id=tt.id))
body_html = sx_call("events-buy-types-wrapper", items=SxExpr(type_items))
else:
qty = user_ticket_count or 0
body_html = sx_call("events-buy-default",
price_str=f"\u00a3{tp:.2f}",
adjust_controls=_ticket_adjust_controls(csrf, adjust_url, target, eid, qty))
return sx_call("events-buy-panel",
entry_id=eid_s, info=SxExpr(info_html), body=body_html)
def _ticket_adjust_controls(csrf, adjust_url, target, entry_id, count, *, ticket_type_id=None):
"""Render +/- ticket controls for buy form."""
from quart import url_for
tt_html = sx_call("events-adjust-tt-hidden",
ticket_type_id=str(ticket_type_id)) if ticket_type_id else ""
eid_s = str(entry_id)
def _adj_form(count_val, btn_html, *, extra_cls=""):
return sx_call("events-adjust-form",
adjust_url=adjust_url, target=target,
extra_cls=extra_cls, csrf=csrf,
entry_id=eid_s, tt=tt_html or None,
count_val=str(count_val), btn=btn_html)
if count == 0:
return _adj_form(1, sx_call("events-adjust-cart-plus"),
extra_cls="flex items-center")
my_tickets_href = url_for("defpage_my_tickets")
minus = _adj_form(count - 1, sx_call("events-adjust-minus"))
cart_icon = sx_call("events-adjust-cart-icon",
href=my_tickets_href, count=str(count))
plus = _adj_form(count + 1, sx_call("events-adjust-plus"))
return sx_call("events-adjust-controls",
minus=minus, cart_icon=cart_icon, plus=plus)
# ---------------------------------------------------------------------------
@@ -411,44 +567,13 @@ def render_buy_form(entry, ticket_remaining, ticket_sold_count,
def render_adjust_response(entry, ticket_remaining, ticket_sold_count,
user_ticket_count, user_ticket_counts_by_type,
cart_count) -> str:
"""Render ticket adjust response — single response component with OOB cart."""
from quart import url_for
from shared.browser.app.csrf import generate_csrf_token
tp = getattr(entry, "ticket_price", None)
if tp is None:
return ""
ticket_types_orm = getattr(entry, "ticket_types", None) or []
active_types = [tt for tt in ticket_types_orm if getattr(tt, "deleted_at", None) is None]
types_data = [
{"id": tt.id, "name": tt.name,
"cost_str": f"\u00a3{tt.cost:.2f}" if tt.cost is not None else "\u00a30.00"}
for tt in active_types
]
# String keys so .sx can look up via (get counts (str id))
counts_by_type = {}
if user_ticket_counts_by_type:
counts_by_type = {str(k): v for k, v in user_ticket_counts_by_type.items()}
cart_ctx = _cart_icon_ctx(cart_count)
return sx_call("events-adjust-response",
entry_id=entry.id,
state=getattr(entry, "state", ""),
price_str=f"\u00a3{tp:.2f}",
adjust_url=url_for("tickets.adjust_quantity"),
csrf=generate_csrf_token(),
my_tickets_href=url_for("defpage_my_tickets"),
info_sold=ticket_sold_count or None,
info_remaining=ticket_remaining,
info_basket=user_ticket_count or None,
ticket_types=types_data if types_data else None,
user_ticket_counts_by_type=counts_by_type if counts_by_type else None,
user_ticket_count=user_ticket_count or 0,
**cart_ctx)
"""Render ticket adjust response: OOB cart icon + buy form."""
cart_html = _cart_icon_oob(cart_count)
form_html = render_buy_form(
entry, ticket_remaining, ticket_sold_count,
user_ticket_count, user_ticket_counts_by_type,
)
return cart_html + form_html
# ---------------------------------------------------------------------------
@@ -574,7 +699,7 @@ def render_ticket_type_add_form(entry, calendar, day, month, year) -> str:
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}"}}'
csrf_hdr = {"X-CSRFToken": csrf}
return sx_call("events-ticket-type-add-form",
post_url=post_url, csrf=csrf_hdr,