10 Commits

Author SHA1 Message Date
418ac9424f Eliminate Python page helpers from account, federation, and cart
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m8s
All three services now fetch page data via (service ...) IO primitives
in .sx defpages instead of Python middleman functions.

- Account: newsletters-data → AccountPageService.newsletters_data
- Federation: 8 page helpers → FederationPageService methods
  (timeline, compose, search, following, followers, notifications)
- Cart: 4 page helpers → CartPageService methods
  (overview, page-cart, admin, payments)
- Serializers moved to service modules, thin delegates kept for routes
- ~520 lines of Python page helpers removed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 02:01:50 +00:00
fb8f115acb Fix orders defpage: length→len primitive, handle _RawHTML in serialize()
- Fix undefined symbol 'length' → use 'len' primitive in orders.sx
- Add _RawHTML handling in serialize() — wraps as (raw! "...") for SX wire format
  instead of falling through to repr() which produced unparseable symbol names

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 01:55:32 +00:00
63b895afd8 Eliminate Python page helpers from orders — pure .sx defpages with IO primitives
Orders defpages now fetch data via (service ...) and generate URLs via
(url-for ...) and (route-prefix) directly in .sx. No Python middleman.

- Add url-for, route-prefix IO primitives to shared/sx/primitives_io.py
- Add generic register()/\_\_getattr\_\_ to ServiceRegistry for dynamic services
- Create OrdersPageService with list_page_data/detail_page_data methods
- Rewrite orders.sx defpages to use IO primitives + defcomp calls
- Remove ~320 lines of Python page helpers from orders/sxc/pages/__init__.py
- Convert :data env merge to use kebab-case keys for SX symbol access

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 01:50:15 +00:00
50b33ab08e Fix page helper results being quoted as string literals in defpage slots
Page helpers return SX source strings from render_to_sx(), but _aser's
serialize() was wrapping them in double quotes. In async_eval_slot_to_sx,
pass string results through directly since they're already SX source.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 01:43:00 +00:00
bd314a0be7 Guard against empty SxExpr in _as_sx and _build_component_ast
Fragment responses with text/sx content-type but empty body create
SxExpr(""), which is truthy but fails to parse. Handle this by
returning None from _as_sx for empty SxExpr sources, and treating
empty SxExpr as NIL in _build_component_ast.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 01:37:27 +00:00
41cdd6eab8 Add sxc/ volume mounts to docker-compose.dev.yml for all services
The sxc/ directories (defpages, layouts, page helpers) were not
bind-mounted, so dev containers used stale code from the Docker image.
This caused the orders.defpage_order_detail BuildError since the
container had old sxc/pages/__init__.py without the fix.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 01:34:14 +00:00
1a6503782d Phase 4: Delete cart/sx/sx_components.py, move renders to sxc/pages
Move all render functions (orders page/rows/oob, order detail/oob,
checkout error, payments panel), header helpers, and serializers from
cart/sx/sx_components.py into cart/sxc/pages/__init__.py. Update all
route imports from sx.sx_components to sxc.pages. Replace
import sx.sx_components in app.py with load_service_components("cart").

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 01:30:30 +00:00
72997068c6 Fix orders defpage endpoint references — app-level not blueprint
defpages mounted via auto_mount_pages() register endpoints without
blueprint prefix. Fix url_for("orders.defpage_*") → url_for("defpage_*").

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 01:24:14 +00:00
dacb61b0ae Delete orders + federation sx_components.py — rendering inlined to routes
Phase 2 (Orders):
- Checkout error/return renders moved directly into route handlers
- Removed orphaned test_sx_helpers.py

Phase 3 (Federation):
- Auth pages use _render_social_auth_page() helper in routes
- Choose-username render inlined into identity routes
- Timeline/search/follow/interaction renders inlined into social routes
  using serializers imported from sxc.pages
- Added _social_page() to sxc/pages/__init__.py for shared use
- Home page renders inline in app.py

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 01:22:33 +00:00
400667b15a Delete account/sx/sx_components.py — all rendering now in .sx
Phase 1 of zero-Python rendering: account service.

- Auth pages (login, device, check-email) use _render_auth_page() helper
  calling render_to_sx() + full_page_sx() directly in routes
- Newsletter toggle POST renders inline via render_to_sx()
- Newsletter page helper returns data dict; defpage :data slot fetches,
  :content slot renders via ~account-newsletters-content defcomp
- Fragment page uses (frag ...) IO primitive directly in .sx
- Defpage _eval_slot now uses async_eval_slot_to_sx which expands
  component bodies server-side (executing IO) but serializes tags as SX
- Fix pre-existing OOB ParseError: _eval_slot was producing HTML instead
  of s-expressions for component content slots
- Fix market url_for endpoint: defpage_market_home (app-level, not blueprint)
- Fix events calendar nav: wrap multiple SX parts in fragment

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 01:16:01 +00:00
47 changed files with 1575 additions and 2097 deletions

View File

@@ -1,6 +1,5 @@
from __future__ import annotations
import path_setup # noqa: F401 # adds shared/ to sys.path
import sx.sx_components as sx_components # noqa: F401 # ensure Hypercorn --reload watches this file
from pathlib import Path
from quart import g, request
@@ -72,8 +71,9 @@ def create_app() -> "Quart":
app.jinja_loader,
])
# Setup defpage routes
import sx.sx_components # noqa: F811 — ensure components loaded
# Load .sx component files and setup defpage routes
from shared.sx.jinja_bridge import load_service_components
load_service_components(str(Path(__file__).resolve().parent), service_name="account")
from sxc.pages import setup_account_pages
setup_account_pages()

View File

@@ -7,14 +7,13 @@ from __future__ import annotations
from quart import (
Blueprint,
request,
g,
)
from sqlalchemy import select
from shared.models import UserNewsletter
from shared.infrastructure.fragments import fetch_fragments
from shared.sx.helpers import sx_response
from shared.sx.helpers import sx_response, render_to_sx
def register(url_prefix="/"):
@@ -55,7 +54,26 @@ def register(url_prefix="/"):
await g.s.flush()
from sx.sx_components import render_newsletter_toggle
return sx_response(await render_newsletter_toggle(un))
# Render toggle directly — no sx_components intermediary
from shared.browser.app.csrf import generate_csrf_token
from shared.infrastructure.urls import account_url
nid = un.newsletter_id
url_fn = getattr(g, "_account_url", None) or account_url
toggle_url = url_fn(f"/newsletter/{nid}/toggle/")
csrf = generate_csrf_token()
bg = "bg-emerald-500" if un.subscribed else "bg-stone-300"
translate = "translate-x-6" if un.subscribed else "translate-x-1"
checked = "true" if un.subscribed else "false"
return sx_response(await render_to_sx(
"account-newsletter-toggle",
id=f"nl-{nid}", url=toggle_url,
hdrs=f'{{"X-CSRFToken": "{csrf}"}}',
target=f"#nl-{nid}",
cls=f"relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 {bg}",
checked=checked,
knob_cls=f"inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform {translate}",
))
return account_bp

View File

@@ -44,6 +44,17 @@ from .services import (
SESSION_USER_KEY = "uid"
ACCOUNT_SESSION_KEY = "account_sid"
async def _render_auth_page(component: str, title: str, **kwargs) -> str:
"""Render an auth page with root layout — replaces sx_components helpers."""
from shared.sx.helpers import render_to_sx, full_page_sx, root_header_sx
from shared.sx.page import get_template_context
ctx = await get_template_context()
hdr = await root_header_sx(ctx)
content = await render_to_sx(component, **{k: v for k, v in kwargs.items() if v})
return await full_page_sx(ctx, header_rows=hdr, content=content,
meta_html=f"<title>{title}</title>")
ALLOWED_CLIENTS = {"blog", "market", "cart", "events", "federation", "orders", "test", "sx", "artdag", "artdag_l2"}
@@ -275,10 +286,7 @@ def register(url_prefix="/auth"):
redirect_url = pop_login_redirect_target()
return redirect(redirect_url)
from shared.sx.page import get_template_context
from sx.sx_components import render_login_page
ctx = await get_template_context()
return await render_login_page(ctx)
return await _render_auth_page("account-login-content", "Login \u2014 Rose Ash")
@rate_limit(
key_func=lambda: request.headers.get("X-Forwarded-For", request.remote_addr),
@@ -291,20 +299,20 @@ def register(url_prefix="/auth"):
is_valid, email = validate_email(email_input)
if not is_valid:
from shared.sx.page import get_template_context
from sx.sx_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
return await _render_auth_page(
"account-login-content", "Login \u2014 Rose Ash",
error="Please enter a valid email address.", email=email_input,
), 400
# Per-email rate limit: 5 magic links per 15 minutes
from shared.infrastructure.rate_limit import _check_rate_limit
try:
allowed, _ = await _check_rate_limit(f"magic_email:{email}", 5, 900)
if not allowed:
from shared.sx.page import get_template_context
from sx.sx_components import render_check_email_page
ctx = await get_template_context(email=email, email_error=None)
return await render_check_email_page(ctx), 200
return await _render_auth_page(
"account-check-email-content", "Check your email \u2014 Rose Ash",
email=email,
), 200
except Exception:
pass # Redis down — allow the request
@@ -324,10 +332,10 @@ def register(url_prefix="/auth"):
"Please try again in a moment."
)
from shared.sx.page import get_template_context
from sx.sx_components import render_check_email_page
ctx = await get_template_context(email=email, email_error=email_error)
return await render_check_email_page(ctx)
return await _render_auth_page(
"account-check-email-content", "Check your email \u2014 Rose Ash",
email=email, email_error=email_error,
)
@auth_bp.get("/magic/<token>/")
async def magic(token: str):
@@ -340,17 +348,17 @@ def register(url_prefix="/auth"):
user, error = await validate_magic_link(s, token)
if error:
from shared.sx.page import get_template_context
from sx.sx_components import render_login_page
ctx = await get_template_context(error=error)
return await render_login_page(ctx), 400
return await _render_auth_page(
"account-login-content", "Login \u2014 Rose Ash",
error=error,
), 400
user_id = user.id
except Exception:
from shared.sx.page import get_template_context
from sx.sx_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
return await _render_auth_page(
"account-login-content", "Login \u2014 Rose Ash",
error="Could not sign you in right now. Please try again.",
), 502
assert user_id is not None
@@ -679,11 +687,11 @@ def register(url_prefix="/auth"):
@auth_bp.get("/device/")
async def device_form():
"""Browser form where user enters the code displayed in terminal."""
from shared.sx.page import get_template_context
from sx.sx_components import render_device_page
code = request.args.get("code", "")
ctx = await get_template_context(code=code)
return await render_device_page(ctx)
return await _render_auth_page(
"account-device-content", "Authorize Device \u2014 Rose Ash",
code=code,
)
@auth_bp.post("/device")
@auth_bp.post("/device/")
@@ -693,20 +701,20 @@ def register(url_prefix="/auth"):
user_code = (form.get("code") or "").strip().replace("-", "").upper()
if not user_code or len(user_code) != 8:
from shared.sx.page import get_template_context
from sx.sx_components import render_device_page
ctx = await get_template_context(error="Please enter a valid 8-character code.", code=form.get("code", ""))
return await render_device_page(ctx), 400
return await _render_auth_page(
"account-device-content", "Authorize Device \u2014 Rose Ash",
error="Please enter a valid 8-character code.", code=form.get("code", ""),
), 400
from shared.infrastructure.auth_redis import get_auth_redis
r = await get_auth_redis()
device_code = await r.get(f"devflow_uc:{user_code}")
if not device_code:
from shared.sx.page import get_template_context
from sx.sx_components import render_device_page
ctx = await get_template_context(error="Code not found or expired. Please try again.", code=form.get("code", ""))
return await render_device_page(ctx), 400
return await _render_auth_page(
"account-device-content", "Authorize Device \u2014 Rose Ash",
error="Code not found or expired. Please try again.", code=form.get("code", ""),
), 400
if isinstance(device_code, bytes):
device_code = device_code.decode()
@@ -720,23 +728,19 @@ def register(url_prefix="/auth"):
# Logged in — approve immediately
ok = await _approve_device(device_code, g.user)
if not ok:
from shared.sx.page import get_template_context
from sx.sx_components import render_device_page
ctx = await get_template_context(error="Code expired or already used.")
return await render_device_page(ctx), 400
return await _render_auth_page(
"account-device-content", "Authorize Device \u2014 Rose Ash",
error="Code expired or already used.",
), 400
from shared.sx.page import get_template_context
from sx.sx_components import render_device_approved_page
ctx = await get_template_context()
return await render_device_approved_page(ctx)
return await _render_auth_page(
"account-device-approved", "Device Authorized \u2014 Rose Ash",
)
@auth_bp.get("/device/complete")
@auth_bp.get("/device/complete/")
async def device_complete():
"""Post-login redirect — completes approval after magic link auth."""
from shared.sx.page import get_template_context
from sx.sx_components import render_device_page, render_device_approved_page
device_code = request.args.get("code", "")
if not device_code:
@@ -748,12 +752,13 @@ def register(url_prefix="/auth"):
ok = await _approve_device(device_code, g.user)
if not ok:
ctx = await get_template_context(
return await _render_auth_page(
"account-device-content", "Authorize Device \u2014 Rose Ash",
error="Code expired or already used. Please start the login process again in your terminal.",
)
return await render_device_page(ctx), 400
), 400
ctx = await get_template_context()
return await render_device_approved_page(ctx)
return await _render_auth_page(
"account-device-approved", "Device Authorized \u2014 Rose Ash",
)
return auth_bp

View File

@@ -3,9 +3,7 @@ from __future__ import annotations
def register_domain_services() -> None:
"""Register services for the account app.
Account is a consumer-only dashboard app. It has no own domain.
All cross-app data comes via fragments and HTTP data endpoints.
"""
pass
"""Register services for the account app."""
from shared.services.registry import services
from .account_page import AccountPageService
services.register("account_page", AccountPageService())

View File

@@ -0,0 +1,40 @@
"""Account page data service — provides serialized dicts for .sx defpages."""
from __future__ import annotations
class AccountPageService:
"""Service for account page data, callable via (service "account-page" ...)."""
async def newsletters_data(self, session, **kw):
"""Return newsletter list with user subscription status."""
from quart import g
from sqlalchemy import select
from shared.models import UserNewsletter
from shared.models.ghost_membership_entities import GhostNewsletter
result = await session.execute(
select(GhostNewsletter).order_by(GhostNewsletter.name)
)
all_newsletters = result.scalars().all()
sub_result = await session.execute(
select(UserNewsletter).where(
UserNewsletter.user_id == g.user.id,
)
)
user_subs = {un.newsletter_id: un for un in sub_result.scalars().all()}
newsletter_list = []
for nl in all_newsletters:
un = user_subs.get(nl.id)
newsletter_list.append({
"newsletter": {"id": nl.id, "name": nl.name, "description": nl.description},
"un": {"newsletter_id": un.newsletter_id, "subscribed": un.subscribed} if un else None,
"subscribed": un.subscribed if un else False,
})
from shared.infrastructure.urls import account_url
return {
"newsletter_list": newsletter_list,
"account_url": account_url(""),
}

View File

@@ -1,107 +0,0 @@
"""
Account service s-expression page components.
Renders login, device auth, and check-email pages. Dashboard and newsletters
are now fully handled by .sx defcomps called from defpage expressions.
"""
from __future__ import annotations
import os
from typing import Any
from shared.sx.jinja_bridge import load_service_components
from shared.sx.helpers import (
render_to_sx,
root_header_sx, full_page_sx,
)
# Load account-specific .sx components + handlers at import time
load_service_components(os.path.dirname(os.path.dirname(__file__)),
service_name="account")
# ---------------------------------------------------------------------------
# Public API: Auth pages (login, device, check_email)
# ---------------------------------------------------------------------------
async def render_login_page(ctx: dict) -> str:
"""Full page: login form."""
error = ctx.get("error", "")
email = ctx.get("email", "")
hdr = await root_header_sx(ctx)
content = await render_to_sx("account-login-content",
error=error or None, email=email)
return await full_page_sx(ctx, header_rows=hdr,
content=content,
meta_html='<title>Login \u2014 Rose Ash</title>')
async def render_device_page(ctx: dict) -> str:
"""Full page: device authorization form."""
error = ctx.get("error", "")
code = ctx.get("code", "")
hdr = await root_header_sx(ctx)
content = await render_to_sx("account-device-content",
error=error or None, code=code)
return await full_page_sx(ctx, header_rows=hdr,
content=content,
meta_html='<title>Authorize Device \u2014 Rose Ash</title>')
async def render_device_approved_page(ctx: dict) -> str:
"""Full page: device approved."""
hdr = await root_header_sx(ctx)
content = await render_to_sx("account-device-approved")
return await full_page_sx(ctx, header_rows=hdr,
content=content,
meta_html='<title>Device Authorized \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")
hdr = await root_header_sx(ctx)
content = await render_to_sx("account-check-email-content",
email=email, email_error=email_error)
return await full_page_sx(ctx, header_rows=hdr,
content=content,
meta_html='<title>Check your email \u2014 Rose Ash</title>')
# ---------------------------------------------------------------------------
# Public API: Fragment renderers for POST handlers
# ---------------------------------------------------------------------------
async def render_newsletter_toggle(un) -> str:
"""Render a newsletter toggle switch for POST response."""
from shared.browser.app.csrf import generate_csrf_token
nid = un.newsletter_id
from quart import g
account_url_fn = getattr(g, "_account_url", None)
if account_url_fn is None:
from shared.infrastructure.urls import account_url
account_url_fn = account_url
toggle_url = account_url_fn(f"/newsletter/{nid}/toggle/")
csrf = generate_csrf_token()
if un.subscribed:
bg = "bg-emerald-500"
translate = "translate-x-6"
checked = "true"
else:
bg = "bg-stone-300"
translate = "translate-x-1"
checked = "false"
return await render_to_sx(
"account-newsletter-toggle",
id=f"nl-{nid}", url=toggle_url,
hdrs=f'{{"X-CSRFToken": "{csrf}"}}',
target=f"#nl-{nid}",
cls=f"relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 {bg}",
checked=checked,
knob_cls=f"inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform {translate}",
)

View File

@@ -1,13 +1,12 @@
"""Account defpage setup — registers layouts, page helpers, and loads .sx pages."""
"""Account defpage setup — registers layouts and loads .sx pages."""
from __future__ import annotations
from typing import Any
def setup_account_pages() -> None:
"""Register account-specific layouts, page helpers, and load page definitions."""
"""Register account-specific layouts and load page definitions."""
_register_account_layouts()
_register_account_helpers()
_load_account_page_files()
@@ -54,6 +53,7 @@ async def _account_oob(ctx: dict, **kw: Any) -> str:
async def _account_mobile(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import mobile_menu_sx, mobile_root_nav_sx, render_to_sx
from shared.sx.parser import SxExpr
ctx = _inject_account_nav(ctx)
nav_items = await render_to_sx("auth-nav-items",
account_url=_call_url(ctx, "account_url", ""),
@@ -89,77 +89,3 @@ def _as_sx_nav(ctx: dict) -> Any:
return _as_sx(ctx.get("account_nav"))
# ---------------------------------------------------------------------------
# Page helpers
# ---------------------------------------------------------------------------
def _register_account_helpers() -> None:
from shared.sx.pages import register_page_helpers
register_page_helpers("account", {
"newsletters-content": _h_newsletters_content,
"fragment-content": _h_fragment_content,
})
async def _h_newsletters_content(**kw):
"""Fetch newsletter data, return assembled defcomp call."""
from quart import g
from sqlalchemy import select
from shared.models import UserNewsletter
from shared.models.ghost_membership_entities import GhostNewsletter
from shared.sx.helpers import render_to_sx
result = await g.s.execute(
select(GhostNewsletter).order_by(GhostNewsletter.name)
)
all_newsletters = result.scalars().all()
sub_result = await g.s.execute(
select(UserNewsletter).where(
UserNewsletter.user_id == g.user.id,
)
)
user_subs = {un.newsletter_id: un for un in sub_result.scalars().all()}
newsletter_list = []
for nl in all_newsletters:
un = user_subs.get(nl.id)
newsletter_list.append({
"newsletter": {"id": nl.id, "name": nl.name, "description": nl.description},
"un": {"newsletter_id": un.newsletter_id, "subscribed": un.subscribed} if un else None,
"subscribed": un.subscribed if un else False,
})
account_url = getattr(g, "_account_url", None)
if account_url is None:
from shared.infrastructure.urls import account_url as _account_url
account_url = _account_url
# Call account_url to get the base URL string
account_url_str = account_url("") if callable(account_url) else str(account_url or "")
return await render_to_sx("account-newsletters-content",
newsletter_list=newsletter_list,
account_url=account_url_str)
async def _h_fragment_content(slug=None, **kw):
from quart import g, abort
from shared.infrastructure.fragments import fetch_fragment
if not slug or not g.get("user"):
return ""
fragment_html = await fetch_fragment(
"events", "account-page",
params={"slug": slug, "user_id": str(g.user.id)},
)
if not fragment_html:
abort(404)
from shared.sx.parser import SxExpr
if isinstance(fragment_html, SxExpr):
return fragment_html.source
s = str(fragment_html) if fragment_html else ""
if not s:
return ""
from shared.sx.helpers import render_to_sx
return await render_to_sx("rich-text", html=s)

View File

@@ -18,7 +18,10 @@
:path "/newsletters/"
:auth :login
:layout :account
:content (newsletters-content))
:data (service "account-page" "newsletters-data")
:content (~account-newsletters-content
:newsletter-list newsletter-list
:account-url account-url))
;; ---------------------------------------------------------------------------
;; Fragment pages (tickets, bookings, etc. from events service)
@@ -28,4 +31,10 @@
:path "/<slug>/"
:auth :login
:layout :account
:content (fragment-content slug))
:content (let* ((user (current-user))
(result (frag "events" "account-page"
:slug slug
:user-id (str (get user "id")))))
(if (or (nil? result) (empty? result))
(abort 404)
result)))

