Move 24 defcomp definitions from Python string constants in components.py to 7 grouped .sexp files under shared/sexp/templates/. Add load_sexp_dir() to jinja_bridge.py for file-based loading. Migrate events and market link-card fragment handlers from render_template to sexp. Delete 9 superseded Jinja HTML fragment templates. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
226 lines
9.2 KiB
Python
226 lines
9.2 KiB
Python
"""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 sexp as render_sexp
|
||
|
||
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}/calendars/{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_sexp(
|
||
'(~calendar-entry-nav :href href :name name :date-str date-str :nav-class nav-class)',
|
||
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(
|
||
f'<div id="entries-load-sentinel-{page}"'
|
||
f' hx-get="{paginate_url_base}?page={page + 1}"'
|
||
f' hx-trigger="intersect once"'
|
||
f' hx-swap="beforebegin"'
|
||
f' _="on htmx:afterRequest trigger scroll on #associated-entries-container"'
|
||
f' class="flex-shrink-0 w-1"></div>'
|
||
)
|
||
|
||
# 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}/calendars/{cal.slug}/")
|
||
html_parts.append(render_sexp(
|
||
'(~calendar-link-nav :href href :name name :nav-class nav-class)',
|
||
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
|
||
|
||
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(
|
||
f'<div class="relative nav-group">'
|
||
f'<a href="{href}" hx-get="{href}" hx-target="#main-panel"'
|
||
f' hx-select="{hx_select}" hx-swap="outerHTML"'
|
||
f' hx-push-url="true" class="{nav_class}">{label}</a></div>'
|
||
)
|
||
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 sexp as render_sexp
|
||
|
||
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_sexp(
|
||
'(~link-card :title title :image image :subtitle subtitle :link link)',
|
||
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_sexp(
|
||
'(~link-card :title title :image image :subtitle subtitle :link link)',
|
||
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
|