Remove last Jinja fragment templates, use sx_components directly

Events fragment routes now call render_fragment_container_cards(),
render_fragment_account_tickets(), and render_fragment_account_bookings()
from sx_components instead of render_template(). Account sx_components
handles both SxExpr (text/sx) and HTML (text/html) fragment responses.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 01:07:02 +00:00
parent 5578923242
commit 8445c36270
6 changed files with 51 additions and 170 deletions

View File

@@ -294,26 +294,49 @@ async def render_newsletters_oob(ctx: dict, newsletter_list: list) -> str:
# Public API: Fragment pages
# ---------------------------------------------------------------------------
async def render_fragment_page(ctx: dict, page_fragment_html: str) -> str:
"""Full page: fragment-provided content."""
async def render_fragment_page(ctx: dict, page_fragment: str) -> str:
"""Full page: fragment-provided content.
*page_fragment* may be sx source (from text/sx fragments wrapped in
SxExpr) or HTML (from text/html fragments). Sx source is embedded
directly; HTML is wrapped in ``~rich-text``.
"""
from shared.sx.parser import SxExpr
hdr = root_header_sx(ctx)
hdr_child = header_child_sx(_auth_header_sx(ctx))
header_rows = "(<> " + hdr + " " + hdr_child + ")"
content = _fragment_content(page_fragment)
return full_page_sx(ctx, header_rows=header_rows,
content=f'(~rich-text :html "{_sx_escape(page_fragment_html)}")',
content=content,
menu=_auth_nav_mobile_sx(ctx))
async def render_fragment_oob(ctx: dict, page_fragment_html: str) -> str:
async def render_fragment_oob(ctx: dict, page_fragment: str) -> str:
"""OOB response for fragment pages."""
oobs = "(<> " + _auth_header_sx(ctx, oob=True) + " " + root_header_sx(ctx, oob=True) + ")"
content = _fragment_content(page_fragment)
return oob_page_sx(oobs=oobs,
content=f'(~rich-text :html "{_sx_escape(page_fragment_html)}")',
content=content,
menu=_auth_nav_mobile_sx(ctx))
def _fragment_content(frag: object) -> str:
"""Convert a fragment response to sx content string.
SxExpr (from text/sx responses) is embedded as-is; plain strings
(from text/html) are wrapped in ``~rich-text``.
"""
from shared.sx.parser import SxExpr
if isinstance(frag, SxExpr):
return frag.source
s = str(frag) if frag else ""
if not s:
return ""
return f'(~rich-text :html "{_sx_escape(s)}")'
# ---------------------------------------------------------------------------
# Public API: Auth pages (login, device)
# ---------------------------------------------------------------------------

View File