View File

@@ -1,6 +1,6 @@
from __future__ import annotations
import path_setup # noqa: F401 # adds shared/ to sys.path
import sx.sx_components as sx_components # noqa: F401 # ensure Hypercorn --reload watches this file
from shared.sx.jinja_bridge import load_service_components # noqa: F401
from decimal import Decimal
from pathlib import Path
@@ -140,6 +140,8 @@ def create_app() -> "Quart":
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}/"
load_service_components("cart")
from shared.sx.handlers import auto_mount_fragment_handlers
auto_mount_fragment_handlers(app, "cart")

View File

@@ -151,7 +151,7 @@ def register(url_prefix: str) -> Blueprint:
page_config = await resolve_page_config(g.s, cart, calendar_entries, tickets)
except ValueError as e:
from shared.sx.page import get_template_context
from sx.sx_components import render_checkout_error_page
from sxc.pages import render_checkout_error_page
tctx = await get_template_context()
html = await render_checkout_error_page(tctx, error=str(e))
return await make_response(html, 400)
@@ -208,7 +208,7 @@ def register(url_prefix: str) -> Blueprint:
if not hosted_url:
from shared.sx.page import get_template_context
from sx.sx_components import render_checkout_error_page
from sxc.pages import render_checkout_error_page
tctx = await get_template_context()
html = await render_checkout_error_page(tctx, error="No hosted checkout URL returned from SumUp.")
return await make_response(html, 500)

View File

@@ -73,7 +73,7 @@ def register(url_prefix: str) -> Blueprint:
if not hosted_url:
from shared.sx.page import get_template_context
from sx.sx_components import render_checkout_error_page
from sxc.pages import render_checkout_error_page
tctx = await get_template_context()
html = await render_checkout_error_page(tctx, error="No hosted checkout URL returned from SumUp.")
return await make_response(html, 500)

View File

@@ -57,7 +57,7 @@ def register() -> Blueprint:
if not order:
return await make_response("Order not found", 404)
from shared.sx.page import get_template_context
from sx.sx_components import render_order_page, render_order_oob
from sxc.pages import render_order_page, render_order_oob
ctx = await get_template_context()
calendar_entries = ctx.get("calendar_entries")
@@ -122,7 +122,7 @@ def register() -> Blueprint:
if not hosted_url:
from shared.sx.page import get_template_context
from sx.sx_components import render_checkout_error_page
from sxc.pages import render_checkout_error_page
tctx = await get_template_context()
html = await render_checkout_error_page(tctx, error="No hosted checkout URL returned from SumUp when trying to reopen payment.", order=order)
return await make_response(html, 500)

View File

