Files
rose-ash/events/bp/fragments/routes.py
giles f9d9697c67
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m20s
Externalize sexp to .sexpr files + render() API
Replace all 676 inline sexp() string calls across 7 services with
render(component_name, **kwargs) calls backed by 46 external .sexpr
component definition files (587 defcomps total).

- Add render() function to shared/sexp/jinja_bridge.py
- Add load_service_components() helper and update load_sexp_dir() for *.sexpr
- Update parser keyword regex to support HTMX hx-on::event syntax
- Convert remaining inline HTML in route files to render() calls
- Add shared/sexp/templates/misc.sexp for cross-service utility components

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 16:14:58 +00:00

225 lines
8.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Events app fragment endpoints.
Exposes HTML fragments at ``/internal/fragments/<type>`` for consumption
by other coop apps via the fragment client.
"""
from __future__ import annotations
from quart import Blueprint, Response, g, render_template, request
from shared.infrastructure.fragments import FRAGMENT_HEADER
from shared.infrastructure.data_client import fetch_data
from shared.contracts.dtos import PostDTO, dto_from_dict
from shared.services.registry import services
def register():
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
_handlers: dict[str, object] = {}
@bp.before_request
async def _require_fragment_header():
if not request.headers.get(FRAGMENT_HEADER):
return Response("", status=403)
@bp.get("/<fragment_type>")
async def get_fragment(fragment_type: str):
handler = _handlers.get(fragment_type)
if handler is None:
return Response("", status=200, content_type="text/html")
html = await handler()
return Response(html, status=200, content_type="text/html")
# --- container-nav fragment: calendar entries + calendar links -----------
async def _container_nav_handler():
from quart import current_app
from shared.infrastructure.urls import events_url
from shared.sexp.jinja_bridge import render as render_comp
container_type = request.args.get("container_type", "page")
container_id = int(request.args.get("container_id", 0))
post_slug = request.args.get("post_slug", "")
paginate_url_base = request.args.get("paginate_url", "")
page = int(request.args.get("page", 1))
exclude = request.args.get("exclude", "")
excludes = [e.strip() for e in exclude.split(",") if e.strip()]
styles = current_app.jinja_env.globals.get("styles", {})
nav_class = styles.get("nav_button_less_pad", "")
html_parts = []
# Calendar entries nav
if not any(e.startswith("calendar") for e in excludes):
entries, has_more = await services.calendar.associated_entries(
g.s, container_type, container_id, page,
)
for entry in entries:
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}/"
)
date_str = entry.start_at.strftime("%b %d, %Y at %H:%M")
if entry.end_at:
date_str += f" {entry.end_at.strftime('%H:%M')}"
html_parts.append(render_comp(
"calendar-entry-nav",
href=events_url(entry_path), name=entry.name,
date_str=date_str, nav_class=nav_class,
))
# Infinite scroll sentinel (kept as raw HTML — HTMX-specific)
if has_more and paginate_url_base:
html_parts.append(render_comp(
"htmx-sentinel",
id=f"entries-load-sentinel-{page}",
hx_get=f"{paginate_url_base}?page={page + 1}",
hx_trigger="intersect once",
hx_swap="beforebegin",
**{"class": "flex-shrink-0 w-1"},
))
# Calendar links nav
if not any(e.startswith("calendar") for e in excludes):
calendars = await services.calendar.calendars_for_container(
g.s, container_type, container_id,
)
for cal in calendars:
href = events_url(f"/{post_slug}/{cal.slug}/")
html_parts.append(render_comp(
"calendar-link-nav",
href=href, name=cal.name, nav_class=nav_class,
))
return "\n".join(html_parts)
_handlers["container-nav"] = _container_nav_handler
# --- container-cards fragment: entries for blog listing cards ------------
async def _container_cards_handler():
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()]
post_slugs = [x.strip() for x in post_slugs_raw.split(",") if x.strip()]
if not post_ids:
return ""
# Build post_id -> slug mapping
slug_map = {}
for i, pid in enumerate(post_ids):
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,
)
_handlers["container-cards"] = _container_cards_handler
# --- account-nav-item fragment: tickets + bookings links for account nav -
async def _account_nav_item_handler():
from quart import current_app
from shared.infrastructure.urls import account_url
from shared.sexp.jinja_bridge import render as render_comp
styles = current_app.jinja_env.globals.get("styles", {})
nav_class = styles.get("nav_button", "")
hx_select = (
"#main-panel, #search-mobile, #search-count-mobile,"
" #search-desktop, #search-count-desktop, #menu-items-nav-wrapper"
)
tickets_url = account_url("/tickets/")
bookings_url = account_url("/bookings/")
# These two links use HTMX navigation — kept as raw HTML for the
# hx-* attributes that don't map neatly to a reusable component.
parts = []
for href, label in [(tickets_url, "tickets"), (bookings_url, "bookings")]:
parts.append(render_comp(
"nav-group-link",
href=href, hx_select=hx_select, nav_class=nav_class, label=label,
))
return "\n".join(parts)
_handlers["account-nav-item"] = _account_nav_item_handler
# --- account-page fragment: tickets or bookings panel --------------------
async def _account_page_handler():
slug = request.args.get("slug", "")
user_id = request.args.get("user_id", type=int)
if not user_id:
return ""
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,
)
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 ""
_handlers["account-page"] = _account_page_handler
# --- link-card fragment: event page preview card ----------------------------
async def _link_card_handler():
from shared.infrastructure.urls import events_url
from shared.sexp.jinja_bridge import render as render_comp
slug = request.args.get("slug", "")
keys_raw = request.args.get("keys", "")
# Batch mode
if keys_raw:
slugs = [k.strip() for k in keys_raw.split(",") if k.strip()]
parts = []
for s in slugs:
parts.append(f"<!-- fragment:{s} -->")
raw = await fetch_data("blog", "post-by-slug", params={"slug": s}, required=False)
post = dto_from_dict(PostDTO, raw) if raw else None
if post:
calendars = await services.calendar.calendars_for_container(
g.s, "page", post.id,
)
cal_names = ", ".join(c.name for c in calendars) if calendars else ""
parts.append(render_comp(
"link-card",
title=post.title, image=post.feature_image,
subtitle=cal_names, link=events_url(f"/{post.slug}"),
))
return "\n".join(parts)
# Single mode
if not slug:
return ""
raw = await fetch_data("blog", "post-by-slug", params={"slug": slug}, required=False)
post = dto_from_dict(PostDTO, raw) if raw else None
if not post:
return ""
calendars = await services.calendar.calendars_for_container(
g.s, "page", post.id,
)
cal_names = ", ".join(c.name for c in calendars) if calendars else ""
return render_comp(
"link-card",
title=post.title, image=post.feature_image,
subtitle=cal_names, link=events_url(f"/{post.slug}"),
)
_handlers["link-card"] = _link_card_handler
bp._fragment_handlers = _handlers
return bp