Files
mono/blog/bp/blog/routes.py
giles e085fe43b4 Replace sx_call() with render_to_sx() across all services
Python no longer generates s-expression strings. All SX rendering now
goes through render_to_sx() which builds AST from native Python values
and evaluates via async_eval_to_sx() — no SX string literals in Python.

- Add render_to_sx()/render_to_html() infrastructure in shared/sx/helpers.py
- Add (abort status msg) IO primitive in shared/sx/primitives_io.py
- Convert all 9 services: ~650 sx_call() invocations replaced
- Convert shared helpers (root_header_sx, full_page_sx, etc.) to async
- Fix likes service import bug (likes.models → models)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:08:33 +00:00

335 lines
12 KiB
Python

from __future__ import annotations
#from quart import Blueprint, g
import json
import os
from quart import (
request,
make_response,
g,
Blueprint,
redirect,
url_for,
)
from .ghost_db import DBClient # adjust import path
from .filters.qs import makeqs_factory, decode
from .services.posts_data import posts_data
from .services.pages_data import pages_data
from shared.browser.app.redis_cacher import cache_page, invalidate_tag_cache
from shared.browser.app.utils.htmx import is_htmx_request
from shared.browser.app.authz import require_admin
from shared.sx.helpers import sx_response
from shared.utils import host_url
def register(url_prefix, title):
blogs_bp = Blueprint("blog", __name__, url_prefix)
from .web_hooks.routes import ghost_webhooks
blogs_bp.register_blueprint(ghost_webhooks)
from .ghost.editor_api import editor_api_bp
blogs_bp.register_blueprint(editor_api_bp)
from ..post.routes import register as register_blog
blogs_bp.register_blueprint(
register_blog(),
)
from .admin.routes import register as register_tag_groups_admin
blogs_bp.register_blueprint(register_tag_groups_admin())
@blogs_bp.before_app_serving
async def init():
# Ghost startup sync disabled (Phase 1) — blog service owns content
# directly. The final_ghost_sync.py script was run before cutover.
pass
@blogs_bp.before_request
async def route():
g.makeqs_factory = makeqs_factory
@blogs_bp.context_processor
async def inject_root():
return {
"blog_title": title,
"qs": makeqs_factory()(),
"unsplash_api_key": os.environ.get("UNSPLASH_ACCESS_KEY", ""),
}
SORT_MAP = {
"newest": "published_at DESC",
"oldest": "published_at ASC",
"az": "title ASC",
"za": "title DESC",
"featured": "featured DESC, published_at DESC",
}
@blogs_bp.get("/")
async def home():
"""Render the Ghost page with slug 'home' as the site homepage."""
from ..post.services.post_data import post_data as _post_data
from shared.config import config as get_config
from shared.infrastructure.cart_identity import current_cart_identity
from shared.infrastructure.data_client import fetch_data
from shared.contracts.dtos import CartSummaryDTO, dto_from_dict
from shared.infrastructure.fragments import fetch_fragment
p_data = await _post_data("home", g.s, include_drafts=False)
if not p_data:
# Fall back to blog index if "home" page doesn't exist yet
return redirect(host_url(url_for("blog.index")))
g.post_data = p_data
# Build the same context the post blueprint's context_processor provides
db_post_id = p_data["post"]["id"]
post_slug = p_data["post"]["slug"]
# Fetch container nav from relations service
container_nav = await fetch_fragment("relations", "container-nav", params={
"container_type": "page",
"container_id": str(db_post_id),
"post_slug": post_slug,
})
ctx = {
**p_data,
"base_title": get_config()["title"],
"container_nav": container_nav,
}
# Page cart badge via HTTP
if p_data["post"].get("is_page"):
ident = current_cart_identity()
summary_params = {"page_slug": post_slug}
if ident["user_id"] is not None:
summary_params["user_id"] = ident["user_id"]
if ident["session_id"] is not None:
summary_params["session_id"] = ident["session_id"]
raw_summary = await fetch_data("cart", "cart-summary", params=summary_params, required=False)
page_summary = dto_from_dict(CartSummaryDTO, raw_summary) if raw_summary else CartSummaryDTO()
ctx["page_cart_count"] = page_summary.count + page_summary.calendar_count + page_summary.ticket_count
ctx["page_cart_total"] = float(page_summary.total + page_summary.calendar_total + page_summary.ticket_total)
from shared.sx.page import get_template_context
from sx.sx_components import render_home_page, render_home_oob
tctx = await get_template_context()
tctx.update(ctx)
if not is_htmx_request():
html = await render_home_page(tctx)
return await make_response(html)
else:
sx_src = await render_home_oob(tctx)
return sx_response(sx_src)
@blogs_bp.get("/index")
@blogs_bp.get("/index/")
async def index():
"""Blog listing — moved from / to /index."""
q = decode()
content_type = request.args.get("type", "posts")
if content_type == "pages":
data = await pages_data(g.s, q.page, q.search)
context = {
**data,
"content_type": "pages",
"search": q.search,
"selected_tags": (),
"selected_authors": (),
"selected_groups": (),
"sort": None,
"view": None,
"drafts": None,
"draft_count": 0,
"tags": [],
"authors": [],
"tag_groups": [],
"posts": data.get("pages", []),
}
from shared.sx.page import get_template_context
from sx.sx_components import render_blog_page, render_blog_oob, render_blog_page_cards
tctx = await get_template_context()
tctx.update(context)
if not is_htmx_request():
html = await render_blog_page(tctx)
return await make_response(html)
elif q.page > 1:
sx_src = await render_blog_page_cards(tctx)
return sx_response(sx_src)
else:
sx_src = await render_blog_oob(tctx)
return sx_response(sx_src)
# Default: posts listing
# Drafts filter requires login; ignore if not logged in
show_drafts = bool(q.drafts and g.user)
is_admin = bool((g.get("rights") or {}).get("admin"))
drafts_user_id = None if (not show_drafts or is_admin) else g.user.id
# For the draft count badge: admin sees all drafts, non-admin sees own
count_drafts_uid = None if (g.user and is_admin) else (g.user.id if g.user else False)
data = await posts_data(
g.s, q.page, q.search, q.sort, q.selected_tags, q.selected_authors, q.liked,
drafts=show_drafts, drafts_user_id=drafts_user_id,
count_drafts_for_user_id=count_drafts_uid,
selected_groups=q.selected_groups,
)
context = {
**data,
"content_type": "posts",
"selected_tags": q.selected_tags,
"selected_authors": q.selected_authors,
"selected_groups": q.selected_groups,
"sort": q.sort,
"search": q.search,
"view": q.view,
"drafts": q.drafts if show_drafts else None,
}
from shared.sx.page import get_template_context
from sx.sx_components import render_blog_page, render_blog_oob, render_blog_cards
tctx = await get_template_context()
tctx.update(context)
if not is_htmx_request():
html = await render_blog_page(tctx)
return await make_response(html)
elif q.page > 1:
# Sx wire format — client renders blog cards
sx_src = await render_blog_cards(tctx)
return sx_response(sx_src)
else:
sx_src = await render_blog_oob(tctx)
return sx_response(sx_src)
@blogs_bp.post("/new/")
@require_admin
async def new_post_save():
from .ghost.lexical_validator import validate_lexical
from services.post_writer import create_post as writer_create
form = await request.form
title = form.get("title", "").strip() or "Untitled"
lexical_raw = form.get("lexical", "")
status = form.get("status", "draft")
feature_image = form.get("feature_image", "").strip()
custom_excerpt = form.get("custom_excerpt", "").strip()
feature_image_caption = form.get("feature_image_caption", "").strip()
# Validate
try:
lexical_doc = json.loads(lexical_raw)
except (json.JSONDecodeError, TypeError):
from shared.sx.page import get_template_context
from sx.sx_components import render_new_post_page, render_editor_panel
tctx = await get_template_context()
tctx["editor_html"] = await render_editor_panel(save_error="Invalid JSON in editor content.")
html = await render_new_post_page(tctx)
return await make_response(html, 400)
ok, reason = validate_lexical(lexical_doc)
if not ok:
from shared.sx.page import get_template_context
from sx.sx_components import render_new_post_page, render_editor_panel
tctx = await get_template_context()
tctx["editor_html"] = await render_editor_panel(save_error=reason)
html = await render_new_post_page(tctx)
return await make_response(html, 400)
# Create directly in db_blog
sx_content_raw = form.get("sx_content", "").strip() or None
post = await writer_create(
g.s,
title=title,
lexical_json=lexical_raw,
status=status,
user_id=g.user.id,
feature_image=feature_image or None,
custom_excerpt=custom_excerpt or None,
feature_image_caption=feature_image_caption or None,
sx_content=sx_content_raw,
)
await g.s.flush()
# Clear blog listing cache
await invalidate_tag_cache("blog")
# Redirect to the edit page
return redirect(host_url(url_for("defpage_post_edit", slug=post.slug)))
@blogs_bp.post("/new-page/")
@require_admin
async def new_page_save():
from .ghost.lexical_validator import validate_lexical
from services.post_writer import create_page as writer_create_page
form = await request.form
title = form.get("title", "").strip() or "Untitled"
lexical_raw = form.get("lexical", "")
status = form.get("status", "draft")
feature_image = form.get("feature_image", "").strip()
custom_excerpt = form.get("custom_excerpt", "").strip()
feature_image_caption = form.get("feature_image_caption", "").strip()
# Validate
try:
lexical_doc = json.loads(lexical_raw)
except (json.JSONDecodeError, TypeError):
from shared.sx.page import get_template_context
from sx.sx_components import render_new_post_page, render_editor_panel
tctx = await get_template_context()
tctx["editor_html"] = await render_editor_panel(save_error="Invalid JSON in editor content.", is_page=True)
tctx["is_page"] = True
html = await render_new_post_page(tctx)
return await make_response(html, 400)
ok, reason = validate_lexical(lexical_doc)
if not ok:
from shared.sx.page import get_template_context
from sx.sx_components import render_new_post_page, render_editor_panel
tctx = await get_template_context()
tctx["editor_html"] = await render_editor_panel(save_error=reason, is_page=True)
tctx["is_page"] = True
html = await render_new_post_page(tctx)
return await make_response(html, 400)
# Create directly in db_blog
sx_content_raw = form.get("sx_content", "").strip() or None
page = await writer_create_page(
g.s,
title=title,
lexical_json=lexical_raw,
status=status,
user_id=g.user.id,
feature_image=feature_image or None,
custom_excerpt=custom_excerpt or None,
feature_image_caption=feature_image_caption or None,
sx_content=sx_content_raw,
)
await g.s.flush()
# Clear blog listing cache
await invalidate_tag_cache("blog")
# Redirect to the page admin
return redirect(host_url(url_for("defpage_post_edit", slug=page.slug)))
@blogs_bp.get("/drafts/")
async def drafts():
return redirect(host_url(url_for("blog.index")) + "?drafts=1")
return blogs_bp