Phase 7: Replace render_template() with s-expression rendering in all POST/PUT/DELETE routes

Eliminates all render_template() calls from POST/PUT/DELETE handlers across
all 7 services. Moves sexp_components.py into sexp/ packages per service.

- Blog: like toggle, snippets, cache clear, features/sumup/entry panels,
  create/delete market, WYSIWYG editor panel (render_editor_panel)
- Federation: like/unlike/boost/unboost, follow/unfollow, actor card,
  interaction buttons
- Events: ticket widget, checkin, confirm/decline/provisional, tickets
  config, posts CRUD, description edit/save, calendar/slot/ticket_type
  CRUD, payments, buy tickets, day main panel, entry page
- Market: like toggle, cart add response
- Account: newsletter toggle
- Cart: checkout error pages (3 handlers)
- Orders: checkout error page (1 handler)

Remaining render_template() calls are exclusively in GET handlers and
internal services (email templates, fragment endpoints).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 01:15:29 +00:00
parent e65232761b
commit 838ec982eb
64 changed files with 2920 additions and 545 deletions

View File

@@ -1,6 +1,6 @@
from __future__ import annotations
import path_setup # noqa: F401 # adds shared/ to sys.path
import sexp_components # noqa: F401 # ensure Hypercorn --reload watches this file
import sexp.sexp_components as sexp_components # noqa: F401 # ensure Hypercorn --reload watches this file
from pathlib import Path
from quart import g, request
@@ -96,7 +96,7 @@ def create_app() -> "Quart":
async def home():
from quart import make_response
from shared.sexp.page import get_template_context
from sexp_components import render_federation_home
from sexp.sexp_components import render_federation_home
ctx = await get_template_context()
html = await render_federation_home(ctx)

View File

@@ -11,7 +11,6 @@ from datetime import datetime, timezone, timedelta
from quart import (
Blueprint,
request,
render_template,
redirect,
url_for,
session as qsession,
@@ -101,7 +100,7 @@ def register(url_prefix="/auth"):
redirect_url = pop_login_redirect_target()
return redirect(redirect_url)
from shared.sexp.page import get_template_context
from sexp_components import render_login_page
from sexp.sexp_components import render_login_page
ctx = await get_template_context()
return await render_login_page(ctx)
@@ -112,14 +111,10 @@ def register(url_prefix="/auth"):
is_valid, email = validate_email(email_input)
if not is_valid:
return (
await render_template(
"auth/login.html",
error="Please enter a valid email address.",
email=email_input,
),
400,
)
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_login_page
ctx = await get_template_context(error="Please enter a valid email address.", email=email_input)
return await render_login_page(ctx), 400
user = await find_or_create_user(g.s, email)
token, expires = await create_magic_link(g.s, user.id)
@@ -137,11 +132,10 @@ def register(url_prefix="/auth"):
"Please try again in a moment."
)
return await render_template(
"auth/check_email.html",
email=email,
email_error=email_error,
)
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_check_email_page
ctx = await get_template_context(email=email, email_error=email_error)
return await render_check_email_page(ctx)
@auth_bp.get("/magic/<token>/")
async def magic(token: str):
@@ -154,20 +148,17 @@ def register(url_prefix="/auth"):
user, error = await validate_magic_link(s, token)
if error:
return (
await render_template("auth/login.html", error=error),
400,
)
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_login_page
ctx = await get_template_context(error=error)
return await render_login_page(ctx), 400
user_id = user.id
except Exception:
return (
await render_template(
"auth/login.html",
error="Could not sign you in right now. Please try again.",
),
502,
)
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_login_page
ctx = await get_template_context(error="Could not sign you in right now. Please try again.")
return await render_login_page(ctx), 502
assert user_id is not None

View File

