Files
mono/market/app.py
giles 22802bd36b Send all responses as sexp wire format with client-side rendering
- 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>
2026-03-01 09:45:07 +00:00

269 lines
9.3 KiB
Python

from __future__ import annotations
import path_setup # noqa: F401 # adds shared/ to sys.path
import sexp.sexp_components as sexp_components # noqa: F401 # ensure Hypercorn --reload watches this file
from pathlib import Path
from quart import g, abort, request
from jinja2 import FileSystemLoader, ChoiceLoader
from sqlalchemy import select
from shared.infrastructure.factory import create_base_app
from shared.config import config
from bp import register_market_bp, register_all_markets, register_page_markets, register_page_admin, register_fragments, register_actions, register_data
async def market_context() -> dict:
"""
Market app context processor.
- nav_tree: fetched from blog as fragment
- cart_count/cart_total: via cart service (includes calendar entries)
- cart: direct ORM query (templates need .product relationship)
"""
from shared.infrastructure.context import base_context
from shared.infrastructure.cart_identity import current_cart_identity
from shared.infrastructure.fragments import fetch_fragments
from shared.infrastructure.data_client import fetch_data
from shared.contracts.dtos import CartSummaryDTO, dto_from_dict
ctx = await base_context()
# menu_nodes lives in db_blog; nav-tree fragment provides the real nav
ctx["menu_items"] = []
ident = current_cart_identity()
# cart_count/cart_total via internal data endpoint
summary_params = {}
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 = await fetch_data("cart", "cart-summary", params=summary_params, required=False)
summary = dto_from_dict(CartSummaryDTO, raw) if raw else CartSummaryDTO()
ctx["cart_count"] = summary.count + summary.calendar_count
ctx["cart_total"] = float(summary.total + summary.calendar_total)
# Pre-fetch cross-app HTML fragments concurrently
user = getattr(g, "user", None)
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, auth_menu, nav_tree = 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": "market", "path": request.path}),
])
ctx["cart_mini"] = cart_mini
ctx["auth_menu"] = auth_menu
ctx["nav_tree"] = nav_tree
# Cart items for product templates — fetched via internal data endpoint
# (cart_items table lives in db_cart, not db_market)
cart_items_raw = await fetch_data("cart", "cart-items", params=summary_params, required=False)
if cart_items_raw:
# Wrap as namespace objects so Jinja selectattr("product.slug", ...) works
from types import SimpleNamespace
ctx["cart"] = [
SimpleNamespace(
product=SimpleNamespace(slug=item["product_slug"]),
quantity=item["quantity"],
)
for item in cart_items_raw
]
else:
ctx["cart"] = []
return ctx
def create_app() -> "Quart":
from models.market_place import MarketPlace
from services import register_domain_services
app = create_base_app(
"market",
context_fn=market_context,
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,
])
# All markets: / — global view across all pages
app.register_blueprint(
register_all_markets(),
url_prefix="/",
)
# Page markets: /<slug>/ — markets for a single page
app.register_blueprint(
register_page_markets(),
url_prefix="/<slug>",
)
# Page admin: /<slug>/admin/ — post-level admin for markets
app.register_blueprint(
register_page_admin(),
url_prefix="/<slug>/admin",
)
# Market blueprint nested under post slug: /<page_slug>/<market_slug>/
app.register_blueprint(
register_market_bp(
url_prefix="/",
title=config()["market_title"],
),
url_prefix="/<page_slug>/<market_slug>",
)
app.register_blueprint(register_fragments())
app.register_blueprint(register_actions())
app.register_blueprint(register_data())
# --- Auto-inject slugs into url_for() calls ---
@app.url_value_preprocessor
def pull_slugs(endpoint, values):
if values:
# page_markets blueprint uses "slug"
if "slug" in values:
g.post_slug = values.pop("slug")
# market blueprint uses "page_slug" / "market_slug"
if "page_slug" in values:
g.post_slug = values.pop("page_slug")
if "market_slug" in values:
g.market_slug = values.pop("market_slug")
@app.url_defaults
def inject_slugs(endpoint, values):
slug = g.get("post_slug")
if slug:
for param in ("slug", "page_slug"):
if param not in values and app.url_map.is_endpoint_expecting(endpoint, param):
values[param] = slug
market_slug = g.get("market_slug")
if market_slug and "market_slug" not in values:
if app.url_map.is_endpoint_expecting(endpoint, "market_slug"):
values["market_slug"] = market_slug
# --- Load post and market data ---
@app.before_request
async def hydrate_market():
from shared.infrastructure.data_client import fetch_data
post_slug = getattr(g, "post_slug", None)
market_slug = getattr(g, "market_slug", None)
if not post_slug:
return
# Load post by slug via blog data endpoint
post = await fetch_data("blog", "post-by-slug", params={"slug": post_slug})
if not post:
abort(404)
g.post_data = {
"post": {
"id": post["id"],
"title": post["title"],
"slug": post["slug"],
"feature_image": post.get("feature_image"),
"html": post.get("html"),
"status": post["status"],
"visibility": post["visibility"],
"is_page": post.get("is_page", False),
},
}
# Only load market when market_slug is present (/<page_slug>/<market_slug>/)
if not market_slug:
return
market = (
await g.s.execute(
select(MarketPlace).where(
MarketPlace.slug == market_slug,
MarketPlace.container_type == "page",
MarketPlace.container_id == post["id"],
MarketPlace.deleted_at.is_(None),
)
)
).scalar_one_or_none()
if not market:
abort(404)
g.market = market
@app.context_processor
async def inject_post():
post_data = getattr(g, "post_data", None)
if not post_data:
return {}
ctx = {**post_data}
# Fetch container nav fragments (calendar + market links for this page)
post_dict = post_data.get("post") or {}
db_post_id = post_dict.get("id")
post_slug = post_dict.get("slug", "")
if db_post_id:
from shared.infrastructure.fragments import fetch_fragments
nav_params = {
"container_type": "page",
"container_id": str(db_post_id),
"post_slug": post_slug,
}
events_nav, market_nav = await fetch_fragments([
("events", "container-nav", nav_params),
("market", "container-nav", nav_params),
], required=False)
ctx["container_nav"] = events_nav + market_nav
return ctx
# --- oEmbed endpoint ---
@app.get("/oembed")
async def oembed():
from urllib.parse import urlparse
from quart import jsonify
from shared.models.market import Product
from shared.infrastructure.urls import market_url
from shared.infrastructure.oembed import build_oembed_response
url = request.args.get("url", "")
if not url:
return jsonify({"error": "url parameter required"}), 400
parsed = urlparse(url)
# Market product URLs: /.../<page_slug>/<market_slug>/product/<slug>/
parts = [p for p in parsed.path.strip("/").split("/") if p]
slug = ""
for i, part in enumerate(parts):
if part == "product" and i + 1 < len(parts):
slug = parts[i + 1]
break
if not slug:
return jsonify({"error": "could not extract product slug"}), 404
product = (
await g.s.execute(select(Product).where(Product.slug == slug))
).scalar_one_or_none()
if not product:
return jsonify({"error": "not found"}), 404
resp = build_oembed_response(
title=product.title or slug,
oembed_type="link",
thumbnail_url=product.image,
url=market_url(f"/product/{product.slug}/"),
)
return jsonify(resp)
return app
app = create_app()