Files
rose-ash/cart/app.py
giles d53b9648a9
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m15s
Phase 6: Replace render_template() with s-expression rendering in all GET routes
Migrate ~52 GET route handlers across all 7 services from Jinja
render_template() to s-expression component rendering. Each service
gets a sexp_components.py with page/oob/cards render functions.

- Add per-service sexp_components.py (account, blog, cart, events,
  federation, market, orders) with full page, OOB, and pagination
  card rendering
- Add shared/sexp/helpers.py with call_url, root_header_html,
  full_page, oob_page utilities
- Update all GET routes to use get_template_context() + render fns
- Fix get_template_context() to inject Jinja globals (URL helpers)
- Add qs_filter to base_context for sexp filter URL building
- Mount sexp_components.py in docker-compose.dev.yml for all services
- Import sexp_components in app.py for Hypercorn --reload watching
- Fix route_prefix import (shared.utils not shared.infrastructure.urls)
- Fix federation choose-username missing actor in context
- Fix market page_markets missing post in context

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 23:19:33 +00:00

208 lines
6.8 KiB
Python

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
from decimal import Decimal
from pathlib import Path
from types import SimpleNamespace
from quart import g, abort, request
from jinja2 import FileSystemLoader, ChoiceLoader
from shared.infrastructure.factory import create_base_app
from bp import (
register_cart_overview,
register_page_cart,
register_cart_global,
register_fragments,
register_actions,
register_data,
register_inbox,
)
from bp.cart.services import (
get_cart,
total,
get_calendar_cart_entries,
calendar_total,
get_ticket_cart_entries,
ticket_total,
)
from bp.cart.services.page_cart import (
get_cart_for_page,
get_calendar_entries_for_page,
get_tickets_for_page,
)
from bp.cart.services.ticket_groups import group_tickets
async def _load_cart():
"""Load the full cart for the cart app (before each request)."""
g.cart = await get_cart(g.s)
async def cart_context() -> dict:
"""
Cart app context processor.
- cart / calendar_cart_entries / total / calendar_total: direct DB
(cart app owns this data)
- cart_count: derived from cart + calendar entries (for _mini.html)
- nav_tree_html: fetched from blog as fragment
When g.page_post exists, cart and calendar_cart_entries are page-scoped.
Global cart_count / cart_total stay global for cart-mini.
"""
from shared.infrastructure.context import base_context
from shared.infrastructure.cart_identity import current_cart_identity
from shared.infrastructure.fragments import fetch_fragments
ctx = await base_context()
# menu_nodes lives in db_blog; nav-tree fragment provides the real nav
ctx["menu_items"] = []
# Pre-fetch cross-app HTML fragments concurrently
user = getattr(g, "user", None)
ident = current_cart_identity()
cart_params = {}
if ident["user_id"] is not None:
cart_params["user_id"] = ident["user_id"]
if ident["session_id"] is not None:
cart_params["session_id"] = ident["session_id"]
cart_mini_html, auth_menu_html, nav_tree_html = await fetch_fragments([
("cart", "cart-mini", cart_params or None),
("account", "auth-menu", {"email": user.email} if user else None),
("blog", "nav-tree", {"app_name": "cart", "path": request.path}),
])
ctx["cart_mini_html"] = cart_mini_html
ctx["auth_menu_html"] = auth_menu_html
ctx["nav_tree_html"] = nav_tree_html
# Cart app owns cart data — use g.cart from _load_cart
all_cart = getattr(g, "cart", None) or []
all_cal = await get_calendar_cart_entries(g.s)
all_tickets = await get_ticket_cart_entries(g.s)
# Global counts for cart-mini (always global)
cart_qty = sum(ci.quantity for ci in all_cart) if all_cart else 0
ctx["cart_count"] = cart_qty + len(all_cal) + len(all_tickets)
ctx["cart_total"] = (total(all_cart) or Decimal(0)) + (calendar_total(all_cal) or Decimal(0)) + (ticket_total(all_tickets) or Decimal(0))
# Page-scoped data when viewing a page cart
page_post = getattr(g, "page_post", None)
if page_post:
page_cart = await get_cart_for_page(g.s, page_post.id)
page_cal = await get_calendar_entries_for_page(g.s, page_post.id)
page_tickets = await get_tickets_for_page(g.s, page_post.id)
ctx["cart"] = page_cart
ctx["calendar_cart_entries"] = page_cal
ctx["ticket_cart_entries"] = page_tickets
ctx["page_post"] = page_post
ctx["page_config"] = getattr(g, "page_config", None)
else:
ctx["cart"] = all_cart
ctx["calendar_cart_entries"] = all_cal
ctx["ticket_cart_entries"] = all_tickets
ctx["ticket_groups"] = group_tickets(ctx.get("ticket_cart_entries", []))
ctx["total"] = total
ctx["calendar_total"] = calendar_total
ctx["ticket_total"] = ticket_total
return ctx
def _make_page_config(raw: dict) -> SimpleNamespace:
"""Convert a page-config JSON dict to a namespace for SumUp helpers."""
return SimpleNamespace(**raw)
def create_app() -> "Quart":
from services import register_domain_services
app = create_base_app(
"cart",
context_fn=cart_context,
before_request_fns=[_load_cart],
domain_services_fn=register_domain_services,
)
# App-specific templates override shared templates
app_templates = str(Path(__file__).resolve().parent / "templates")
app.jinja_loader = ChoiceLoader([
FileSystemLoader(app_templates),
app.jinja_loader,
])
app.jinja_env.globals["cart_quantity_url"] = lambda product_id: f"/quantity/{product_id}/"
app.jinja_env.globals["cart_delete_url"] = lambda product_id: f"/delete/{product_id}/"
app.register_blueprint(register_fragments())
app.register_blueprint(register_actions())
app.register_blueprint(register_data())
app.register_blueprint(register_inbox())
# --- Page slug hydration (follows events/market app pattern) ---
@app.url_value_preprocessor
def pull_page_slug(endpoint, values):
if values and "page_slug" in values:
g.page_slug = values.pop("page_slug")
@app.url_defaults
def inject_page_slug(endpoint, values):
slug = g.get("page_slug")
if slug and "page_slug" not in values:
if app.url_map.is_endpoint_expecting(endpoint, "page_slug"):
values["page_slug"] = slug
@app.before_request
async def hydrate_page():
from shared.infrastructure.data_client import fetch_data
from shared.contracts.dtos import PostDTO, dto_from_dict
slug = getattr(g, "page_slug", None)
if not slug:
return
raw = await fetch_data("blog", "post-by-slug", params={"slug": slug})
if not raw:
abort(404)
post = dto_from_dict(PostDTO, raw)
if not post or not post.is_page:
abort(404)
g.page_post = post
raw_pc = await fetch_data(
"blog", "page-config",
params={"container_type": "page", "container_id": post.id},
required=False,
)
g.page_config = _make_page_config(raw_pc) if raw_pc else None
# --- Blueprint registration ---
# Static prefixes first, dynamic (page_slug) last
# Global routes (add, quantity, delete, checkout — specific paths under /)
app.register_blueprint(
register_cart_global(url_prefix="/"),
url_prefix="/",
)
# Cart overview at GET /
app.register_blueprint(
register_cart_overview(url_prefix="/"),
url_prefix="/",
)
# Page cart at /<page_slug>/ (dynamic, matched last)
app.register_blueprint(
register_page_cart(url_prefix="/"),
url_prefix="/<page_slug>",
)
return app
app = create_app()