@@ -138,7 +138,7 @@ def register(url_prefix: str) -> Blueprint:
orders = result.scalars().all()
from shared.sx.page import get_template_context
from sx.sx_components import (
from sxc.pages import (
render_orders_page,
render_orders_rows,
render_orders_oob,

View File

@@ -47,7 +47,7 @@ def register():
g.page_config = SimpleNamespace(**raw_pc) if raw_pc else None
from shared.sx.page import get_template_context
from sx.sx_components import render_cart_payments_panel
from sxc.pages import render_cart_payments_panel
ctx = await get_template_context()
html = await render_cart_payments_panel(ctx)
return sx_response(html)

View File

@@ -12,3 +12,6 @@ def register_domain_services() -> None:
from shared.services.cart_impl import SqlCartService
services.cart = SqlCartService()
from .cart_page import CartPageService
services.register("cart_page", CartPageService())

187
cart/services/cart_page.py Normal file
View File

@@ -0,0 +1,187 @@
"""Cart page data service — provides serialized dicts for .sx defpages."""
from __future__ import annotations
from typing import Any
def _serialize_cart_item(item: Any) -> dict:
from quart import url_for
from shared.infrastructure.urls import market_product_url
p = item.product if hasattr(item, "product") else item
slug = p.slug if hasattr(p, "slug") else ""
unit_price = getattr(p, "special_price", None) or getattr(p, "regular_price", None)
currency = getattr(p, "regular_price_currency", "GBP") or "GBP"
return {
"slug": slug,
"title": p.title if hasattr(p, "title") else "",
"image": p.image if hasattr(p, "image") else None,
"brand": getattr(p, "brand", None),
"is_deleted": getattr(item, "is_deleted", False),
"unit_price": float(unit_price) if unit_price else None,
"special_price": float(p.special_price) if getattr(p, "special_price", None) else None,
"regular_price": float(p.regular_price) if getattr(p, "regular_price", None) else None,
"currency": currency,
"quantity": item.quantity,
"product_id": p.id,
"product_url": market_product_url(slug),
"qty_url": url_for("cart_global.update_quantity", product_id=p.id),
}
def _serialize_cal_entry(e: Any) -> dict:
name = getattr(e, "name", None) or getattr(e, "calendar_name", "")
start = e.start_at if hasattr(e, "start_at") else ""
end = getattr(e, "end_at", None)
cost = getattr(e, "cost", 0) or 0
end_str = f" \u2013 {end}" if end else ""
return {
"name": name,
"date_str": f"{start}{end_str}",
"cost": float(cost),
}
def _serialize_ticket_group(tg: Any) -> dict:
name = tg.entry_name if hasattr(tg, "entry_name") else tg.get("entry_name", "")
tt_name = tg.ticket_type_name if hasattr(tg, "ticket_type_name") else tg.get("ticket_type_name", "")
price = tg.price if hasattr(tg, "price") else tg.get("price", 0)
quantity = tg.quantity if hasattr(tg, "quantity") else tg.get("quantity", 0)
line_total = tg.line_total if hasattr(tg, "line_total") else tg.get("line_total", 0)
entry_id = tg.entry_id if hasattr(tg, "entry_id") else tg.get("entry_id", "")
tt_id = tg.ticket_type_id if hasattr(tg, "ticket_type_id") else tg.get("ticket_type_id", "")
start_at = tg.entry_start_at if hasattr(tg, "entry_start_at") else tg.get("entry_start_at")
end_at = tg.entry_end_at if hasattr(tg, "entry_end_at") else tg.get("entry_end_at")
date_str = start_at.strftime("%-d %b %Y, %H:%M") if start_at else ""
if end_at:
date_str += f" \u2013 {end_at.strftime('%-d %b %Y, %H:%M')}"
return {
"entry_name": name,
"ticket_type_name": tt_name or None,
"price": float(price or 0),
"quantity": quantity,
"line_total": float(line_total or 0),
"entry_id": entry_id,
"ticket_type_id": tt_id or None,
"date_str": date_str,
}
def _serialize_page_group(grp: Any) -> dict | None:
post = grp.get("post") if isinstance(grp, dict) else getattr(grp, "post", None)
cart_items = grp.get("cart_items", []) if isinstance(grp, dict) else getattr(grp, "cart_items", [])
cal_entries = grp.get("calendar_entries", []) if isinstance(grp, dict) else getattr(grp, "calendar_entries", [])
tickets = grp.get("tickets", []) if isinstance(grp, dict) else getattr(grp, "tickets", [])
if not cart_items and not cal_entries and not tickets:
return None
post_data = None
if post:
post_data = {
"slug": post.slug if hasattr(post, "slug") else post.get("slug", ""),
"title": post.title if hasattr(post, "title") else post.get("title", ""),
"feature_image": post.feature_image if hasattr(post, "feature_image") else post.get("feature_image"),
}
market_place = grp.get("market_place") if isinstance(grp, dict) else getattr(grp, "market_place", None)
mp_data = None
if market_place:
mp_data = {"name": market_place.name if hasattr(market_place, "name") else market_place.get("name", "")}
return {
"post": post_data,
"product_count": grp.get("product_count", 0) if isinstance(grp, dict) else getattr(grp, "product_count", 0),
"calendar_count": grp.get("calendar_count", 0) if isinstance(grp, dict) else getattr(grp, "calendar_count", 0),
"ticket_count": grp.get("ticket_count", 0) if isinstance(grp, dict) else getattr(grp, "ticket_count", 0),
"total": float(grp.get("total", 0) if isinstance(grp, dict) else getattr(grp, "total", 0)),
"market_place": mp_data,
}
class CartPageService:
"""Service for cart page data, callable via (service "cart-page" ...)."""
async def overview_data(self, session, **kw):
from shared.infrastructure.urls import cart_url
from bp.cart.services import get_cart_grouped_by_page
page_groups = await get_cart_grouped_by_page(session)
grp_dicts = [d for d in (_serialize_page_group(grp) for grp in page_groups) if d]
return {
"page_groups": grp_dicts,
"cart_url_base": cart_url(""),
}
async def page_cart_data(self, session, **kw):
from quart import g, request, url_for
from shared.infrastructure.urls import login_url
from shared.utils import route_prefix
from bp.cart.services import total, calendar_total, 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
post = g.page_post
cart = await get_cart_for_page(session, post.id)
cal_entries = await get_calendar_entries_for_page(session, post.id)
page_tickets = await get_tickets_for_page(session, post.id)
ticket_groups = group_tickets(page_tickets)
# Build summary data
product_qty = sum(ci.quantity for ci in cart) if cart else 0
ticket_qty = len(page_tickets) if page_tickets else 0
item_count = product_qty + ticket_qty
product_total = total(cart) or 0
cal_total = calendar_total(cal_entries) or 0
tk_total = ticket_total(page_tickets) or 0
grand = float(product_total) + float(cal_total) + float(tk_total)
symbol = "\u00a3"
if cart and hasattr(cart[0], "product") and getattr(cart[0].product, "regular_price_currency", None):
cur = cart[0].product.regular_price_currency
symbol = "\u00a3" if cur == "GBP" else cur
user = getattr(g, "user", None)
page_post = getattr(g, "page_post", None)
summary = {
"item_count": item_count,
"grand_total": grand,
"symbol": symbol,
"is_logged_in": bool(user),
}
if user:
if page_post:
action = url_for("page_cart.page_checkout")
else:
action = url_for("cart_global.checkout")
summary["checkout_action"] = route_prefix() + action
summary["user_email"] = user.email
else:
summary["login_href"] = login_url(request.url)
return {
"cart_items": [_serialize_cart_item(i) for i in cart],
"cal_entries": [_serialize_cal_entry(e) for e in cal_entries],
"ticket_groups": [_serialize_ticket_group(tg) for tg in ticket_groups],
"summary": summary,
}
async def payments_data(self, session, **kw):
from shared.sx.page import get_template_context
ctx = await get_template_context()
page_config = ctx.get("page_config")
pc_data = None
if page_config:
pc_data = {
"sumup_api_key": bool(getattr(page_config, "sumup_api_key", None)),
"sumup_merchant_code": getattr(page_config, "sumup_merchant_code", None) or "",
"sumup_checkout_prefix": getattr(page_config, "sumup_checkout_prefix", None) or "",
}
return {"page_config": pc_data}

View File

@@ -1,408 +0,0 @@
"""
Cart service s-expression page components.
Thin Python wrappers for header/layout helpers and route-level render
functions. All visual rendering logic lives in .sx defcomps.
"""
from __future__ import annotations
import os
from typing import Any
from markupsafe import escape
from shared.sx.jinja_bridge import load_service_components
from shared.sx.helpers import (
call_url, root_header_sx, post_admin_header_sx,
post_header_sx as _shared_post_header_sx,
search_desktop_sx, search_mobile_sx,
full_page_sx, oob_page_sx, header_child_sx,
render_to_sx,
)
from shared.sx.parser import SxExpr
from shared.infrastructure.urls import cart_url
# Load cart-specific .sx components + handlers at import time
load_service_components(os.path.dirname(os.path.dirname(__file__)),
service_name="cart")
# ---------------------------------------------------------------------------
# Header helpers (used by layouts in sxc/pages/__init__.py)
# ---------------------------------------------------------------------------
def _ensure_post_ctx(ctx: dict, page_post: Any) -> dict:
"""Ensure ctx has a 'post' dict from page_post DTO (for shared post_header_sx)."""
if ctx.get("post") or not page_post:
return ctx
ctx = {**ctx, "post": {
"id": getattr(page_post, "id", None),
"slug": getattr(page_post, "slug", ""),
"title": getattr(page_post, "title", ""),
"feature_image": getattr(page_post, "feature_image", None),
}}
return ctx
async def _ensure_container_nav(ctx: dict) -> dict:
"""Fetch container_nav if not already present (for post header row)."""
if ctx.get("container_nav"):
return ctx
post = ctx.get("post") or {}
post_id = post.get("id")
slug = post.get("slug", "")
if not post_id:
return ctx
from shared.infrastructure.fragments import fetch_fragments
nav_params = {
"container_type": "page",
"container_id": str(post_id),
"post_slug": slug,
}
events_nav, market_nav = await fetch_fragments([
("events", "container-nav", nav_params),
("market", "container-nav", nav_params),
], required=False)
return {**ctx, "container_nav": events_nav + market_nav}
async def _post_header_sx(ctx: dict, page_post: Any, *, oob: bool = False) -> str:
"""Build post-level header row from page_post DTO, using shared helper."""
ctx = _ensure_post_ctx(ctx, page_post)
ctx = await _ensure_container_nav(ctx)
return await _shared_post_header_sx(ctx, oob=oob)
async def _cart_header_sx(ctx: dict, *, oob: bool = False) -> str:
"""Build the cart section header row."""
return await render_to_sx(
"menu-row-sx",
id="cart-row", level=1, colour="sky",
link_href=call_url(ctx, "cart_url", "/"),
link_label="cart", icon="fa fa-shopping-cart",
child_id="cart-header-child", oob=oob,
)
async def _page_cart_header_sx(ctx: dict, page_post: Any, *, oob: bool = False) -> str:
"""Build the per-page cart header row."""
slug = page_post.slug if page_post else ""
title = ((page_post.title if page_post else None) or "")[:160]
label_parts = []
if page_post and page_post.feature_image:
label_parts.append(await render_to_sx("cart-page-label-img", src=page_post.feature_image))
label_parts.append(f'(span "{escape(title)}")')
label_sx = "(<> " + " ".join(label_parts) + ")"
nav_sx = await render_to_sx("cart-all-carts-link", href=call_url(ctx, "cart_url", "/"))
return await render_to_sx(
"menu-row-sx",
id="page-cart-row", level=2, colour="sky",
link_href=call_url(ctx, "cart_url", f"/{slug}/"),
link_label_content=SxExpr(label_sx),
nav=SxExpr(nav_sx), oob=oob,
)
async def _auth_header_sx(ctx: dict, *, oob: bool = False) -> str:
"""Build the account section header row (for orders)."""
return await render_to_sx(
"auth-header-row-simple",
account_url=call_url(ctx, "account_url", ""),
oob=oob,
)
async def _orders_header_sx(ctx: dict, list_url: str) -> str:
"""Build the orders section header row."""
return await render_to_sx("orders-header-row", list_url=list_url)
async def _cart_page_admin_header_sx(ctx: dict, page_post: Any, *, oob: bool = False,
selected: str = "") -> str:
"""Build the page-level admin header row."""
slug = page_post.slug if page_post else ""
ctx = _ensure_post_ctx(ctx, page_post)
return await post_admin_header_sx(ctx, slug, oob=oob, selected=selected)
# ---------------------------------------------------------------------------
# Serialization helpers (shared with sxc/pages/__init__.py)
# ---------------------------------------------------------------------------
def _serialize_order(order: Any) -> dict:
"""Serialize an order for SX defcomps."""
from shared.infrastructure.urls import market_product_url
created = order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else "\u2014"
items = []
if order.items:
for item in order.items:
items.append({
"product_image": item.product_image,
"product_title": item.product_title or "Unknown product",
"product_id": item.product_id,
"product_slug": item.product_slug,
"product_url": market_product_url(item.product_slug),
"quantity": item.quantity,
"unit_price_formatted": f"{item.unit_price or 0:.2f}",
"currency": item.currency or order.currency or "GBP",
})
return {
"id": order.id,
"status": order.status or "pending",
"created_at_formatted": created,
"description": order.description or "",
"total_formatted": f"{order.total_amount or 0:.2f}",
"total_amount": float(order.total_amount or 0),
"currency": order.currency or "GBP",
"items": items,
}
def _serialize_calendar_entry(e: Any) -> dict:
"""Serialize an order calendar entry for SX defcomps."""
st = e.state or ""
ds = e.start_at.strftime("%-d %b %Y, %H:%M") if e.start_at else ""
if e.end_at:
ds += f" \u2013 {e.end_at.strftime('%-d %b %Y, %H:%M')}"
return {
"name": e.name,
"state": st,
"date_str": ds,
"cost_formatted": f"{e.cost or 0:.2f}",
}
# ---------------------------------------------------------------------------
# Public API: Orders list (used by cart/bp/orders/routes.py)
# ---------------------------------------------------------------------------
async def render_orders_page(ctx: dict, orders: list, page: int,
total_pages: int, search: str | None,
search_count: int, url_for_fn: Any,
qs_fn: Any) -> str:
"""Full page: orders list."""
from shared.utils import route_prefix
ctx["search"] = search
ctx["search_count"] = search_count
pfx = route_prefix()
list_url = pfx + url_for_fn("orders.list_orders")
rows_url = list_url
detail_url_prefix = pfx + url_for_fn("orders.order.order_detail", order_id=0).rsplit("0/", 1)[0]
order_dicts = [_serialize_order(o) for o in orders]
content = await render_to_sx("orders-list-content",
orders=order_dicts,
page=page, total_pages=total_pages,
rows_url=rows_url, detail_url_prefix=detail_url_prefix)
hdr = await root_header_sx(ctx)
auth = await _auth_header_sx(ctx)
orders_hdr = await _orders_header_sx(ctx, list_url)
auth_child_inner = await render_to_sx("header-child-sx", id="auth-header-child", inner=SxExpr(orders_hdr))
auth_child = await render_to_sx(
"header-child-sx",
inner=SxExpr("(<> " + auth + " " + auth_child_inner + ")"),
)
header_rows = "(<> " + hdr + " " + auth_child + ")"
filt = await render_to_sx("order-list-header", search_mobile=SxExpr(await search_mobile_sx(ctx)))
return await full_page_sx(ctx, header_rows=header_rows,
filter=filt,
aside=await search_desktop_sx(ctx),
content=content)
async def render_orders_rows(ctx: dict, orders: list, page: int,
total_pages: int, url_for_fn: Any,
qs_fn: Any) -> str:
"""Pagination: just the table rows."""
from shared.utils import route_prefix
pfx = route_prefix()
list_url = pfx + url_for_fn("orders.list_orders")
detail_url_prefix = pfx + url_for_fn("orders.order.order_detail", order_id=0).rsplit("0/", 1)[0]
order_dicts = [_serialize_order(o) for o in orders]
parts = []
for od in order_dicts:
parts.append(await render_to_sx("order-row-pair",
order=od,
detail_url_prefix=detail_url_prefix))
if page < total_pages:
next_url = list_url + qs_fn(page=page + 1)
parts.append(await render_to_sx(
"infinite-scroll",
url=next_url, page=page, total_pages=total_pages,
id_prefix="orders", colspan=5,
))
else:
parts.append(await render_to_sx("order-end-row"))
return "(<> " + " ".join(parts) + ")"
async def render_orders_oob(ctx: dict, orders: list, page: int,
total_pages: int, search: str | None,
search_count: int, url_for_fn: Any,
qs_fn: Any) -> str:
"""OOB response for orders list."""
from shared.utils import route_prefix
ctx["search"] = search
ctx["search_count"] = search_count
pfx = route_prefix()
list_url = pfx + url_for_fn("orders.list_orders")
rows_url = list_url
detail_url_prefix = pfx + url_for_fn("orders.order.order_detail", order_id=0).rsplit("0/", 1)[0]
order_dicts = [_serialize_order(o) for o in orders]
content = await render_to_sx("orders-list-content",
orders=order_dicts,
page=page, total_pages=total_pages,
rows_url=rows_url, detail_url_prefix=detail_url_prefix)
auth_oob = await _auth_header_sx(ctx, oob=True)
orders_hdr = await _orders_header_sx(ctx, list_url)
auth_child_oob = await render_to_sx(
"oob-header-sx",
parent_id="auth-header-child",
row=SxExpr(orders_hdr),
)
root_oob = await root_header_sx(ctx, oob=True)
oobs = "(<> " + auth_oob + " " + auth_child_oob + " " + root_oob + ")"
filt = await render_to_sx("order-list-header", search_mobile=SxExpr(await search_mobile_sx(ctx)))
return await oob_page_sx(oobs=oobs,
filter=filt,
aside=await search_desktop_sx(ctx),
content=content)
# ---------------------------------------------------------------------------
# Public API: Single order detail (used by cart/bp/order/routes.py)
# ---------------------------------------------------------------------------
async def render_order_page(ctx: dict, order: Any,
calendar_entries: list | None,
url_for_fn: Any) -> str:
"""Full page: single order detail."""
from shared.utils import route_prefix
from shared.browser.app.csrf import generate_csrf_token
pfx = route_prefix()
detail_url = pfx + url_for_fn("orders.order.order_detail", order_id=order.id)
list_url = pfx + url_for_fn("orders.list_orders")
recheck_url = pfx + url_for_fn("orders.order.order_recheck", order_id=order.id)
pay_url = pfx + url_for_fn("orders.order.order_pay", order_id=order.id)
order_data = _serialize_order(order)
cal_data = [_serialize_calendar_entry(e) for e in (calendar_entries or [])]
main = await render_to_sx("order-detail-content",
order=order_data,
calendar_entries=cal_data)
filt = await render_to_sx("order-detail-filter-content",
order=order_data,
list_url=list_url, recheck_url=recheck_url,
pay_url=pay_url, csrf=generate_csrf_token())
hdr = await root_header_sx(ctx)
order_row = await render_to_sx(
"menu-row-sx",
id="order-row", level=3, colour="sky",
link_href=detail_url, link_label=f"Order {order.id}", icon="fa fa-gbp",
)
auth = await _auth_header_sx(ctx)
orders_hdr = await _orders_header_sx(ctx, list_url)
orders_child = await render_to_sx("header-child-sx", id="orders-header-child", inner=SxExpr(order_row))
auth_inner = "(<> " + orders_hdr + " " + orders_child + ")"
auth_child = await render_to_sx("header-child-sx", id="auth-header-child", inner=SxExpr(auth_inner))
order_child = await render_to_sx(
"header-child-sx",
inner=SxExpr("(<> " + auth + " " + auth_child + ")"),
)
header_rows = "(<> " + hdr + " " + order_child + ")"
return await full_page_sx(ctx, header_rows=header_rows, filter=filt, content=main)
async def render_order_oob(ctx: dict, order: Any,
calendar_entries: list | None,
url_for_fn: Any) -> str:
"""OOB response for single order detail."""
from shared.utils import route_prefix
from shared.browser.app.csrf import generate_csrf_token
pfx = route_prefix()
detail_url = pfx + url_for_fn("orders.order.order_detail", order_id=order.id)
list_url = pfx + url_for_fn("orders.list_orders")
recheck_url = pfx + url_for_fn("orders.order.order_recheck", order_id=order.id)
pay_url = pfx + url_for_fn("orders.order.order_pay", order_id=order.id)
order_data = _serialize_order(order)
cal_data = [_serialize_calendar_entry(e) for e in (calendar_entries or [])]
main = await render_to_sx("order-detail-content",
order=order_data,
calendar_entries=cal_data)
filt = await render_to_sx("order-detail-filter-content",
order=order_data,
list_url=list_url, recheck_url=recheck_url,
pay_url=pay_url, csrf=generate_csrf_token())
order_row_oob = await render_to_sx(
"menu-row-sx",
id="order-row", level=3, colour="sky",
link_href=detail_url, link_label=f"Order {order.id}", icon="fa fa-gbp",
oob=True,
)
orders_child_oob = await render_to_sx("oob-header-sx",
parent_id="orders-header-child",
row=SxExpr(order_row_oob))
root_oob = await root_header_sx(ctx, oob=True)
oobs = "(<> " + orders_child_oob + " " + root_oob + ")"
return await oob_page_sx(oobs=oobs, filter=filt, content=main)
# ---------------------------------------------------------------------------
# Public API: Checkout error (used by cart/bp/cart routes + order routes)
# ---------------------------------------------------------------------------
async def render_checkout_error_page(ctx: dict, error: str | None = None,
order: Any | None = None) -> str:
"""Full page: checkout error."""
err_msg = error or "Unexpected error while creating the hosted checkout session."
order_sx = None
if order:
order_sx = await render_to_sx("checkout-error-order-id", oid=f"#{order.id}")
back_url = cart_url("/")
hdr = await root_header_sx(ctx)
filt = await render_to_sx("checkout-error-header")
content = await render_to_sx(
"checkout-error-content",
msg=err_msg,
order=SxExpr(order_sx) if order_sx else None,
back_url=back_url,
)
return await full_page_sx(ctx, header_rows=hdr, filter=filt, content=content)
# ---------------------------------------------------------------------------
# Public API: POST response renderers
# ---------------------------------------------------------------------------
async def render_cart_payments_panel(ctx: dict) -> str:
"""Render the payments config panel for PUT response."""
page_config = ctx.get("page_config")
pc_data = None
if page_config:
pc_data = {
"sumup_api_key": bool(getattr(page_config, "sumup_api_key", None)),
"sumup_merchant_code": getattr(page_config, "sumup_merchant_code", None) or "",
"sumup_checkout_prefix": getattr(page_config, "sumup_checkout_prefix", None) or "",
}
return await render_to_sx("cart-payments-content",
page_config=pc_data)

View File

@@ -1,13 +1,15 @@
"""Cart defpage setup — registers layouts, page helpers, and loads .sx pages."""
"""Cart defpage setup — registers layouts and loads .sx pages."""
from __future__ import annotations
from typing import Any
from markupsafe import escape
from shared.sx.parser import SxExpr
def setup_cart_pages() -> None:
"""Register cart-specific layouts, page helpers, and load page definitions."""
"""Register cart-specific layouts and load page definitions."""
_register_cart_layouts()
_register_cart_helpers()
_load_cart_page_files()
@@ -17,6 +19,280 @@ def _load_cart_page_files() -> None:
load_page_dir(os.path.dirname(__file__), "cart")
# ---------------------------------------------------------------------------
# Header helpers (moved from sx_components.py)
# ---------------------------------------------------------------------------
def _ensure_post_ctx(ctx: dict, page_post: Any) -> dict:
"""Ensure ctx has a 'post' dict from page_post DTO."""
if ctx.get("post") or not page_post:
return ctx
return {**ctx, "post": {
"id": getattr(page_post, "id", None),
"slug": getattr(page_post, "slug", ""),
"title": getattr(page_post, "title", ""),
"feature_image": getattr(page_post, "feature_image", None),
}}
async def _ensure_container_nav(ctx: dict) -> dict:
"""Fetch container_nav if not already present."""
if ctx.get("container_nav"):
return ctx
post = ctx.get("post") or {}
post_id = post.get("id")
if not post_id:
return ctx
slug = post.get("slug", "")
from shared.infrastructure.fragments import fetch_fragments
nav_params = {
"container_type": "page",
"container_id": str(post_id),
"post_slug": slug,
}
events_nav, market_nav = await fetch_fragments([
("events", "container-nav", nav_params),
("market", "container-nav", nav_params),
], required=False)
return {**ctx, "container_nav": events_nav + market_nav}
async def _post_header_sx(ctx: dict, page_post: Any, *, oob: bool = False) -> str:
from shared.sx.helpers import post_header_sx as _shared_post_header_sx
ctx = _ensure_post_ctx(ctx, page_post)
ctx = await _ensure_container_nav(ctx)
return await _shared_post_header_sx(ctx, oob=oob)
async def _cart_header_sx(ctx: dict, *, oob: bool = False) -> str:
from shared.sx.helpers import render_to_sx, call_url
return await render_to_sx(
"menu-row-sx",
id="cart-row", level=1, colour="sky",
link_href=call_url(ctx, "cart_url", "/"),
link_label="cart", icon="fa fa-shopping-cart",
child_id="cart-header-child", oob=oob,
)
async def _page_cart_header_sx(ctx: dict, page_post: Any, *, oob: bool = False) -> str:
from shared.sx.helpers import render_to_sx, call_url
slug = page_post.slug if page_post else ""
title = ((page_post.title if page_post else None) or "")[:160]
label_parts = []
if page_post and page_post.feature_image:
label_parts.append(await render_to_sx("cart-page-label-img", src=page_post.feature_image))
label_parts.append(f'(span "{escape(title)}")')
label_sx = "(<> " + " ".join(label_parts) + ")"
nav_sx = await render_to_sx("cart-all-carts-link", href=call_url(ctx, "cart_url", "/"))
return await render_to_sx(
"menu-row-sx",
id="page-cart-row", level=2, colour="sky",
link_href=call_url(ctx, "cart_url", f"/{slug}/"),
link_label_content=SxExpr(label_sx),
nav=SxExpr(nav_sx), oob=oob,
)
async def _auth_header_sx(ctx: dict, *, oob: bool = False) -> str:
from shared.sx.helpers import render_to_sx, call_url
return await render_to_sx(
"auth-header-row-simple",
account_url=call_url(ctx, "account_url", ""),
oob=oob,
)
async def _orders_header_sx(ctx: dict, list_url: str) -> str:
from shared.sx.helpers import render_to_sx
return await render_to_sx("orders-header-row", list_url=list_url)
async def _cart_page_admin_header_sx(ctx: dict, page_post: Any, *, oob: bool = False,
selected: str = "") -> str:
from shared.sx.helpers import post_admin_header_sx
slug = page_post.slug if page_post else ""
ctx = _ensure_post_ctx(ctx, page_post)
return await post_admin_header_sx(ctx, slug, oob=oob, selected=selected)
# ---------------------------------------------------------------------------
# Order serialization helpers (used by route render functions below)
# ---------------------------------------------------------------------------
def _serialize_order(order: Any) -> dict:
from shared.infrastructure.urls import market_product_url
created = order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else "\u2014"
items = []
if order.items:
for item in order.items:
items.append({
"product_image": item.product_image,
"product_title": item.product_title or "Unknown product",
"product_id": item.product_id,
"product_slug": item.product_slug,
"product_url": market_product_url(item.product_slug),
"quantity": item.quantity,
"unit_price_formatted": f"{item.unit_price or 0:.2f}",
"currency": item.currency or order.currency or "GBP",
})
return {
"id": order.id,
"status": order.status or "pending",
"created_at_formatted": created,
"description": order.description or "",
"total_formatted": f"{order.total_amount or 0:.2f}",
"total_amount": float(order.total_amount or 0),
"currency": order.currency or "GBP",
"items": items,
}
def _serialize_calendar_entry(e: Any) -> dict:
st = e.state or ""
ds = e.start_at.strftime("%-d %b %Y, %H:%M") if e.start_at else ""
if e.end_at:
ds += f" \u2013 {e.end_at.strftime('%-d %b %Y, %H:%M')}"
return {"name": e.name, "state": st, "date_str": ds, "cost_formatted": f"{e.cost or 0:.2f}"}
# ---------------------------------------------------------------------------
# Render functions (called by routes)
# ---------------------------------------------------------------------------
async def render_orders_page(ctx, orders, page, total_pages, search, search_count, url_for_fn, qs_fn):
from shared.sx.helpers import render_to_sx, root_header_sx, search_desktop_sx, search_mobile_sx, full_page_sx
from shared.utils import route_prefix
ctx["search"] = search
ctx["search_count"] = search_count
pfx = route_prefix()
list_url = pfx + url_for_fn("orders.list_orders")
detail_url_prefix = pfx + url_for_fn("orders.order.order_detail", order_id=0).rsplit("0/", 1)[0]
order_dicts = [_serialize_order(o) for o in orders]
content = await render_to_sx("orders-list-content", orders=order_dicts,
page=page, total_pages=total_pages, rows_url=list_url, detail_url_prefix=detail_url_prefix)
hdr = await root_header_sx(ctx)
auth = await _auth_header_sx(ctx)
orders_hdr = await _orders_header_sx(ctx, list_url)
auth_child_inner = await render_to_sx("header-child-sx", id="auth-header-child", inner=SxExpr(orders_hdr))
auth_child = await render_to_sx("header-child-sx", inner=SxExpr("(<> " + auth + " " + auth_child_inner + ")"))
header_rows = "(<> " + hdr + " " + auth_child + ")"
filt = await render_to_sx("order-list-header", search_mobile=SxExpr(await search_mobile_sx(ctx)))
return await full_page_sx(ctx, header_rows=header_rows, filter=filt,
aside=await search_desktop_sx(ctx), content=content)
async def render_orders_rows(ctx, orders, page, total_pages, url_for_fn, qs_fn):
from shared.sx.helpers import render_to_sx
from shared.utils import route_prefix
pfx = route_prefix()
list_url = pfx + url_for_fn("orders.list_orders")
detail_url_prefix = pfx + url_for_fn("orders.order.order_detail", order_id=0).rsplit("0/", 1)[0]
order_dicts = [_serialize_order(o) for o in orders]
parts = []
for od in order_dicts:
parts.append(await render_to_sx("order-row-pair", order=od, detail_url_prefix=detail_url_prefix))
if page < total_pages:
next_url = list_url + qs_fn(page=page + 1)
parts.append(await render_to_sx("infinite-scroll", url=next_url, page=page,
total_pages=total_pages, id_prefix="orders", colspan=5))
else:
parts.append(await render_to_sx("order-end-row"))
return "(<> " + " ".join(parts) + ")"
async def render_orders_oob(ctx, orders, page, total_pages, search, search_count, url_for_fn, qs_fn):
from shared.sx.helpers import render_to_sx, root_header_sx, search_desktop_sx, search_mobile_sx, oob_page_sx
from shared.utils import route_prefix
ctx["search"] = search
ctx["search_count"] = search_count
pfx = route_prefix()
list_url = pfx + url_for_fn("orders.list_orders")
detail_url_prefix = pfx + url_for_fn("orders.order.order_detail", order_id=0).rsplit("0/", 1)[0]
order_dicts = [_serialize_order(o) for o in orders]
content = await render_to_sx("orders-list-content", orders=order_dicts,
page=page, total_pages=total_pages, rows_url=list_url, detail_url_prefix=detail_url_prefix)
auth_oob = await _auth_header_sx(ctx, oob=True)
orders_hdr = await _orders_header_sx(ctx, list_url)
auth_child_oob = await render_to_sx("oob-header-sx", parent_id="auth-header-child", row=SxExpr(orders_hdr))
root_oob = await root_header_sx(ctx, oob=True)
oobs = "(<> " + auth_oob + " " + auth_child_oob + " " + root_oob + ")"
filt = await render_to_sx("order-list-header", search_mobile=SxExpr(await search_mobile_sx(ctx)))
return await oob_page_sx(oobs=oobs, filter=filt, aside=await search_desktop_sx(ctx), content=content)
async def render_order_page(ctx, order, calendar_entries, url_for_fn):
from shared.sx.helpers import render_to_sx, root_header_sx, full_page_sx
from shared.utils import route_prefix
from shared.browser.app.csrf import generate_csrf_token
pfx = route_prefix()
detail_url = pfx + url_for_fn("orders.order.order_detail", order_id=order.id)
list_url = pfx + url_for_fn("orders.list_orders")
recheck_url = pfx + url_for_fn("orders.order.order_recheck", order_id=order.id)
pay_url = pfx + url_for_fn("orders.order.order_pay", order_id=order.id)
order_data = _serialize_order(order)
cal_data = [_serialize_calendar_entry(e) for e in (calendar_entries or [])]
main = await render_to_sx("order-detail-content", order=order_data, calendar_entries=cal_data)
filt = await render_to_sx("order-detail-filter-content", order=order_data,
list_url=list_url, recheck_url=recheck_url, pay_url=pay_url, csrf=generate_csrf_token())
hdr = await root_header_sx(ctx)
order_row = await render_to_sx("menu-row-sx", id="order-row", level=3, colour="sky",
link_href=detail_url, link_label=f"Order {order.id}", icon="fa fa-gbp")
auth = await _auth_header_sx(ctx)
orders_hdr = await _orders_header_sx(ctx, list_url)
orders_child = await render_to_sx("header-child-sx", id="orders-header-child", inner=SxExpr(order_row))
auth_inner = "(<> " + orders_hdr + " " + orders_child + ")"
auth_child = await render_to_sx("header-child-sx", id="auth-header-child", inner=SxExpr(auth_inner))
order_child = await render_to_sx("header-child-sx", inner=SxExpr("(<> " + auth + " " + auth_child + ")"))
return await full_page_sx(ctx, header_rows="(<> " + hdr + " " + order_child + ")", filter=filt, content=main)
async def render_order_oob(ctx, order, calendar_entries, url_for_fn):
from shared.sx.helpers import render_to_sx, root_header_sx, oob_page_sx
from shared.utils import route_prefix
from shared.browser.app.csrf import generate_csrf_token
pfx = route_prefix()
detail_url = pfx + url_for_fn("orders.order.order_detail", order_id=order.id)
list_url = pfx + url_for_fn("orders.list_orders")
recheck_url = pfx + url_for_fn("orders.order.order_recheck", order_id=order.id)
pay_url = pfx + url_for_fn("orders.order.order_pay", order_id=order.id)
order_data = _serialize_order(order)
cal_data = [_serialize_calendar_entry(e) for e in (calendar_entries or [])]
main = await render_to_sx("order-detail-content", order=order_data, calendar_entries=cal_data)
filt = await render_to_sx("order-detail-filter-content", order=order_data,
list_url=list_url, recheck_url=recheck_url, pay_url=pay_url, csrf=generate_csrf_token())
order_row_oob = await render_to_sx("menu-row-sx", id="order-row", level=3, colour="sky",
link_href=detail_url, link_label=f"Order {order.id}", icon="fa fa-gbp", oob=True)
orders_child_oob = await render_to_sx("oob-header-sx", parent_id="orders-header-child", row=SxExpr(order_row_oob))
root_oob = await root_header_sx(ctx, oob=True)
return await oob_page_sx(oobs="(<> " + orders_child_oob + " " + root_oob + ")", filter=filt, content=main)
async def render_checkout_error_page(ctx, error=None, order=None):
from shared.sx.helpers import render_to_sx, root_header_sx, full_page_sx
from shared.infrastructure.urls import cart_url
err_msg = error or "Unexpected error while creating the hosted checkout session."
order_sx = await render_to_sx("checkout-error-order-id", oid=f"#{order.id}") if order else None
hdr = await root_header_sx(ctx)
filt = await render_to_sx("checkout-error-header")
content = await render_to_sx("checkout-error-content", msg=err_msg,
order=SxExpr(order_sx) if order_sx else None, back_url=cart_url("/"))
return await full_page_sx(ctx, header_rows=hdr, filter=filt, content=content)
async def render_cart_payments_panel(ctx):
from shared.sx.helpers import render_to_sx
page_config = ctx.get("page_config")
pc_data = None
if page_config:
pc_data = {
"sumup_api_key": bool(getattr(page_config, "sumup_api_key", None)),
"sumup_merchant_code": getattr(page_config, "sumup_merchant_code", None) or "",
"sumup_checkout_prefix": getattr(page_config, "sumup_checkout_prefix", None) or "",
}
return await render_to_sx("cart-payments-content", page_config=pc_data)
# ---------------------------------------------------------------------------
# Layouts
# ---------------------------------------------------------------------------
@@ -30,8 +306,7 @@ def _register_cart_layouts() -> None:
async def _cart_page_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, render_to_sx
from shared.sx.parser import SxExpr
from sx.sx_components import _cart_header_sx, _page_cart_header_sx
page_post = ctx.get("page_post")
root_hdr = await root_header_sx(ctx)
child = await _cart_header_sx(ctx)
@@ -47,8 +322,7 @@ async def _cart_page_full(ctx: dict, **kw: Any) -> str:
async def _cart_page_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, render_to_sx
from shared.sx.parser import SxExpr
from sx.sx_components import _cart_header_sx, _page_cart_header_sx
page_post = ctx.get("page_post")
page_hdr = await _page_cart_header_sx(ctx, page_post)
child_oob = await render_to_sx("oob-header-sx",
@@ -61,8 +335,7 @@ async def _cart_page_oob(ctx: dict, **kw: Any) -> str:
async def _cart_admin_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx
from sx.sx_components import _post_header_sx, _cart_page_admin_header_sx
page_post = ctx.get("page_post")
selected = kw.get("selected", "")
root_hdr = await root_header_sx(ctx)
@@ -72,246 +345,9 @@ async def _cart_admin_full(ctx: dict, **kw: Any) -> str:
async def _cart_admin_oob(ctx: dict, **kw: Any) -> str:
from sx.sx_components import _cart_page_admin_header_sx
page_post = ctx.get("page_post")
selected = kw.get("selected", "")
return await _cart_page_admin_header_sx(ctx, page_post, oob=True, selected=selected)
# ---------------------------------------------------------------------------
# Page helpers
# ---------------------------------------------------------------------------
def _register_cart_helpers() -> None:
from shared.sx.pages import register_page_helpers
register_page_helpers("cart", {
"overview-content": _h_overview_content,
"page-cart-content": _h_page_cart_content,
"cart-admin-content": _h_cart_admin_content,
"cart-payments-content": _h_cart_payments_content,
})
# ---------------------------------------------------------------------------
# Serialization helpers
# ---------------------------------------------------------------------------
def _serialize_cart_item(item: Any) -> dict:
"""Serialize a cart item + product for SX defcomps."""
from quart import url_for
from shared.infrastructure.urls import market_product_url
p = item.product if hasattr(item, "product") else item
slug = p.slug if hasattr(p, "slug") else ""
unit_price = getattr(p, "special_price", None) or getattr(p, "regular_price", None)
currency = getattr(p, "regular_price_currency", "GBP") or "GBP"
return {
"slug": slug,
"title": p.title if hasattr(p, "title") else "",
"image": p.image if hasattr(p, "image") else None,
"brand": getattr(p, "brand", None),
"is_deleted": getattr(item, "is_deleted", False),
"unit_price": float(unit_price) if unit_price else None,
"special_price": float(p.special_price) if getattr(p, "special_price", None) else None,
"regular_price": float(p.regular_price) if getattr(p, "regular_price", None) else None,
"currency": currency,
"quantity": item.quantity,
"product_id": p.id,
"product_url": market_product_url(slug),
"qty_url": url_for("cart_global.update_quantity", product_id=p.id),
}
def _serialize_cal_entry(e: Any) -> dict:
"""Serialize a calendar entry for SX defcomps."""
name = getattr(e, "name", None) or getattr(e, "calendar_name", "")
start = e.start_at if hasattr(e, "start_at") else ""
end = getattr(e, "end_at", None)
cost = getattr(e, "cost", 0) or 0
end_str = f" \u2013 {end}" if end else ""
return {
"name": name,
"date_str": f"{start}{end_str}",
"cost": float(cost),
}
def _serialize_ticket_group(tg: Any) -> dict:
"""Serialize a ticket group for SX defcomps."""
name = tg.entry_name if hasattr(tg, "entry_name") else tg.get("entry_name", "")
tt_name = tg.ticket_type_name if hasattr(tg, "ticket_type_name") else tg.get("ticket_type_name", "")
price = tg.price if hasattr(tg, "price") else tg.get("price", 0)
quantity = tg.quantity if hasattr(tg, "quantity") else tg.get("quantity", 0)
line_total = tg.line_total if hasattr(tg, "line_total") else tg.get("line_total", 0)
entry_id = tg.entry_id if hasattr(tg, "entry_id") else tg.get("entry_id", "")
tt_id = tg.ticket_type_id if hasattr(tg, "ticket_type_id") else tg.get("ticket_type_id", "")
start_at = tg.entry_start_at if hasattr(tg, "entry_start_at") else tg.get("entry_start_at")
end_at = tg.entry_end_at if hasattr(tg, "entry_end_at") else tg.get("entry_end_at")
date_str = start_at.strftime("%-d %b %Y, %H:%M") if start_at else ""
if end_at:
date_str += f" \u2013 {end_at.strftime('%-d %b %Y, %H:%M')}"
return {
"entry_name": name,
"ticket_type_name": tt_name or None,
"price": float(price or 0),
"quantity": quantity,
"line_total": float(line_total or 0),
"entry_id": entry_id,
"ticket_type_id": tt_id or None,
"date_str": date_str,
}
def _serialize_page_group(grp: Any) -> dict:
"""Serialize a page group for SX defcomps."""
post = grp.get("post") if isinstance(grp, dict) else getattr(grp, "post", None)
cart_items = grp.get("cart_items", []) if isinstance(grp, dict) else getattr(grp, "cart_items", [])
cal_entries = grp.get("calendar_entries", []) if isinstance(grp, dict) else getattr(grp, "calendar_entries", [])
tickets = grp.get("tickets", []) if isinstance(grp, dict) else getattr(grp, "tickets", [])
if not cart_items and not cal_entries and not tickets:
return None
post_data = None
if post:
post_data = {
"slug": post.slug if hasattr(post, "slug") else post.get("slug", ""),
"title": post.title if hasattr(post, "title") else post.get("title", ""),
"feature_image": post.feature_image if hasattr(post, "feature_image") else post.get("feature_image"),
}
market_place = grp.get("market_place") if isinstance(grp, dict) else getattr(grp, "market_place", None)
mp_data = None
if market_place:
mp_data = {"name": market_place.name if hasattr(market_place, "name") else market_place.get("name", "")}
return {
"post": post_data,
"product_count": grp.get("product_count", 0) if isinstance(grp, dict) else getattr(grp, "product_count", 0),
"calendar_count": grp.get("calendar_count", 0) if isinstance(grp, dict) else getattr(grp, "calendar_count", 0),
"ticket_count": grp.get("ticket_count", 0) if isinstance(grp, dict) else getattr(grp, "ticket_count", 0),
"total": float(grp.get("total", 0) if isinstance(grp, dict) else getattr(grp, "total", 0)),
"market_place": mp_data,
}
def _build_summary_data(ctx: dict, cart: list, cal_entries: list, tickets: list,
total_fn, cal_total_fn, ticket_total_fn) -> dict:
"""Build cart summary data dict for SX defcomps."""
from quart import g, request, url_for
from shared.infrastructure.urls import login_url
from shared.utils import route_prefix
product_qty = sum(ci.quantity for ci in cart) if cart else 0
ticket_qty = len(tickets) if tickets else 0
item_count = product_qty + ticket_qty
product_total = total_fn(cart) or 0
cal_total = cal_total_fn(cal_entries) or 0
tk_total = ticket_total_fn(tickets) or 0
grand = float(product_total) + float(cal_total) + float(tk_total)
symbol = "\u00a3"
if cart and hasattr(cart[0], "product") and getattr(cart[0].product, "regular_price_currency", None):
cur = cart[0].product.regular_price_currency
symbol = "\u00a3" if cur == "GBP" else cur
user = getattr(g, "user", None)
page_post = ctx.get("page_post")
result = {
"item_count": item_count,
"grand_total": grand,
"symbol": symbol,
"is_logged_in": bool(user),
}
if user:
if page_post:
action = url_for("page_cart.page_checkout")
else:
action = url_for("cart_global.checkout")
result["checkout_action"] = route_prefix() + action
result["user_email"] = user.email
else:
result["login_href"] = login_url(request.url)
return result
# ---------------------------------------------------------------------------
# Page helper implementations
# ---------------------------------------------------------------------------
async def _h_overview_content(**kw):
from quart import g
from shared.sx.helpers import render_to_sx
from shared.infrastructure.urls import cart_url
from bp.cart.services import get_cart_grouped_by_page
page_groups = await get_cart_grouped_by_page(g.s)
grp_dicts = [d for d in (_serialize_page_group(grp) for grp in page_groups) if d]
return await render_to_sx("cart-overview-content",
page_groups=grp_dicts,
cart_url_base=cart_url(""))
async def _h_page_cart_content(page_slug=None, **kw):
from quart import g
from shared.sx.helpers import render_to_sx
from shared.sx.parser import SxExpr
from shared.sx.page import get_template_context
from bp.cart.services import total, calendar_total, 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
post = g.page_post
cart = await get_cart_for_page(g.s, post.id)
cal_entries = await get_calendar_entries_for_page(g.s, post.id)
page_tickets = await get_tickets_for_page(g.s, post.id)
ticket_groups = group_tickets(page_tickets)
ctx = await get_template_context()
sd = _build_summary_data(ctx, cart, cal_entries, page_tickets,
total, calendar_total, ticket_total)
summary_sx = await render_to_sx("cart-summary-from-data",
item_count=sd["item_count"],
grand_total=sd["grand_total"],
symbol=sd["symbol"],
is_logged_in=sd["is_logged_in"],
checkout_action=sd.get("checkout_action"),
login_href=sd.get("login_href"),
user_email=sd.get("user_email"))
return await render_to_sx("cart-page-cart-content",
cart_items=[_serialize_cart_item(i) for i in cart],
cal_entries=[_serialize_cal_entry(e) for e in cal_entries],
ticket_groups=[_serialize_ticket_group(tg) for tg in ticket_groups],
summary=SxExpr(summary_sx))
async def _h_cart_admin_content(page_slug=None, **kw):
return '(~cart-admin-content)'
async def _h_cart_payments_content(page_slug=None, **kw):
from shared.sx.page import get_template_context
from shared.sx.helpers import render_to_sx
ctx = await get_template_context()
page_config = ctx.get("page_config")
pc_data = None
if page_config:
pc_data = {
"sumup_api_key": bool(getattr(page_config, "sumup_api_key", None)),
"sumup_merchant_code": getattr(page_config, "sumup_merchant_code", None) or "",
"sumup_checkout_prefix": getattr(page_config, "sumup_checkout_prefix", None) or "",
}
return await render_to_sx("cart-payments-content",
page_config=pc_data)

View File

@@ -1,25 +1,43 @@
;; Cart app defpage declarations.
;; All data fetching via (service ...) IO primitives, no Python helpers.
(defpage cart-overview
:path "/"
:auth :public
:layout :root
:content (overview-content))
:data (service "cart-page" "overview-data")
:content (~cart-overview-content
:page-groups page-groups
:cart-url-base cart-url-base))
(defpage page-cart-view
:path "/<page_slug>/"
:auth :public
:layout :cart-page
:content (page-cart-content))
:data (service "cart-page" "page-cart-data")
:content (~cart-page-cart-content
:cart-items cart-items
:cal-entries cal-entries
:ticket-groups ticket-groups
:summary (~cart-summary-from-data
:item-count (get summary "item_count")
:grand-total (get summary "grand_total")
:symbol (get summary "symbol")
:is-logged-in (get summary "is_logged_in")
:checkout-action (get summary "checkout_action")
:login-href (get summary "login_href")
:user-email (get summary "user_email"))))
(defpage cart-admin
:path "/<page_slug>/admin/"
:auth :admin
:layout :cart-admin
:content (cart-admin-content))
:content (~cart-admin-content))
(defpage cart-payments
:path "/<page_slug>/admin/payments/"
:auth :admin
:layout (:cart-admin :selected "payments")
:content (cart-payments-content))
:data (service "cart-page" "payments-data")
:content (~cart-payments-content
:page-config page-config))

