All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 5m35s
- Server sends sexp source text, client (sexp.js) renders everything - SexpExpr marker class for nested sexp composition in serialize() - sexp_page() HTML shell with data-mount="body" for full page loads - sexp_response() returns text/sexp for OOB/partial responses - ~app-body layout component replaces ~app-layout (no raw!) - ~rich-text is the only component using raw! (for CMS HTML content) - Fragment endpoints return text/sexp, auto-wrapped in SexpExpr - All _*_html() helpers converted to _*_sexp() returning sexp source - Head auto-hoist: sexp.js moves meta/title/link/script[ld+json] from rendered body to document.head automatically - Unknown components render warning box instead of crashing page - Component kwargs preserve AST for lazy rendering (fixes <> in kwargs) - Fix unterminated paren in events/sexp/tickets.sexpr Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
217 lines
8.5 KiB
Python
217 lines
8.5 KiB
Python
"""Events app fragment endpoints.
|
||
|
||
Exposes sexp 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] = {}
|
||
|
||
# Fragment types that still return HTML (Jinja templates)
|
||
_html_types = {"container-cards", "account-page"}
|
||
|
||
@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/sexp")
|
||
result = await handler()
|
||
ct = "text/html" if fragment_type in _html_types else "text/sexp"
|
||
return Response(result, status=200, content_type=ct)
|
||
|
||
# --- 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.helpers import sexp_call
|
||
|
||
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", "")
|
||
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')}"
|
||
parts.append(sexp_call("calendar-entry-nav",
|
||
href=events_url(entry_path), name=entry.name,
|
||
date_str=date_str, nav_class=nav_class))
|
||
if has_more and paginate_url_base:
|
||
parts.append(sexp_call("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}/")
|
||
parts.append(sexp_call("calendar-link-nav",
|
||
href=href, name=cal.name, nav_class=nav_class))
|
||
|
||
if not parts:
|
||
return ""
|
||
return "(<> " + " ".join(parts) + ")"
|
||
|
||
_handlers["container-nav"] = _container_nav_handler
|
||
|
||
# --- container-cards fragment: entries for blog listing cards (still Jinja) --
|
||
|
||
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 ""
|
||
|
||
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 -----------------
|
||
|
||
async def _account_nav_item_handler():
|
||
from quart import current_app
|
||
from shared.infrastructure.urls import account_url
|
||
from shared.sexp.helpers import sexp_call
|
||
|
||
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/")
|
||
parts = []
|
||
for href, label in [(tickets_url, "tickets"), (bookings_url, "bookings")]:
|
||
parts.append(sexp_call("nav-group-link",
|
||
href=href, hx_select=hx_select, nav_class=nav_class, label=label))
|
||
return "(<> " + " ".join(parts) + ")"
|
||
|
||
_handlers["account-nav-item"] = _account_nav_item_handler
|
||
|
||
# --- account-page fragment: tickets or bookings panel (still Jinja) ------
|
||
|
||
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.helpers import sexp_call
|
||
|
||
slug = request.args.get("slug", "")
|
||
keys_raw = request.args.get("keys", "")
|
||
|
||
def _event_link_card_sexp(post, cal_names: str) -> str:
|
||
return sexp_call("link-card",
|
||
title=post.title, image=post.feature_image,
|
||
subtitle=cal_names,
|
||
link=events_url(f"/{post.slug}"))
|
||
|
||
# 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(_event_link_card_sexp(post, cal_names))
|
||
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 _event_link_card_sexp(post, cal_names)
|
||
|
||
_handlers["link-card"] = _link_card_handler
|
||
|
||
bp._fragment_handlers = _handlers
|
||
|
||
return bp
|