@@ -8,7 +8,7 @@ from __future__ import annotations
import re
from quart import (
Blueprint, request, render_template, redirect, url_for, g, abort,
Blueprint, request, redirect, url_for, g, abort,
)
from shared.services.registry import services
@@ -40,7 +40,7 @@ def register(url_prefix="/identity"):
return redirect(url_for("activitypub.actor_profile", username=actor.preferred_username))
from shared.sexp.page import get_template_context
from sexp_components import render_choose_username_page
from sexp.sexp_components import render_choose_username_page
ctx = await get_template_context()
ctx["actor"] = actor
return await render_choose_username_page(ctx)
@@ -71,11 +71,11 @@ def register(url_prefix="/identity"):
error = "This username is already taken."
if error:
return await render_template(
"federation/choose_username.html",
error=error,
username=username,
), 400
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_choose_username_page
ctx = await get_template_context(error=error, username=username)
ctx["actor"] = None
return await render_choose_username_page(ctx), 400
# Create ActorProfile with RSA keys
display_name = g.user.name or username

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
import logging
from datetime import datetime
from quart import Blueprint, request, g, redirect, url_for, abort, render_template, Response
from quart import Blueprint, request, g, redirect, url_for, abort, Response
from shared.services.registry import services
@@ -40,7 +40,7 @@ def register(url_prefix="/social"):
actor = _require_actor()
items = await services.federation.get_home_timeline(g.s, actor.id)
from shared.sexp.page import get_template_context
from sexp_components import render_timeline_page
from sexp.sexp_components import render_timeline_page
ctx = await get_template_context()
return await render_timeline_page(ctx, items, "home", actor)
@@ -57,7 +57,7 @@ def register(url_prefix="/social"):
items = await services.federation.get_home_timeline(
g.s, actor.id, before=before,
)
from sexp_components import render_timeline_items
from sexp.sexp_components import render_timeline_items
return await render_timeline_items(items, "home", actor)
@bp.get("/public")
@@ -65,7 +65,7 @@ def register(url_prefix="/social"):
items = await services.federation.get_public_timeline(g.s)
actor = getattr(g, "_social_actor", None)
from shared.sexp.page import get_template_context
from sexp_components import render_timeline_page
from sexp.sexp_components import render_timeline_page
ctx = await get_template_context()
return await render_timeline_page(ctx, items, "public", actor)
@@ -80,7 +80,7 @@ def register(url_prefix="/social"):
pass
items = await services.federation.get_public_timeline(g.s, before=before)
actor = getattr(g, "_social_actor", None)
from sexp_components import render_timeline_items
from sexp.sexp_components import render_timeline_items
return await render_timeline_items(items, "public", actor)
# -- Compose --------------------------------------------------------------
@@ -90,7 +90,7 @@ def register(url_prefix="/social"):
actor = _require_actor()
reply_to = request.args.get("reply_to")
from shared.sexp.page import get_template_context
from sexp_components import render_compose_page
from sexp.sexp_components import render_compose_page
ctx = await get_template_context()
return await render_compose_page(ctx, actor, reply_to)
@@ -136,7 +136,7 @@ def register(url_prefix="/social"):
)
followed_urls = {a.actor_url for a in following}
from shared.sexp.page import get_template_context
from sexp_components import render_search_page
from sexp.sexp_components import render_search_page
ctx = await get_template_context()
return await render_search_page(ctx, query, actors, total, 1, followed_urls, actor)
@@ -157,7 +157,7 @@ def register(url_prefix="/social"):
g.s, actor.preferred_username, page=1, per_page=1000,
)
followed_urls = {a.actor_url for a in following}
from sexp_components import render_search_results
from sexp.sexp_components import render_search_results
return await render_search_results(actors, query, page, followed_urls, actor)
@bp.post("/follow")
@@ -200,15 +200,8 @@ def register(url_prefix="/social"):
list_type = "followers"
else:
list_type = "following"
return await render_template(
"federation/_actor_list_items.html",
actors=[remote_dto],
total=0,
page=1,
list_type=list_type,
followed_urls=followed_urls,
actor=actor,
)
from sexp.sexp_components import render_actor_card
return render_actor_card(remote_dto, actor, followed_urls, list_type=list_type)
# -- Interactions ---------------------------------------------------------
@@ -296,10 +289,10 @@ def register(url_prefix="/social"):
).limit(1)
)).scalar())
return await render_template(
"federation/_interaction_buttons.html",
item_object_id=object_id,
item_author_inbox=author_inbox,
from sexp.sexp_components import render_interaction_buttons
return render_interaction_buttons(
object_id=object_id,
author_inbox=author_inbox,
like_count=like_count,
boost_count=boost_count,
liked_by_me=liked_by_me,
@@ -316,7 +309,7 @@ def register(url_prefix="/social"):
g.s, actor.preferred_username,
)
from shared.sexp.page import get_template_context
from sexp_components import render_following_page
from sexp.sexp_components import render_following_page
ctx = await get_template_context()
return await render_following_page(ctx, actors, total, actor)
@@ -327,7 +320,7 @@ def register(url_prefix="/social"):
actors, total = await services.federation.get_following(
g.s, actor.preferred_username, page=page,
)
from sexp_components import render_following_items
from sexp.sexp_components import render_following_items
return await render_following_items(actors, page, actor)
@bp.get("/followers")
@@ -342,7 +335,7 @@ def register(url_prefix="/social"):
)
followed_urls = {a.actor_url for a in following}
from shared.sexp.page import get_template_context
from sexp_components import render_followers_page
from sexp.sexp_components import render_followers_page
ctx = await get_template_context()
return await render_followers_page(ctx, actors, total, followed_urls, actor)
@@ -357,7 +350,7 @@ def register(url_prefix="/social"):
g.s, actor.preferred_username, page=1, per_page=1000,
)
followed_urls = {a.actor_url for a in following}
from sexp_components import render_followers_items
from sexp.sexp_components import render_followers_items
return await render_followers_items(actors, page, followed_urls, actor)
@bp.get("/actor/<int:id>")
@@ -390,7 +383,7 @@ def register(url_prefix="/social"):
).scalar_one_or_none()
is_following = existing is not None
from shared.sexp.page import get_template_context
from sexp_components import render_actor_timeline_page
from sexp.sexp_components import render_actor_timeline_page
ctx = await get_template_context()
return await render_actor_timeline_page(ctx, remote_dto, items, is_following, actor)
@@ -407,7 +400,7 @@ def register(url_prefix="/social"):
items = await services.federation.get_actor_timeline(
g.s, id, before=before,
)
from sexp_components import render_actor_timeline_items
from sexp.sexp_components import render_actor_timeline_items
return await render_actor_timeline_items(items, id, actor)
# -- Notifications --------------------------------------------------------
@@ -418,7 +411,7 @@ def register(url_prefix="/social"):
items = await services.federation.get_notifications(g.s, actor.id)
await services.federation.mark_notifications_read(g.s, actor.id)
from shared.sexp.page import get_template_context
from sexp_components import render_notifications_page
from sexp.sexp_components import render_notifications_page
ctx = await get_template_context()
return await render_notifications_page(ctx, items, actor)