View File

@@ -46,6 +46,7 @@ services:
- ./blog/alembic:/app/blog/alembic:ro
- ./blog/app.py:/app/app.py
- ./blog/sx:/app/sx
- ./blog/sxc:/app/sxc
- ./blog/bp:/app/bp
- ./blog/services:/app/services
- ./blog/templates:/app/templates
@@ -84,6 +85,7 @@ services:
- ./market/alembic:/app/market/alembic:ro
- ./market/app.py:/app/app.py
- ./market/sx:/app/sx
- ./market/sxc:/app/sxc
- ./market/bp:/app/bp
- ./market/services:/app/services
- ./market/templates:/app/templates
@@ -121,6 +123,7 @@ services:
- ./cart/alembic:/app/cart/alembic:ro
- ./cart/app.py:/app/app.py
- ./cart/sx:/app/sx
- ./cart/sxc:/app/sxc
- ./cart/bp:/app/bp
- ./cart/services:/app/services
- ./cart/templates:/app/templates
@@ -158,6 +161,7 @@ services:
- ./events/alembic:/app/events/alembic:ro
- ./events/app.py:/app/app.py
- ./events/sx:/app/sx
- ./events/sxc:/app/sxc
- ./events/bp:/app/bp
- ./events/services:/app/services
- ./events/templates:/app/templates
@@ -195,6 +199,7 @@ services:
- ./federation/alembic:/app/federation/alembic:ro
- ./federation/app.py:/app/app.py
- ./federation/sx:/app/sx
- ./federation/sxc:/app/sxc
- ./federation/bp:/app/bp
- ./federation/services:/app/services
- ./federation/templates:/app/templates
@@ -232,6 +237,7 @@ services:
- ./account/alembic:/app/account/alembic:ro
- ./account/app.py:/app/app.py
- ./account/sx:/app/sx
- ./account/sxc:/app/sxc
- ./account/bp:/app/bp
- ./account/services:/app/services
- ./account/templates:/app/templates
@@ -331,6 +337,7 @@ services:
- ./orders/alembic:/app/orders/alembic:ro
- ./orders/app.py:/app/app.py
- ./orders/sx:/app/sx
- ./orders/sxc:/app/sxc
- ./orders/bp:/app/bp
- ./orders/services:/app/services
- ./orders/templates:/app/templates

View File

@@ -198,7 +198,7 @@ async def _calendar_nav_sx(ctx: dict) -> str:
admin_href = url_for("defpage_calendar_admin", calendar_slug=cal_slug)
parts.append(await render_to_sx("nav-link", href=admin_href, icon="fa fa-cog",
select_colours=select_colours))
return "".join(parts)
return "(<> " + " ".join(parts) + ")" if parts else ""
# ---------------------------------------------------------------------------

View File

@@ -1,6 +1,5 @@
from __future__ import annotations
import path_setup # noqa: F401 # adds shared/ to sys.path
import sx.sx_components as sx_components # noqa: F401 # ensure Hypercorn --reload watches this file
from pathlib import Path
from quart import g, request
@@ -83,7 +82,9 @@ def create_app() -> "Quart":
app.jinja_loader,
])
# --- defpage setup ---
# Load .sx component files and setup defpage routes
from shared.sx.jinja_bridge import load_service_components
load_service_components(str(Path(__file__).resolve().parent), service_name="federation")
from sxc.pages import setup_federation_pages
setup_federation_pages()
@@ -106,10 +107,11 @@ def create_app() -> "Quart":
async def home():
from quart import make_response
from shared.sx.page import get_template_context
from sx.sx_components import render_federation_home
from shared.sx.helpers import root_header_sx, full_page_sx
ctx = await get_template_context()
html = await render_federation_home(ctx)
hdr = await root_header_sx(ctx)
html = await full_page_sx(ctx, header_rows=hdr)
return await make_response(html)
return app

View File

@@ -42,6 +42,16 @@ SESSION_USER_KEY = "uid"
ALLOWED_CLIENTS = {"blog", "market", "cart", "events", "account"}
async def _render_social_auth_page(component: str, title: str, **kwargs) -> str:
"""Render an auth page with social layout — replaces sx_components helpers."""
from shared.sx.helpers import render_to_sx
from shared.sx.page import get_template_context
from sxc.pages import _social_page
ctx = await get_template_context()
content = await render_to_sx(component, **{k: v for k, v in kwargs.items() if v})
return await _social_page(ctx, None, content=content, title=title)
def register(url_prefix="/auth"):
auth_bp = Blueprint("auth", __name__, url_prefix=url_prefix)
@@ -99,10 +109,7 @@ def register(url_prefix="/auth"):
# If there's a pending redirect (e.g. OAuth authorize), follow it
redirect_url = pop_login_redirect_target()
return redirect(redirect_url)
from shared.sx.page import get_template_context
from sx.sx_components import render_login_page
ctx = await get_template_context()
return await render_login_page(ctx)
return await _render_social_auth_page("account-login-content", "Login \u2014 Rose Ash")
@auth_bp.post("/start/")
async def start_login():
@@ -111,10 +118,10 @@ def register(url_prefix="/auth"):
is_valid, email = validate_email(email_input)
if not is_valid:
from shared.sx.page import get_template_context
from sx.sx_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
return await _render_social_auth_page(
"account-login-content", "Login \u2014 Rose Ash",
error="Please enter a valid email address.", email=email_input,
), 400
user = await find_or_create_user(g.s, email)
token, expires = await create_magic_link(g.s, user.id)
@@ -132,10 +139,10 @@ def register(url_prefix="/auth"):
"Please try again in a moment."
)
from shared.sx.page import get_template_context
from sx.sx_components import render_check_email_page
ctx = await get_template_context(email=email, email_error=email_error)
return await render_check_email_page(ctx)
return await _render_social_auth_page(
"account-check-email-content", "Check your email \u2014 Rose Ash",
email=email, email_error=email_error,
)
@auth_bp.get("/magic/<token>/")
async def magic(token: str):
@@ -148,17 +155,17 @@ def register(url_prefix="/auth"):
user, error = await validate_magic_link(s, token)
if error:
from shared.sx.page import get_template_context
from sx.sx_components import render_login_page
ctx = await get_template_context(error=error)
return await render_login_page(ctx), 400
return await _render_social_auth_page(
"account-login-content", "Login \u2014 Rose Ash",
error=error,
), 400
user_id = user.id
except Exception:
from shared.sx.page import get_template_context
from sx.sx_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
return await _render_social_auth_page(
"account-login-content", "Login \u2014 Rose Ash",
error="Could not sign you in right now. Please try again.",
), 502
assert user_id is not None

View File