@@ -3,16 +3,17 @@
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
by other coop apps via the fragment client.
Most handlers are defined declaratively in .sx files under
All handlers are defined declaratively in .sx files under
``events/sx/handlers/`` and dispatched via the sx handler registry.
Jinja HTML handlers (container-cards, account-page) remain as Python
because they return ``text/html`` templates, not sx source.
container-cards and account-page remain as Python handlers because they
call domain service methods and return batched/conditional content, but
they use sx_call() for rendering (no Jinja templates).
"""
from __future__ import annotations
from quart import Blueprint, Response, g, render_template, request
from quart import Blueprint, Response, g, request
from shared.infrastructure.fragments import FRAGMENT_HEADER
from shared.services.registry import services
@@ -24,8 +25,8 @@ def register():
_handlers: dict[str, object] = {}
# Fragment types that return HTML (Jinja templates)
_html_types = {"container-cards", "account-page"}
# Fragment types that return HTML (comment-delimited batch)
_html_types = {"container-cards"}
@bp.before_request
async def _require_fragment_header():
@@ -34,7 +35,7 @@ def register():
@bp.get("/<fragment_type>")
async def get_fragment(fragment_type: str):
# 1. Check Python handlers first (Jinja HTML types)
# 1. Check Python handlers first
handler = _handlers.get(fragment_type)
if handler is not None:
result = await handler()
@@ -51,9 +52,13 @@ def register():
return Response("", status=200, content_type="text/sx")
# --- container-cards fragment: entries for blog listing cards (Jinja HTML) --
# --- container-cards fragment: entries for blog listing cards -----------
# Returns text/html with <!-- card-widget:POST_ID --> comment markers
# so the blog consumer can split per-post fragments.
async def _container_cards_handler():
from sx.sx_components import render_fragment_container_cards
post_ids_raw = request.args.get("post_ids", "")
post_slugs_raw = request.args.get("post_slugs", "")
post_ids = [int(x) for x in post_ids_raw.split(",") if x.strip()]
@@ -66,16 +71,19 @@ def register():
slug_map[pid] = post_slugs[i] if i < len(post_slugs) else ""
batch = await services.calendar.confirmed_entries_for_posts(g.s, post_ids)
return await render_template(
"fragments/container_cards_entries.html",
batch=batch, post_ids=post_ids, slug_map=slug_map,
)
return render_fragment_container_cards(batch, post_ids, slug_map)
_handlers["container-cards"] = _container_cards_handler
# --- account-page fragment: tickets or bookings panel (Jinja HTML) ------
# --- account-page fragment: tickets or bookings panel ------------------
# Returns text/sx — the account app embeds this as sx source.
async def _account_page_handler():
from sx.sx_components import (
render_fragment_account_tickets,
render_fragment_account_bookings,
)
slug = request.args.get("slug", "")
user_id = request.args.get("user_id", type=int)
if not user_id:
@@ -83,16 +91,10 @@ def register():
if slug == "tickets":
tickets = await services.calendar.user_tickets(g.s, user_id=user_id)
return await render_template(
"fragments/account_page_tickets.html",
tickets=tickets,
)
return render_fragment_account_tickets(tickets)
elif slug == "bookings":
bookings = await services.calendar.user_bookings(g.s, user_id=user_id)
return await render_template(
"fragments/account_page_bookings.html",
bookings=bookings,
)
return render_fragment_account_bookings(bookings)
return ""
_handlers["account-page"] = _account_page_handler

View File

@@ -1,23 +0,0 @@
{# Account nav items: tickets + bookings links for the account dashboard #}
<div class="relative nav-group">
<a href="{{ account_url('/tickets/') }}"
sx-get="{{ account_url('/tickets/') }}"
sx-target="#main-panel"
sx-select="{{ hx_select_search }}"
sx-swap="outerHTML"
sx-push-url="true"
class="{{styles.nav_button}}">
tickets
</a>
</div>
<div class="relative nav-group">
<a href="{{ account_url('/bookings/') }}"
sx-get="{{ account_url('/bookings/') }}"
sx-target="#main-panel"
sx-select="{{ hx_select_search }}"
sx-swap="outerHTML"
sx-push-url="true"
class="{{styles.nav_button}}">
bookings
</a>
</div>

View File

@@ -1,44 +0,0 @@
<div class="w-full max-w-3xl mx-auto px-4 py-6">
<div class="bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-6">
<h1 class="text-xl font-semibold tracking-tight">Bookings</h1>
{% if bookings %}
<div class="divide-y divide-stone-100">
{% for booking in bookings %}
<div class="py-4 first:pt-0 last:pb-0">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-stone-800">{{ booking.name }}</p>
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-stone-500">
<span>{{ booking.start_at.strftime('%d %b %Y, %H:%M') }}</span>
{% if booking.end_at %}
<span>&ndash; {{ booking.end_at.strftime('%H:%M') }}</span>
{% endif %}
{% if booking.calendar_name %}
<span>&middot; {{ booking.calendar_name }}</span>
{% endif %}
{% if booking.cost %}
<span>&middot; &pound;{{ booking.cost }}</span>
{% endif %}
</div>
</div>
<div class="flex-shrink-0">
{% if booking.state == 'confirmed' %}
<span class="inline-flex items-center rounded-full bg-emerald-50 border border-emerald-200 px-2.5 py-0.5 text-xs font-medium text-emerald-700">confirmed</span>
{% elif booking.state == 'provisional' %}
<span class="inline-flex items-center rounded-full bg-amber-50 border border-amber-200 px-2.5 py-0.5 text-xs font-medium text-amber-700">provisional</span>
{% else %}
<span class="inline-flex items-center rounded-full bg-stone-50 border border-stone-200 px-2.5 py-0.5 text-xs font-medium text-stone-600">{{ booking.state }}</span>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-sm text-stone-500">No bookings yet.</p>
{% endif %}
</div>
</div>

View File

@@ -1,44 +0,0 @@
<div class="w-full max-w-3xl mx-auto px-4 py-6">
<div class="bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-6">
<h1 class="text-xl font-semibold tracking-tight">Tickets</h1>
{% if tickets %}
<div class="divide-y divide-stone-100">
{% for ticket in tickets %}
<div class="py-4 first:pt-0 last:pb-0">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<a href="{{ events_url('/tickets/' ~ ticket.code ~ '/') }}"
class="text-sm font-medium text-stone-800 hover:text-emerald-700 transition">
{{ ticket.entry_name }}
</a>
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-stone-500">
<span>{{ ticket.entry_start_at.strftime('%d %b %Y, %H:%M') }}</span>
{% if ticket.calendar_name %}
<span>&middot; {{ ticket.calendar_name }}</span>
{% endif %}
{% if ticket.ticket_type_name %}
<span>&middot; {{ ticket.ticket_type_name }}</span>
{% endif %}
</div>
</div>
<div class="flex-shrink-0">
{% if ticket.state == 'checked_in' %}
<span class="inline-flex items-center rounded-full bg-blue-50 border border-blue-200 px-2.5 py-0.5 text-xs font-medium text-blue-700">checked in</span>
{% elif ticket.state == 'confirmed' %}
<span class="inline-flex items-center rounded-full bg-emerald-50 border border-emerald-200 px-2.5 py-0.5 text-xs font-medium text-emerald-700">confirmed</span>
{% else %}
<span class="inline-flex items-center rounded-full bg-amber-50 border border-amber-200 px-2.5 py-0.5 text-xs font-medium text-amber-700">{{ ticket.state }}</span>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-sm text-stone-500">No tickets yet.</p>
{% endif %}
</div>
</div>

View File

@@ -1,33 +0,0 @@
{# Calendar entries for blog listing cards — served as fragment from events app.
Each post's entries are delimited by comment markers so the consumer can
extract per-post HTML via simple string splitting. #}
{% for post_id in post_ids %}
<!-- card-widget:{{ post_id }} -->
{% set widget_entries = batch.get(post_id, []) %}
{% if widget_entries %}
<div class="mt-4 mb-2">
<h3 class="text-sm font-semibold text-stone-700 mb-2 px-2">Events:</h3>
<div class="overflow-x-auto scrollbar-hide" style="scroll-behavior: smooth;">
<div class="flex gap-2 px-2">
{% for entry in widget_entries %}
{% set _post_slug = slug_map.get(post_id, '') %}
{% set _entry_path = '/' + _post_slug + '/' + entry.calendar_slug + '/' + entry.start_at.year|string + '/' + entry.start_at.month|string + '/' + entry.start_at.day|string + '/entries/' + entry.id|string + '/' %}
<a
href="{{ events_url(_entry_path) }}"
class="flex flex-col gap-1 px-3 py-2 bg-stone-50 hover:bg-stone-100 rounded border border-stone-200 transition text-sm whitespace-nowrap flex-shrink-0 min-w-[180px]">
<div class="font-medium text-stone-900 truncate">{{ entry.name }}</div>
<div class="text-xs text-stone-600">
{{ entry.start_at.strftime('%a, %b %d') }}
</div>
<div class="text-xs text-stone-500">
{{ entry.start_at.strftime('%H:%M') }}
{% if entry.end_at %} {{ entry.end_at.strftime('%H:%M') }}{% endif %}
</div>
</a>
{% endfor %}
</div>
</div>
</div>
{% endif %}
<!-- /card-widget:{{ post_id }} -->
{% endfor %}