View File

View File

@@ -398,6 +398,30 @@ async def render_login_page(ctx: dict) -> str:
meta_html="<title>Login \u2014 Rose Ash</title>")
async def render_check_email_page(ctx: dict) -> str:
"""Full page: check email after magic link sent."""
email = ctx.get("email", "")
email_error = ctx.get("email_error")
error_html = ""
if email_error:
error_html = (
f'<div class="bg-yellow-50 border border-yellow-200 text-yellow-700 p-3 rounded mt-4">'
f'{escape(email_error)}</div>'
)
content = (
'<div class="py-8 max-w-md mx-auto text-center">'
'<h1 class="text-2xl font-bold mb-4">Check your email</h1>'
f'<p class="text-stone-600 mb-2">We sent a sign-in link to <strong>{escape(email)}</strong>.</p>'
'<p class="text-stone-500 text-sm">Click the link in the email to sign in. The link expires in 15 minutes.</p>'
f'{error_html}</div>'
)
hdr = root_header_html(ctx)
return full_page(ctx, header_rows_html=hdr, content_html=content,
meta_html='<title>Check your email \u2014 Rose Ash</title>')
# ---------------------------------------------------------------------------
# Public API: Timeline
# ---------------------------------------------------------------------------
@@ -708,3 +732,30 @@ async def render_profile_page(ctx: dict, actor: Any, activities: list,
return _social_page(ctx, actor, content_html=content,
title=f"@{actor.preferred_username} \u2014 Rose Ash")
# ---------------------------------------------------------------------------
# Public API: POST handler fragment renderers
# ---------------------------------------------------------------------------
def render_interaction_buttons(object_id: str, author_inbox: str,
like_count: int, boost_count: int,
liked_by_me: bool, boosted_by_me: bool,
actor: Any) -> str:
"""Render interaction buttons fragment for HTMX POST response."""
from types import SimpleNamespace
item = SimpleNamespace(
object_id=object_id,
author_inbox=author_inbox,
like_count=like_count,
boost_count=boost_count,
liked_by_me=liked_by_me,
boosted_by_me=boosted_by_me,
)
return _interaction_buttons_html(item, actor)
def render_actor_card(actor_dto: Any, actor: Any, followed_urls: set,
*, list_type: str = "following") -> str:
"""Render a single actor card fragment for HTMX POST response."""
return _actor_card_html(actor_dto, actor, followed_urls, list_type=list_type)