@@ -26,6 +26,33 @@ RESERVED = frozenset({
})
async def _render_choose_username(*, actor=None, error="", username=""):
"""Render choose-username page — replaces sx_components helper."""
from shared.browser.app.csrf import generate_csrf_token
from shared.config import config
from shared.sx.helpers import render_to_sx
from shared.sx.parser import SxExpr
from shared.sx.page import get_template_context
from sxc.pages import _social_page
from markupsafe import escape
ctx = await get_template_context()
csrf = generate_csrf_token()
ap_domain = config().get("ap_domain", "rose-ash.com")
check_url = url_for("identity.check_username")
error_sx = await render_to_sx("auth-error-banner", error=error) if error else ""
content = await render_to_sx(
"federation-choose-username",
domain=str(escape(ap_domain)),
error=SxExpr(error_sx) if error_sx else None,
csrf=csrf, username=str(escape(username)),
check_url=check_url,
)
return await _social_page(ctx, actor, content=content,
title="Choose Username \u2014 Rose Ash")
def register(url_prefix="/identity"):
bp = Blueprint("identity", __name__, url_prefix=url_prefix)
@@ -39,11 +66,7 @@ def register(url_prefix="/identity"):
if actor:
return redirect(url_for("activitypub.actor_profile", username=actor.preferred_username))
from shared.sx.page import get_template_context
from sx.sx_components import render_choose_username_page
ctx = await get_template_context()
ctx["actor"] = actor
return await render_choose_username_page(ctx)
return await _render_choose_username(actor=actor)
@bp.post("/choose-username")
async def choose_username():
@@ -71,11 +94,7 @@ def register(url_prefix="/identity"):
error = "This username is already taken."
if error:
from shared.sx.page import get_template_context
from sx.sx_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
return await _render_choose_username(error=error, username=username), 400
# Create ActorProfile with RSA keys
display_name = g.user.name or username

View File

