From 8af7c69090b226e1c5afb8e353804236226906df Mon Sep 17 00:00:00 2001 From: giles Date: Thu, 19 Feb 2026 18:04:26 +0000 Subject: [PATCH] Decouple blog UI via widget registry Replace explicit calendar/market service calls in post routes, auth routes, and listing cards with widget-driven iteration. Zero cross-domain imports remain in blog bp layer. Co-Authored-By: Claude Opus 4.6 --- bp/auth/routes.py | 52 ++++++++++---------------------- bp/blog/services/posts_data.py | 13 ++++---- bp/post/routes.py | 55 ++++++++++++++++++---------------- 3 files changed, 51 insertions(+), 69 deletions(-) diff --git a/bp/auth/routes.py b/bp/auth/routes.py index a8a1566..942c10a 100644 --- a/bp/auth/routes.py +++ b/bp/auth/routes.py @@ -27,6 +27,7 @@ from shared.models.ghost_membership_entities import GhostNewsletter from shared.config import config from shared.utils import host_url from shared.infrastructure.urls import coop_url +from shared.services.widget_registry import widgets from sqlalchemy.orm import selectinload from shared.browser.app.redis_cacher import clear_cache @@ -68,6 +69,7 @@ def register(url_prefix="/auth"): def context(): return { "oob": oob, + "account_nav_links": widgets.account_nav, } # NOTE: load_current_user moved to shared/user_loader.py @@ -150,56 +152,32 @@ def register(url_prefix="/auth"): return await make_response(html) - @auth_bp.get("/tickets/") - async def tickets(): + @auth_bp.get("//") + async def widget_page(slug): from shared.browser.app.utils.htmx import is_htmx_request - from shared.services.registry import services + + widget = widgets.account_page_by_slug(slug) + if not widget: + from quart import abort + abort(404) if not g.get("user"): return redirect(host_url(url_for("auth.login_form"))) - user_tickets = await services.calendar.user_tickets(g.s, user_id=g.user.id) - - tk_oob = {**oob, "main": "_types/auth/_tickets_panel.html"} + ctx = await widget.context_fn(g.s, user_id=g.user.id) + w_oob = {**oob, "main": widget.template} if not is_htmx_request(): html = await render_template( "_types/auth/index.html", - oob=tk_oob, - tickets=user_tickets, + oob=w_oob, + **ctx, ) else: html = await render_template( "_types/auth/_oob_elements.html", - oob=tk_oob, - tickets=user_tickets, - ) - - return await make_response(html) - - @auth_bp.get("/bookings/") - async def bookings(): - from shared.browser.app.utils.htmx import is_htmx_request - from shared.services.registry import services - - if not g.get("user"): - return redirect(host_url(url_for("auth.login_form"))) - - user_bookings = await services.calendar.user_bookings(g.s, user_id=g.user.id) - - bk_oob = {**oob, "main": "_types/auth/_bookings_panel.html"} - - if not is_htmx_request(): - html = await render_template( - "_types/auth/index.html", - oob=bk_oob, - bookings=user_bookings, - ) - else: - html = await render_template( - "_types/auth/_oob_elements.html", - oob=bk_oob, - bookings=user_bookings, + oob=w_oob, + **ctx, ) return await make_response(html) diff --git a/bp/blog/services/posts_data.py b/bp/blog/services/posts_data.py index 3214f18..7a08296 100644 --- a/bp/blog/services/posts_data.py +++ b/bp/blog/services/posts_data.py @@ -1,7 +1,7 @@ from ..ghost_db import DBClient # adjust import path from sqlalchemy import select from models.ghost_content import PostLike -from shared.services.registry import services +from shared.services.widget_registry import widgets from quart import g async def posts_data( @@ -85,12 +85,11 @@ async def posts_data( for post in posts: post["is_liked"] = False - # Fetch associated entries for each post via calendar service - entries_by_post = await services.calendar.confirmed_entries_for_posts(session, post_ids) - - # Add associated_entries to each post - for post in posts: - post["associated_entries"] = entries_by_post.get(post["id"], []) + # Widget-driven card decoration + for w in widgets.container_cards: + batch_data = await w.batch_fn(session, post_ids) + for post in posts: + post[w.context_key] = batch_data.get(post["id"], []) tags=await client.list_tags( limit=50000 diff --git a/bp/post/routes.py b/bp/post/routes.py index 7cf725b..6fcf877 100644 --- a/bp/post/routes.py +++ b/bp/post/routes.py @@ -8,10 +8,12 @@ from quart import ( Blueprint, abort, url_for, + request, ) from .services.post_data import post_data from .services.post_operations import toggle_post_like from shared.services.registry import services +from shared.services.widget_registry import widgets from shared.browser.app.redis_cacher import cache_page, clear_cache @@ -62,25 +64,29 @@ def register(): async def context(): p_data = getattr(g, "post_data", None) if p_data: - from .services.entry_associations import get_associated_entries from shared.infrastructure.cart_identity import current_cart_identity - db_post_id = (g.post_data.get("post") or {}).get("id") # <-- integer - calendars = await services.calendar.calendars_for_container(g.s, "page", db_post_id) - markets = await services.market.marketplaces_for_container(g.s, "page", db_post_id) + db_post_id = (g.post_data.get("post") or {}).get("id") + post_slug = (g.post_data.get("post") or {}).get("slug", "") - # Fetch associated entries for nav display - associated_entries = await get_associated_entries(g.s, db_post_id) + # Widget-driven container nav — only include widgets with data + container_nav_loaded = [] + for w in widgets.container_nav: + wctx = await w.context_fn( + g.s, container_type="page", container_id=db_post_id, + post_slug=post_slug, + ) + # Include widget if it has any list data + if any(v for v in wctx.values() if isinstance(v, list) and v): + container_nav_loaded.append({"widget": w, "ctx": wctx}) ctx = { **p_data, "base_title": f"{config()['title']} {p_data['post']['title']}", - "calendars": calendars, - "markets": markets, - "associated_entries": associated_entries, + "container_nav_widgets": container_nav_loaded, } - # Page cart badge via service (replaces cross-app HTTP API) + # Page cart badge via service post_dict = p_data.get("post") or {} if post_dict.get("is_page"): ident = current_cart_identity() @@ -143,24 +149,23 @@ def register(): ) return html - @bp.get("/entries/") - async def get_entries(slug: str): - """Get paginated associated entries for infinite scroll in nav""" - from .services.entry_associations import get_associated_entries - from quart import request - + @bp.get("/w//") + async def widget_paginate(slug: str, widget_domain: str): + """Generic paginated widget endpoint for infinite scroll.""" page = int(request.args.get("page", 1)) post_id = g.post_data["post"]["id"] - result = await get_associated_entries(g.s, post_id, page=page, per_page=10) - - html = await render_template( - "_types/post/_entry_items.html", - entries=result["entries"], - page=result["page"], - has_more=result["has_more"], - ) - return await make_response(html) + for w in widgets.container_nav: + if w.domain == widget_domain: + ctx = await w.context_fn( + g.s, container_type="page", container_id=post_id, + post_slug=slug, page=page, + ) + html = await render_template( + w.template, ctx=ctx, post=g.post_data["post"], + ) + return await make_response(html) + abort(404) return bp