@@ -7,7 +7,7 @@ from datetime import datetime
from quart import Blueprint, request, g, redirect, url_for, abort, Response
from shared.services.registry import services
from shared.sx.helpers import sx_response
from shared.sx.helpers import sx_response, render_to_sx
log = logging.getLogger(__name__)
@@ -47,8 +47,7 @@ def register(url_prefix="/social"):
items = await services.federation.get_home_timeline(
g.s, actor.id, before=before,
)
from sx.sx_components import render_timeline_items
sx_src = await render_timeline_items(items, "home", actor)
sx_src = await _render_timeline_items(items, "home", actor)
return sx_response(sx_src)
@bp.get("/public/timeline")
@@ -62,8 +61,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 sx.sx_components import render_timeline_items
sx_src = await render_timeline_items(items, "public", actor)
sx_src = await _render_timeline_items(items, "public", actor)
return sx_response(sx_src)
# -- Compose ---------------------------------------------------------------
@@ -97,6 +95,8 @@ def register(url_prefix="/social"):
@bp.get("/search/page")
async def search_page():
from sxc.pages import _serialize_remote_actor, _serialize_actor
actor = getattr(g, "_social_actor", None)
query = request.args.get("q", "").strip()
page = request.args.get("page", 1, type=int)
@@ -112,8 +112,18 @@ 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 sx.sx_components import render_search_results
sx_src = await render_search_results(actors_list, query, page, followed_urls, actor)
actor_dicts = [_serialize_remote_actor(a) for a in actors_list]
actor_data = _serialize_actor(actor)
parts = []
for ad in actor_dicts:
parts.append(await render_to_sx("federation-actor-card-from-data",
a=ad, actor=actor_data,
followed_urls=list(followed_urls), list_type="search"))
if len(actors_list) >= 20:
next_url = url_for("social.search_page", q=query, page=page + 1)
parts.append(await render_to_sx("federation-scroll-sentinel", url=next_url))
sx_src = "(<> " + " ".join(parts) + ")" if parts else ""
return sx_response(sx_src)
@bp.post("/follow")
@@ -144,6 +154,8 @@ def register(url_prefix="/social"):
async def _actor_card_response(actor, remote_actor_url, is_followed):
"""Re-render a single actor card after follow/unfollow via HTMX."""
from sxc.pages import _serialize_remote_actor, _serialize_actor
remote_dto = await services.federation.get_or_fetch_remote_actor(
g.s, remote_actor_url,
)
@@ -151,12 +163,12 @@ def register(url_prefix="/social"):
return Response("", status=200)
followed_urls = {remote_actor_url} if is_followed else set()
referer = request.referrer or ""
if "/followers" in referer:
list_type = "followers"
else:
list_type = "following"
from sx.sx_components import render_actor_card
return sx_response(await render_actor_card(remote_dto, actor, followed_urls, list_type=list_type))
list_type = "followers" if "/followers" in referer else "following"
actor_data = _serialize_actor(actor)
ad = _serialize_remote_actor(remote_dto)
return sx_response(await render_to_sx("federation-actor-card-from-data",
a=ad, actor=actor_data,
followed_urls=list(followed_urls), list_type=list_type))
# -- Interactions ----------------------------------------------------------
@@ -198,7 +210,9 @@ def register(url_prefix="/social"):
async def _interaction_buttons_response(actor, object_id, author_inbox):
"""Re-render interaction buttons after a like/boost action."""
from shared.models.federation import APInteraction, APRemotePost, APActivity
from shared.models.federation import APInteraction
from shared.browser.app.csrf import generate_csrf_token
from shared.sx.parser import SxExpr
from sqlalchemy import select
svc = services.federation
@@ -242,32 +256,72 @@ def register(url_prefix="/social"):
).limit(1)
)).scalar())
from sx.sx_components import render_interaction_buttons
return sx_response(await 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,
boosted_by_me=boosted_by_me,
actor=actor,
))
csrf = generate_csrf_token()
safe_id = object_id.replace("/", "_").replace(":", "_")
target = f"#interactions-{safe_id}"
if liked_by_me:
like_action = url_for("social.unlike")
like_cls = "text-red-500 hover:text-red-600"
like_icon = "\u2665"
else:
like_action = url_for("social.like")
like_cls = "hover:text-red-500"
like_icon = "\u2661"
if boosted_by_me:
boost_action = url_for("social.unboost")
boost_cls = "text-green-600 hover:text-green-700"
else:
boost_action = url_for("social.boost")
boost_cls = "hover:text-green-600"
reply_url = url_for("social.defpage_compose_form", reply_to=object_id) if object_id else ""
reply_sx = await render_to_sx("federation-reply-link", url=reply_url) if reply_url else ""
like_form = await render_to_sx("federation-like-form",
action=like_action, target=target, oid=object_id, ainbox=author_inbox,
csrf=csrf, cls=f"flex items-center gap-1 {like_cls}",
icon=like_icon, count=str(like_count))
boost_form = await render_to_sx("federation-boost-form",
action=boost_action, target=target, oid=object_id, ainbox=author_inbox,
csrf=csrf, cls=f"flex items-center gap-1 {boost_cls}",
count=str(boost_count))
return sx_response(await render_to_sx("federation-interaction-buttons",
like=SxExpr(like_form),
boost=SxExpr(boost_form),
reply=SxExpr(reply_sx) if reply_sx else None))
# -- Following / Followers pagination --------------------------------------
@bp.get("/following/page")
async def following_list_page():
from sxc.pages import _serialize_remote_actor, _serialize_actor
actor = _require_actor()
page = request.args.get("page", 1, type=int)
actors_list, total = await services.federation.get_following(
g.s, actor.preferred_username, page=page,
)
from sx.sx_components import render_following_items
sx_src = await render_following_items(actors_list, page, actor)
actor_dicts = [_serialize_remote_actor(a) for a in actors_list]
actor_data = _serialize_actor(actor)
parts = []
for ad in actor_dicts:
parts.append(await render_to_sx("federation-actor-card-from-data",
a=ad, actor=actor_data,
followed_urls=[], list_type="following"))
if len(actors_list) >= 20:
next_url = url_for("social.following_list_page", page=page + 1)
parts.append(await render_to_sx("federation-scroll-sentinel", url=next_url))
sx_src = "(<> " + " ".join(parts) + ")" if parts else ""
return sx_response(sx_src)
@bp.get("/followers/page")
async def followers_list_page():
from sxc.pages import _serialize_remote_actor, _serialize_actor
actor = _require_actor()
page = request.args.get("page", 1, type=int)
actors_list, total = await services.federation.get_followers_paginated(
@@ -277,8 +331,17 @@ 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 sx.sx_components import render_followers_items
sx_src = await render_followers_items(actors_list, page, followed_urls, actor)
actor_dicts = [_serialize_remote_actor(a) for a in actors_list]
actor_data = _serialize_actor(actor)
parts = []
for ad in actor_dicts:
parts.append(await render_to_sx("federation-actor-card-from-data",
a=ad, actor=actor_data,
followed_urls=list(followed_urls), list_type="followers"))
if len(actors_list) >= 20:
next_url = url_for("social.followers_list_page", page=page + 1)
parts.append(await render_to_sx("federation-scroll-sentinel", url=next_url))
sx_src = "(<> " + " ".join(parts) + ")" if parts else ""
return sx_response(sx_src)
@bp.get("/actor/<int:id>/timeline")
@@ -294,8 +357,7 @@ def register(url_prefix="/social"):
items = await services.federation.get_actor_timeline(
g.s, id, before=before,
)
from sx.sx_components import render_actor_timeline_items
sx_src = await render_actor_timeline_items(items, id, actor)
sx_src = await _render_timeline_items(items, "actor", actor, id)
return sx_response(sx_src)
# -- Notifications ---------------------------------------------------------
@@ -321,3 +383,26 @@ def register(url_prefix="/social"):
return redirect(url_for("defpage_notifications"))
return bp
async def _render_timeline_items(items, timeline_type, actor, actor_id=None):
"""Render timeline pagination items as SX fragment."""
from sxc.pages import _serialize_timeline_item, _serialize_actor
item_dicts = [_serialize_timeline_item(i) for i in items]
actor_data = _serialize_actor(actor)
next_url = None
if items:
last = items[-1]
before = last.published.isoformat() if last.published else ""
if timeline_type == "actor" and actor_id is not None:
next_url = url_for("social.actor_timeline_page", id=actor_id, before=before)
else:
next_url = url_for(f"social.{timeline_type}_timeline_page", before=before)
return await render_to_sx("federation-timeline-items",
items=item_dicts,
timeline_type=timeline_type,
actor=actor_data,
next_url=next_url)

View File

@@ -13,3 +13,6 @@ def register_domain_services() -> None:
from shared.services.federation_impl import SqlFederationService
services.federation = SqlFederationService()
from .federation_page import FederationPageService
services.register("federation_page", FederationPageService())

View File

@@ -0,0 +1,205 @@
"""Federation page data service — provides serialized dicts for .sx defpages."""
from __future__ import annotations
def _serialize_actor(actor) -> dict | None:
if not actor:
return None
return {
"id": actor.id,
"preferred_username": actor.preferred_username,
"display_name": getattr(actor, "display_name", None),
"icon_url": getattr(actor, "icon_url", None),
"summary": getattr(actor, "summary", None),
"actor_url": getattr(actor, "actor_url", ""),
"domain": getattr(actor, "domain", ""),
}
def _serialize_timeline_item(item) -> dict:
published = getattr(item, "published", None)
return {
"object_id": getattr(item, "object_id", "") or "",
"author_inbox": getattr(item, "author_inbox", "") or "",
"actor_icon": getattr(item, "actor_icon", None),
"actor_name": getattr(item, "actor_name", "?"),
"actor_username": getattr(item, "actor_username", ""),
"actor_domain": getattr(item, "actor_domain", ""),
"content": getattr(item, "content", ""),
"summary": getattr(item, "summary", None),
"published": published.strftime("%b %d, %H:%M") if published else "",
"before_cursor": published.isoformat() if published else "",
"url": getattr(item, "url", None),
"post_type": getattr(item, "post_type", ""),
"boosted_by": getattr(item, "boosted_by", None),
"like_count": getattr(item, "like_count", 0) or 0,
"boost_count": getattr(item, "boost_count", 0) or 0,
"liked_by_me": getattr(item, "liked_by_me", False),
"boosted_by_me": getattr(item, "boosted_by_me", False),
}
def _serialize_remote_actor(a) -> dict:
return {
"id": getattr(a, "id", None),
"display_name": getattr(a, "display_name", None) or getattr(a, "preferred_username", ""),
"preferred_username": getattr(a, "preferred_username", ""),
"domain": getattr(a, "domain", ""),
"icon_url": getattr(a, "icon_url", None),
"actor_url": getattr(a, "actor_url", ""),
"summary": getattr(a, "summary", None),
}
def _get_actor():
from quart import g
return getattr(g, "_social_actor", None)
def _require_actor():
from quart import abort
actor = _get_actor()
if not actor:
abort(403, "You need to choose a federation username first")
return actor
class FederationPageService:
"""Service for federation page data, callable via (service "federation-page" ...)."""
async def home_timeline_data(self, session, **kw):
actor = _require_actor()
from shared.services.registry import services
items = await services.federation.get_home_timeline(session, actor.id)
return {
"items": [_serialize_timeline_item(i) for i in items],
"timeline_type": "home",
"actor": _serialize_actor(actor),
}
async def public_timeline_data(self, session, **kw):
actor = _get_actor()
from shared.services.registry import services
items = await services.federation.get_public_timeline(session)
return {
"items": [_serialize_timeline_item(i) for i in items],
"timeline_type": "public",
"actor": _serialize_actor(actor),
}
async def compose_data(self, session, **kw):
from quart import request
_require_actor()
reply_to = request.args.get("reply_to")
return {"reply_to": reply_to or None}
async def search_data(self, session, **kw):
from quart import request
actor = _get_actor()
from shared.services.registry import services
query = request.args.get("q", "").strip()
actors_list = []
total = 0
followed_urls: list[str] = []
if query:
actors_list, total = await services.federation.search_actors(session, query)
if actor:
following, _ = await services.federation.get_following(
session, actor.preferred_username, page=1, per_page=1000,
)
followed_urls = [a.actor_url for a in following]
return {
"query": query,
"actors": [_serialize_remote_actor(a) for a in actors_list],
"total": total,
"followed_urls": followed_urls,
"actor": _serialize_actor(actor),
}
async def following_data(self, session, **kw):
actor = _require_actor()
from shared.services.registry import services
actors_list, total = await services.federation.get_following(
session, actor.preferred_username,
)
return {
"actors": [_serialize_remote_actor(a) for a in actors_list],
"total": total,
"actor": _serialize_actor(actor),
}
async def followers_data(self, session, **kw):
actor = _require_actor()
from shared.services.registry import services
actors_list, total = await services.federation.get_followers_paginated(
session, actor.preferred_username,
)
following, _ = await services.federation.get_following(
session, actor.preferred_username, page=1, per_page=1000,
)
followed_urls = [a.actor_url for a in following]
return {
"actors": [_serialize_remote_actor(a) for a in actors_list],
"total": total,
"followed_urls": followed_urls,
"actor": _serialize_actor(actor),
}
async def actor_timeline_data(self, session, *, id=None, **kw):
from quart import abort
from sqlalchemy import select as sa_select
from shared.models.federation import RemoteActor
from shared.services.registry import services
from shared.services.federation_impl import _remote_actor_to_dto
actor = _get_actor()
actor_id = id
remote = (
await session.execute(
sa_select(RemoteActor).where(RemoteActor.id == actor_id)
)
).scalar_one_or_none()
if not remote:
abort(404)
remote_dto = _remote_actor_to_dto(remote)
items = await services.federation.get_actor_timeline(session, actor_id)
is_following = False
if actor:
from shared.models.federation import APFollowing
existing = (
await session.execute(
sa_select(APFollowing).where(
APFollowing.actor_profile_id == actor.id,
APFollowing.remote_actor_id == actor_id,
)
)
).scalar_one_or_none()
is_following = existing is not None
return {
"remote_actor": _serialize_remote_actor(remote_dto),
"items": [_serialize_timeline_item(i) for i in items],
"is_following": is_following,
"actor": _serialize_actor(actor),
}
async def notifications_data(self, session, **kw):
actor = _require_actor()
from shared.services.registry import services
items = await services.federation.get_notifications(session, actor.id)
await services.federation.mark_notifications_read(session, actor.id)
notif_dicts = []
for n in items:
created = getattr(n, "created_at", None)
notif_dicts.append({
"from_actor_name": getattr(n, "from_actor_name", "?"),
"from_actor_username": getattr(n, "from_actor_username", ""),
"from_actor_domain": getattr(n, "from_actor_domain", ""),
"from_actor_icon": getattr(n, "from_actor_icon", None),
"notification_type": getattr(n, "notification_type", ""),
"target_content_preview": getattr(n, "target_content_preview", None),
"created_at_formatted": created.strftime("%b %d, %H:%M") if created else "",
"read": getattr(n, "read", True),
"app_domain": getattr(n, "app_domain", ""),
})
return {"notifications": notif_dicts}

View File

@@ -1,293 +0,0 @@
"""
Federation service s-expression page components.
Page helpers now call assembled defcomps in .sx files. This file contains
only functions still called directly from route handlers: full-page renders
(login, choose-username, profile) and POST fragment renderers (interaction
buttons, actor cards, pagination items).
"""
from __future__ import annotations
import os
from typing import Any
from markupsafe import escape
from shared.sx.jinja_bridge import load_service_components
from shared.sx.helpers import (
render_to_sx,
root_header_sx, full_page_sx, header_child_sx,
)
# Load federation-specific .sx components + handlers at import time
load_service_components(os.path.dirname(os.path.dirname(__file__)),
service_name="federation")
# ---------------------------------------------------------------------------
# Serialization helpers (shared with pages/__init__.py)
# ---------------------------------------------------------------------------
def _serialize_actor(actor) -> dict | None:
if not actor:
return None
return {
"id": actor.id,
"preferred_username": actor.preferred_username,
"display_name": getattr(actor, "display_name", None),
"icon_url": getattr(actor, "icon_url", None),
"summary": getattr(actor, "summary", None),
"actor_url": getattr(actor, "actor_url", ""),
"domain": getattr(actor, "domain", ""),
}
def _serialize_timeline_item(item) -> dict:
published = getattr(item, "published", None)
return {
"object_id": getattr(item, "object_id", "") or "",
"author_inbox": getattr(item, "author_inbox", "") or "",
"actor_icon": getattr(item, "actor_icon", None),
"actor_name": getattr(item, "actor_name", "?"),
"actor_username": getattr(item, "actor_username", ""),
"actor_domain": getattr(item, "actor_domain", ""),
"content": getattr(item, "content", ""),
"summary": getattr(item, "summary", None),
"published": published.strftime("%b %d, %H:%M") if published else "",
"before_cursor": published.isoformat() if published else "",
"url": getattr(item, "url", None),
"post_type": getattr(item, "post_type", ""),
"boosted_by": getattr(item, "boosted_by", None),
"like_count": getattr(item, "like_count", 0) or 0,
"boost_count": getattr(item, "boost_count", 0) or 0,
"liked_by_me": getattr(item, "liked_by_me", False),
"boosted_by_me": getattr(item, "boosted_by_me", False),
}
def _serialize_remote_actor(a) -> dict:
return {
"id": getattr(a, "id", None),
"display_name": getattr(a, "display_name", None) or getattr(a, "preferred_username", ""),
"preferred_username": getattr(a, "preferred_username", ""),
"domain": getattr(a, "domain", ""),
"icon_url": getattr(a, "icon_url", None),
"actor_url": getattr(a, "actor_url", ""),
"summary": getattr(a, "summary", None),
}
# ---------------------------------------------------------------------------
# Social page shell
# ---------------------------------------------------------------------------
async def _social_page(ctx: dict, actor: Any, *, content: str,
title: str = "Rose Ash", meta_html: str = "") -> str:
from shared.sx.parser import SxExpr
actor_data = _serialize_actor(actor)
nav = await render_to_sx("federation-social-nav", actor=actor_data)
social_hdr = await render_to_sx("federation-social-header", nav=SxExpr(nav))
hdr = await root_header_sx(ctx)
child = await header_child_sx(social_hdr)
header_rows = "(<> " + hdr + " " + child + ")"
return await full_page_sx(ctx, header_rows=header_rows, content=content,
meta_html=meta_html or f'<title>{escape(title)}</title>')
# ---------------------------------------------------------------------------
# Public API: Full page renders
# ---------------------------------------------------------------------------
async def render_federation_home(ctx: dict) -> str:
hdr = await root_header_sx(ctx)
return await full_page_sx(ctx, header_rows=hdr)
async def render_login_page(ctx: dict) -> str:
error = ctx.get("error", "")
email = ctx.get("email", "")
content = await render_to_sx("account-login-content",
error=error or None, email=str(escape(email)))
return await _social_page(ctx, None, content=content, title="Login \u2014 Rose Ash")
async def render_check_email_page(ctx: dict) -> str:
email = ctx.get("email", "")
email_error = ctx.get("email_error")
content = await render_to_sx("account-check-email-content",
email=str(escape(email)), email_error=email_error)
return await _social_page(ctx, None, content=content,
title="Check your email \u2014 Rose Ash")
async def render_choose_username_page(ctx: dict) -> str:
from shared.browser.app.csrf import generate_csrf_token
from quart import url_for
from shared.config import config
from shared.sx.parser import SxExpr
csrf = generate_csrf_token()
error = ctx.get("error", "")
username = ctx.get("username", "")
ap_domain = config().get("ap_domain", "rose-ash.com")
check_url = url_for("identity.check_username")
actor = ctx.get("actor")
error_sx = await render_to_sx("auth-error-banner", error=error) if error else ""
content = await render_to_sx(
"federation-choose-username",
domain=str(escape(ap_domain)),
error=SxExpr(error_sx) if error_sx else None,
csrf=csrf, username=str(escape(username)),
check_url=check_url,
)
return await _social_page(ctx, actor, content=content,
title="Choose Username \u2014 Rose Ash")
# ---------------------------------------------------------------------------
# Public API: Pagination fragment renderers
# ---------------------------------------------------------------------------
async def render_timeline_items(items: list, timeline_type: str,
actor: Any, actor_id: int | None = None) -> str:
from quart import url_for
item_dicts = [_serialize_timeline_item(i) for i in items]
actor_data = _serialize_actor(actor)
# Build next URL
next_url = None
if items:
last = items[-1]
before = last.published.isoformat() if last.published else ""
if timeline_type == "actor" and actor_id is not None:
next_url = url_for("social.actor_timeline_page", id=actor_id, before=before)
else:
next_url = url_for(f"social.{timeline_type}_timeline_page", before=before)
return await render_to_sx("federation-timeline-items",
items=item_dicts,
timeline_type=timeline_type,
actor=actor_data,
next_url=next_url)
async def render_search_results(actors: list, query: str, page: int,
followed_urls: set, actor: Any) -> str:
from quart import url_for
actor_dicts = [_serialize_remote_actor(a) for a in actors]
actor_data = _serialize_actor(actor)
parts = []
for ad in actor_dicts:
parts.append(await render_to_sx("federation-actor-card-from-data",
a=ad,
actor=actor_data,
followed_urls=list(followed_urls),
list_type="search"))
if len(actors) >= 20:
next_url = url_for("social.search_page", q=query, page=page + 1)
parts.append(await render_to_sx("federation-scroll-sentinel", url=next_url))
return "(<> " + " ".join(parts) + ")" if parts else ""
async def render_following_items(actors: list, page: int, actor: Any) -> str:
from quart import url_for
actor_dicts = [_serialize_remote_actor(a) for a in actors]
actor_data = _serialize_actor(actor)
parts = []
for ad in actor_dicts:
parts.append(await render_to_sx("federation-actor-card-from-data",
a=ad,
actor=actor_data,
followed_urls=[],
list_type="following"))
if len(actors) >= 20:
next_url = url_for("social.following_list_page", page=page + 1)
parts.append(await render_to_sx("federation-scroll-sentinel", url=next_url))
return "(<> " + " ".join(parts) + ")" if parts else ""
async def render_followers_items(actors: list, page: int,
followed_urls: set, actor: Any) -> str:
from quart import url_for
actor_dicts = [_serialize_remote_actor(a) for a in actors]
actor_data = _serialize_actor(actor)
parts = []
for ad in actor_dicts:
parts.append(await render_to_sx("federation-actor-card-from-data",
a=ad,
actor=actor_data,
followed_urls=list(followed_urls),
list_type="followers"))
if len(actors) >= 20:
next_url = url_for("social.followers_list_page", page=page + 1)
parts.append(await render_to_sx("federation-scroll-sentinel", url=next_url))
return "(<> " + " ".join(parts) + ")" if parts else ""
async def render_actor_timeline_items(items: list, actor_id: int,
actor: Any) -> str:
return await render_timeline_items(items, "actor", actor, actor_id)
# ---------------------------------------------------------------------------
# Public API: POST handler fragment renderers
# ---------------------------------------------------------------------------
async 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 POST response."""
from shared.browser.app.csrf import generate_csrf_token
from quart import url_for
from shared.sx.parser import SxExpr
csrf = generate_csrf_token()
safe_id = object_id.replace("/", "_").replace(":", "_")
target = f"#interactions-{safe_id}"
if liked_by_me:
like_action = url_for("social.unlike")
like_cls = "text-red-500 hover:text-red-600"
like_icon = "\u2665"
else:
like_action = url_for("social.like")
like_cls = "hover:text-red-500"
like_icon = "\u2661"
if boosted_by_me:
boost_action = url_for("social.unboost")
boost_cls = "text-green-600 hover:text-green-700"
else:
boost_action = url_for("social.boost")
boost_cls = "hover:text-green-600"
reply_url = url_for("social.defpage_compose_form", reply_to=object_id) if object_id else ""
reply_sx = await render_to_sx("federation-reply-link", url=reply_url) if reply_url else ""
like_form = await render_to_sx("federation-like-form",
action=like_action, target=target, oid=object_id, ainbox=author_inbox,
csrf=csrf, cls=f"flex items-center gap-1 {like_cls}",
icon=like_icon, count=str(like_count))
boost_form = await render_to_sx("federation-boost-form",
action=boost_action, target=target, oid=object_id, ainbox=author_inbox,
csrf=csrf, cls=f"flex items-center gap-1 {boost_cls}",
count=str(boost_count))
return await render_to_sx("federation-interaction-buttons",
like=SxExpr(like_form),
boost=SxExpr(boost_form),
reply=SxExpr(reply_sx) if reply_sx else None)
async def render_actor_card(actor_dto: Any, actor: Any, followed_urls: set,
*, list_type: str = "following") -> str:
"""Render a single actor card fragment for POST response."""
actor_data = _serialize_actor(actor)
ad = _serialize_remote_actor(actor_dto)
return await render_to_sx("federation-actor-card-from-data",
a=ad,
actor=actor_data,
followed_urls=list(followed_urls),
list_type=list_type)

View File

@@ -1,13 +1,12 @@
"""Federation defpage setup — registers layouts, page helpers, and loads .sx pages."""
"""Federation defpage setup — registers layouts and loads .sx pages."""
from __future__ import annotations
from typing import Any
def setup_federation_pages() -> None:
"""Register federation-specific layouts, page helpers, and load page definitions."""
"""Register federation-specific layouts and load page definitions."""
_register_federation_layouts()
_register_federation_helpers()
_load_federation_page_files()
@@ -55,74 +54,42 @@ async def _social_oob(ctx: dict, **kw: Any) -> str:
# ---------------------------------------------------------------------------
# Page helpers
# Serializers and helpers — still used by layouts and route handlers
# ---------------------------------------------------------------------------
def _register_federation_helpers() -> None:
from shared.sx.pages import register_page_helpers
register_page_helpers("federation", {
"home-timeline-content": _h_home_timeline_content,
"public-timeline-content": _h_public_timeline_content,
"compose-content": _h_compose_content,
"search-content": _h_search_content,
"following-content": _h_following_content,
"followers-content": _h_followers_content,
"actor-timeline-content": _h_actor_timeline_content,
"notifications-content": _h_notifications_content,
})
def _serialize_actor(actor) -> dict | None:
"""Serialize an actor profile to a dict for sx defcomps."""
if not actor:
return None
return {
"id": actor.id,
"preferred_username": actor.preferred_username,
"display_name": getattr(actor, "display_name", None),
"icon_url": getattr(actor, "icon_url", None),
"summary": getattr(actor, "summary", None),
"actor_url": getattr(actor, "actor_url", ""),
"domain": getattr(actor, "domain", ""),
}
from services.federation_page import _serialize_actor as _impl
return _impl(actor)
def _serialize_timeline_item(item) -> dict:
"""Serialize a timeline item DTO to a dict for sx defcomps."""
published = getattr(item, "published", None)
return {
"object_id": getattr(item, "object_id", "") or "",
"author_inbox": getattr(item, "author_inbox", "") or "",
"actor_icon": getattr(item, "actor_icon", None),
"actor_name": getattr(item, "actor_name", "?"),
"actor_username": getattr(item, "actor_username", ""),
"actor_domain": getattr(item, "actor_domain", ""),
"content": getattr(item, "content", ""),
"summary": getattr(item, "summary", None),
"published": published.strftime("%b %d, %H:%M") if published else "",
"before_cursor": published.isoformat() if published else "",
"url": getattr(item, "url", None),
"post_type": getattr(item, "post_type", ""),
"boosted_by": getattr(item, "boosted_by", None),
"like_count": getattr(item, "like_count", 0) or 0,
"boost_count": getattr(item, "boost_count", 0) or 0,
"liked_by_me": getattr(item, "liked_by_me", False),
"boosted_by_me": getattr(item, "boosted_by_me", False),
}
from services.federation_page import _serialize_timeline_item as _impl
return _impl(item)
def _serialize_remote_actor(a) -> dict:
"""Serialize a remote actor DTO to a dict for sx defcomps."""
return {
"id": getattr(a, "id", None),
"display_name": getattr(a, "display_name", None) or getattr(a, "preferred_username", ""),
"preferred_username": getattr(a, "preferred_username", ""),
"domain": getattr(a, "domain", ""),
"icon_url": getattr(a, "icon_url", None),
"actor_url": getattr(a, "actor_url", ""),
"summary": getattr(a, "summary", None),
}
from services.federation_page import _serialize_remote_actor as _impl
return _impl(a)
async def _social_page(ctx: dict, actor, *, content: str,
title: str = "Rose Ash", meta_html: str = "") -> str:
"""Build a full social page with social header."""
from shared.sx.helpers import render_to_sx, root_header_sx, header_child_sx, full_page_sx
from shared.sx.parser import SxExpr
from markupsafe import escape
actor_data = _serialize_actor(actor)
nav = await render_to_sx("federation-social-nav", actor=actor_data)
social_hdr = await render_to_sx("federation-social-header", nav=SxExpr(nav))
hdr = await root_header_sx(ctx)
child = await header_child_sx(social_hdr)
header_rows = "(<> " + hdr + " " + child + ")"
return await full_page_sx(ctx, header_rows=header_rows, content=content,
meta_html=meta_html or f'<title>{escape(title)}</title>')
def _get_actor():
@@ -138,156 +105,3 @@ def _require_actor():
if not actor:
abort(403, "You need to choose a federation username first")
return actor
async def _h_home_timeline_content(**kw):
from quart import g
from shared.services.registry import services
from shared.sx.helpers import render_to_sx
actor = _require_actor()
items = await services.federation.get_home_timeline(g.s, actor.id)
return await render_to_sx("federation-timeline-content",
items=[_serialize_timeline_item(i) for i in items],
timeline_type="home",
actor=_serialize_actor(actor))
async def _h_public_timeline_content(**kw):
from quart import g
from shared.services.registry import services
from shared.sx.helpers import render_to_sx
actor = _get_actor()
items = await services.federation.get_public_timeline(g.s)
return await render_to_sx("federation-timeline-content",
items=[_serialize_timeline_item(i) for i in items],
timeline_type="public",
actor=_serialize_actor(actor))
async def _h_compose_content(**kw):
from quart import request
from shared.sx.helpers import render_to_sx
_require_actor()
reply_to = request.args.get("reply_to")
return await render_to_sx("federation-compose-content",
reply_to=reply_to or None)
async def _h_search_content(**kw):
from quart import g, request
from shared.services.registry import services
from shared.sx.helpers import render_to_sx
actor = _get_actor()
query = request.args.get("q", "").strip()
actors_list = []
total = 0
followed_urls: set[str] = set()
if query:
actors_list, total = await services.federation.search_actors(g.s, query)
if actor:
following, _ = await services.federation.get_following(
g.s, actor.preferred_username, page=1, per_page=1000,
)
followed_urls = {a.actor_url for a in following}
return await render_to_sx("federation-search-content",
query=query,
actors=[_serialize_remote_actor(a) for a in actors_list],
total=total,
followed_urls=list(followed_urls),
actor=_serialize_actor(actor))
async def _h_following_content(**kw):
from quart import g
from shared.services.registry import services
from shared.sx.helpers import render_to_sx
actor = _require_actor()
actors_list, total = await services.federation.get_following(
g.s, actor.preferred_username,
)
return await render_to_sx("federation-following-content",
actors=[_serialize_remote_actor(a) for a in actors_list],
total=total,
actor=_serialize_actor(actor))
async def _h_followers_content(**kw):
from quart import g
from shared.services.registry import services
from shared.sx.helpers import render_to_sx
actor = _require_actor()
actors_list, total = await services.federation.get_followers_paginated(
g.s, actor.preferred_username,
)
following, _ = await services.federation.get_following(
g.s, actor.preferred_username, page=1, per_page=1000,
)
followed_urls = {a.actor_url for a in following}
return await render_to_sx("federation-followers-content",
actors=[_serialize_remote_actor(a) for a in actors_list],
total=total,
followed_urls=list(followed_urls),
actor=_serialize_actor(actor))
async def _h_actor_timeline_content(id=None, **kw):
from quart import g, abort
from shared.services.registry import services
from shared.sx.helpers import render_to_sx
actor = _get_actor()
actor_id = id
from shared.models.federation import RemoteActor
from sqlalchemy import select as sa_select
remote = (
await g.s.execute(
sa_select(RemoteActor).where(RemoteActor.id == actor_id)
)
).scalar_one_or_none()
if not remote:
abort(404)
from shared.services.federation_impl import _remote_actor_to_dto
remote_dto = _remote_actor_to_dto(remote)
items = await services.federation.get_actor_timeline(g.s, actor_id)
is_following = False
if actor:
from shared.models.federation import APFollowing
existing = (
await g.s.execute(
sa_select(APFollowing).where(
APFollowing.actor_profile_id == actor.id,
APFollowing.remote_actor_id == actor_id,
)
)
).scalar_one_or_none()
is_following = existing is not None
return await render_to_sx("federation-actor-timeline-content",
remote_actor=_serialize_remote_actor(remote_dto),
items=[_serialize_timeline_item(i) for i in items],
is_following=is_following,
actor=_serialize_actor(actor))
async def _h_notifications_content(**kw):
from quart import g
from shared.services.registry import services
from shared.sx.helpers import render_to_sx
actor = _require_actor()
items = await services.federation.get_notifications(g.s, actor.id)
await services.federation.mark_notifications_read(g.s, actor.id)
notif_dicts = []
for n in items:
created = getattr(n, "created_at", None)
notif_dicts.append({
"from_actor_name": getattr(n, "from_actor_name", "?"),
"from_actor_username": getattr(n, "from_actor_username", ""),
"from_actor_domain": getattr(n, "from_actor_domain", ""),
"from_actor_icon": getattr(n, "from_actor_icon", None),
"notification_type": getattr(n, "notification_type", ""),
"target_content_preview": getattr(n, "target_content_preview", None),
"created_at_formatted": created.strftime("%b %d, %H:%M") if created else "",
"read": getattr(n, "read", True),
"app_domain": getattr(n, "app_domain", ""),
})
return await render_to_sx("federation-notifications-content",
notifications=notif_dicts)

View File

@@ -1,49 +1,82 @@
;; Federation social pages
;; All data fetching via (service ...) IO primitives, no Python helpers.
(defpage home-timeline
:path "/social/"
:auth :login
:layout :social
:content (home-timeline-content))
:data (service "federation-page" "home-timeline-data")
:content (~federation-timeline-content
:items items
:timeline-type timeline-type
:actor actor))
(defpage public-timeline
:path "/social/public"
:auth :public
:layout :social
:content (public-timeline-content))
:data (service "federation-page" "public-timeline-data")
:content (~federation-timeline-content
:items items
:timeline-type timeline-type
:actor actor))
(defpage compose-form
:path "/social/compose"
:auth :login
:layout :social
:content (compose-content))
:data (service "federation-page" "compose-data")
:content (~federation-compose-content
:reply-to reply-to))
(defpage search
:path "/social/search"
:auth :public
:layout :social
:content (search-content))
:data (service "federation-page" "search-data")
:content (~federation-search-content
:query query
:actors actors
:total total
:followed-urls followed-urls
:actor actor))
(defpage following-list
:path "/social/following"
:auth :login
:layout :social
:content (following-content))
:data (service "federation-page" "following-data")
:content (~federation-following-content
:actors actors
:total total
:actor actor))
(defpage followers-list
:path "/social/followers"
:auth :login
:layout :social
:content (followers-content))
:data (service "federation-page" "followers-data")
:content (~federation-followers-content
:actors actors
:total total
:followed-urls followed-urls
:actor actor))
(defpage actor-timeline
:path "/social/actor/<int:id>"
:auth :public
:layout :social
:content (actor-timeline-content id))
:data (service "federation-page" "actor-timeline-data" :id id)
:content (~federation-actor-timeline-content
:remote-actor remote-actor
:items items
:is-following is-following
:actor actor))
(defpage notifications
:path "/social/notifications"
:auth :login
:layout :social
:content (notifications-content))
:data (service "federation-page" "notifications-data")
:content (~federation-notifications-content
:notifications notifications))

View File

@@ -111,7 +111,7 @@ async def _market_header_sx(ctx: dict, *, oob: bool = False) -> str:
sub_div=SxExpr(sub_div) if sub_div else None,
)
link_href = url_for("market.browse.defpage_market_home")
link_href = url_for("defpage_market_home")
# Build desktop nav from categories
categories = ctx.get("categories", {})

View File

@@ -1,7 +1,5 @@
from __future__ import annotations
import path_setup # noqa: F401 # adds shared/ to sys.path
import sx.sx_components as sx_components # noqa: F401 # ensure Hypercorn --reload watches this file
from pathlib import Path
from types import SimpleNamespace
@@ -69,10 +67,9 @@ def create_app() -> "Quart":
app.jinja_loader,
])
# Load orders-specific s-expression components (loaded at import time)
import sx.sx_components # noqa: F811
# Setup defpage routes
# Load .sx component files and setup defpage routes
from shared.sx.jinja_bridge import load_service_components
load_service_components(str(Path(__file__).resolve().parent), service_name="orders")
from sxc.pages import setup_orders_pages
setup_orders_pages()

View File

@@ -11,6 +11,118 @@ from services.checkout import validate_webhook_secret, get_order_with_details
from services.check_sumup_status import check_sumup_status
async def _render_checkout_return(ctx: dict, order=None, status: str = "",
calendar_entries=None, order_tickets=None) -> str:
"""Render checkout return page — replaces sx_components helper."""
from shared.sx.helpers import (
render_to_sx, root_header_sx, header_child_sx, full_page_sx, call_url,
)
from shared.sx.parser import SxExpr
from shared.infrastructure.urls import market_product_url
filt = await render_to_sx("checkout-return-header", status=status)
if not order:
content = await render_to_sx("checkout-return-missing")
else:
summary = await render_to_sx("order-summary-card",
order_id=order.id,
created_at=order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else None,
description=order.description, status=order.status,
currency=order.currency,
total_amount=f"{order.total_amount:.2f}" if order.total_amount else None,
)
items = ""
if order.items:
item_parts = []
for item in order.items:
product_url = market_product_url(item.product_slug)
if item.product_image:
img = await render_to_sx("order-item-image",
src=item.product_image,
alt=item.product_title or "Product image")
else:
img = await render_to_sx("order-item-no-image")
item_parts.append(await render_to_sx("order-item-row",
href=product_url, img=SxExpr(img),
title=item.product_title or "Unknown product",
pid=f"Product ID: {item.product_id}",
qty=f"Qty: {item.quantity}",
price=f"{item.currency or order.currency or 'GBP'} {item.unit_price or 0:.2f}",
))
items = await render_to_sx("order-items-panel",
items=SxExpr("(<> " + " ".join(item_parts) + ")"))
calendar = ""
if calendar_entries:
cal_parts = []
for e in calendar_entries:
st = e.state or ""
pill = (
"bg-emerald-100 text-emerald-800" if st == "confirmed"
else "bg-amber-100 text-amber-800" if st == "provisional"
else "bg-blue-100 text-blue-800" if st == "ordered"
else "bg-stone-100 text-stone-700"
)
ds = e.start_at.strftime("%-d %b %Y, %H:%M") if e.start_at else ""
if e.end_at:
ds += f" \u2013 {e.end_at.strftime('%-d %b %Y, %H:%M')}"
cal_parts.append(await render_to_sx("order-calendar-entry",
name=e.name,
pill=f"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium {pill}",
status=st.capitalize(), date_str=ds,
cost=f"\u00a3{e.cost or 0:.2f}",
))
calendar = await render_to_sx("order-calendar-section",
items=SxExpr("(<> " + " ".join(cal_parts) + ")"))
tickets = ""
if order_tickets:
tk_parts = []
for tk in order_tickets:
st = tk.state or ""
pill = (
"bg-emerald-100 text-emerald-800" if st == "confirmed"
else "bg-amber-100 text-amber-800" if st == "reserved"
else "bg-blue-100 text-blue-800" if st == "checked_in"
else "bg-stone-100 text-stone-700"
)
pill_cls = f"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium {pill}"
ds = tk.entry_start_at.strftime("%-d %b %Y, %H:%M") if tk.entry_start_at else ""
if tk.entry_end_at:
ds += f" \u2013 {tk.entry_end_at.strftime('%-d %b %Y, %H:%M')}"
tk_parts.append(await render_to_sx("checkout-return-ticket",
name=tk.entry_name, pill=pill_cls,
state=st.replace("_", " ").capitalize(),
type_name=tk.ticket_type_name or None,
date_str=ds, code=tk.code,
price=f"\u00a3{tk.price or 0:.2f}",
))
tickets = await render_to_sx("checkout-return-tickets",
items=SxExpr("(<> " + " ".join(tk_parts) + ")"))
status_msg = ""
if order.status == "failed":
status_msg = await render_to_sx("checkout-return-failed", order_id=order.id)
elif order.status == "paid":
status_msg = await render_to_sx("checkout-return-paid")
content = await render_to_sx("checkout-return-content",
summary=SxExpr(summary),
items=SxExpr(items) if items else None,
calendar=SxExpr(calendar) if calendar else None,
tickets=SxExpr(tickets) if tickets else None,
status_message=SxExpr(status_msg) if status_msg else None,
)
account_url = call_url(ctx, "account_url", "")
auth_hdr = await render_to_sx("auth-header-row", account_url=account_url)
hdr = "(<> " + await root_header_sx(ctx) + " " + await header_child_sx(auth_hdr) + ")"
return await full_page_sx(ctx, header_rows=hdr, filter=filt, content=content)
def register() -> Blueprint:
bp = Blueprint("checkout", __name__, url_prefix="/checkout")
@@ -47,12 +159,11 @@ def register() -> Blueprint:
async def checkout_return(order_id: int):
"""Handle the browser returning from SumUp after payment."""
from shared.sx.page import get_template_context
from sx.sx_components import render_checkout_return_page
order = await get_order_with_details(g.s, order_id)
if not order:
tctx = await get_template_context()
html = await render_checkout_return_page(tctx, order=None, status="missing")
html = await _render_checkout_return(tctx, order=None, status="missing")
return await make_response(html)
if order.page_config_id:
@@ -90,7 +201,7 @@ def register() -> Blueprint:
await g.s.flush()
tctx = await get_template_context()
html = await render_checkout_return_page(
html = await _render_checkout_return(
tctx, order=order, status=status,
calendar_entries=calendar_entries,
order_tickets=order_tickets,

View File

@@ -42,7 +42,7 @@ def register() -> Blueprint:
return await make_response("Order not found", 404)
if order.status == "paid":
return redirect(url_for("orders.defpage_order_detail", order_id=order.id))
return redirect(url_for("defpage_order_detail", order_id=order.id))
if order.sumup_hosted_url:
return redirect(order.sumup_hosted_url)
@@ -70,9 +70,22 @@ def register() -> Blueprint:
if not hosted_url:
from shared.sx.page import get_template_context
from sx.sx_components import render_checkout_error_page
from shared.sx.helpers import render_to_sx, root_header_sx, header_child_sx, full_page_sx, call_url
from shared.sx.parser import SxExpr
from shared.infrastructure.urls import cart_url
tctx = await get_template_context()
html = await render_checkout_error_page(tctx, error="No hosted checkout URL returned from SumUp when trying to reopen payment.", order=order)
account_url = call_url(tctx, "account_url", "")
auth_hdr = await render_to_sx("auth-header-row", account_url=account_url)
hdr = "(<> " + await root_header_sx(tctx) + " " + await header_child_sx(auth_hdr) + ")"
filt = await render_to_sx("checkout-error-header")
order_sx = await render_to_sx("checkout-error-order-id", oid=f"#{order.id}")
content = await render_to_sx(
"checkout-error-content",
msg="No hosted checkout URL returned from SumUp when trying to reopen payment.",
order=SxExpr(order_sx),
back_url=cart_url("/"),
)
html = await full_page_sx(tctx, header_rows=hdr, filter=filt, content=content)
return await make_response(html, 500)
return redirect(hosted_url)
@@ -89,13 +102,13 @@ def register() -> Blueprint:
return await make_response("Order not found", 404)
if not order.sumup_checkout_id:
return redirect(url_for("orders.defpage_order_detail", order_id=order.id))
return redirect(url_for("defpage_order_detail", order_id=order.id))
try:
await check_sumup_status(g.s, order)
except Exception:
pass
return redirect(url_for("orders.defpage_order_detail", order_id=order.id))
return redirect(url_for("defpage_order_detail", order_id=order.id))
return bp

View File

@@ -88,7 +88,7 @@ def register(url_prefix: str) -> Blueprint:
"total_formatted": f"{o.total_amount or 0:.2f}",
})
detail_prefix = pfx + url_for("orders.defpage_order_detail", order_id=0).rsplit("0/", 1)[0]
detail_prefix = pfx + url_for("defpage_order_detail", order_id=0).rsplit("0/", 1)[0]
qs_fn = makeqs_factory()
rows_url = pfx + url_for("orders.orders_rows")

View File

@@ -4,3 +4,6 @@ from __future__ import annotations
def register_domain_services() -> None:
"""Register services for the orders app."""
from shared.services.registry import services
from .orders_page import OrdersPageService
services.register("orders_page", OrdersPageService())

View File

@@ -0,0 +1,138 @@
"""Orders page data service — provides serialized dicts for .sx defpages."""
from __future__ import annotations
from sqlalchemy import select, func, or_, cast, String, exists
from sqlalchemy.orm import selectinload
from shared.models.order import Order, OrderItem
from shared.infrastructure.cart_identity import current_cart_identity
from shared.infrastructure.urls import market_product_url
class OrdersPageService:
"""Service for orders page data, callable via (service "orders-page" ...)."""
async def list_page_data(self, session, *, search="", page=1):
"""Return orders list + pagination metadata as a dict."""
PER_PAGE = 10
ident = current_cart_identity()
if ident["user_id"]:
owner = Order.user_id == ident["user_id"]
elif ident["session_id"]:
owner = Order.session_id == ident["session_id"]
else:
return {"orders": [], "page": 1, "total_pages": 1,
"search": "", "search_count": 0}
page = max(1, int(page))
where = None
if search:
term = f"%{search.strip()}%"
conds = [
Order.status.ilike(term),
Order.currency.ilike(term),
Order.sumup_checkout_id.ilike(term),
Order.sumup_status.ilike(term),
Order.description.ilike(term),
exists(
select(1).select_from(OrderItem)
.where(OrderItem.order_id == Order.id,
or_(OrderItem.product_title.ilike(term),
OrderItem.product_slug.ilike(term)))
),
]
try:
conds.append(Order.id == int(search))
except (TypeError, ValueError):
conds.append(cast(Order.id, String).ilike(term))
where = or_(*conds)
count_q = select(func.count()).select_from(Order).where(owner)
if where is not None:
count_q = count_q.where(where)
total_count = (await session.execute(count_q)).scalar_one() or 0
total_pages = max(1, (total_count + PER_PAGE - 1) // PER_PAGE)
if page > total_pages:
page = total_pages
stmt = (select(Order).where(owner)
.order_by(Order.created_at.desc())
.offset((page - 1) * PER_PAGE).limit(PER_PAGE))
if where is not None:
stmt = stmt.where(where)
rows = (await session.execute(stmt)).scalars().all()
orders = []
for o in rows:
orders.append({
"id": o.id,
"status": o.status or "pending",
"created_at_formatted": (
o.created_at.strftime("%-d %b %Y, %H:%M")
if o.created_at else "\u2014"),
"description": o.description or "",
"currency": o.currency or "GBP",
"total_formatted": f"{o.total_amount or 0:.2f}",
})
return {
"orders": orders,
"page": page,
"total_pages": total_pages,
"search": search or "",
"search_count": total_count,
}
async def detail_page_data(self, session, *, order_id=None):
"""Return order detail data as a dict."""
from quart import abort
if order_id is None:
abort(404)
ident = current_cart_identity()
if ident["user_id"]:
owner = Order.user_id == ident["user_id"]
elif ident["session_id"]:
owner = Order.session_id == ident["session_id"]
else:
abort(404)
return {}
result = await session.execute(
select(Order).options(selectinload(Order.items))
.where(Order.id == int(order_id), owner)
)
order = result.scalar_one_or_none()
if not order:
abort(404)
return {}
items = []
for item in (order.items or []):
items.append({
"product_url": market_product_url(item.product_slug),
"product_image": item.product_image,
"product_title": item.product_title,
"product_id": item.product_id,
"quantity": item.quantity,
"currency": item.currency,
"unit_price_formatted": f"{item.unit_price or 0:.2f}",
})
return {
"order": {
"id": order.id,
"status": order.status or "pending",
"created_at_formatted": (
order.created_at.strftime("%-d %b %Y, %H:%M")
if order.created_at else "\u2014"),
"description": order.description or "",
"currency": order.currency or "GBP",
"total_formatted": (
f"{order.total_amount:.2f}"
if order.total_amount else "0.00"),
"items": items,
},
}

View File

@@ -1,189 +0,0 @@
"""
Orders service s-expression page components.
Checkout error/return pages are still rendered from Python because they
use ``full_page_sx()`` with custom layouts. All other order rendering
is now handled by .sx defcomps.
"""
from __future__ import annotations
import os
from typing import Any
from shared.sx.jinja_bridge import load_service_components
from shared.sx.helpers import (
call_url, render_to_sx,
root_header_sx, full_page_sx, header_child_sx,
)
from shared.infrastructure.urls import market_product_url, cart_url
# Load orders-specific .sx components + handlers at import time
load_service_components(os.path.dirname(os.path.dirname(__file__)),
service_name="orders")
# ---------------------------------------------------------------------------
# Public API: Checkout error
# ---------------------------------------------------------------------------
async def render_checkout_error_page(ctx: dict, error: str | None = None, order: Any | None = None) -> str:
"""Full page: checkout error (sx wire format)."""
account_url = call_url(ctx, "account_url", "")
auth_hdr = await render_to_sx("auth-header-row", account_url=account_url)
hdr = await root_header_sx(ctx)
hdr = "(<> " + hdr + " " + await header_child_sx(auth_hdr) + ")"
filt = await render_to_sx("checkout-error-header")
err_msg = error or "Unexpected error while creating the hosted checkout session."
order_sx = ""
if order:
order_sx = await render_to_sx("checkout-error-order-id", oid=f"#{order.id}")
from shared.sx.parser import SxExpr
content = await render_to_sx(
"checkout-error-content",
msg=err_msg,
order=SxExpr(order_sx) if order_sx else None,
back_url=cart_url("/"),
)
return await full_page_sx(ctx, header_rows=hdr, filter=filt, content=content)
# ---------------------------------------------------------------------------
# Public API: Checkout return
# ---------------------------------------------------------------------------
async def render_checkout_return_page(ctx: dict, order: Any | None,
status: str,
calendar_entries: list | None = None,
order_tickets: list | None = None) -> str:
"""Full page: checkout return after SumUp payment (sx wire format)."""
from shared.sx.parser import SxExpr
filt = await render_to_sx("checkout-return-header", status=status)
if not order:
content = await render_to_sx("checkout-return-missing")
else:
# Serialize order data for defcomp
order_dict = {
"id": order.id,
"status": order.status or "pending",
"created_at_formatted": order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else None,
"description": order.description,
"currency": order.currency,
"total_formatted": f"{order.total_amount:.2f}" if order.total_amount else None,
"items": [
{
"product_url": market_product_url(item.product_slug),
"product_image": item.product_image,
"product_title": item.product_title,
"product_id": item.product_id,
"quantity": item.quantity,
"currency": item.currency,
"unit_price_formatted": f"{item.unit_price or 0:.2f}",
}
for item in (order.items or [])
],
}
summary = await render_to_sx("order-summary-card",
order_id=order.id,
created_at=order_dict["created_at_formatted"],
description=order.description, status=order.status,
currency=order.currency,
total_amount=order_dict["total_formatted"],
)
# Items
items = ""
if order.items:
item_parts = []
for item_d in order_dict["items"]:
if item_d["product_image"]:
img = await render_to_sx("order-item-image",
src=item_d["product_image"],
alt=item_d["product_title"] or "Product image")
else:
img = await render_to_sx("order-item-no-image")
item_parts.append(await render_to_sx("order-item-row",
href=item_d["product_url"], img=SxExpr(img),
title=item_d["product_title"] or "Unknown product",
pid=f"Product ID: {item_d['product_id']}",
qty=f"Qty: {item_d['quantity']}",
price=f"{item_d['currency'] or order.currency or 'GBP'} {item_d['unit_price_formatted']}",
))
items = await render_to_sx("order-items-panel",
items=SxExpr("(<> " + " ".join(item_parts) + ")"))
# Calendar entries
calendar = ""
if calendar_entries:
cal_parts = []
for e in calendar_entries:
st = e.state or ""
pill = (
"bg-emerald-100 text-emerald-800" if st == "confirmed"
else "bg-amber-100 text-amber-800" if st == "provisional"
else "bg-blue-100 text-blue-800" if st == "ordered"
else "bg-stone-100 text-stone-700"
)
ds = e.start_at.strftime("%-d %b %Y, %H:%M") if e.start_at else ""
if e.end_at:
ds += f" \u2013 {e.end_at.strftime('%-d %b %Y, %H:%M')}"
cal_parts.append(await render_to_sx("order-calendar-entry",
name=e.name,
pill=f"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium {pill}",
status=st.capitalize(), date_str=ds,
cost=f"\u00a3{e.cost or 0:.2f}",
))
calendar = await render_to_sx("order-calendar-section",
items=SxExpr("(<> " + " ".join(cal_parts) + ")"))
# Tickets
tickets = ""
if order_tickets:
tk_parts = []
for tk in order_tickets:
st = tk.state or ""
pill = (
"bg-emerald-100 text-emerald-800" if st == "confirmed"
else "bg-amber-100 text-amber-800" if st == "reserved"
else "bg-blue-100 text-blue-800" if st == "checked_in"
else "bg-stone-100 text-stone-700"
)
pill_cls = f"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium {pill}"
ds = tk.entry_start_at.strftime("%-d %b %Y, %H:%M") if tk.entry_start_at else ""
if tk.entry_end_at:
ds += f" \u2013 {tk.entry_end_at.strftime('%-d %b %Y, %H:%M')}"
tk_parts.append(await render_to_sx("checkout-return-ticket",
name=tk.entry_name, pill=pill_cls,
state=st.replace("_", " ").capitalize(),
type_name=tk.ticket_type_name or None,
date_str=ds, code=tk.code,
price=f"\u00a3{tk.price or 0:.2f}",
))
tickets = await render_to_sx("checkout-return-tickets",
items=SxExpr("(<> " + " ".join(tk_parts) + ")"))
# Status message
status_msg = ""
if order.status == "failed":
status_msg = await render_to_sx("checkout-return-failed", order_id=order.id)
elif order.status == "paid":
status_msg = await render_to_sx("checkout-return-paid")
content = await render_to_sx("checkout-return-content",
summary=SxExpr(summary),
items=SxExpr(items) if items else None,
calendar=SxExpr(calendar) if calendar else None,
tickets=SxExpr(tickets) if tickets else None,
status_message=SxExpr(status_msg) if status_msg else None,
)
account_url = call_url(ctx, "account_url", "")
auth_hdr = await render_to_sx("auth-header-row", account_url=account_url)
hdr = await root_header_sx(ctx)
hdr = "(<> " + hdr + " " + await header_child_sx(auth_hdr) + ")"
return await full_page_sx(ctx, header_rows=hdr, filter=filt, content=content)

View File

@@ -1,13 +1,12 @@
"""Orders defpage setup — registers layouts, page helpers, and loads .sx pages."""
"""Orders defpage setup — registers layouts and loads .sx pages."""
from __future__ import annotations
from typing import Any
def setup_orders_pages() -> None:
"""Register orders-specific layouts, page helpers, and load page definitions."""
"""Register orders-specific layouts and load page definitions."""
_register_orders_layouts()
_register_orders_helpers()
_load_orders_page_files()
@@ -125,325 +124,3 @@ def _as_sx_nav(ctx: dict) -> Any:
"""Convert account_nav fragment to SxExpr for use in component calls."""
from shared.sx.helpers import _as_sx
return _as_sx(ctx.get("account_nav"))
# ---------------------------------------------------------------------------
# Page helpers — Python functions callable from defpage expressions
# ---------------------------------------------------------------------------
def _register_orders_helpers() -> None:
from shared.sx.pages import register_page_helpers
register_page_helpers("orders", {
# Orders list
"orders-list-content": _h_orders_list_content,
"orders-list-filter": _h_orders_list_filter,
"orders-list-aside": _h_orders_list_aside,
"orders-list-url": _h_orders_list_url,
# Order detail
"order-detail-content": _h_order_detail_content,
"order-detail-filter": _h_order_detail_filter,
"order-detail-url": _h_order_detail_url,
"order-list-url-from-detail": _h_order_list_url_from_detail,
})
async def _ensure_orders_list():
"""Fetch orders list data and store in g.orders_page_data."""
from quart import g, url_for
if hasattr(g, "orders_page_data"):
return
from sqlalchemy import select, func, or_, cast, String, exists
from shared.models.order import Order, OrderItem
from shared.infrastructure.cart_identity import current_cart_identity
from shared.utils import route_prefix
ORDERS_PER_PAGE = 10
ident = current_cart_identity()
if ident["user_id"]:
owner_clause = Order.user_id == ident["user_id"]
elif ident["session_id"]:
owner_clause = Order.session_id == ident["session_id"]
else:
g.orders_page_data = None
return
from bp.orders.filters.qs import makeqs_factory, decode
q = decode()
page, search = q.page, q.search
if page < 1:
page = 1
where_clause = None
if search:
term = f"%{search.strip()}%"
conditions = [
Order.status.ilike(term),
Order.currency.ilike(term),
Order.sumup_checkout_id.ilike(term),
Order.sumup_status.ilike(term),
Order.description.ilike(term),
]
conditions.append(
exists(
select(1).select_from(OrderItem)
.where(OrderItem.order_id == Order.id,
or_(OrderItem.product_title.ilike(term),
OrderItem.product_slug.ilike(term)))
)
)
try:
search_id = int(search)
except (TypeError, ValueError):
search_id = None
if search_id is not None:
conditions.append(Order.id == search_id)
else:
conditions.append(cast(Order.id, String).ilike(term))
where_clause = or_(*conditions)
count_stmt = select(func.count()).select_from(Order).where(owner_clause)
if where_clause is not None:
count_stmt = count_stmt.where(where_clause)
total_count_result = await g.s.execute(count_stmt)
total_count = total_count_result.scalar_one() or 0
total_pages = max(1, (total_count + ORDERS_PER_PAGE - 1) // ORDERS_PER_PAGE)
if page > total_pages:
page = total_pages
offset = (page - 1) * ORDERS_PER_PAGE
stmt = (
select(Order).where(owner_clause)
.order_by(Order.created_at.desc())
.offset(offset).limit(ORDERS_PER_PAGE)
)
if where_clause is not None:
stmt = stmt.where(where_clause)
result = await g.s.execute(stmt)
orders = result.scalars().all()
pfx = route_prefix()
qs_fn = makeqs_factory()
g.orders_page_data = {
"orders": orders,
"page": page,
"total_pages": total_pages,
"search": search,
"search_count": total_count,
"url_for_fn": url_for,
"qs_fn": qs_fn,
"list_url": pfx + url_for("defpage_orders_list"),
}
async def _ensure_order_detail(order_id):
"""Fetch order detail data and store in g.order_detail_data."""
from quart import g, url_for, abort
if hasattr(g, "order_detail_data"):
return
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from shared.models.order import Order
from shared.infrastructure.cart_identity import current_cart_identity
from shared.utils import route_prefix
from shared.browser.app.csrf import generate_csrf_token
if order_id is None:
abort(404)
ident = current_cart_identity()
if ident["user_id"]:
owner = Order.user_id == ident["user_id"]
elif ident["session_id"]:
owner = Order.session_id == ident["session_id"]
else:
abort(404)
return
result = await g.s.execute(
select(Order).options(selectinload(Order.items))
.where(Order.id == order_id, owner)
)
order = result.scalar_one_or_none()
if not order:
abort(404)
return
pfx = route_prefix()
g.order_detail_data = {
"order": order,
"calendar_entries": None,
"detail_url": pfx + url_for("defpage_order_detail", order_id=order.id),
"list_url": pfx + url_for("defpage_orders_list"),
"recheck_url": pfx + url_for("orders.order.order_recheck", order_id=order.id),
"pay_url": pfx + url_for("orders.order.order_pay", order_id=order.id),
"csrf_token": generate_csrf_token(),
}
async def _h_orders_list_content(**kw):
await _ensure_orders_list()
from quart import g
from shared.sx.helpers import render_to_sx
d = getattr(g, "orders_page_data", None)
if not d:
return await render_to_sx("order-empty-state")
orders = d["orders"]
url_for_fn = d["url_for_fn"]
pfx = d.get("list_url", "/").rsplit("/", 1)[0] if d.get("list_url") else ""
order_dicts = []
for o in orders:
order_dicts.append({
"id": o.id,
"status": o.status or "pending",
"created_at_formatted": o.created_at.strftime("%-d %b %Y, %H:%M") if o.created_at else "\u2014",
"description": o.description or "",
"currency": o.currency or "GBP",
"total_formatted": f"{o.total_amount or 0:.2f}",
})
from shared.utils import route_prefix
rpfx = route_prefix()
detail_prefix = rpfx + url_for_fn("orders.defpage_order_detail", order_id=0).rsplit("0/", 1)[0]
rows_url = rpfx + url_for_fn("orders.orders_rows")
return await render_to_sx("orders-list-content",
orders=order_dicts,
page=d["page"],
total_pages=d["total_pages"],
rows_url=rows_url,
detail_url_prefix=detail_prefix)
async def _h_orders_list_filter(**kw):
await _ensure_orders_list()
from quart import g
from shared.sx.helpers import render_to_sx
from shared.sx.page import SEARCH_HEADERS_MOBILE
from shared.sx.parser import SxExpr
d = getattr(g, "orders_page_data", None)
search = d.get("search", "") if d else ""
search_count = d.get("search_count", "") if d else ""
search_mobile = await render_to_sx("search-mobile",
current_local_href="/",
search=search or "",
search_count=search_count or "",
hx_select="#main-panel",
search_headers_mobile=SEARCH_HEADERS_MOBILE,
)
return await render_to_sx("order-list-header", search_mobile=SxExpr(search_mobile))
async def _h_orders_list_aside(**kw):
await _ensure_orders_list()
from quart import g
from shared.sx.helpers import render_to_sx
from shared.sx.page import SEARCH_HEADERS_DESKTOP
d = getattr(g, "orders_page_data", None)
search = d.get("search", "") if d else ""
search_count = d.get("search_count", "") if d else ""
return await render_to_sx("search-desktop",
current_local_href="/",
search=search or "",
search_count=search_count or "",
hx_select="#main-panel",
search_headers_desktop=SEARCH_HEADERS_DESKTOP,
)
async def _h_orders_list_url(**kw):
await _ensure_orders_list()
from quart import g
d = getattr(g, "orders_page_data", None)
return d["list_url"] if d else "/"
async def _h_order_detail_content(order_id=None, **kw):
await _ensure_order_detail(order_id)
from quart import g
from shared.sx.helpers import render_to_sx
from shared.infrastructure.urls import market_product_url
d = getattr(g, "order_detail_data", None)
if not d:
return ""
order = d["order"]
order_dict = {
"id": order.id,
"status": order.status or "pending",
"created_at_formatted": order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else None,
"description": order.description,
"currency": order.currency,
"total_formatted": f"{order.total_amount:.2f}" if order.total_amount else None,
"items": [
{
"product_url": market_product_url(item.product_slug),
"product_image": item.product_image,
"product_title": item.product_title,
"product_id": item.product_id,
"quantity": item.quantity,
"currency": item.currency,
"unit_price_formatted": f"{item.unit_price or 0:.2f}",
}
for item in (order.items or [])
],
}
cal_entries = d["calendar_entries"]
cal_dicts = None
if cal_entries:
cal_dicts = []
for e in cal_entries:
ds = e.start_at.strftime("%-d %b %Y, %H:%M") if e.start_at else ""
if e.end_at:
ds += f" \u2013 {e.end_at.strftime('%-d %b %Y, %H:%M')}"
cal_dicts.append({
"name": e.name,
"state": e.state or "",
"date_str": ds,
"cost_formatted": f"{e.cost or 0:.2f}",
})
return await render_to_sx("order-detail-content",
order=order_dict,
calendar_entries=cal_dicts)
async def _h_order_detail_filter(order_id=None, **kw):
await _ensure_order_detail(order_id)
from quart import g
from shared.sx.helpers import render_to_sx
d = getattr(g, "order_detail_data", None)
if not d:
return ""
order = d["order"]
order_dict = {
"status": order.status or "pending",
"created_at_formatted": order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else "\u2014",
}
return await render_to_sx("order-detail-filter-content",
order=order_dict,
list_url=d["list_url"],
recheck_url=d["recheck_url"],
pay_url=d["pay_url"],
csrf=d["csrf_token"])
async def _h_order_detail_url(order_id=None, **kw):
await _ensure_order_detail(order_id)
from quart import g
d = getattr(g, "order_detail_data", None)
return d["detail_url"] if d else "/"
async def _h_order_list_url_from_detail(order_id=None, **kw):
await _ensure_order_detail(order_id)
from quart import g
d = getattr(g, "order_detail_data", None)
return d["list_url"] if d else "/"

View File

@@ -1,4 +1,5 @@
;; Orders app — declarative page definitions
;; All data fetching via (service ...) IO primitives, no Python helpers.
;; ---------------------------------------------------------------------------
;; Orders list
@@ -7,11 +8,34 @@
(defpage orders-list
:path "/"
:auth :public
:data (service "orders-page" "list-page-data"
:search (or (request-arg "search") "")
:page (or (request-arg "page" "1") "1"))
:layout (:orders
:list-url (orders-list-url))
:filter (orders-list-filter)
:aside (orders-list-aside)
:content (orders-list-content))
:list-url (str (route-prefix) (url-for "defpage_orders_list")))
:filter (~order-list-header
:search-mobile (~search-mobile
:current-local-href "/"
:search (or search "")
:search-count (or search-count "")
:hx-select "#main-panel"
:search-headers-mobile "{\"X-Origin\":\"search-mobile\",\"X-Search\":\"true\"}"))
:aside (~search-desktop
:current-local-href "/"
:search (or search "")
:search-count (or search-count "")
:hx-select "#main-panel"
:search-headers-desktop "{\"X-Origin\":\"search-desktop\",\"X-Search\":\"true\"}")
:content (let* ((pfx (route-prefix))
(detail-url-raw (str pfx (url-for "defpage_order_detail" :order-id 0)))
(detail-prefix (slice detail-url-raw 0 (- (len detail-url-raw) 2)))
(rows-url (str pfx (url-for "orders.orders_rows"))))
(~orders-list-content
:orders orders
:page page
:total-pages total-pages
:rows-url rows-url
:detail-url-prefix detail-prefix)))
;; ---------------------------------------------------------------------------
;; Order detail
@@ -20,8 +44,17 @@
(defpage order-detail
:path "/<int:order_id>/"
:auth :public
:data (service "orders-page" "detail-page-data" :order-id order-id)
:layout (:order-detail
:list-url (order-list-url-from-detail order-id)
:detail-url (order-detail-url order-id))
:filter (order-detail-filter order-id)
:content (order-detail-content order-id))
:list-url (str (route-prefix) (url-for "defpage_orders_list"))
:detail-url (str (route-prefix) (url-for "defpage_order_detail" :order-id order-id)))
:filter (let* ((pfx (route-prefix)))
(~order-detail-filter-content
:order order
:list-url (str pfx (url-for "defpage_orders_list"))
:recheck-url (str pfx (url-for "orders.order.order_recheck" :order-id order-id))
:pay-url (str pfx (url-for "orders.order.order_pay" :order-id order-id))
:csrf (csrf-token)))
:content (~order-detail-content
:order order
:calendar-entries calendar-entries))

View File

@@ -1,32 +0,0 @@
"""Unit tests for orders sx component helpers."""
from __future__ import annotations
import pytest
from orders.sx.sx_components import _status_pill_cls
class TestStatusPillCls:
def test_paid(self):
result = _status_pill_cls("paid")
assert "emerald" in result
def test_Paid_uppercase(self):
result = _status_pill_cls("Paid")
assert "emerald" in result
def test_failed(self):
result = _status_pill_cls("failed")
assert "rose" in result
def test_cancelled(self):
result = _status_pill_cls("cancelled")
assert "rose" in result
def test_pending(self):
result = _status_pill_cls("pending")
assert "stone" in result
def test_unknown(self):
result = _status_pill_cls("refunded")
assert "stone" in result

View File

@@ -15,6 +15,8 @@ Usage::
"""
from __future__ import annotations
from typing import Any
from shared.contracts.protocols import (
CalendarService,
MarketService,
@@ -36,6 +38,7 @@ class _ServiceRegistry:
self._market: MarketService | None = None
self._cart: CartService | None = None
self._federation: FederationService | None = None
self._extra: dict[str, Any] = {}
# -- calendar -------------------------------------------------------------
@property
@@ -81,10 +84,27 @@ class _ServiceRegistry:
def federation(self, impl: FederationService) -> None:
self._federation = impl
# -- generic registration --------------------------------------------------
def register(self, name: str, impl: Any) -> None:
"""Register a service by name (for services without typed properties)."""
self._extra[name] = impl
def __getattr__(self, name: str) -> Any:
# Fallback to _extra dict for dynamically registered services
try:
extra = object.__getattribute__(self, "_extra")
if name in extra:
return extra[name]
except AttributeError:
pass
raise AttributeError(f"No service registered as: {name}")
# -- introspection --------------------------------------------------------
def has(self, name: str) -> bool:
"""Check whether a domain service is registered."""
return getattr(self, f"_{name}", None) is not None
if getattr(self, f"_{name}", None) is not None:
return True
return name in self._extra
# Module-level singleton — import this everywhere.

View File

@@ -910,6 +910,46 @@ async def async_eval_to_sx(
return serialize(result)
async def async_eval_slot_to_sx(
expr: Any,
env: dict[str, Any],
ctx: RequestContext | None = None,
) -> str:
"""Like async_eval_to_sx but expands component calls.
Used by defpage slot evaluation where the content expression is
typically a component call like ``(~dashboard-content)``. Normal
``async_eval_to_sx`` serializes component calls without expanding;
this variant expands one level so IO primitives in the body execute,
then serializes the result as SX wire format.
"""
if ctx is None:
ctx = RequestContext()
# If expr is a component call, expand it through _aser
if isinstance(expr, list) and expr:
head = expr[0]
if isinstance(head, Symbol) and head.name.startswith("~"):
comp = env.get(head.name)
if isinstance(comp, Component):
result = await _aser_component(comp, expr[1:], env, ctx)
if isinstance(result, SxExpr):
return result.source
if result is None or result is NIL:
return ""
return serialize(result)
# Fall back to normal async_eval_to_sx
result = await _aser(expr, env, ctx)
if isinstance(result, SxExpr):
return result.source
if result is None or result is NIL:
return ""
# Page helpers return SX source strings from render_to_sx() —
# pass through directly instead of quoting via serialize().
if isinstance(result, str):
return result
return serialize(result)
async def _aser(expr: Any, env: dict[str, Any], ctx: RequestContext) -> Any:
"""Evaluate *expr*, producing SxExpr for rendering forms, raw values
for everything else."""
@@ -1022,6 +1062,33 @@ async def _aser_fragment(children: list, env: dict, ctx: RequestContext) -> SxEx
return SxExpr("(<> " + " ".join(parts) + ")")
async def _aser_component(
comp: Component, args: list, env: dict, ctx: RequestContext,
) -> Any:
"""Expand a component body through _aser — produces SX, not HTML."""
kwargs: dict[str, Any] = {}
children: list[Any] = []
i = 0
while i < len(args):
arg = args[i]
if isinstance(arg, Keyword) and i + 1 < len(args):
kwargs[arg.name] = await async_eval(args[i + 1], env, ctx)
i += 2
else:
children.append(arg)
i += 1
local = dict(comp.closure)
local.update(env)
for p in comp.params:
local[p] = kwargs.get(p, NIL)
if comp.has_children:
child_parts = []
for c in children:
child_parts.append(serialize(await _aser(c, env, ctx)))
local["children"] = SxExpr("(<> " + " ".join(child_parts) + ")")
return await _aser(comp.body, local, ctx)
async def _aser_call(
name: str, args: list, env: dict, ctx: RequestContext,
) -> SxExpr:

View File

@@ -77,7 +77,7 @@ def _as_sx(val: Any) -> SxExpr | None:
if not val:
return None
if isinstance(val, SxExpr):
return val
return val if val.source else None
html = str(val)
escaped = html.replace("\\", "\\\\").replace('"', '\\"')
return SxExpr(f'(~rich-text :html "{escaped}")')
@@ -376,7 +376,10 @@ def _build_component_ast(__name: str, **kwargs: Any) -> list:
elif isinstance(val, SxExpr):
# SxExpr values need to be parsed into AST
from .parser import parse
ast.append(parse(val.source))
if not val.source:
ast.append(NIL)
else:
ast.append(parse(val.source))
else:
ast.append(val)
return ast

View File

@@ -132,31 +132,14 @@ def load_page_dir(directory: str, service_name: str) -> list[PageDef]:
# Page execution
# ---------------------------------------------------------------------------
async def _eval_slot(expr: Any, env: dict, ctx: Any,
async_eval_fn: Any, async_eval_to_sx_fn: Any) -> str:
async def _eval_slot(expr: Any, env: dict, ctx: Any) -> str:
"""Evaluate a page slot expression and return an sx source string.
If the expression evaluates to a plain string (e.g. from a Python content
builder), use it directly as sx source. If it evaluates to an AST/list,
serialize it to sx wire format via async_eval_to_sx.
Expands component calls (so IO in the body executes) but serializes
the result as SX wire format, not HTML.
"""
from .html import _RawHTML
from .parser import SxExpr
# First try async_eval to get the raw value
result = await async_eval_fn(expr, env, ctx)
# If it's already an sx source string, use as-is
if isinstance(result, str):
return result
if isinstance(result, _RawHTML):
return result.html
if isinstance(result, SxExpr):
return result.source
if result is None:
return ""
# For other types (lists, components rendered to HTML via _RawHTML, etc.),
# serialize to sx wire format
from .parser import serialize
return serialize(result)
from .async_eval import async_eval_slot_to_sx
return await async_eval_slot_to_sx(expr, env, ctx)
async def execute_page(
@@ -174,7 +157,7 @@ async def execute_page(
6. Branch: full_page_sx() vs oob_page_sx() based on is_htmx_request()
"""
from .jinja_bridge import get_component_env, _get_request_context
from .async_eval import async_eval, async_eval_to_sx
from .async_eval import async_eval
from .page import get_template_context
from .helpers import full_page_sx, oob_page_sx, sx_response
from .layouts import get_layout
@@ -201,23 +184,25 @@ async def execute_page(
if page_def.data_expr is not None:
data_result = await async_eval(page_def.data_expr, env, ctx)
if isinstance(data_result, dict):
env.update(data_result)
# Merge with kebab-case keys so SX symbols can reference them
for k, v in data_result.items():
env[k.replace("_", "-")] = v
# Render content slot (required)
content_sx = await _eval_slot(page_def.content_expr, env, ctx, async_eval, async_eval_to_sx)
content_sx = await _eval_slot(page_def.content_expr, env, ctx)
# Render optional slots
filter_sx = ""
if page_def.filter_expr is not None:
filter_sx = await _eval_slot(page_def.filter_expr, env, ctx, async_eval, async_eval_to_sx)
filter_sx = await _eval_slot(page_def.filter_expr, env, ctx)
aside_sx = ""
if page_def.aside_expr is not None:
aside_sx = await _eval_slot(page_def.aside_expr, env, ctx, async_eval, async_eval_to_sx)
aside_sx = await _eval_slot(page_def.aside_expr, env, ctx)
menu_sx = ""
if page_def.menu_expr is not None:
menu_sx = await _eval_slot(page_def.menu_expr, env, ctx, async_eval, async_eval_to_sx)
menu_sx = await _eval_slot(page_def.menu_expr, env, ctx)
# Resolve layout → header rows + mobile menu fallback
tctx = await get_template_context()

View File

@@ -336,6 +336,17 @@ def serialize(expr: Any, indent: int = 0, pretty: bool = False) -> str:
items.append(serialize(v, indent, pretty))
return "{" + " ".join(items) + "}"
# _RawHTML — pre-rendered HTML; wrap as (raw! "...") for SX wire format
from .html import _RawHTML
if isinstance(expr, _RawHTML):
escaped = (
expr.html.replace("\\", "\\\\")
.replace('"', '\\"')
.replace("\n", "\\n")
.replace("</script", "<\\/script")
)
return f'(raw! "{escaped}")'
# Catch callables (Python functions leaked into sx data)
if callable(expr):
import logging

View File

@@ -43,6 +43,8 @@ IO_PRIMITIVES: frozenset[str] = frozenset({
"g",
"csrf-token",
"abort",
"url-for",
"route-prefix",
})
@@ -345,6 +347,34 @@ async def _io_abort(
abort(status, message)
async def _io_url_for(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> str:
"""``(url-for "endpoint" :key val ...)`` → url_for(endpoint, **kwargs).
Generates a URL for the given endpoint. Keyword args become URL
parameters (kebab-case converted to snake_case).
"""
if not args:
raise ValueError("url-for requires an endpoint name")
from quart import url_for
endpoint = str(args[0])
clean = {k.replace("-", "_"): v for k, v in _clean_kwargs(kwargs).items()}
# Convert numeric values for int URL params
for k, v in clean.items():
if isinstance(v, str) and v.isdigit():
clean[k] = int(v)
return url_for(endpoint, **clean)
async def _io_route_prefix(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> str:
"""``(route-prefix)`` → current route prefix string."""
from shared.utils import route_prefix
return route_prefix()
_IO_HANDLERS: dict[str, Any] = {
"frag": _io_frag,
"query": _io_query,
@@ -359,4 +389,6 @@ _IO_HANDLERS: dict[str, Any] = {
"g": _io_g,
"csrf-token": _io_csrf_token,
"abort": _io_abort,
"url-for": _io_url_for,
"route-prefix": _io_route_prefix,
}