23 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
44503a7d9b Add Client Reactivity and SX Native essays to sx docs app
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m33s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:11:48 +00:00
e085fe43b4 Replace sx_call() with render_to_sx() across all services
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m6s
Python no longer generates s-expression strings. All SX rendering now
goes through render_to_sx() which builds AST from native Python values
and evaluates via async_eval_to_sx() — no SX string literals in Python.

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:08:33 +00:00
0554f8a113 Refactor sx.js: extract string renderer, deduplicate helpers, remove dead code
Extract Node-only string renderer (renderToString, renderStr, etc.) to
sx-test.js. Add shared helpers (_processOOBSwaps, _postSwap, _processBindings,
_evalCond, _logParseError) replacing duplicated logic. Remove dead isTruthy
and _sxCssKnown class-list fallback. Compress section banners. sx.js goes
from 2652 to 2279 lines (-14%) with zero browser-side behavior change.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 23:00:58 +00:00
4e5f9ff16c Remove dead render_profile_page from federation sx_components
This function was replaced by defpage-based rendering but never deleted.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 22:41:19 +00:00
193578ef88 Move SX construction from Python to .sx defcomps (phases 0-4)
Eliminate Python s-expression string building across account, orders,
federation, and cart services. Visual rendering logic now lives entirely
in .sx defcomp components; Python files contain only data serialization,
header/layout wiring, and thin wrappers that call defcomps.

Phase 0: Shared DRY extraction — auth/orders header defcomps, format-decimal/
pluralize/escape/route-prefix primitives.
Phase 1: Account — dashboard, newsletters, login/device/check-email content.
Phase 2: Orders — order list, detail, filter, checkout return assembled defcomps.
Phase 3: Federation — social nav, post cards, timeline, search, actors,
notifications, compose, profile assembled defcomps.
Phase 4: Cart — overview, page cart items/calendar/tickets/summary, admin,
payments assembled defcomps; orders rendering reuses Phase 2 shared defcomps.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 22:36:34 +00:00
03f0929fdf Fix SX nav morphing, retry error modal, and aria-selected CSS extraction
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 12m18s
- Re-read verb URL from element attributes at execution time so morphed
  nav links navigate to the correct destination
- Reset retry backoff on fresh requests; skip error modal when sx-retry
  handles the failure
- Strip attribute selectors in CSS registry so aria-selected:* classes
  resolve correctly for on-demand CSS
- Add @css annotations for dynamic aria-selected variant classes
- Add SX docs integration test suite (102 tests)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 20:37:17 +00:00
f551fc7453 Convert last Python fragment handlers to SX defhandlers: 100% declarative fragment API
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 34m5s
- Add dict recursion to _convert_result for service methods returning dict[K, list[DTO]]
- New container-cards.sx: parses post_ids/slugs, calls confirmed-entries-for-posts, emits card-widget markers
- New account-page.sx: dispatches on slug for tickets/bookings panels with status pills and empty states
- Fix blog _parse_card_fragments to handle SxExpr via str() cast
- Remove events Python fragment handlers and simplify app.py to plain auto_mount

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 19:42:19 +00:00
e30cb0a992 Auto-mount fragment handlers: eliminate fragment blueprint boilerplate across all 8 services
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 16m38s
Fragment read API is now fully declarative — every handler is a defhandler
s-expression dispatched through one shared auto_mount_fragment_handlers()
function. Replaces 8 near-identical blueprint files (~35 lines each) with
a single function call per service. Events Python handlers (container-cards,
account-page) extracted to a standalone module.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 19:13:15 +00:00
293f7713d6 Auto-mount defpages: eliminate Python route stubs across all 9 services
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 16s
Defpages are now declared with absolute paths in .sx files and auto-mounted
directly on the Quart app, removing ~850 lines of blueprint mount_pages calls,
before_request hooks, and g.* wrapper boilerplate. A new page = one defpage
declaration, nothing else.

Infrastructure:
- async_eval awaits coroutine results from callable dispatch
- auto_mount_pages() mounts all registered defpages on the app
- g._defpage_ctx pattern passes helper data to layout context

Migrated: sx, account, orders, federation, cart, market, events, blog

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 19:03:15 +00:00
4ba63bda17 Add server-driven architecture principle and React feature analysis
Documents why sx stays server-driven by default, maps React features
to sx equivalents, and defines targeted escape hatches for the few
interactions that genuinely need client-side state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 17:48:35 +00:00
0a81a2af01 Convert social and federation profile from Jinja to SX rendering
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 14m34s
Add primitives (replace, strip-tags, slice, csrf-token), convert all
social blueprint routes and federation profile to SX content builders,
delete 12 unused Jinja templates and social_lite layout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 17:43:47 +00:00
0c9dbd6657 Add attribute detail pages with live demos for SX reference
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m45s
Per-attribute documentation pages at /reference/attributes/<slug> with:
- Live interactive demos (demo components in reference.sx)
- S-expression source code display
- Server handler code shown as s-expressions (defhandlers in handlers/reference.sx)
- Wire response display via OOB swaps on demo interaction
- Linked attribute names in the reference table

Covers all 20 implemented attributes (sx-get/post/put/delete/patch,
sx-trigger/target/swap/swap-oob/select/confirm/push-url/sync/encoding/
headers/include/vals/media/disable/on:*, sx-retry, data-sx, data-sx-env).

Also adds sx-on:* to BEHAVIOR_ATTRS, updates REFERENCE_NAV to link
/reference/attributes, and makes /reference/ an index page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 17:12:57 +00:00
a4377668be Add isomorphic SX architecture migration plan
Documents the 5-phase plan for making the sx s-expression layer a
universal view language that renders on either client or server, with
pages as cached components and data-only navigation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 16:52:12 +00:00
169 changed files with 8503 additions and 6710 deletions

View File

@@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import path_setup # noqa: F401 # adds shared/ to sys.path 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 pathlib import Path
from quart import g, request from quart import g, request
@@ -8,7 +7,7 @@ from jinja2 import FileSystemLoader, ChoiceLoader
from shared.infrastructure.factory import create_base_app from shared.infrastructure.factory import create_base_app
from bp import register_account_bp, register_auth_bp, register_fragments from bp import register_account_bp, register_auth_bp
async def account_context() -> dict: async def account_context() -> dict:
@@ -72,8 +71,9 @@ def create_app() -> "Quart":
app.jinja_loader, app.jinja_loader,
]) ])
# Setup defpage routes # Load .sx component files and setup defpage routes
import sx.sx_components # noqa: F811 — ensure components loaded 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 from sxc.pages import setup_account_pages
setup_account_pages() setup_account_pages()
@@ -81,11 +81,13 @@ def create_app() -> "Quart":
app.register_blueprint(register_auth_bp()) app.register_blueprint(register_auth_bp())
account_bp = register_account_bp() account_bp = register_account_bp()
from shared.sx.pages import mount_pages
mount_pages(account_bp, "account")
app.register_blueprint(account_bp) app.register_blueprint(account_bp)
app.register_blueprint(register_fragments()) from shared.sx.pages import auto_mount_pages
auto_mount_pages(app, "account")
from shared.sx.handlers import auto_mount_fragment_handlers
auto_mount_fragment_handlers(app, "account")
from bp.actions.routes import register as register_actions from bp.actions.routes import register as register_actions
app.register_blueprint(register_actions()) app.register_blueprint(register_actions())

View File

@@ -1,3 +1,2 @@
from .account.routes import register as register_account_bp from .account.routes import register as register_account_bp
from .auth.routes import register as register_auth_bp from .auth.routes import register as register_auth_bp
from .fragments import register_fragments

View File

@@ -7,17 +7,13 @@ from __future__ import annotations
from quart import ( from quart import (
Blueprint, Blueprint,
request,
redirect,
g, g,
) )
from sqlalchemy import select from sqlalchemy import select
from shared.models import UserNewsletter from shared.models import UserNewsletter
from shared.models.ghost_membership_entities import GhostNewsletter from shared.infrastructure.fragments import fetch_fragments
from shared.infrastructure.urls import login_url from shared.sx.helpers import sx_response, render_to_sx
from shared.infrastructure.fragments import fetch_fragment, fetch_fragments
from shared.sx.helpers import sx_response
def register(url_prefix="/"): def register(url_prefix="/"):
@@ -25,8 +21,7 @@ def register(url_prefix="/"):
@account_bp.before_request @account_bp.before_request
async def _prepare_page_data(): async def _prepare_page_data():
"""Fetch account_nav fragments and load data for defpage routes.""" """Fetch account_nav fragments for layout."""
# Fetch account nav items for layout (was in context_processor)
events_nav, cart_nav, artdag_nav = await fetch_fragments([ events_nav, cart_nav, artdag_nav = await fetch_fragments([
("events", "account-nav-item", {}), ("events", "account-nav-item", {}),
("cart", "account-nav-item", {}), ("cart", "account-nav-item", {}),
@@ -34,48 +29,6 @@ def register(url_prefix="/"):
], required=False) ], required=False)
g.account_nav = events_nav + cart_nav + artdag_nav g.account_nav = events_nav + cart_nav + artdag_nav
if request.method != "GET":
return
endpoint = request.endpoint or ""
# Newsletters page — load newsletter data
if endpoint.endswith("defpage_newsletters"):
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": nl,
"un": un,
"subscribed": un.subscribed if un else False,
})
g.newsletters_data = newsletter_list
# Fragment page — load fragment from events service
elif endpoint.endswith("defpage_fragment_page"):
slug = request.view_args.get("slug")
if slug and g.get("user"):
fragment_html = await fetch_fragment(
"events", "account-page",
params={"slug": slug, "user_id": str(g.user.id)},
)
if not fragment_html:
from quart import abort
abort(404)
g.fragment_page_data = fragment_html
@account_bp.post("/newsletter/<int:newsletter_id>/toggle/") @account_bp.post("/newsletter/<int:newsletter_id>/toggle/")
async def toggle_newsletter(newsletter_id: int): async def toggle_newsletter(newsletter_id: int):
if not g.get("user"): if not g.get("user"):
@@ -101,7 +54,26 @@ def register(url_prefix="/"):
await g.s.flush() await g.s.flush()
from sx.sx_components import render_newsletter_toggle # Render toggle directly — no sx_components intermediary
return sx_response(render_newsletter_toggle(un)) 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 return account_bp

View File

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

View File

@@ -1 +0,0 @@
from .routes import register as register_fragments

View File

@@ -1,36 +0,0 @@
"""Account app fragment endpoints.
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
by other coop apps via the fragment client.
All handlers are defined declaratively in .sx files under
``account/sx/handlers/`` and dispatched via the sx handler registry.
"""
from __future__ import annotations
from quart import Blueprint, Response, request
from shared.infrastructure.fragments import FRAGMENT_HEADER
from shared.sx.handlers import get_handler, execute_handler
def register():
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
@bp.before_request
async def _require_fragment_header():
if not request.headers.get(FRAGMENT_HEADER):
return Response("", status=403)
@bp.get("/<fragment_type>")
async def get_fragment(fragment_type: str):
handler_def = get_handler("account", fragment_type)
if handler_def is not None:
result = await execute_handler(
handler_def, "account", args=dict(request.args),
)
return Response(result, status=200, content_type="text/sx")
return Response("", status=200, content_type="text/sx")
return bp

View File

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

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

@@ -27,3 +27,25 @@
(h1 :class "text-2xl font-bold mb-4" "Device authorized") (h1 :class "text-2xl font-bold mb-4" "Device authorized")
(p :class "text-stone-600" "You can close this window and return to your terminal."))) (p :class "text-stone-600" "You can close this window and return to your terminal.")))
;; Assembled auth page content — replaces Python _login_page_content etc.
(defcomp ~account-login-content (&key error email)
(~auth-login-form
:error (when error (~auth-error-banner :error error))
:action (url-for "auth.start_login")
:csrf-token (csrf-token)
:email (or email "")))
(defcomp ~account-device-content (&key error code)
(~account-device-form
:error (when error (~account-device-error :error error))
:action (url-for "auth.device_submit")
:csrf-token (csrf-token)
:code (or code "")))
(defcomp ~account-check-email-content (&key email email-error)
(~auth-check-email
:email (escape (or email ""))
:error (when email-error
(~auth-check-email-error :error (escape email-error)))))

View File

@@ -41,3 +41,20 @@
name) name)
logout) logout)
labels))) labels)))
;; Assembled dashboard content — replaces Python _account_main_panel_sx
(defcomp ~account-dashboard-content (&key error)
(let* ((user (current-user))
(csrf (csrf-token)))
(~account-main-panel
:error (when error (~account-error-banner :error error))
:email (when (get user "email")
(~account-user-email :email (get user "email")))
:name (when (get user "name")
(~account-user-name :name (get user "name")))
:logout (~account-logout-form :csrf-token csrf)
:labels (when (not (empty? (or (get user "labels") (list))))
(~account-labels-section
:items (map (lambda (label)
(~account-label-item :name (get label "name")))
(get user "labels")))))))

View File

@@ -29,3 +29,34 @@
(div :class "bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-6" (div :class "bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-6"
(h1 :class "text-xl font-semibold tracking-tight" "Newsletters") (h1 :class "text-xl font-semibold tracking-tight" "Newsletters")
list))) list)))
;; Assembled newsletters content — replaces Python _newsletters_panel_sx
;; Takes pre-fetched newsletter-list from page helper
(defcomp ~account-newsletters-content (&key newsletter-list account-url)
(let* ((csrf (csrf-token)))
(if (empty? newsletter-list)
(~account-newsletter-empty)
(~account-newsletters-panel
:list (~account-newsletter-list
:items (map (lambda (item)
(let* ((nl (get item "newsletter"))
(un (get item "un"))
(nid (get nl "id"))
(subscribed (get item "subscribed"))
(toggle-url (str (or account-url "") "/newsletter/" nid "/toggle/"))
(bg (if subscribed "bg-emerald-500" "bg-stone-300"))
(translate (if subscribed "translate-x-6" "translate-x-1"))
(checked (if subscribed "true" "false")))
(~account-newsletter-item
:name (get nl "name")
:desc (when (get nl "description")
(~account-newsletter-desc :description (get nl "description")))
:toggle (~account-newsletter-toggle
:id (str "nl-" nid)
:url toggle-url
:hdrs (str "{\"X-CSRFToken\": \"" csrf "\"}")
:target (str "#nl-" nid)
:cls (str "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 (str "inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform " translate)))))
newsletter-list))))))

View File

@@ -1,339 +0,0 @@
"""
Account service s-expression page components.
Renders account dashboard, newsletters, fragment pages, login, and device
auth pages. Called from route handlers in place of ``render_template()``.
"""
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, sx_call, SxExpr,
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")
# ---------------------------------------------------------------------------
# Header helpers
# ---------------------------------------------------------------------------
def _auth_nav_sx(ctx: dict) -> str:
"""Auth section desktop nav items."""
parts = [
sx_call("nav-link",
href=call_url(ctx, "account_url", "/newsletters/"),
label="newsletters",
select_colours=ctx.get("select_colours", ""),
)
]
account_nav = ctx.get("account_nav")
if account_nav:
parts.append(account_nav)
return "(<> " + " ".join(parts) + ")"
def _auth_header_sx(ctx: dict, *, oob: bool = False) -> str:
"""Build the account section header row."""
return sx_call(
"menu-row-sx",
id="auth-row", level=1, colour="sky",
link_href=call_url(ctx, "account_url", "/"),
link_label="account", icon="fa-solid fa-user",
nav=SxExpr(_auth_nav_sx(ctx)),
child_id="auth-header-child", oob=oob,
)
def _auth_nav_mobile_sx(ctx: dict) -> str:
"""Mobile nav menu for auth section."""
parts = [
sx_call("nav-link",
href=call_url(ctx, "account_url", "/newsletters/"),
label="newsletters",
select_colours=ctx.get("select_colours", ""),
)
]
account_nav = ctx.get("account_nav")
if account_nav:
parts.append(account_nav)
return "(<> " + " ".join(parts) + ")"
# ---------------------------------------------------------------------------
# Account dashboard (GET /)
# ---------------------------------------------------------------------------
def _account_main_panel_sx(ctx: dict) -> str:
"""Account info panel with user details and logout."""
from quart import g
from shared.browser.app.csrf import generate_csrf_token
user = getattr(g, "user", None)
error = ctx.get("error", "")
error_sx = sx_call("account-error-banner", error=error) if error else ""
user_email_sx = ""
user_name_sx = ""
if user:
user_email_sx = sx_call("account-user-email", email=user.email)
if user.name:
user_name_sx = sx_call("account-user-name", name=user.name)
logout_sx = sx_call("account-logout-form", csrf_token=generate_csrf_token())
labels_sx = ""
if user and hasattr(user, "labels") and user.labels:
label_items = " ".join(
sx_call("account-label-item", name=label.name)
for label in user.labels
)
labels_sx = sx_call("account-labels-section",
items=SxExpr("(<> " + label_items + ")"))
return sx_call(
"account-main-panel",
error=SxExpr(error_sx) if error_sx else None,
email=SxExpr(user_email_sx) if user_email_sx else None,
name=SxExpr(user_name_sx) if user_name_sx else None,
logout=SxExpr(logout_sx),
labels=SxExpr(labels_sx) if labels_sx else None,
)
# ---------------------------------------------------------------------------
# Newsletters (GET /newsletters/)
# ---------------------------------------------------------------------------
def _newsletter_toggle_sx(un: Any, account_url_fn: Any, csrf_token: str) -> str:
"""Render a single newsletter toggle switch."""
nid = un.newsletter_id
toggle_url = account_url_fn(f"/newsletter/{nid}/toggle/")
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 sx_call(
"account-newsletter-toggle",
id=f"nl-{nid}", url=toggle_url,
hdrs=f'{{"X-CSRFToken": "{csrf_token}"}}',
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}",
)
def _newsletter_toggle_off_sx(nid: int, toggle_url: str, csrf_token: str) -> str:
"""Render an unsubscribed newsletter toggle (no subscription record yet)."""
return sx_call(
"account-newsletter-toggle",
id=f"nl-{nid}", url=toggle_url,
hdrs=f'{{"X-CSRFToken": "{csrf_token}"}}',
target=f"#nl-{nid}",
cls="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-stone-300",
checked="false",
knob_cls="inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform translate-x-1",
)
def _newsletters_panel_sx(ctx: dict, newsletter_list: list) -> str:
"""Newsletters management panel."""
from shared.browser.app.csrf import generate_csrf_token
account_url_fn = ctx.get("account_url") or (lambda p: p)
csrf = generate_csrf_token()
if newsletter_list:
items = []
for item in newsletter_list:
nl = item["newsletter"]
un = item.get("un")
desc_sx = sx_call(
"account-newsletter-desc", description=nl.description
) if nl.description else ""
if un:
toggle = _newsletter_toggle_sx(un, account_url_fn, csrf)
else:
toggle_url = account_url_fn(f"/newsletter/{nl.id}/toggle/")
toggle = _newsletter_toggle_off_sx(nl.id, toggle_url, csrf)
items.append(sx_call(
"account-newsletter-item",
name=nl.name,
desc=SxExpr(desc_sx) if desc_sx else None,
toggle=SxExpr(toggle),
))
list_sx = sx_call(
"account-newsletter-list",
items=SxExpr("(<> " + " ".join(items) + ")"),
)
else:
list_sx = sx_call("account-newsletter-empty")
return sx_call("account-newsletters-panel", list=SxExpr(list_sx))
# ---------------------------------------------------------------------------
# Auth pages (login, device, check_email)
# ---------------------------------------------------------------------------
def _login_page_content(ctx: dict) -> str:
"""Login form content."""
from shared.browser.app.csrf import generate_csrf_token
from quart import url_for
error = ctx.get("error", "")
email = ctx.get("email", "")
action = url_for("auth.start_login")
error_sx = sx_call("auth-error-banner", error=error) if error else ""
return sx_call(
"auth-login-form",
error=SxExpr(error_sx) if error_sx else None,
action=action,
csrf_token=generate_csrf_token(), email=email,
)
def _device_page_content(ctx: dict) -> str:
"""Device authorization form content."""
from shared.browser.app.csrf import generate_csrf_token
from quart import url_for
error = ctx.get("error", "")
code = ctx.get("code", "")
action = url_for("auth.device_submit")
error_sx = sx_call("account-device-error", error=error) if error else ""
return sx_call(
"account-device-form",
error=SxExpr(error_sx) if error_sx else None,
action=action,
csrf_token=generate_csrf_token(), code=code,
)
def _device_approved_content() -> str:
"""Device approved success content."""
return sx_call("account-device-approved")
# ---------------------------------------------------------------------------
# Public API: Account dashboard
# ---------------------------------------------------------------------------
def _fragment_content(frag: object) -> str:
"""Convert a fragment response to sx content string.
SxExpr (from text/sx responses) is embedded as-is; plain strings
(from text/html) are wrapped in ``~rich-text``.
"""
from shared.sx.parser import SxExpr
if isinstance(frag, SxExpr):
return frag.source
s = str(frag) if frag else ""
if not s:
return ""
return f'(~rich-text :html "{_sx_escape(s)}")'
# ---------------------------------------------------------------------------
# Public API: Auth pages (login, device)
# ---------------------------------------------------------------------------
async def render_login_page(ctx: dict) -> str:
"""Full page: login form."""
hdr = root_header_sx(ctx)
return full_page_sx(ctx, header_rows=hdr,
content=_login_page_content(ctx),
meta_html='<title>Login \u2014 Rose Ash</title>')
async def render_device_page(ctx: dict) -> str:
"""Full page: device authorization form."""
hdr = root_header_sx(ctx)
return full_page_sx(ctx, header_rows=hdr,
content=_device_page_content(ctx),
meta_html='<title>Authorize Device \u2014 Rose Ash</title>')
async def render_device_approved_page(ctx: dict) -> str:
"""Full page: device approved."""
hdr = root_header_sx(ctx)
return full_page_sx(ctx, header_rows=hdr,
content=_device_approved_content(),
meta_html='<title>Device Authorized \u2014 Rose Ash</title>')
# ---------------------------------------------------------------------------
# Public API: Check email page (POST /start/ success)
# ---------------------------------------------------------------------------
def _check_email_content(email: str, email_error: str | None = None) -> str:
"""Check email confirmation content."""
from markupsafe import escape
error_sx = sx_call(
"auth-check-email-error", error=str(escape(email_error))
) if email_error else ""
return sx_call(
"auth-check-email",
email=str(escape(email)),
error=SxExpr(error_sx) if error_sx else None,
)
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 = root_header_sx(ctx)
return full_page_sx(ctx, header_rows=hdr,
content=_check_email_content(email, email_error),
meta_html='<title>Check your email \u2014 Rose Ash</title>')
# ---------------------------------------------------------------------------
# Public API: Fragment renderers for POST handlers
# ---------------------------------------------------------------------------
def render_newsletter_toggle(un) -> str:
"""Render a newsletter toggle switch for POST response (uses account_url)."""
from shared.browser.app.csrf import generate_csrf_token
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
return _newsletter_toggle_sx(un, account_url_fn, generate_csrf_token())
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _sx_escape(s: str) -> str:
"""Escape a string for embedding in sx string literals."""
return s.replace("\\", "\\\\").replace('"', '\\"')

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 __future__ import annotations
from typing import Any from typing import Any
def setup_account_pages() -> None: 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_layouts()
_register_account_helpers()
_load_account_page_files() _load_account_page_files()
@@ -26,30 +25,52 @@ def _register_account_layouts() -> None:
register_custom_layout("account", _account_full, _account_oob, _account_mobile) register_custom_layout("account", _account_full, _account_oob, _account_mobile)
def _account_full(ctx: dict, **kw: Any) -> str: async def _account_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, header_child_sx from shared.sx.helpers import root_header_sx, header_child_sx, render_to_sx
from sx.sx_components import _auth_header_sx
root_hdr = root_header_sx(ctx) root_hdr = await root_header_sx(ctx)
hdr_child = header_child_sx(_auth_header_sx(ctx)) auth_hdr = await render_to_sx("auth-header-row",
account_url=_call_url(ctx, "account_url", ""),
select_colours=ctx.get("select_colours", ""),
account_nav=_as_sx_nav(ctx),
)
hdr_child = await header_child_sx(auth_hdr)
return "(<> " + root_hdr + " " + hdr_child + ")" return "(<> " + root_hdr + " " + hdr_child + ")"
def _account_oob(ctx: dict, **kw: Any) -> str: async def _account_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx from shared.sx.helpers import root_header_sx, render_to_sx
from sx.sx_components import _auth_header_sx
return "(<> " + _auth_header_sx(ctx, oob=True) + " " + root_header_sx(ctx, oob=True) + ")" auth_hdr = await render_to_sx("auth-header-row",
account_url=_call_url(ctx, "account_url", ""),
select_colours=ctx.get("select_colours", ""),
account_nav=_as_sx_nav(ctx),
oob=True,
)
return "(<> " + auth_hdr + " " + await root_header_sx(ctx, oob=True) + ")"
def _account_mobile(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, sx_call, SxExpr from shared.sx.helpers import mobile_menu_sx, mobile_root_nav_sx, render_to_sx
from sx.sx_components import _auth_nav_mobile_sx from shared.sx.parser import SxExpr
ctx = _inject_account_nav(ctx) ctx = _inject_account_nav(ctx)
auth_section = sx_call("mobile-menu-section", nav_items = await render_to_sx("auth-nav-items",
account_url=_call_url(ctx, "account_url", ""),
select_colours=ctx.get("select_colours", ""),
account_nav=_as_sx_nav(ctx),
)
auth_section = await render_to_sx("mobile-menu-section",
label="account", href="/", level=1, colour="sky", label="account", href="/", level=1, colour="sky",
items=SxExpr(_auth_nav_mobile_sx(ctx))) items=SxExpr(nav_items))
return mobile_menu_sx(auth_section, mobile_root_nav_sx(ctx)) return mobile_menu_sx(auth_section, await mobile_root_nav_sx(ctx))
def _call_url(ctx: dict, key: str, path: str = "/") -> str:
fn = ctx.get(key)
if callable(fn):
return fn(path)
return str(fn or "") + path
def _inject_account_nav(ctx: dict) -> dict: def _inject_account_nav(ctx: dict) -> dict:
@@ -61,45 +82,10 @@ def _inject_account_nav(ctx: dict) -> dict:
return ctx return ctx
# --------------------------------------------------------------------------- def _as_sx_nav(ctx: dict) -> Any:
# Page helpers """Convert account_nav fragment to SxExpr for use in component calls."""
# --------------------------------------------------------------------------- from shared.sx.helpers import _as_sx
ctx = _inject_account_nav(ctx)
def _register_account_helpers() -> None: return _as_sx(ctx.get("account_nav"))
from shared.sx.pages import register_page_helpers
register_page_helpers("account", {
"account-content": _h_account_content,
"newsletters-content": _h_newsletters_content,
"fragment-content": _h_fragment_content,
})
def _h_account_content():
from sx.sx_components import _account_main_panel_sx
return _account_main_panel_sx({})
def _h_newsletters_content():
from quart import g
d = getattr(g, "newsletters_data", None)
if not d:
from shared.sx.helpers import sx_call
return sx_call("account-newsletter-empty")
from shared.sx.page import get_template_context_sync
from sx.sx_components import _newsletters_panel_sx
# Build a minimal ctx with account_url
ctx = {"account_url": getattr(g, "_account_url", None)}
if ctx["account_url"] is None:
from shared.infrastructure.urls import account_url
ctx["account_url"] = account_url
return _newsletters_panel_sx(ctx, d)
def _h_fragment_content():
from quart import g
frag = getattr(g, "fragment_page_data", None)
if not frag:
return ""
from sx.sx_components import _fragment_content
return _fragment_content(frag)

View File

@@ -8,7 +8,7 @@
:path "/" :path "/"
:auth :login :auth :login
:layout :account :layout :account
:content (account-content)) :content (~account-dashboard-content))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; Newsletters ;; Newsletters
@@ -18,7 +18,10 @@
:path "/newsletters/" :path "/newsletters/"
:auth :login :auth :login
:layout :account :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) ;; Fragment pages (tickets, bookings, etc. from events service)
@@ -28,4 +31,10 @@
:path "/<slug>/" :path "/<slug>/"
:auth :login :auth :login
:layout :account :layout :account
:content (fragment-content)) :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

@@ -16,7 +16,6 @@ from bp import (
register_admin, register_admin,
register_menu_items, register_menu_items,
register_snippets, register_snippets,
register_fragments,
register_data, register_data,
register_actions, register_actions,
) )
@@ -108,7 +107,9 @@ def create_app() -> "Quart":
app.register_blueprint(register_admin("/settings")) app.register_blueprint(register_admin("/settings"))
app.register_blueprint(register_menu_items()) app.register_blueprint(register_menu_items())
app.register_blueprint(register_snippets()) app.register_blueprint(register_snippets())
app.register_blueprint(register_fragments()) from shared.sx.handlers import auto_mount_fragment_handlers
auto_mount_fragment_handlers(app, "blog")
app.register_blueprint(register_data()) app.register_blueprint(register_data())
app.register_blueprint(register_actions()) app.register_blueprint(register_actions())
@@ -162,6 +163,23 @@ def create_app() -> "Quart":
) )
return jsonify(resp) return jsonify(resp)
# Auto-mount all defpages with absolute paths
from shared.sx.pages import auto_mount_pages
auto_mount_pages(app, "blog")
# --- Pass defpage helper data to template context for layouts ---
@app.context_processor
async def inject_blog_data():
import os
from shared.config import config as get_config
ctx = {
"blog_title": get_config()["blog_title"],
"base_title": get_config()["title"],
"unsplash_api_key": os.environ.get("UNSPLASH_ACCESS_KEY", ""),
}
ctx.update(getattr(g, '_defpage_ctx', {}))
return ctx
# --- debug: url rules --- # --- debug: url rules ---
@app.get("/__rules") @app.get("/__rules")
async def dump_rules(): async def dump_rules():

View File

@@ -2,6 +2,5 @@ from .blog.routes import register as register_blog_bp
from .admin.routes import register as register_admin from .admin.routes import register as register_admin
from .menu_items.routes import register as register_menu_items from .menu_items.routes import register as register_menu_items
from .snippets.routes import register as register_snippets from .snippets.routes import register as register_snippets
from .fragments import register_fragments
from .data import register_data from .data import register_data
from .actions.routes import register as register_actions from .actions.routes import register as register_actions

View File

@@ -3,13 +3,9 @@ from __future__ import annotations
#from quart import Blueprint, g #from quart import Blueprint, g
from quart import ( from quart import (
render_template,
make_response,
Blueprint, Blueprint,
redirect, redirect,
url_for, url_for,
request,
jsonify
) )
from shared.browser.app.redis_cacher import clear_all_cache from shared.browser.app.redis_cacher import clear_all_cache
from shared.browser.app.authz import require_admin from shared.browser.app.authz import require_admin
@@ -27,23 +23,6 @@ def register(url_prefix):
"base_title": f"{config()['title']} settings", "base_title": f"{config()['title']} settings",
} }
@bp.before_request
async def _prepare_page_data():
ep = request.endpoint or ""
if "defpage_settings_home" in ep:
from shared.sx.page import get_template_context
from sx.sx_components import _settings_main_panel_sx
tctx = await get_template_context()
g.settings_content = _settings_main_panel_sx(tctx)
elif "defpage_cache_page" in ep:
from shared.sx.page import get_template_context
from sx.sx_components import _cache_main_panel_sx
tctx = await get_template_context()
g.cache_content = _cache_main_panel_sx(tctx)
from shared.sx.pages import mount_pages
mount_pages(bp, "blog", names=["settings-home", "cache-page"])
@bp.post("/cache_clear/") @bp.post("/cache_clear/")
@require_admin @require_admin
async def cache_clear(): async def cache_clear():
@@ -54,7 +33,7 @@ def register(url_prefix):
html = render_comp("cache-cleared", time_str=now.strftime("%H:%M:%S")) html = render_comp("cache-cleared", time_str=now.strftime("%H:%M:%S"))
return sx_response(html) return sx_response(html)
return redirect(url_for("settings.defpage_cache_page")) return redirect(url_for("defpage_cache_page"))
return bp return bp

View File

@@ -2,8 +2,6 @@ from __future__ import annotations
import re import re
from quart import ( from quart import (
render_template,
make_response,
Blueprint, Blueprint,
redirect, redirect,
url_for, url_for,
@@ -13,9 +11,7 @@ from quart import (
from sqlalchemy import select, delete from sqlalchemy import select, delete
from shared.browser.app.authz import require_admin from shared.browser.app.authz import require_admin
from shared.browser.app.utils.htmx import is_htmx_request
from shared.browser.app.redis_cacher import invalidate_tag_cache from shared.browser.app.redis_cacher import invalidate_tag_cache
from shared.sx.helpers import sx_response
from models.tag_group import TagGroup, TagGroupTag from models.tag_group import TagGroup, TagGroupTag
from models.ghost_content import Tag from models.ghost_content import Tag
@@ -46,60 +42,13 @@ async def _unassigned_tags(session):
def register(): def register():
bp = Blueprint("tag_groups_admin", __name__, url_prefix="/settings/tag-groups") bp = Blueprint("tag_groups_admin", __name__, url_prefix="/settings/tag-groups")
@bp.before_request
async def _prepare_page_data():
ep = request.endpoint or ""
if "defpage_tag_groups_page" in ep:
groups = list(
(await g.s.execute(
select(TagGroup).order_by(TagGroup.sort_order, TagGroup.name)
)).scalars()
)
unassigned = await _unassigned_tags(g.s)
from shared.sx.page import get_template_context
from sx.sx_components import _tag_groups_main_panel_sx
tctx = await get_template_context()
tctx.update({"groups": groups, "unassigned_tags": unassigned})
g.tag_groups_content = _tag_groups_main_panel_sx(tctx)
elif "defpage_tag_group_edit" in ep:
tag_id = (request.view_args or {}).get("id")
tg = await g.s.get(TagGroup, tag_id)
if not tg:
from quart import abort
abort(404)
assigned_rows = list(
(await g.s.execute(
select(TagGroupTag.tag_id).where(TagGroupTag.tag_group_id == tag_id)
)).scalars()
)
all_tags = list(
(await g.s.execute(
select(Tag).where(
Tag.deleted_at.is_(None),
(Tag.visibility == "public") | (Tag.visibility.is_(None)),
).order_by(Tag.name)
)).scalars()
)
from shared.sx.page import get_template_context
from sx.sx_components import _tag_groups_edit_main_panel_sx
tctx = await get_template_context()
tctx.update({
"group": tg,
"all_tags": all_tags,
"assigned_tag_ids": set(assigned_rows),
})
g.tag_group_edit_content = _tag_groups_edit_main_panel_sx(tctx)
from shared.sx.pages import mount_pages
mount_pages(bp, "blog", names=["tag-groups-page", "tag-group-edit"])
@bp.post("/") @bp.post("/")
@require_admin @require_admin
async def create(): async def create():
form = await request.form form = await request.form
name = (form.get("name") or "").strip() name = (form.get("name") or "").strip()
if not name: if not name:
return redirect(url_for("blog.tag_groups_admin.defpage_tag_groups_page")) return redirect(url_for("defpage_tag_groups_page"))
slug = _slugify(name) slug = _slugify(name)
feature_image = (form.get("feature_image") or "").strip() or None feature_image = (form.get("feature_image") or "").strip() or None
@@ -115,14 +64,14 @@ def register():
await g.s.flush() await g.s.flush()
await invalidate_tag_cache("blog") await invalidate_tag_cache("blog")
return redirect(url_for("blog.tag_groups_admin.defpage_tag_groups_page")) return redirect(url_for("defpage_tag_groups_page"))
@bp.post("/<int:id>/") @bp.post("/<int:id>/")
@require_admin @require_admin
async def save(id: int): async def save(id: int):
tg = await g.s.get(TagGroup, id) tg = await g.s.get(TagGroup, id)
if not tg: if not tg:
return redirect(url_for("blog.tag_groups_admin.defpage_tag_groups_page")) return redirect(url_for("defpage_tag_groups_page"))
form = await request.form form = await request.form
name = (form.get("name") or "").strip() name = (form.get("name") or "").strip()
@@ -153,7 +102,7 @@ def register():
await g.s.flush() await g.s.flush()
await invalidate_tag_cache("blog") await invalidate_tag_cache("blog")
return redirect(url_for("blog.tag_groups_admin.defpage_tag_group_edit", id=id)) return redirect(url_for("defpage_tag_group_edit", id=id))
@bp.post("/<int:id>/delete/") @bp.post("/<int:id>/delete/")
@require_admin @require_admin
@@ -163,6 +112,6 @@ def register():
await g.s.delete(tg) await g.s.delete(tg)
await g.s.flush() await g.s.flush()
await invalidate_tag_cache("blog") await invalidate_tag_cache("blog")
return redirect(url_for("blog.tag_groups_admin.defpage_tag_groups_page")) return redirect(url_for("defpage_tag_groups_page"))
return bp return bp

View File

@@ -53,16 +53,6 @@ def register(url_prefix, title):
@blogs_bp.before_request @blogs_bp.before_request
async def route(): async def route():
g.makeqs_factory = makeqs_factory g.makeqs_factory = makeqs_factory
ep = request.endpoint or ""
if "defpage_new_post" in ep:
from sx.sx_components import render_editor_panel
g.editor_content = render_editor_panel()
elif "defpage_new_page" in ep:
from sx.sx_components import render_editor_panel
g.editor_page_content = render_editor_panel(is_page=True)
from shared.sx.pages import mount_pages
mount_pages(blogs_bp, "blog", names=["new-post", "new-page"])
@blogs_bp.context_processor @blogs_bp.context_processor
async def inject_root(): async def inject_root():
@@ -245,7 +235,7 @@ def register(url_prefix, title):
from shared.sx.page import get_template_context from shared.sx.page import get_template_context
from sx.sx_components import render_new_post_page, render_editor_panel from sx.sx_components import render_new_post_page, render_editor_panel
tctx = await get_template_context() tctx = await get_template_context()
tctx["editor_html"] = render_editor_panel(save_error="Invalid JSON in editor content.") tctx["editor_html"] = await render_editor_panel(save_error="Invalid JSON in editor content.")
html = await render_new_post_page(tctx) html = await render_new_post_page(tctx)
return await make_response(html, 400) return await make_response(html, 400)
@@ -254,7 +244,7 @@ def register(url_prefix, title):
from shared.sx.page import get_template_context from shared.sx.page import get_template_context
from sx.sx_components import render_new_post_page, render_editor_panel from sx.sx_components import render_new_post_page, render_editor_panel
tctx = await get_template_context() tctx = await get_template_context()
tctx["editor_html"] = render_editor_panel(save_error=reason) tctx["editor_html"] = await render_editor_panel(save_error=reason)
html = await render_new_post_page(tctx) html = await render_new_post_page(tctx)
return await make_response(html, 400) return await make_response(html, 400)
@@ -277,7 +267,7 @@ def register(url_prefix, title):
await invalidate_tag_cache("blog") await invalidate_tag_cache("blog")
# Redirect to the edit page # Redirect to the edit page
return redirect(host_url(url_for("blog.post.admin.defpage_post_edit", slug=post.slug))) return redirect(host_url(url_for("defpage_post_edit", slug=post.slug)))
@blogs_bp.post("/new-page/") @blogs_bp.post("/new-page/")
@@ -301,7 +291,7 @@ def register(url_prefix, title):
from shared.sx.page import get_template_context from shared.sx.page import get_template_context
from sx.sx_components import render_new_post_page, render_editor_panel from sx.sx_components import render_new_post_page, render_editor_panel
tctx = await get_template_context() tctx = await get_template_context()
tctx["editor_html"] = render_editor_panel(save_error="Invalid JSON in editor content.", is_page=True) tctx["editor_html"] = await render_editor_panel(save_error="Invalid JSON in editor content.", is_page=True)
tctx["is_page"] = True tctx["is_page"] = True
html = await render_new_post_page(tctx) html = await render_new_post_page(tctx)
return await make_response(html, 400) return await make_response(html, 400)
@@ -311,7 +301,7 @@ def register(url_prefix, title):
from shared.sx.page import get_template_context from shared.sx.page import get_template_context
from sx.sx_components import render_new_post_page, render_editor_panel from sx.sx_components import render_new_post_page, render_editor_panel
tctx = await get_template_context() tctx = await get_template_context()
tctx["editor_html"] = render_editor_panel(save_error=reason, is_page=True) tctx["editor_html"] = await render_editor_panel(save_error=reason, is_page=True)
tctx["is_page"] = True tctx["is_page"] = True
html = await render_new_post_page(tctx) html = await render_new_post_page(tctx)
return await make_response(html, 400) return await make_response(html, 400)
@@ -335,7 +325,7 @@ def register(url_prefix, title):
await invalidate_tag_cache("blog") await invalidate_tag_cache("blog")
# Redirect to the page admin # Redirect to the page admin
return redirect(host_url(url_for("blog.post.admin.defpage_post_edit", slug=page.slug))) return redirect(host_url(url_for("defpage_post_edit", slug=page.slug)))
@blogs_bp.get("/drafts/") @blogs_bp.get("/drafts/")

View File

@@ -126,7 +126,7 @@ _CARD_MARKER_RE = re.compile(
def _parse_card_fragments(html: str) -> dict[str, str]: def _parse_card_fragments(html: str) -> dict[str, str]:
"""Parse the container-cards fragment into {post_id_str: html} dict.""" """Parse the container-cards fragment into {post_id_str: html} dict."""
result = {} result = {}
for m in _CARD_MARKER_RE.finditer(html): for m in _CARD_MARKER_RE.finditer(str(html)):
post_id_str = m.group(1) post_id_str = m.group(1)
inner = m.group(2).strip() inner = m.group(2).strip()
if inner: if inner:

View File

@@ -1 +0,0 @@
from .routes import register as register_fragments

View File

@@ -1,36 +0,0 @@
"""Blog app fragment endpoints.
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
by other coop apps via the fragment client.
All handlers are defined declaratively in .sx files under
``blog/sx/handlers/`` and dispatched via the sx handler registry.
"""
from __future__ import annotations
from quart import Blueprint, Response, request
from shared.infrastructure.fragments import FRAGMENT_HEADER
from shared.sx.handlers import get_handler, execute_handler
def register():
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
@bp.before_request
async def _require_fragment_header():
if not request.headers.get(FRAGMENT_HEADER):
return Response("", status=403)
@bp.get("/<fragment_type>")
async def get_fragment(fragment_type: str):
handler_def = get_handler("blog", fragment_type)
if handler_def is not None:
result = await execute_handler(
handler_def, "blog", args=dict(request.args),
)
return Response(result, status=200, content_type="text/sx")
return Response("", status=200, content_type="text/sx")
return bp

View File

@@ -12,30 +12,15 @@ from .services.menu_items import (
search_pages, search_pages,
MenuItemError, MenuItemError,
) )
from shared.browser.app.utils.htmx import is_htmx_request
from shared.sx.helpers import sx_response from shared.sx.helpers import sx_response
def register(): def register():
bp = Blueprint("menu_items", __name__, url_prefix='/settings/menu_items') bp = Blueprint("menu_items", __name__, url_prefix='/settings/menu_items')
def get_menu_items_nav_oob_sync(menu_items): async def get_menu_items_nav_oob_async(menu_items):
"""Helper to generate OOB update for root nav menu items""" """Helper to generate OOB update for root nav menu items"""
from sx.sx_components import render_menu_items_nav_oob from sx.sx_components import render_menu_items_nav_oob
return render_menu_items_nav_oob(menu_items) return await render_menu_items_nav_oob(menu_items)
@bp.before_request
async def _prepare_page_data():
if "defpage_" not in (request.endpoint or ""):
return
menu_items = await get_all_menu_items(g.s)
from shared.sx.page import get_template_context
from sx.sx_components import _menu_items_main_panel_sx
tctx = await get_template_context()
tctx["menu_items"] = menu_items
g.menu_items_content = _menu_items_main_panel_sx(tctx)
from shared.sx.pages import mount_pages
mount_pages(bp, "blog", names=["menu-items-page"])
@bp.get("/new/") @bp.get("/new/")
@require_admin @require_admin
@@ -66,8 +51,8 @@ def register():
# Get updated list and nav OOB # Get updated list and nav OOB
menu_items = await get_all_menu_items(g.s) menu_items = await get_all_menu_items(g.s)
from sx.sx_components import render_menu_items_list from sx.sx_components import render_menu_items_list
html = render_menu_items_list(menu_items) html = await render_menu_items_list(menu_items)
nav_oob = get_menu_items_nav_oob_sync(menu_items) nav_oob = await get_menu_items_nav_oob_async(menu_items)
return sx_response(html + nav_oob) return sx_response(html + nav_oob)
except MenuItemError as e: except MenuItemError as e:
@@ -106,8 +91,8 @@ def register():
# Get updated list and nav OOB # Get updated list and nav OOB
menu_items = await get_all_menu_items(g.s) menu_items = await get_all_menu_items(g.s)
from sx.sx_components import render_menu_items_list from sx.sx_components import render_menu_items_list
html = render_menu_items_list(menu_items) html = await render_menu_items_list(menu_items)
nav_oob = get_menu_items_nav_oob_sync(menu_items) nav_oob = await get_menu_items_nav_oob_async(menu_items)
return sx_response(html + nav_oob) return sx_response(html + nav_oob)
except MenuItemError as e: except MenuItemError as e:
@@ -127,8 +112,8 @@ def register():
# Get updated list and nav OOB # Get updated list and nav OOB
menu_items = await get_all_menu_items(g.s) menu_items = await get_all_menu_items(g.s)
from sx.sx_components import render_menu_items_list from sx.sx_components import render_menu_items_list
html = render_menu_items_list(menu_items) html = await render_menu_items_list(menu_items)
nav_oob = get_menu_items_nav_oob_sync(menu_items) nav_oob = await get_menu_items_nav_oob_async(menu_items)
return sx_response(html + nav_oob) return sx_response(html + nav_oob)
@bp.get("/pages/search/") @bp.get("/pages/search/")
@@ -143,7 +128,7 @@ def register():
has_more = (page * per_page) < total has_more = (page * per_page) < total
from sx.sx_components import render_page_search_results from sx.sx_components import render_page_search_results
return sx_response(render_page_search_results(pages, query, page, has_more)) return sx_response(await render_page_search_results(pages, query, page, has_more))
@bp.post("/reorder/") @bp.post("/reorder/")
@require_admin @require_admin
@@ -168,8 +153,8 @@ def register():
# Get updated list and nav OOB # Get updated list and nav OOB
menu_items = await get_all_menu_items(g.s) menu_items = await get_all_menu_items(g.s)
from sx.sx_components import render_menu_items_list from sx.sx_components import render_menu_items_list
html = render_menu_items_list(menu_items) html = await render_menu_items_list(menu_items)
nav_oob = get_menu_items_nav_oob_sync(menu_items) nav_oob = await get_menu_items_nav_oob_async(menu_items)
return sx_response(html + nav_oob) return sx_response(html + nav_oob)
return bp return bp

View File

@@ -10,7 +10,6 @@ from quart import (
url_for, url_for,
) )
from shared.browser.app.authz import require_admin, require_post_author from shared.browser.app.authz import require_admin, require_post_author
from shared.browser.app.utils.htmx import is_htmx_request
from shared.sx.helpers import sx_response from shared.sx.helpers import sx_response
from shared.utils import host_url from shared.utils import host_url
@@ -55,155 +54,6 @@ def _post_to_edit_dict(post) -> dict:
def register(): def register():
bp = Blueprint("admin", __name__, url_prefix='/admin') bp = Blueprint("admin", __name__, url_prefix='/admin')
@bp.before_request
async def _prepare_page_data():
ep = request.endpoint or ""
if "defpage_post_admin" in ep:
from sqlalchemy import select
from shared.models.page_config import PageConfig
post = (g.post_data or {}).get("post", {})
features = {}
sumup_configured = False
sumup_merchant_code = ""
sumup_checkout_prefix = ""
if post.get("is_page"):
pc = (await g.s.execute(
select(PageConfig).where(
PageConfig.container_type == "page",
PageConfig.container_id == post["id"],
)
)).scalar_one_or_none()
if pc:
features = pc.features or {}
sumup_configured = bool(pc.sumup_api_key)
sumup_merchant_code = pc.sumup_merchant_code or ""
sumup_checkout_prefix = pc.sumup_checkout_prefix or ""
from shared.sx.page import get_template_context
from sx.sx_components import _post_admin_main_panel_sx
tctx = await get_template_context()
tctx.update({
"features": features,
"sumup_configured": sumup_configured,
"sumup_merchant_code": sumup_merchant_code,
"sumup_checkout_prefix": sumup_checkout_prefix,
})
g.post_admin_content = _post_admin_main_panel_sx(tctx)
elif "defpage_post_data" in ep:
from shared.sx.page import get_template_context
from sx.sx_components import _post_data_content_sx
tctx = await get_template_context()
g.post_data_content = _post_data_content_sx(tctx)
elif "defpage_post_preview" in ep:
from models.ghost_content import Post
from sqlalchemy import select as sa_select
post_id = g.post_data["post"]["id"]
post = (await g.s.execute(
sa_select(Post).where(Post.id == post_id)
)).scalar_one_or_none()
preview_ctx = {}
sx_content = getattr(post, "sx_content", None) or ""
if sx_content:
from shared.sx.prettify import sx_to_pretty_sx
preview_ctx["sx_pretty"] = sx_to_pretty_sx(sx_content)
lexical_raw = getattr(post, "lexical", None) or ""
if lexical_raw:
from shared.sx.prettify import json_to_pretty_sx
preview_ctx["json_pretty"] = json_to_pretty_sx(lexical_raw)
if sx_content:
from shared.sx.parser import parse as sx_parse
from shared.sx.html import render as sx_html_render
from shared.sx.jinja_bridge import _COMPONENT_ENV
try:
parsed = sx_parse(sx_content)
preview_ctx["sx_rendered"] = sx_html_render(parsed, dict(_COMPONENT_ENV))
except Exception:
preview_ctx["sx_rendered"] = "<em>Error rendering sx</em>"
if lexical_raw:
from bp.blog.ghost.lexical_renderer import render_lexical
try:
preview_ctx["lex_rendered"] = render_lexical(lexical_raw)
except Exception:
preview_ctx["lex_rendered"] = "<em>Error rendering lexical</em>"
from shared.sx.page import get_template_context
from sx.sx_components import _preview_main_panel_sx
tctx = await get_template_context()
tctx.update(preview_ctx)
g.post_preview_content = _preview_main_panel_sx(tctx)
elif "defpage_post_entries" in ep:
from sqlalchemy import select
from shared.models.calendars import Calendar
from ..services.entry_associations import get_post_entry_ids
post_id = g.post_data["post"]["id"]
associated_entry_ids = await get_post_entry_ids(post_id)
result = await g.s.execute(
select(Calendar)
.where(Calendar.deleted_at.is_(None))
.order_by(Calendar.name.asc())
)
all_calendars = result.scalars().all()
for calendar in all_calendars:
await g.s.refresh(calendar, ["entries", "post"])
from shared.sx.page import get_template_context
from sx.sx_components import _post_entries_content_sx
tctx = await get_template_context()
tctx["all_calendars"] = all_calendars
tctx["associated_entry_ids"] = associated_entry_ids
g.post_entries_content = _post_entries_content_sx(tctx)
elif "defpage_post_settings" in ep:
from models.ghost_content import Post
from sqlalchemy import select as sa_select
from sqlalchemy.orm import selectinload
post_id = g.post_data["post"]["id"]
post = (await g.s.execute(
sa_select(Post)
.where(Post.id == post_id)
.options(selectinload(Post.tags))
)).scalar_one_or_none()
ghost_post = _post_to_edit_dict(post) if post else {}
save_success = request.args.get("saved") == "1"
from shared.sx.page import get_template_context
from sx.sx_components import _post_settings_content_sx
tctx = await get_template_context()
tctx["ghost_post"] = ghost_post
tctx["save_success"] = save_success
g.post_settings_content = _post_settings_content_sx(tctx)
elif "defpage_post_edit" in ep:
from models.ghost_content import Post
from sqlalchemy import select as sa_select
from sqlalchemy.orm import selectinload
from shared.infrastructure.data_client import fetch_data
post_id = g.post_data["post"]["id"]
post = (await g.s.execute(
sa_select(Post)
.where(Post.id == post_id)
.options(selectinload(Post.tags))
)).scalar_one_or_none()
ghost_post = _post_to_edit_dict(post) if post else {}
save_success = request.args.get("saved") == "1"
save_error = request.args.get("error", "")
raw_newsletters = await fetch_data("account", "newsletters", required=False) or []
from types import SimpleNamespace
newsletters = [SimpleNamespace(**nl) for nl in raw_newsletters]
from shared.sx.page import get_template_context
from sx.sx_components import _post_edit_content_sx
tctx = await get_template_context()
tctx["ghost_post"] = ghost_post
tctx["save_success"] = save_success
tctx["save_error"] = save_error
tctx["newsletters"] = newsletters
g.post_edit_content = _post_edit_content_sx(tctx)
from shared.sx.pages import mount_pages
mount_pages(bp, "blog", names=[
"post-admin", "post-data", "post-preview",
"post-entries", "post-settings", "post-edit",
])
@bp.put("/features/") @bp.put("/features/")
@require_admin @require_admin
async def update_features(slug: str): async def update_features(slug: str):
@@ -240,7 +90,7 @@ def register():
features = result.get("features", {}) features = result.get("features", {})
from sx.sx_components import render_features_panel from sx.sx_components import render_features_panel
html = render_features_panel( html = await render_features_panel(
features, post, features, post,
sumup_configured=result.get("sumup_configured", False), sumup_configured=result.get("sumup_configured", False),
sumup_merchant_code=result.get("sumup_merchant_code") or "", sumup_merchant_code=result.get("sumup_merchant_code") or "",
@@ -279,7 +129,7 @@ def register():
features = result.get("features", {}) features = result.get("features", {})
from sx.sx_components import render_features_panel from sx.sx_components import render_features_panel
html = render_features_panel( html = await render_features_panel(
features, post, features, post,
sumup_configured=result.get("sumup_configured", False), sumup_configured=result.get("sumup_configured", False),
sumup_merchant_code=result.get("sumup_merchant_code") or "", sumup_merchant_code=result.get("sumup_merchant_code") or "",
@@ -409,8 +259,8 @@ def register():
from sx.sx_components import render_associated_entries, render_nav_entries_oob from sx.sx_components import render_associated_entries, render_nav_entries_oob
post = g.post_data["post"] post = g.post_data["post"]
admin_list = render_associated_entries(all_calendars, associated_entry_ids, post["slug"]) admin_list = await render_associated_entries(all_calendars, associated_entry_ids, post["slug"])
nav_entries_html = render_nav_entries_oob(associated_entries, calendars, post) nav_entries_html = await render_nav_entries_oob(associated_entries, calendars, post)
return sx_response(admin_list + nav_entries_html) return sx_response(admin_list + nav_entries_html)
@@ -468,7 +318,7 @@ def register():
except OptimisticLockError: except OptimisticLockError:
from urllib.parse import quote from urllib.parse import quote
return redirect( return redirect(
host_url(url_for("blog.post.admin.defpage_post_settings", slug=slug)) host_url(url_for("defpage_post_settings", slug=slug))
+ "?error=" + quote("Someone else edited this post. Please reload and try again.") + "?error=" + quote("Someone else edited this post. Please reload and try again.")
) )
@@ -479,7 +329,7 @@ def register():
await invalidate_tag_cache("post.post_detail") await invalidate_tag_cache("post.post_detail")
# Redirect using the (possibly new) slug # Redirect using the (possibly new) slug
return redirect(host_url(url_for("blog.post.admin.defpage_post_settings", slug=post.slug)) + "?saved=1") return redirect(host_url(url_for("defpage_post_settings", slug=post.slug)) + "?saved=1")
@bp.post("/edit/") @bp.post("/edit/")
@require_post_author @require_post_author
@@ -504,11 +354,11 @@ def register():
try: try:
lexical_doc = json.loads(lexical_raw) lexical_doc = json.loads(lexical_raw)
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
return redirect(host_url(url_for("blog.post.admin.defpage_post_edit", slug=slug)) + "?error=" + quote("Invalid JSON in editor content.")) return redirect(host_url(url_for("defpage_post_edit", slug=slug)) + "?error=" + quote("Invalid JSON in editor content."))
ok, reason = validate_lexical(lexical_doc) ok, reason = validate_lexical(lexical_doc)
if not ok: if not ok:
return redirect(host_url(url_for("blog.post.admin.defpage_post_edit", slug=slug)) + "?error=" + quote(reason)) return redirect(host_url(url_for("defpage_post_edit", slug=slug)) + "?error=" + quote(reason))
# Publish workflow # Publish workflow
is_admin = bool((g.get("rights") or {}).get("admin")) is_admin = bool((g.get("rights") or {}).get("admin"))
@@ -544,7 +394,7 @@ def register():
) )
except OptimisticLockError: except OptimisticLockError:
return redirect( return redirect(
host_url(url_for("blog.post.admin.defpage_post_edit", slug=slug)) host_url(url_for("defpage_post_edit", slug=slug))
+ "?error=" + quote("Someone else edited this post. Please reload and try again.") + "?error=" + quote("Someone else edited this post. Please reload and try again.")
) )
@@ -560,7 +410,7 @@ def register():
await invalidate_tag_cache("post.post_detail") await invalidate_tag_cache("post.post_detail")
# Redirect to GET (PRG pattern) — use post.slug in case it changed # Redirect to GET (PRG pattern) — use post.slug in case it changed
redirect_url = host_url(url_for("blog.post.admin.defpage_post_edit", slug=post.slug)) + "?saved=1" redirect_url = host_url(url_for("defpage_post_edit", slug=post.slug)) + "?saved=1"
if publish_requested_msg: if publish_requested_msg:
redirect_url += "&publish_requested=1" redirect_url += "&publish_requested=1"
return redirect(redirect_url) return redirect(redirect_url)
@@ -586,7 +436,7 @@ def register():
page_markets = await _fetch_page_markets(post_id) page_markets = await _fetch_page_markets(post_id)
from sx.sx_components import render_markets_panel from sx.sx_components import render_markets_panel
return sx_response(render_markets_panel(page_markets, post)) return sx_response(await render_markets_panel(page_markets, post))
@bp.post("/markets/new/") @bp.post("/markets/new/")
@require_admin @require_admin
@@ -612,7 +462,7 @@ def register():
page_markets = await _fetch_page_markets(post_id) page_markets = await _fetch_page_markets(post_id)
from sx.sx_components import render_markets_panel from sx.sx_components import render_markets_panel
return sx_response(render_markets_panel(page_markets, post)) return sx_response(await render_markets_panel(page_markets, post))
@bp.delete("/markets/<market_slug>/") @bp.delete("/markets/<market_slug>/")
@require_admin @require_admin
@@ -632,6 +482,6 @@ def register():
page_markets = await _fetch_page_markets(post_id) page_markets = await _fetch_page_markets(post_id)
from sx.sx_components import render_markets_panel from sx.sx_components import render_markets_panel
return sx_response(render_markets_panel(page_markets, post)) return sx_response(await render_markets_panel(page_markets, post))
return bp return bp

View File

@@ -125,7 +125,7 @@ def register():
# Get post_id from g.post_data # Get post_id from g.post_data
if not g.user: if not g.user:
return sx_response(render_like_toggle_button(slug, False, like_url), status=403) return sx_response(await render_like_toggle_button(slug, False, like_url), status=403)
post_id = g.post_data["post"]["id"] post_id = g.post_data["post"]["id"]
user_id = g.user.id user_id = g.user.id
@@ -135,7 +135,7 @@ def register():
}) })
liked = result["liked"] liked = result["liked"]
return sx_response(render_like_toggle_button(slug, liked, like_url)) return sx_response(await render_like_toggle_button(slug, liked, like_url))
@bp.get("/w/<widget_domain>/") @bp.get("/w/<widget_domain>/")
async def widget_paginate(slug: str, widget_domain: str): async def widget_paginate(slug: str, widget_domain: str):

View File

@@ -1,11 +1,9 @@
from __future__ import annotations from __future__ import annotations
from quart import Blueprint, make_response, request, g, abort from quart import Blueprint, request, g, abort
from sqlalchemy import select, or_ from sqlalchemy import select, or_
from sqlalchemy.orm import selectinload
from shared.browser.app.authz import require_login from shared.browser.app.authz import require_login
from shared.browser.app.utils.htmx import is_htmx_request
from shared.sx.helpers import sx_response from shared.sx.helpers import sx_response
from models import Snippet from models import Snippet
@@ -32,22 +30,6 @@ async def _visible_snippets(session):
def register(): def register():
bp = Blueprint("snippets", __name__, url_prefix="/settings/snippets") bp = Blueprint("snippets", __name__, url_prefix="/settings/snippets")
@bp.before_request
async def _prepare_page_data():
if "defpage_" not in (request.endpoint or ""):
return
snippets = await _visible_snippets(g.s)
is_admin = g.rights.get("admin")
from shared.sx.page import get_template_context
from sx.sx_components import _snippets_main_panel_sx
tctx = await get_template_context()
tctx["snippets"] = snippets
tctx["is_admin"] = is_admin
g.snippets_content = _snippets_main_panel_sx(tctx)
from shared.sx.pages import mount_pages
mount_pages(bp, "blog", names=["snippets-page"])
@bp.delete("/<int:snippet_id>/") @bp.delete("/<int:snippet_id>/")
@require_login @require_login
async def delete_snippet(snippet_id: int): async def delete_snippet(snippet_id: int):
@@ -65,7 +47,7 @@ def register():
snippets = await _visible_snippets(g.s) snippets = await _visible_snippets(g.s)
from sx.sx_components import render_snippets_list from sx.sx_components import render_snippets_list
return sx_response(render_snippets_list(snippets, is_admin)) return sx_response(await render_snippets_list(snippets, is_admin))
@bp.patch("/<int:snippet_id>/visibility/") @bp.patch("/<int:snippet_id>/visibility/")
@require_login @require_login
@@ -89,6 +71,6 @@ def register():
snippets = await _visible_snippets(g.s) snippets = await _visible_snippets(g.s)
from sx.sx_components import render_snippets_list from sx.sx_components import render_snippets_list
return sx_response(render_snippets_list(snippets, True)) return sx_response(await render_snippets_list(snippets, True))
return bp return bp

File diff suppressed because it is too large Load Diff

View File

@@ -17,6 +17,96 @@ def _load_blog_page_files() -> None:
load_page_dir(os.path.dirname(__file__), "blog") load_page_dir(os.path.dirname(__file__), "blog")
# ---------------------------------------------------------------------------
# Shared hydration helpers
# ---------------------------------------------------------------------------
def _add_to_defpage_ctx(**kwargs: Any) -> None:
from quart import g
if not hasattr(g, '_defpage_ctx'):
g._defpage_ctx = {}
g._defpage_ctx.update(kwargs)
async def _ensure_post_data(slug: str | None) -> None:
"""Load post data and set g.post_data + defpage context.
Replicates post bp's hydrate_post_data + context_processor.
"""
from quart import g, abort
if hasattr(g, 'post_data') and g.post_data:
await _inject_post_context(g.post_data)
return
if not slug:
abort(404)
from bp.post.services.post_data import post_data
is_admin = bool((g.get("rights") or {}).get("admin"))
p_data = await post_data(slug, g.s, include_drafts=True)
if not p_data:
abort(404)
# Draft access control
if p_data["post"].get("status") != "published":
if is_admin:
pass
elif g.user and p_data["post"].get("user_id") == g.user.id:
pass
else:
abort(404)
g.post_data = p_data
g.post_slug = slug
await _inject_post_context(p_data)
async def _inject_post_context(p_data: dict) -> None:
"""Add post context_processor data to defpage context."""
from shared.config import config
from shared.infrastructure.fragments import fetch_fragment
from shared.infrastructure.data_client import fetch_data
from shared.contracts.dtos import CartSummaryDTO, dto_from_dict
from shared.infrastructure.cart_identity import current_cart_identity
db_post_id = p_data["post"]["id"]
post_slug = p_data["post"]["slug"]
container_nav = await fetch_fragment("relations", "container-nav", params={
"container_type": "page",
"container_id": str(db_post_id),
"post_slug": post_slug,
})
ctx: dict = {
**p_data,
"base_title": config()["title"],
"container_nav": container_nav,
}
if p_data["post"].get("is_page"):
ident = current_cart_identity()
summary_params: dict = {"page_slug": post_slug}
if ident["user_id"] is not None:
summary_params["user_id"] = ident["user_id"]
if ident["session_id"] is not None:
summary_params["session_id"] = ident["session_id"]
raw_summary = await fetch_data(
"cart", "cart-summary", params=summary_params, required=False,
)
page_summary = dto_from_dict(CartSummaryDTO, raw_summary) if raw_summary else CartSummaryDTO()
ctx["page_cart_count"] = (
page_summary.count + page_summary.calendar_count + page_summary.ticket_count
)
ctx["page_cart_total"] = float(
page_summary.total + page_summary.calendar_total + page_summary.ticket_total
)
_add_to_defpage_ctx(**ctx)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Layouts # Layouts
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -39,153 +129,153 @@ def _register_blog_layouts() -> None:
# --- Blog layout (root + blog header) --- # --- Blog layout (root + blog header) ---
def _blog_full(ctx: dict, **kw: Any) -> str: async def _blog_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx from shared.sx.helpers import root_header_sx
from sx.sx_components import _blog_header_sx from sx.sx_components import _blog_header_sx
root_hdr = root_header_sx(ctx) root_hdr = await root_header_sx(ctx)
blog_hdr = _blog_header_sx(ctx) blog_hdr = await _blog_header_sx(ctx)
return "(<> " + root_hdr + " " + blog_hdr + ")" return "(<> " + root_hdr + " " + blog_hdr + ")"
def _blog_oob(ctx: dict, **kw: Any) -> str: async def _blog_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, oob_header_sx from shared.sx.helpers import root_header_sx, oob_header_sx
from sx.sx_components import _blog_header_sx from sx.sx_components import _blog_header_sx
root_hdr = root_header_sx(ctx) root_hdr = await root_header_sx(ctx)
blog_hdr = _blog_header_sx(ctx) blog_hdr = await _blog_header_sx(ctx)
rows = "(<> " + root_hdr + " " + blog_hdr + ")" rows = "(<> " + root_hdr + " " + blog_hdr + ")"
return oob_header_sx("root-header-child", "blog-header-child", rows) return await oob_header_sx("root-header-child", "blog-header-child", rows)
# --- Settings layout (root + settings header) --- # --- Settings layout (root + settings header) ---
def _settings_full(ctx: dict, **kw: Any) -> str: async def _settings_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx from shared.sx.helpers import root_header_sx
from sx.sx_components import _settings_header_sx from sx.sx_components import _settings_header_sx
root_hdr = root_header_sx(ctx) root_hdr = await root_header_sx(ctx)
settings_hdr = _settings_header_sx(ctx) settings_hdr = await _settings_header_sx(ctx)
return "(<> " + root_hdr + " " + settings_hdr + ")" return "(<> " + root_hdr + " " + settings_hdr + ")"
def _settings_oob(ctx: dict, **kw: Any) -> str: async def _settings_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, oob_header_sx from shared.sx.helpers import root_header_sx, oob_header_sx
from sx.sx_components import _settings_header_sx from sx.sx_components import _settings_header_sx
root_hdr = root_header_sx(ctx) root_hdr = await root_header_sx(ctx)
settings_hdr = _settings_header_sx(ctx) settings_hdr = await _settings_header_sx(ctx)
rows = "(<> " + root_hdr + " " + settings_hdr + ")" rows = "(<> " + root_hdr + " " + settings_hdr + ")"
return oob_header_sx("root-header-child", "root-settings-header-child", rows) return await oob_header_sx("root-header-child", "root-settings-header-child", rows)
def _settings_mobile(ctx: dict, **kw: Any) -> str: async def _settings_mobile(ctx: dict, **kw: Any) -> str:
from sx.sx_components import _settings_nav_sx from sx.sx_components import _settings_nav_sx
return _settings_nav_sx(ctx) return await _settings_nav_sx(ctx)
# --- Sub-settings helpers --- # --- Sub-settings helpers ---
def _sub_settings_full(ctx: dict, row_id: str, child_id: str, async def _sub_settings_full(ctx: dict, row_id: str, child_id: str,
endpoint: str, icon: str, label: str) -> str: endpoint: str, icon: str, label: str) -> str:
from shared.sx.helpers import root_header_sx from shared.sx.helpers import root_header_sx
from sx.sx_components import _settings_header_sx, _sub_settings_header_sx from sx.sx_components import _settings_header_sx, _sub_settings_header_sx
from quart import url_for as qurl from quart import url_for as qurl
root_hdr = root_header_sx(ctx) root_hdr = await root_header_sx(ctx)
settings_hdr = _settings_header_sx(ctx) settings_hdr = await _settings_header_sx(ctx)
sub_hdr = _sub_settings_header_sx(row_id, child_id, sub_hdr = await _sub_settings_header_sx(row_id, child_id,
qurl(endpoint), icon, label, ctx) qurl(endpoint), icon, label, ctx)
return "(<> " + root_hdr + " " + settings_hdr + " " + sub_hdr + ")" return "(<> " + root_hdr + " " + settings_hdr + " " + sub_hdr + ")"
def _sub_settings_oob(ctx: dict, row_id: str, child_id: str, async def _sub_settings_oob(ctx: dict, row_id: str, child_id: str,
endpoint: str, icon: str, label: str) -> str: endpoint: str, icon: str, label: str) -> str:
from shared.sx.helpers import oob_header_sx from shared.sx.helpers import oob_header_sx
from sx.sx_components import _settings_header_sx, _sub_settings_header_sx from sx.sx_components import _settings_header_sx, _sub_settings_header_sx
from quart import url_for as qurl from quart import url_for as qurl
settings_hdr_oob = _settings_header_sx(ctx, oob=True) settings_hdr_oob = await _settings_header_sx(ctx, oob=True)
sub_hdr = _sub_settings_header_sx(row_id, child_id, sub_hdr = await _sub_settings_header_sx(row_id, child_id,
qurl(endpoint), icon, label, ctx) qurl(endpoint), icon, label, ctx)
sub_oob = oob_header_sx("root-settings-header-child", child_id, sub_hdr) sub_oob = await oob_header_sx("root-settings-header-child", child_id, sub_hdr)
return "(<> " + settings_hdr_oob + " " + sub_oob + ")" return "(<> " + settings_hdr_oob + " " + sub_oob + ")"
# --- Cache --- # --- Cache ---
def _cache_full(ctx: dict, **kw: Any) -> str: async def _cache_full(ctx: dict, **kw: Any) -> str:
return _sub_settings_full(ctx, "cache-row", "cache-header-child", return await _sub_settings_full(ctx, "cache-row", "cache-header-child",
"settings.defpage_cache_page", "refresh", "Cache") "defpage_cache_page", "refresh", "Cache")
def _cache_oob(ctx: dict, **kw: Any) -> str: async def _cache_oob(ctx: dict, **kw: Any) -> str:
return _sub_settings_oob(ctx, "cache-row", "cache-header-child", return await _sub_settings_oob(ctx, "cache-row", "cache-header-child",
"settings.defpage_cache_page", "refresh", "Cache") "defpage_cache_page", "refresh", "Cache")
# --- Snippets --- # --- Snippets ---
def _snippets_full(ctx: dict, **kw: Any) -> str: async def _snippets_full(ctx: dict, **kw: Any) -> str:
return _sub_settings_full(ctx, "snippets-row", "snippets-header-child", return await _sub_settings_full(ctx, "snippets-row", "snippets-header-child",
"snippets.defpage_snippets_page", "puzzle-piece", "Snippets") "defpage_snippets_page", "puzzle-piece", "Snippets")
def _snippets_oob(ctx: dict, **kw: Any) -> str: async def _snippets_oob(ctx: dict, **kw: Any) -> str:
return _sub_settings_oob(ctx, "snippets-row", "snippets-header-child", return await _sub_settings_oob(ctx, "snippets-row", "snippets-header-child",
"snippets.defpage_snippets_page", "puzzle-piece", "Snippets") "defpage_snippets_page", "puzzle-piece", "Snippets")
# --- Menu Items --- # --- Menu Items ---
def _menu_items_full(ctx: dict, **kw: Any) -> str: async def _menu_items_full(ctx: dict, **kw: Any) -> str:
return _sub_settings_full(ctx, "menu_items-row", "menu_items-header-child", return await _sub_settings_full(ctx, "menu_items-row", "menu_items-header-child",
"menu_items.defpage_menu_items_page", "bars", "Menu Items") "defpage_menu_items_page", "bars", "Menu Items")
def _menu_items_oob(ctx: dict, **kw: Any) -> str: async def _menu_items_oob(ctx: dict, **kw: Any) -> str:
return _sub_settings_oob(ctx, "menu_items-row", "menu_items-header-child", return await _sub_settings_oob(ctx, "menu_items-row", "menu_items-header-child",
"menu_items.defpage_menu_items_page", "bars", "Menu Items") "defpage_menu_items_page", "bars", "Menu Items")
# --- Tag Groups --- # --- Tag Groups ---
def _tag_groups_full(ctx: dict, **kw: Any) -> str: async def _tag_groups_full(ctx: dict, **kw: Any) -> str:
return _sub_settings_full(ctx, "tag-groups-row", "tag-groups-header-child", return await _sub_settings_full(ctx, "tag-groups-row", "tag-groups-header-child",
"blog.tag_groups_admin.defpage_tag_groups_page", "tags", "Tag Groups") "defpage_tag_groups_page", "tags", "Tag Groups")
def _tag_groups_oob(ctx: dict, **kw: Any) -> str: async def _tag_groups_oob(ctx: dict, **kw: Any) -> str:
return _sub_settings_oob(ctx, "tag-groups-row", "tag-groups-header-child", return await _sub_settings_oob(ctx, "tag-groups-row", "tag-groups-header-child",
"blog.tag_groups_admin.defpage_tag_groups_page", "tags", "Tag Groups") "defpage_tag_groups_page", "tags", "Tag Groups")
# --- Tag Group Edit --- # --- Tag Group Edit ---
def _tag_group_edit_full(ctx: dict, **kw: Any) -> str: async def _tag_group_edit_full(ctx: dict, **kw: Any) -> str:
from quart import request from quart import request
g_id = (request.view_args or {}).get("id") g_id = (request.view_args or {}).get("id")
from quart import url_for as qurl from quart import url_for as qurl
from shared.sx.helpers import root_header_sx from shared.sx.helpers import root_header_sx
from sx.sx_components import _settings_header_sx, _sub_settings_header_sx from sx.sx_components import _settings_header_sx, _sub_settings_header_sx
root_hdr = root_header_sx(ctx) root_hdr = await root_header_sx(ctx)
settings_hdr = _settings_header_sx(ctx) settings_hdr = await _settings_header_sx(ctx)
sub_hdr = _sub_settings_header_sx("tag-groups-row", "tag-groups-header-child", sub_hdr = await _sub_settings_header_sx("tag-groups-row", "tag-groups-header-child",
qurl("blog.tag_groups_admin.defpage_tag_group_edit", id=g_id), qurl("defpage_tag_group_edit", id=g_id),
"tags", "Tag Groups", ctx) "tags", "Tag Groups", ctx)
return "(<> " + root_hdr + " " + settings_hdr + " " + sub_hdr + ")" return "(<> " + root_hdr + " " + settings_hdr + " " + sub_hdr + ")"
def _tag_group_edit_oob(ctx: dict, **kw: Any) -> str: async def _tag_group_edit_oob(ctx: dict, **kw: Any) -> str:
from quart import request from quart import request
g_id = (request.view_args or {}).get("id") g_id = (request.view_args or {}).get("id")
from quart import url_for as qurl from quart import url_for as qurl
from shared.sx.helpers import oob_header_sx from shared.sx.helpers import oob_header_sx
from sx.sx_components import _settings_header_sx, _sub_settings_header_sx from sx.sx_components import _settings_header_sx, _sub_settings_header_sx
settings_hdr_oob = _settings_header_sx(ctx, oob=True) settings_hdr_oob = await _settings_header_sx(ctx, oob=True)
sub_hdr = _sub_settings_header_sx("tag-groups-row", "tag-groups-header-child", sub_hdr = await _sub_settings_header_sx("tag-groups-row", "tag-groups-header-child",
qurl("blog.tag_groups_admin.defpage_tag_group_edit", id=g_id), qurl("defpage_tag_group_edit", id=g_id),
"tags", "Tag Groups", ctx) "tags", "Tag Groups", ctx)
sub_oob = oob_header_sx("root-settings-header-child", "tag-groups-header-child", sub_hdr) sub_oob = await oob_header_sx("root-settings-header-child", "tag-groups-header-child", sub_hdr)
return "(<> " + settings_hdr_oob + " " + sub_oob + ")" return "(<> " + settings_hdr_oob + " " + sub_oob + ")"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Page helpers (sync functions available in .sx defpage expressions) # Page helpers (async functions available in .sx defpage expressions)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _register_blog_helpers() -> None: def _register_blog_helpers() -> None:
@@ -208,71 +298,277 @@ def _register_blog_helpers() -> None:
}) })
def _h_editor_content(): # --- Editor helpers ---
async def _h_editor_content(**kw):
from sx.sx_components import render_editor_panel
return await render_editor_panel()
async def _h_editor_page_content(**kw):
from sx.sx_components import render_editor_panel
return await render_editor_panel(is_page=True)
# --- Post admin helpers ---
async def _h_post_admin_content(slug=None, **kw):
await _ensure_post_data(slug)
from quart import g from quart import g
return getattr(g, "editor_content", "") from sqlalchemy import select
from shared.models.page_config import PageConfig
post = (g.post_data or {}).get("post", {})
features = {}
sumup_configured = False
sumup_merchant_code = ""
sumup_checkout_prefix = ""
if post.get("is_page"):
pc = (await g.s.execute(
select(PageConfig).where(
PageConfig.container_type == "page",
PageConfig.container_id == post["id"],
)
)).scalar_one_or_none()
if pc:
features = pc.features or {}
sumup_configured = bool(pc.sumup_api_key)
sumup_merchant_code = pc.sumup_merchant_code or ""
sumup_checkout_prefix = pc.sumup_checkout_prefix or ""
from shared.sx.page import get_template_context
from sx.sx_components import _post_admin_main_panel_sx
tctx = await get_template_context()
tctx.update({
"features": features,
"sumup_configured": sumup_configured,
"sumup_merchant_code": sumup_merchant_code,
"sumup_checkout_prefix": sumup_checkout_prefix,
})
return _post_admin_main_panel_sx(tctx)
def _h_editor_page_content(): async def _h_post_data_content(slug=None, **kw):
await _ensure_post_data(slug)
from shared.sx.page import get_template_context
from sx.sx_components import _post_data_content_sx
tctx = await get_template_context()
return _post_data_content_sx(tctx)
async def _h_post_preview_content(slug=None, **kw):
await _ensure_post_data(slug)
from quart import g from quart import g
return getattr(g, "editor_page_content", "") from models.ghost_content import Post
from sqlalchemy import select as sa_select
post_id = g.post_data["post"]["id"]
post = (await g.s.execute(
sa_select(Post).where(Post.id == post_id)
)).scalar_one_or_none()
preview_ctx: dict = {}
sx_content = getattr(post, "sx_content", None) or ""
if sx_content:
from shared.sx.prettify import sx_to_pretty_sx
preview_ctx["sx_pretty"] = sx_to_pretty_sx(sx_content)
lexical_raw = getattr(post, "lexical", None) or ""
if lexical_raw:
from shared.sx.prettify import json_to_pretty_sx
preview_ctx["json_pretty"] = json_to_pretty_sx(lexical_raw)
if sx_content:
from shared.sx.parser import parse as sx_parse
from shared.sx.html import render as sx_html_render
from shared.sx.jinja_bridge import _COMPONENT_ENV
try:
parsed = sx_parse(sx_content)
preview_ctx["sx_rendered"] = sx_html_render(parsed, dict(_COMPONENT_ENV))
except Exception:
preview_ctx["sx_rendered"] = "<em>Error rendering sx</em>"
if lexical_raw:
from bp.blog.ghost.lexical_renderer import render_lexical
try:
preview_ctx["lex_rendered"] = render_lexical(lexical_raw)
except Exception:
preview_ctx["lex_rendered"] = "<em>Error rendering lexical</em>"
from shared.sx.page import get_template_context
from sx.sx_components import _preview_main_panel_sx
tctx = await get_template_context()
tctx.update(preview_ctx)
return await _preview_main_panel_sx(tctx)
def _h_post_admin_content(): async def _h_post_entries_content(slug=None, **kw):
await _ensure_post_data(slug)
from quart import g from quart import g
return getattr(g, "post_admin_content", "") from sqlalchemy import select
from shared.models.calendars import Calendar
from bp.post.services.entry_associations import get_post_entry_ids
post_id = g.post_data["post"]["id"]
associated_entry_ids = await get_post_entry_ids(post_id)
result = await g.s.execute(
select(Calendar)
.where(Calendar.deleted_at.is_(None))
.order_by(Calendar.name.asc())
)
all_calendars = result.scalars().all()
for calendar in all_calendars:
await g.s.refresh(calendar, ["entries", "post"])
from shared.sx.page import get_template_context
from sx.sx_components import _post_entries_content_sx
tctx = await get_template_context()
tctx["all_calendars"] = all_calendars
tctx["associated_entry_ids"] = associated_entry_ids
return await _post_entries_content_sx(tctx)
def _h_post_data_content(): async def _h_post_settings_content(slug=None, **kw):
await _ensure_post_data(slug)
from quart import g, request
from models.ghost_content import Post
from sqlalchemy import select as sa_select
from sqlalchemy.orm import selectinload
from bp.post.admin.routes import _post_to_edit_dict
post_id = g.post_data["post"]["id"]
post = (await g.s.execute(
sa_select(Post)
.where(Post.id == post_id)
.options(selectinload(Post.tags))
)).scalar_one_or_none()
ghost_post = _post_to_edit_dict(post) if post else {}
save_success = request.args.get("saved") == "1"
from shared.sx.page import get_template_context
from sx.sx_components import _post_settings_content_sx
tctx = await get_template_context()
tctx["ghost_post"] = ghost_post
tctx["save_success"] = save_success
return _post_settings_content_sx(tctx)
async def _h_post_edit_content(slug=None, **kw):
await _ensure_post_data(slug)
from quart import g, request
from models.ghost_content import Post
from sqlalchemy import select as sa_select
from sqlalchemy.orm import selectinload
from shared.infrastructure.data_client import fetch_data
from bp.post.admin.routes import _post_to_edit_dict
post_id = g.post_data["post"]["id"]
post = (await g.s.execute(
sa_select(Post)
.where(Post.id == post_id)
.options(selectinload(Post.tags))
)).scalar_one_or_none()
ghost_post = _post_to_edit_dict(post) if post else {}
save_success = request.args.get("saved") == "1"
save_error = request.args.get("error", "")
raw_newsletters = await fetch_data("account", "newsletters", required=False) or []
from types import SimpleNamespace
newsletters = [SimpleNamespace(**nl) for nl in raw_newsletters]
from shared.sx.page import get_template_context
from sx.sx_components import _post_edit_content_sx
tctx = await get_template_context()
tctx["ghost_post"] = ghost_post
tctx["save_success"] = save_success
tctx["save_error"] = save_error
tctx["newsletters"] = newsletters
return await _post_edit_content_sx(tctx)
# --- Settings helpers ---
async def _h_settings_content(**kw):
from shared.sx.page import get_template_context
from sx.sx_components import _settings_main_panel_sx
tctx = await get_template_context()
return _settings_main_panel_sx(tctx)
async def _h_cache_content(**kw):
from shared.sx.page import get_template_context
from sx.sx_components import _cache_main_panel_sx
tctx = await get_template_context()
return await _cache_main_panel_sx(tctx)
# --- Snippets helper ---
async def _h_snippets_content(**kw):
from quart import g from quart import g
return getattr(g, "post_data_content", "") from sqlalchemy import select, or_
from models import Snippet
uid = g.user.id
is_admin = g.rights.get("admin")
filters = [Snippet.user_id == uid, Snippet.visibility == "shared"]
if is_admin:
filters.append(Snippet.visibility == "admin")
rows = (await g.s.execute(
select(Snippet).where(or_(*filters)).order_by(Snippet.name)
)).scalars().all()
from shared.sx.page import get_template_context
from sx.sx_components import _snippets_main_panel_sx
tctx = await get_template_context()
tctx["snippets"] = rows
tctx["is_admin"] = is_admin
return await _snippets_main_panel_sx(tctx)
def _h_post_preview_content(): # --- Menu Items helper ---
async def _h_menu_items_content(**kw):
from quart import g from quart import g
return getattr(g, "post_preview_content", "") from bp.menu_items.services.menu_items import get_all_menu_items
menu_items = await get_all_menu_items(g.s)
from shared.sx.page import get_template_context
from sx.sx_components import _menu_items_main_panel_sx
tctx = await get_template_context()
tctx["menu_items"] = menu_items
return await _menu_items_main_panel_sx(tctx)
def _h_post_entries_content(): # --- Tag Groups helpers ---
async def _h_tag_groups_content(**kw):
from quart import g from quart import g
return getattr(g, "post_entries_content", "") from sqlalchemy import select
from models.tag_group import TagGroup
from bp.blog.admin.routes import _unassigned_tags
groups = list(
(await g.s.execute(
select(TagGroup).order_by(TagGroup.sort_order, TagGroup.name)
)).scalars()
)
unassigned = await _unassigned_tags(g.s)
from shared.sx.page import get_template_context
from sx.sx_components import _tag_groups_main_panel_sx
tctx = await get_template_context()
tctx.update({"groups": groups, "unassigned_tags": unassigned})
return await _tag_groups_main_panel_sx(tctx)
def _h_post_settings_content(): async def _h_tag_group_edit_content(id=None, **kw):
from quart import g from quart import g, abort
return getattr(g, "post_settings_content", "") from sqlalchemy import select
from models.tag_group import TagGroup, TagGroupTag
from models.ghost_content import Tag
def _h_post_edit_content(): tg = await g.s.get(TagGroup, id)
from quart import g if not tg:
return getattr(g, "post_edit_content", "") abort(404)
assigned_rows = list(
(await g.s.execute(
def _h_settings_content(): select(TagGroupTag.tag_id).where(TagGroupTag.tag_group_id == id)
from quart import g )).scalars()
return getattr(g, "settings_content", "") )
all_tags = list(
(await g.s.execute(
def _h_cache_content(): select(Tag).where(
from quart import g Tag.deleted_at.is_(None),
return getattr(g, "cache_content", "") (Tag.visibility == "public") | (Tag.visibility.is_(None)),
).order_by(Tag.name)
)).scalars()
def _h_snippets_content(): )
from quart import g from shared.sx.page import get_template_context
return getattr(g, "snippets_content", "") from sx.sx_components import _tag_groups_edit_main_panel_sx
tctx = await get_template_context()
tctx.update({
def _h_menu_items_content(): "group": tg,
from quart import g "all_tags": all_tags,
return getattr(g, "menu_items_content", "") "assigned_tag_ids": set(assigned_rows),
})
return await _tag_groups_edit_main_panel_sx(tctx)
def _h_tag_groups_content():
from quart import g
return getattr(g, "tag_groups_content", "")
def _h_tag_group_edit_content():
from quart import g
return getattr(g, "tag_group_edit_content", "")

View File

@@ -15,54 +15,54 @@
:layout :blog :layout :blog
:content (editor-page-content)) :content (editor-page-content))
; --- Post admin pages (nested under /<slug>/admin/) --- ; --- Post admin pages (absolute paths under /<slug>/admin/) ---
(defpage post-admin (defpage post-admin
:path "/" :path "/<slug>/admin/"
:auth :admin :auth :admin
:layout (:post-admin :selected "admin") :layout (:post-admin :selected "admin")
:content (post-admin-content)) :content (post-admin-content slug))
(defpage post-data (defpage post-data
:path "/data/" :path "/<slug>/admin/data/"
:auth :admin :auth :admin
:layout (:post-admin :selected "data") :layout (:post-admin :selected "data")
:content (post-data-content)) :content (post-data-content slug))
(defpage post-preview (defpage post-preview
:path "/preview/" :path "/<slug>/admin/preview/"
:auth :admin :auth :admin
:layout (:post-admin :selected "preview") :layout (:post-admin :selected "preview")
:content (post-preview-content)) :content (post-preview-content slug))
(defpage post-entries (defpage post-entries
:path "/entries/" :path "/<slug>/admin/entries/"
:auth :admin :auth :admin
:layout (:post-admin :selected "entries") :layout (:post-admin :selected "entries")
:content (post-entries-content)) :content (post-entries-content slug))
(defpage post-settings (defpage post-settings
:path "/settings/" :path "/<slug>/admin/settings/"
:auth :post_author :auth :post_author
:layout (:post-admin :selected "settings") :layout (:post-admin :selected "settings")
:content (post-settings-content)) :content (post-settings-content slug))
(defpage post-edit (defpage post-edit
:path "/edit/" :path "/<slug>/admin/edit/"
:auth :post_author :auth :post_author
:layout (:post-admin :selected "edit") :layout (:post-admin :selected "edit")
:content (post-edit-content)) :content (post-edit-content slug))
; --- Settings pages --- ; --- Settings pages (absolute paths) ---
(defpage settings-home (defpage settings-home
:path "/" :path "/settings/"
:auth :admin :auth :admin
:layout :blog-settings :layout :blog-settings
:content (settings-content)) :content (settings-content))
(defpage cache-page (defpage cache-page
:path "/cache/" :path "/settings/cache/"
:auth :admin :auth :admin
:layout :blog-cache :layout :blog-cache
:content (cache-content)) :content (cache-content))
@@ -70,7 +70,7 @@
; --- Snippets --- ; --- Snippets ---
(defpage snippets-page (defpage snippets-page
:path "/" :path "/settings/snippets/"
:auth :login :auth :login
:layout :blog-snippets :layout :blog-snippets
:content (snippets-content)) :content (snippets-content))
@@ -78,7 +78,7 @@
; --- Menu Items --- ; --- Menu Items ---
(defpage menu-items-page (defpage menu-items-page
:path "/" :path "/settings/menu_items/"
:auth :admin :auth :admin
:layout :blog-menu-items :layout :blog-menu-items
:content (menu-items-content)) :content (menu-items-content))
@@ -86,13 +86,13 @@
; --- Tag Groups --- ; --- Tag Groups ---
(defpage tag-groups-page (defpage tag-groups-page
:path "/" :path "/settings/tag-groups/"
:auth :admin :auth :admin
:layout :blog-tag-groups :layout :blog-tag-groups
:content (tag-groups-content)) :content (tag-groups-content))
(defpage tag-group-edit (defpage tag-group-edit
:path "/<int:id>/" :path "/settings/tag-groups/<int:id>/"
:auth :admin :auth :admin
:layout :blog-tag-group-edit :layout :blog-tag-group-edit
:content (tag-group-edit-content)) :content (tag-group-edit-content id))

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
import path_setup # noqa: F401 # adds shared/ to sys.path 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 decimal import Decimal
from pathlib import Path from pathlib import Path
@@ -17,7 +17,6 @@ from bp import (
register_page_cart, register_page_cart,
register_cart_global, register_cart_global,
register_page_admin, register_page_admin,
register_fragments,
register_actions, register_actions,
register_data, register_data,
register_inbox, register_inbox,
@@ -141,7 +140,11 @@ def create_app() -> "Quart":
app.jinja_env.globals["cart_quantity_url"] = lambda product_id: f"/quantity/{product_id}/" app.jinja_env.globals["cart_quantity_url"] = lambda product_id: f"/quantity/{product_id}/"
app.jinja_env.globals["cart_delete_url"] = lambda product_id: f"/delete/{product_id}/" app.jinja_env.globals["cart_delete_url"] = lambda product_id: f"/delete/{product_id}/"
app.register_blueprint(register_fragments()) load_service_components("cart")
from shared.sx.handlers import auto_mount_fragment_handlers
auto_mount_fragment_handlers(app, "cart")
app.register_blueprint(register_actions()) app.register_blueprint(register_actions())
app.register_blueprint(register_data()) app.register_blueprint(register_data())
app.register_blueprint(register_inbox()) app.register_blueprint(register_inbox())
@@ -185,8 +188,6 @@ def create_app() -> "Quart":
from sxc.pages import setup_cart_pages from sxc.pages import setup_cart_pages
setup_cart_pages() setup_cart_pages()
from shared.sx.pages import mount_pages
# --- Blueprint registration --- # --- Blueprint registration ---
# Static prefixes first, dynamic (page_slug) last # Static prefixes first, dynamic (page_slug) last
@@ -196,21 +197,22 @@ def create_app() -> "Quart":
url_prefix="/", url_prefix="/",
) )
# Cart overview at GET / # Cart overview blueprint (no defpage routes, just action endpoints)
overview_bp = register_cart_overview(url_prefix="/") overview_bp = register_cart_overview(url_prefix="/")
mount_pages(overview_bp, "cart", names=["cart-overview"])
app.register_blueprint(overview_bp, url_prefix="/") app.register_blueprint(overview_bp, url_prefix="/")
# Page admin at /<page_slug>/admin/ (before page_cart catch-all) # Page admin (PUT /payments/ etc.)
admin_bp = register_page_admin() admin_bp = register_page_admin()
mount_pages(admin_bp, "cart", names=["cart-admin", "cart-payments"])
app.register_blueprint(admin_bp, url_prefix="/<page_slug>/admin") app.register_blueprint(admin_bp, url_prefix="/<page_slug>/admin")
# Page cart at /<page_slug>/ (dynamic, matched last) # Page cart (POST /checkout/ etc.)
page_cart_bp = register_page_cart(url_prefix="/") page_cart_bp = register_page_cart(url_prefix="/")
mount_pages(page_cart_bp, "cart", names=["page-cart-view"])
app.register_blueprint(page_cart_bp, url_prefix="/<page_slug>") app.register_blueprint(page_cart_bp, url_prefix="/<page_slug>")
# Auto-mount all defpages with absolute paths
from shared.sx.pages import auto_mount_pages
auto_mount_pages(app, "cart")
return app return app

View File

@@ -2,7 +2,6 @@ from .cart.overview_routes import register as register_cart_overview
from .cart.page_routes import register as register_page_cart from .cart.page_routes import register as register_page_cart
from .cart.global_routes import register as register_cart_global from .cart.global_routes import register as register_cart_global
from .page_admin.routes import register as register_page_admin from .page_admin.routes import register as register_page_admin
from .fragments import register_fragments
from .actions import register_actions from .actions import register_actions
from .data import register_data from .data import register_data
from .inbox import register_inbox from .inbox import register_inbox

View File

@@ -56,9 +56,9 @@ def register(url_prefix: str) -> Blueprint:
if request.headers.get("SX-Request") == "true" or request.headers.get("HX-Request") == "true": if request.headers.get("SX-Request") == "true" or request.headers.get("HX-Request") == "true":
# Redirect to overview for HTMX # Redirect to overview for HTMX
return redirect(url_for("cart_overview.defpage_cart_overview")) return redirect(url_for("defpage_cart_overview"))
return redirect(url_for("cart_overview.defpage_cart_overview")) return redirect(url_for("defpage_cart_overview"))
@bp.post("/quantity/<int:product_id>/") @bp.post("/quantity/<int:product_id>/")
async def update_quantity(product_id: int): async def update_quantity(product_id: int):
@@ -137,7 +137,7 @@ def register(url_prefix: str) -> Blueprint:
tickets = await get_ticket_cart_entries(g.s) tickets = await get_ticket_cart_entries(g.s)
if not cart and not calendar_entries and not tickets: if not cart and not calendar_entries and not tickets:
return redirect(url_for("cart_overview.defpage_cart_overview")) return redirect(url_for("defpage_cart_overview"))
product_total = total(cart) or 0 product_total = total(cart) or 0
calendar_amount = calendar_total(calendar_entries) or 0 calendar_amount = calendar_total(calendar_entries) or 0
@@ -145,13 +145,13 @@ def register(url_prefix: str) -> Blueprint:
cart_total = product_total + calendar_amount + ticket_amount cart_total = product_total + calendar_amount + ticket_amount
if cart_total <= 0: if cart_total <= 0:
return redirect(url_for("cart_overview.defpage_cart_overview")) return redirect(url_for("defpage_cart_overview"))
try: try:
page_config = await resolve_page_config(g.s, cart, calendar_entries, tickets) page_config = await resolve_page_config(g.s, cart, calendar_entries, tickets)
except ValueError as e: except ValueError as e:
from shared.sx.page import get_template_context 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() tctx = await get_template_context()
html = await render_checkout_error_page(tctx, error=str(e)) html = await render_checkout_error_page(tctx, error=str(e))
return await make_response(html, 400) return await make_response(html, 400)
@@ -208,7 +208,7 @@ def register(url_prefix: str) -> Blueprint:
if not hosted_url: if not hosted_url:
from shared.sx.page import get_template_context 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() tctx = await get_template_context()
html = await render_checkout_error_page(tctx, error="No hosted checkout URL returned from SumUp.") html = await render_checkout_error_page(tctx, error="No hosted checkout URL returned from SumUp.")
return await make_response(html, 500) return await make_response(html, 500)

View File

@@ -3,24 +3,9 @@
from __future__ import annotations from __future__ import annotations
from quart import Blueprint, g, request from quart import Blueprint
from .services import get_cart_grouped_by_page
def register(url_prefix: str) -> Blueprint: def register(url_prefix: str) -> Blueprint:
bp = Blueprint("cart_overview", __name__, url_prefix=url_prefix) bp = Blueprint("cart_overview", __name__, url_prefix=url_prefix)
@bp.before_request
async def _prepare_page_data():
"""Load overview data for defpage route."""
endpoint = request.endpoint or ""
if not endpoint.endswith("defpage_cart_overview"):
return
from shared.sx.page import get_template_context
from sx.sx_components import _overview_main_panel_sx
page_groups = await get_cart_grouped_by_page(g.s)
ctx = await get_template_context()
g.overview_content = _overview_main_panel_sx(page_groups, ctx)
return bp return bp

View File

@@ -19,26 +19,6 @@ from .services import current_cart_identity
def register(url_prefix: str) -> Blueprint: def register(url_prefix: str) -> Blueprint:
bp = Blueprint("page_cart", __name__, url_prefix=url_prefix) bp = Blueprint("page_cart", __name__, url_prefix=url_prefix)
@bp.before_request
async def _prepare_page_data():
"""Load page cart data for defpage route."""
endpoint = request.endpoint or ""
if not endpoint.endswith("defpage_page_cart_view"):
return
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)
from shared.sx.page import get_template_context
from sx.sx_components import _page_cart_main_panel_sx
ctx = await get_template_context()
g.page_cart_content = _page_cart_main_panel_sx(
ctx, cart, cal_entries, page_tickets, ticket_groups,
total, calendar_total, ticket_total,
)
@bp.post("/checkout/") @bp.post("/checkout/")
async def page_checkout(): async def page_checkout():
post = g.page_post post = g.page_post
@@ -48,7 +28,7 @@ def register(url_prefix: str) -> Blueprint:
page_tickets = await get_tickets_for_page(g.s, post.id) page_tickets = await get_tickets_for_page(g.s, post.id)
if not cart and not cal_entries and not page_tickets: if not cart and not cal_entries and not page_tickets:
return redirect(url_for("page_cart.defpage_page_cart_view")) return redirect(url_for("defpage_page_cart_view"))
product_total_val = total(cart) or 0 product_total_val = total(cart) or 0
calendar_amount = calendar_total(cal_entries) or 0 calendar_amount = calendar_total(cal_entries) or 0
@@ -56,7 +36,7 @@ def register(url_prefix: str) -> Blueprint:
cart_total = product_total_val + calendar_amount + ticket_amount cart_total = product_total_val + calendar_amount + ticket_amount
if cart_total <= 0: if cart_total <= 0:
return redirect(url_for("page_cart.defpage_page_cart_view")) return redirect(url_for("defpage_page_cart_view"))
ident = current_cart_identity() ident = current_cart_identity()
@@ -93,7 +73,7 @@ def register(url_prefix: str) -> Blueprint:
if not hosted_url: if not hosted_url:
from shared.sx.page import get_template_context 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() tctx = await get_template_context()
html = await render_checkout_error_page(tctx, error="No hosted checkout URL returned from SumUp.") html = await render_checkout_error_page(tctx, error="No hosted checkout URL returned from SumUp.")
return await make_response(html, 500) return await make_response(html, 500)

View File

@@ -1 +0,0 @@
from .routes import register as register_fragments

View File

@@ -1,36 +0,0 @@
"""Cart app fragment endpoints.
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
by other coop apps via the fragment client.
All handlers are defined declaratively in .sx files under
``cart/sx/handlers/`` and dispatched via the sx handler registry.
"""
from __future__ import annotations
from quart import Blueprint, Response, request
from shared.infrastructure.fragments import FRAGMENT_HEADER
from shared.sx.handlers import get_handler, execute_handler
def register():
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
@bp.before_request
async def _require_fragment_header():
if not request.headers.get(FRAGMENT_HEADER):
return Response("", status=403)
@bp.get("/<fragment_type>")
async def get_fragment(fragment_type: str):
handler_def = get_handler("cart", fragment_type)
if handler_def is not None:
result = await execute_handler(
handler_def, "cart", args=dict(request.args),
)
return Response(result, status=200, content_type="text/sx")
return Response("", status=200, content_type="text/sx")
return bp

View File

@@ -57,7 +57,7 @@ def register() -> Blueprint:
if not order: if not order:
return await make_response("Order not found", 404) return await make_response("Order not found", 404)
from shared.sx.page import get_template_context 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() ctx = await get_template_context()
calendar_entries = ctx.get("calendar_entries") calendar_entries = ctx.get("calendar_entries")
@@ -122,7 +122,7 @@ def register() -> Blueprint:
if not hosted_url: if not hosted_url:
from shared.sx.page import get_template_context 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() 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) 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) return await make_response(html, 500)

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from quart import Blueprint, g, render_template, redirect, url_for, make_response from quart import Blueprint, g, redirect, url_for, make_response
from sqlalchemy import select, func, or_, cast, String, exists from sqlalchemy import select, func, or_, cast, String, exists
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@@ -138,7 +138,7 @@ def register(url_prefix: str) -> Blueprint:
orders = result.scalars().all() orders = result.scalars().all()
from shared.sx.page import get_template_context from shared.sx.page import get_template_context
from sx.sx_components import ( from sxc.pages import (
render_orders_page, render_orders_page,
render_orders_rows, render_orders_rows,
render_orders_oob, render_orders_oob,

View File

@@ -13,23 +13,6 @@ from shared.sx.helpers import sx_response
def register(): def register():
bp = Blueprint("page_admin", __name__) bp = Blueprint("page_admin", __name__)
@bp.before_request
async def _prepare_page_data():
"""Pre-render admin content for defpage routes."""
endpoint = request.endpoint or ""
if request.method != "GET":
return
if endpoint.endswith("defpage_cart_admin"):
from shared.sx.page import get_template_context
from sx.sx_components import _cart_admin_main_panel_sx
ctx = await get_template_context()
g.cart_admin_content = _cart_admin_main_panel_sx(ctx)
elif endpoint.endswith("defpage_cart_payments"):
from shared.sx.page import get_template_context
from sx.sx_components import _cart_payments_main_panel_sx
ctx = await get_template_context()
g.cart_payments_content = _cart_payments_main_panel_sx(ctx)
@bp.put("/payments/") @bp.put("/payments/")
@require_admin @require_admin
async def update_sumup(**kwargs): async def update_sumup(**kwargs):
@@ -64,9 +47,9 @@ def register():
g.page_config = SimpleNamespace(**raw_pc) if raw_pc else None g.page_config = SimpleNamespace(**raw_pc) if raw_pc else None
from shared.sx.page import get_template_context 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() ctx = await get_template_context()
html = render_cart_payments_panel(ctx) html = await render_cart_payments_panel(ctx)
return sx_response(html) return sx_response(html)
return bp return bp

View File

@@ -12,3 +12,6 @@ def register_domain_services() -> None:
from shared.services.cart_impl import SqlCartService from shared.services.cart_impl import SqlCartService
services.cart = 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

@@ -52,3 +52,114 @@
(div :id "cart" (div :id "cart"
(div (section :class "space-y-3 sm:space-y-4" items cal tickets) (div (section :class "space-y-3 sm:space-y-4" items cal tickets)
summary)))) summary))))
;; Assembled cart item from serialized data — replaces Python _cart_item_sx
(defcomp ~cart-item-from-data (&key item)
(let* ((slug (or (get item "slug") ""))
(title (or (get item "title") ""))
(image (get item "image"))
(brand (get item "brand"))
(is-deleted (get item "is_deleted"))
(unit-price (get item "unit_price"))
(special-price (get item "special_price"))
(regular-price (get item "regular_price"))
(currency (or (get item "currency") "GBP"))
(symbol (if (= currency "GBP") "\u00a3" currency))
(quantity (or (get item "quantity") 1))
(product-id (get item "product_id"))
(prod-url (or (get item "product_url") ""))
(qty-url (or (get item "qty_url") ""))
(csrf (csrf-token))
(line-total (when unit-price (* unit-price quantity))))
(~cart-item
:id (str "cart-item-" slug)
:img (if image
(~cart-item-img :src image :alt title)
(~img-or-placeholder :src nil
:size-cls "w-24 h-24 sm:w-32 sm:h-28 rounded-xl border border-dashed border-stone-300"
:placeholder-text "No image"))
:prod-url prod-url
:title title
:brand (when brand (~cart-item-brand :brand brand))
:deleted (when is-deleted (~cart-item-deleted))
:price (if unit-price
(<>
(~cart-item-price :text (str symbol (format-decimal unit-price 2)))
(when (and special-price (!= special-price regular-price))
(~cart-item-price-was :text (str symbol (format-decimal regular-price 2)))))
(~cart-item-no-price))
:qty-url qty-url :csrf csrf
:minus (str (- quantity 1))
:qty (str quantity)
:plus (str (+ quantity 1))
:line-total (when line-total
(~cart-item-line-total :text (str "Line total: " symbol (format-decimal line-total 2)))))))
;; Assembled calendar entries section — replaces Python _calendar_entries_sx
(defcomp ~cart-cal-section-from-data (&key entries)
(when (not (empty? entries))
(~cart-cal-section
:items (map (lambda (e)
(let* ((name (or (get e "name") ""))
(date-str (or (get e "date_str") "")))
(~cart-cal-entry
:name name :date-str date-str
:cost (str "\u00a3" (format-decimal (or (get e "cost") 0) 2)))))
entries))))
;; Assembled ticket groups section — replaces Python _ticket_groups_sx
(defcomp ~cart-tickets-section-from-data (&key ticket-groups)
(when (not (empty? ticket-groups))
(let* ((csrf (csrf-token))
(qty-url (url-for "cart_global.update_ticket_quantity")))
(~cart-tickets-section
:items (map (lambda (tg)
(let* ((name (or (get tg "entry_name") ""))
(tt-name (get tg "ticket_type_name"))
(price (or (get tg "price") 0))
(quantity (or (get tg "quantity") 0))
(line-total (or (get tg "line_total") 0))
(entry-id (str (or (get tg "entry_id") "")))
(tt-id (get tg "ticket_type_id"))
(date-str (or (get tg "date_str") "")))
(~cart-ticket-article
:name name
:type-name (when tt-name (~cart-ticket-type-name :name tt-name))
:date-str date-str
:price (str "\u00a3" (format-decimal price 2))
:qty-url qty-url :csrf csrf
:entry-id entry-id
:type-hidden (when tt-id (~cart-ticket-type-hidden :value (str tt-id)))
:minus (str (max (- quantity 1) 0))
:qty (str quantity)
:plus (str (+ quantity 1))
:line-total (str "Line total: \u00a3" (format-decimal line-total 2)))))
ticket-groups)))))
;; Assembled cart summary — replaces Python _cart_summary_sx
(defcomp ~cart-summary-from-data (&key item-count grand-total symbol is-logged-in checkout-action login-href user-email)
(~cart-summary-panel
:item-count (str item-count)
:subtotal (str symbol (format-decimal grand-total 2))
:checkout (if is-logged-in
(~cart-checkout-form
:action checkout-action :csrf (csrf-token)
:label (str " Checkout as " user-email))
(~cart-checkout-signin :href login-href))))
;; Assembled page cart content — replaces Python _page_cart_main_panel_sx
(defcomp ~cart-page-cart-content (&key cart-items cal-entries ticket-groups summary)
(if (and (empty? (or cart-items (list)))
(empty? (or cal-entries (list)))
(empty? (or ticket-groups (list))))
(div :class "max-w-full px-3 py-3 space-y-3"
(div :id "cart"
(div :class "rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center"
(~empty-state :icon "fa fa-shopping-cart" :message "Your cart is empty" :cls "text-center"))))
(~cart-page-panel
:items (map (lambda (item) (~cart-item-from-data :item item)) (or cart-items (list)))
:cal (when (not (empty? (or cal-entries (list))))
(~cart-cal-section-from-data :entries cal-entries))
:tickets (when (not (empty? (or ticket-groups (list))))
(~cart-tickets-section-from-data :ticket-groups ticket-groups))
:summary summary)))

View File

@@ -39,3 +39,56 @@
(defcomp ~cart-overview-panel (&key cards) (defcomp ~cart-overview-panel (&key cards)
(div :class "max-w-full px-3 py-3 space-y-3" (div :class "max-w-full px-3 py-3 space-y-3"
(div :class "space-y-4" cards))) (div :class "space-y-4" cards)))
(defcomp ~cart-empty ()
(div :class "max-w-full px-3 py-3 space-y-3"
(div :class "rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center"
(~empty-state :icon "fa fa-shopping-cart" :message "Your cart is empty" :cls "text-center"))))
;; Assembled page group card — replaces Python _page_group_card_sx
(defcomp ~cart-page-group-card-from-data (&key grp cart-url-base)
(let* ((post (get grp "post"))
(product-count (or (get grp "product_count") 0))
(calendar-count (or (get grp "calendar_count") 0))
(ticket-count (or (get grp "ticket_count") 0))
(total (or (get grp "total") 0))
(market-place (get grp "market_place"))
(badges (<>
(when (> product-count 0)
(~cart-badge :icon "fa fa-box-open"
:text (str product-count " item" (pluralize product-count))))
(when (> calendar-count 0)
(~cart-badge :icon "fa fa-calendar"
:text (str calendar-count " booking" (pluralize calendar-count))))
(when (> ticket-count 0)
(~cart-badge :icon "fa fa-ticket"
:text (str ticket-count " ticket" (pluralize ticket-count)))))))
(if post
(let* ((slug (or (get post "slug") ""))
(title (or (get post "title") ""))
(feature-image (get post "feature_image"))
(mp-name (if market-place (or (get market-place "name") "") ""))
(display-title (if (!= mp-name "") mp-name title)))
(~cart-group-card
:href (str cart-url-base "/" slug "/")
:img (if feature-image
(~cart-group-card-img :src feature-image :alt title)
(~img-or-placeholder :src nil :size-cls "h-16 w-16 rounded-xl"
:placeholder-icon "fa fa-store text-xl"))
:display-title display-title
:subtitle (when (!= mp-name "")
(~cart-mp-subtitle :title title))
:badges (~cart-badges-wrap :badges badges)
:total (str "\u00a3" (format-decimal total 2))))
(~cart-orphan-card
:badges (~cart-badges-wrap :badges badges)
:total (str "\u00a3" (format-decimal total 2))))))
;; Assembled cart overview content — replaces Python _overview_main_panel_sx
(defcomp ~cart-overview-content (&key page-groups cart-url-base)
(if (empty? page-groups)
(~cart-empty)
(~cart-overview-panel
:cards (map (lambda (grp)
(~cart-page-group-card-from-data :grp grp :cart-url-base cart-url-base))
page-groups))))

View File

@@ -5,3 +5,27 @@
(~sumup-settings-form :update-url update-url :csrf csrf :merchant-code merchant-code (~sumup-settings-form :update-url update-url :csrf csrf :merchant-code merchant-code
:placeholder placeholder :input-cls input-cls :sumup-configured sumup-configured :placeholder placeholder :input-cls input-cls :sumup-configured sumup-configured
:checkout-prefix checkout-prefix :sx-select "#payments-panel"))) :checkout-prefix checkout-prefix :sx-select "#payments-panel")))
;; Assembled cart admin overview content
(defcomp ~cart-admin-content ()
(let* ((payments-href (url-for "defpage_cart_payments")))
(div :id "main-panel"
(div :class "flex items-center justify-between p-3 border-b"
(span :class "font-medium" (i :class "fa fa-credit-card text-purple-600 mr-1") " Payments")
(a :href payments-href :class "text-sm underline" "configure")))))
;; Assembled cart payments content
(defcomp ~cart-payments-content (&key page-config)
(let* ((sumup-configured (and page-config (get page-config "sumup_api_key")))
(merchant-code (or (get page-config "sumup_merchant_code") ""))
(checkout-prefix (or (get page-config "sumup_checkout_prefix") ""))
(placeholder (if sumup-configured "--------" "sup_sk_..."))
(input-cls "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500"))
(~cart-payments-panel
:update-url (url-for "page_admin.update_sumup")
:csrf (csrf-token)
:merchant-code merchant-code
:placeholder placeholder
:input-cls input-cls
:sumup-configured sumup-configured
:checkout-prefix checkout-prefix)))

View File

@@ -1,807 +0,0 @@
"""
Cart service s-expression page components.
Renders cart overview, page cart, orders list, and single order detail.
Called from route handlers in place of ``render_template()``.
"""
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,
sx_call, SxExpr,
)
from shared.infrastructure.urls import market_product_url, 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
# ---------------------------------------------------------------------------
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 _shared_post_header_sx(ctx, oob=oob)
def _cart_header_sx(ctx: dict, *, oob: bool = False) -> str:
"""Build the cart section header row."""
return sx_call(
"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,
)
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(sx_call("cart-page-label-img", src=page_post.feature_image))
label_parts.append(f'(span "{escape(title)}")')
label_sx = "(<> " + " ".join(label_parts) + ")"
nav_sx = sx_call("cart-all-carts-link", href=call_url(ctx, "cart_url", "/"))
return sx_call(
"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,
)
def _auth_header_sx(ctx: dict, *, oob: bool = False) -> str:
"""Build the account section header row (for orders)."""
return sx_call(
"menu-row-sx",
id="auth-row", level=1, colour="sky",
link_href=call_url(ctx, "account_url", "/"),
link_label="account", icon="fa-solid fa-user",
child_id="auth-header-child", oob=oob,
)
def _orders_header_sx(ctx: dict, list_url: str) -> str:
"""Build the orders section header row."""
return sx_call(
"menu-row-sx",
id="orders-row", level=2, colour="sky",
link_href=list_url, link_label="Orders", icon="fa fa-gbp",
child_id="orders-header-child",
)
# ---------------------------------------------------------------------------
# Cart overview
# ---------------------------------------------------------------------------
def _badge_sx(icon: str, count: int, label: str) -> str:
"""Render a count badge."""
s = "s" if count != 1 else ""
return sx_call("cart-badge", icon=icon, text=f"{count} {label}{s}")
def _page_group_card_sx(grp: Any, ctx: dict) -> str:
"""Render a single page group card for cart overview."""
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", [])
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 = grp.get("total", 0) if isinstance(grp, dict) else getattr(grp, "total", 0)
market_place = grp.get("market_place") if isinstance(grp, dict) else getattr(grp, "market_place", None)
if not cart_items and not cal_entries and not tickets:
return ""
# Count badges
badge_parts = []
if product_count > 0:
badge_parts.append(_badge_sx("fa fa-box-open", product_count, "item"))
if calendar_count > 0:
badge_parts.append(_badge_sx("fa fa-calendar", calendar_count, "booking"))
if ticket_count > 0:
badge_parts.append(_badge_sx("fa fa-ticket", ticket_count, "ticket"))
badges_sx = "(<> " + " ".join(badge_parts) + ")" if badge_parts else '""'
badges_wrap = sx_call("cart-badges-wrap", badges=SxExpr(badges_sx))
if post:
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")
cart_href = call_url(ctx, "cart_url", f"/{slug}/")
if feature_image:
img = sx_call("cart-group-card-img", src=feature_image, alt=title)
else:
img = sx_call("img-or-placeholder", src=None,
size_cls="h-16 w-16 rounded-xl",
placeholder_icon="fa fa-store text-xl")
mp_sub = ""
if market_place:
mp_name = market_place.name if hasattr(market_place, "name") else market_place.get("name", "")
mp_sub = sx_call("cart-mp-subtitle", title=title)
else:
mp_name = ""
display_title = mp_name or title
return sx_call(
"cart-group-card",
href=cart_href, img=SxExpr(img), display_title=display_title,
subtitle=SxExpr(mp_sub) if mp_sub else None,
badges=SxExpr(badges_wrap),
total=f"\u00a3{total:.2f}",
)
else:
# Orphan items
return sx_call(
"cart-orphan-card",
badges=SxExpr(badges_wrap),
total=f"\u00a3{total:.2f}",
)
def _empty_cart_sx() -> str:
"""Empty cart state."""
empty = sx_call("empty-state", icon="fa fa-shopping-cart",
message="Your cart is empty", cls="text-center")
return (
'(div :class "max-w-full px-3 py-3 space-y-3"'
' (div :class "rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center"'
f' {empty}))'
)
def _overview_main_panel_sx(page_groups: list, ctx: dict) -> str:
"""Cart overview main panel."""
if not page_groups:
return _empty_cart_sx()
cards = [_page_group_card_sx(grp, ctx) for grp in page_groups]
has_items = any(c for c in cards)
if not has_items:
return _empty_cart_sx()
cards_sx = "(<> " + " ".join(c for c in cards if c) + ")"
return sx_call("cart-overview-panel", cards=SxExpr(cards_sx))
# ---------------------------------------------------------------------------
# Page cart
# ---------------------------------------------------------------------------
def _cart_item_sx(item: Any, ctx: dict) -> str:
"""Render a single product cart item."""
from shared.browser.app.csrf import generate_csrf_token
from quart import url_for
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"
symbol = "\u00a3" if currency == "GBP" else currency
csrf = generate_csrf_token()
qty_url = url_for("cart_global.update_quantity", product_id=p.id)
prod_url = market_product_url(slug)
if p.image:
img = sx_call("cart-item-img", src=p.image, alt=p.title)
else:
img = sx_call("img-or-placeholder", src=None,
size_cls="w-24 h-24 sm:w-32 sm:h-28 rounded-xl border border-dashed border-stone-300",
placeholder_text="No image")
price_parts = []
if unit_price:
price_parts.append(sx_call("cart-item-price", text=f"{symbol}{unit_price:.2f}"))
if p.special_price and p.special_price != p.regular_price:
price_parts.append(sx_call("cart-item-price-was", text=f"{symbol}{p.regular_price:.2f}"))
else:
price_parts.append(sx_call("cart-item-no-price"))
price_sx = "(<> " + " ".join(price_parts) + ")" if len(price_parts) > 1 else price_parts[0]
deleted_sx = sx_call("cart-item-deleted") if getattr(item, "is_deleted", False) else None
brand_sx = sx_call("cart-item-brand", brand=p.brand) if getattr(p, "brand", None) else None
line_total_sx = None
if unit_price:
lt = unit_price * item.quantity
line_total_sx = sx_call("cart-item-line-total", text=f"Line total: {symbol}{lt:.2f}")
return sx_call(
"cart-item",
id=f"cart-item-{slug}", img=SxExpr(img), prod_url=prod_url, title=p.title,
brand=SxExpr(brand_sx) if brand_sx else None,
deleted=SxExpr(deleted_sx) if deleted_sx else None,
price=SxExpr(price_sx),
qty_url=qty_url, csrf=csrf, minus=str(item.quantity - 1),
qty=str(item.quantity), plus=str(item.quantity + 1),
line_total=SxExpr(line_total_sx) if line_total_sx else None,
)
def _calendar_entries_sx(entries: list) -> str:
"""Render calendar booking entries in cart."""
if not entries:
return ""
parts = []
for e in entries:
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 ""
parts.append(sx_call(
"cart-cal-entry",
name=name, date_str=f"{start}{end_str}", cost=f"\u00a3{cost:.2f}",
))
items_sx = "(<> " + " ".join(parts) + ")"
return sx_call("cart-cal-section", items=SxExpr(items_sx))
def _ticket_groups_sx(ticket_groups: list, ctx: dict) -> str:
"""Render ticket groups in cart."""
if not ticket_groups:
return ""
from shared.browser.app.csrf import generate_csrf_token
from quart import url_for
csrf = generate_csrf_token()
qty_url = url_for("cart_global.update_ticket_quantity")
parts = []
for tg in ticket_groups:
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')}"
tt_name_sx = sx_call("cart-ticket-type-name", name=tt_name) if tt_name else None
tt_hidden_sx = sx_call("cart-ticket-type-hidden", value=str(tt_id)) if tt_id else None
parts.append(sx_call(
"cart-ticket-article",
name=name,
type_name=SxExpr(tt_name_sx) if tt_name_sx else None,
date_str=date_str,
price=f"\u00a3{price or 0:.2f}", qty_url=qty_url, csrf=csrf,
entry_id=str(entry_id),
type_hidden=SxExpr(tt_hidden_sx) if tt_hidden_sx else None,
minus=str(max(quantity - 1, 0)), qty=str(quantity),
plus=str(quantity + 1), line_total=f"Line total: \u00a3{line_total:.2f}",
))
items_sx = "(<> " + " ".join(parts) + ")"
return sx_call("cart-tickets-section", items=SxExpr(items_sx))
def _cart_summary_sx(ctx: dict, cart: list, cal_entries: list, tickets: list,
total_fn: Any, cal_total_fn: Any, ticket_total_fn: Any) -> str:
"""Render the order summary sidebar."""
from shared.browser.app.csrf import generate_csrf_token
from quart import g, url_for, request
from shared.infrastructure.urls import login_url
csrf = generate_csrf_token()
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")
if user:
if page_post:
action = url_for("page_cart.page_checkout")
else:
action = url_for("cart_global.checkout")
from shared.utils import route_prefix
action = route_prefix() + action
checkout_sx = sx_call(
"cart-checkout-form",
action=action, csrf=csrf, label=f" Checkout as {user.email}",
)
else:
href = login_url(request.url)
checkout_sx = sx_call("cart-checkout-signin", href=href)
return sx_call(
"cart-summary-panel",
item_count=str(item_count), subtotal=f"{symbol}{grand:.2f}",
checkout=SxExpr(checkout_sx),
)
def _page_cart_main_panel_sx(ctx: dict, cart: list, cal_entries: list,
tickets: list, ticket_groups: list,
total_fn: Any, cal_total_fn: Any,
ticket_total_fn: Any) -> str:
"""Page cart main panel."""
if not cart and not cal_entries and not tickets:
empty = sx_call("empty-state", icon="fa fa-shopping-cart",
message="Your cart is empty", cls="text-center")
return (
'(div :class "max-w-full px-3 py-3 space-y-3"'
' (div :id "cart"'
' (div :class "rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center"'
f' {empty})))'
)
item_parts = [_cart_item_sx(item, ctx) for item in cart]
items_sx = "(<> " + " ".join(item_parts) + ")" if item_parts else '""'
cal_sx = _calendar_entries_sx(cal_entries)
tickets_sx = _ticket_groups_sx(ticket_groups, ctx)
summary_sx = _cart_summary_sx(ctx, cart, cal_entries, tickets, total_fn, cal_total_fn, ticket_total_fn)
return sx_call(
"cart-page-panel",
items=SxExpr(items_sx),
cal=SxExpr(cal_sx) if cal_sx else None,
tickets=SxExpr(tickets_sx) if tickets_sx else None,
summary=SxExpr(summary_sx),
)
# ---------------------------------------------------------------------------
# Orders list (same pattern as orders service)
# ---------------------------------------------------------------------------
def _order_row_sx(order: Any, detail_url: str) -> str:
"""Render a single order as desktop table row + mobile card."""
status = order.status or "pending"
sl = status.lower()
pill = (
"border-emerald-300 bg-emerald-50 text-emerald-700" if sl == "paid"
else "border-rose-300 bg-rose-50 text-rose-700" if sl in ("failed", "cancelled")
else "border-stone-300 bg-stone-50 text-stone-700"
)
pill_cls = f"inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] sm:text-xs {pill}"
created = order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else "\u2014"
total = f"{order.currency or 'GBP'} {order.total_amount or 0:.2f}"
desktop = sx_call(
"order-row-desktop",
oid=f"#{order.id}", created=created, desc=order.description or "",
total=total, pill=pill_cls, status=status, url=detail_url,
)
mobile_pill = f"inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] {pill}"
mobile = sx_call(
"order-row-mobile",
oid=f"#{order.id}", pill=mobile_pill, status=status,
created=created, total=total, url=detail_url,
)
return "(<> " + desktop + " " + mobile + ")"
def _orders_rows_sx(orders: list, page: int, total_pages: int,
url_for_fn: Any, qs_fn: Any) -> str:
"""Render order rows + infinite scroll sentinel."""
from shared.utils import route_prefix
pfx = route_prefix()
parts = [
_order_row_sx(o, pfx + url_for_fn("orders.order.order_detail", order_id=o.id))
for o in orders
]
if page < total_pages:
next_url = pfx + url_for_fn("orders.list_orders") + qs_fn(page=page + 1)
parts.append(sx_call(
"infinite-scroll",
url=next_url, page=page, total_pages=total_pages,
id_prefix="orders", colspan=5,
))
else:
parts.append(sx_call("order-end-row"))
return "(<> " + " ".join(parts) + ")"
def _orders_main_panel_sx(orders: list, rows_sx: str) -> str:
"""Main panel for orders list."""
if not orders:
return sx_call("order-empty-state")
return sx_call("order-table", rows=SxExpr(rows_sx))
def _orders_summary_sx(ctx: dict) -> str:
"""Filter section for orders list."""
return sx_call("order-list-header", search_mobile=SxExpr(search_mobile_sx(ctx)))
# ---------------------------------------------------------------------------
# Single order detail
# ---------------------------------------------------------------------------
def _order_items_sx(order: Any) -> str:
"""Render order items list."""
if not order or not order.items:
return ""
parts = []
for item in order.items:
prod_url = market_product_url(item.product_slug)
if item.product_image:
img = sx_call(
"order-item-image",
src=item.product_image, alt=item.product_title or "Product image",
)
else:
img = sx_call("order-item-no-image")
parts.append(sx_call(
"order-item-row",
href=prod_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_sx = "(<> " + " ".join(parts) + ")"
return sx_call("order-items-panel", items=SxExpr(items_sx))
def _order_summary_sx(order: Any) -> str:
"""Order summary card."""
return sx_call(
"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,
)
def _order_calendar_items_sx(calendar_entries: list | None) -> str:
"""Render calendar bookings for an order."""
if not calendar_entries:
return ""
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"
)
pill_cls = f"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium {pill}"
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')}"
parts.append(sx_call(
"order-calendar-entry",
name=e.name, pill=pill_cls, status=st.capitalize(),
date_str=ds, cost=f"\u00a3{e.cost or 0:.2f}",
))
items_sx = "(<> " + " ".join(parts) + ")"
return sx_call("order-calendar-section", items=SxExpr(items_sx))
def _order_main_sx(order: Any, calendar_entries: list | None) -> str:
"""Main panel for single order detail."""
summary = _order_summary_sx(order)
items = _order_items_sx(order)
cal = _order_calendar_items_sx(calendar_entries)
return sx_call(
"order-detail-panel",
summary=SxExpr(summary),
items=SxExpr(items) if items else None,
calendar=SxExpr(cal) if cal else None,
)
def _order_filter_sx(order: Any, list_url: str, recheck_url: str,
pay_url: str, csrf_token: str) -> str:
"""Filter section for single order detail."""
created = order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else "\u2014"
status = order.status or "pending"
pay_sx = None
if status != "paid":
pay_sx = sx_call("order-pay-btn", url=pay_url)
return sx_call(
"order-detail-filter",
info=f"Placed {created} \u00b7 Status: {status}",
list_url=list_url, recheck_url=recheck_url, csrf=csrf_token,
pay=SxExpr(pay_sx) if pay_sx else None,
)
# ---------------------------------------------------------------------------
# Public API: Cart overview
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Public API: Orders list
# ---------------------------------------------------------------------------
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
list_url = route_prefix() + url_for_fn("orders.list_orders")
rows = _orders_rows_sx(orders, page, total_pages, url_for_fn, qs_fn)
main = _orders_main_panel_sx(orders, rows)
hdr = root_header_sx(ctx)
auth = _auth_header_sx(ctx)
orders_hdr = _orders_header_sx(ctx, list_url)
auth_child = sx_call(
"header-child-sx",
inner=SxExpr("(<> " + auth + " " + sx_call("header-child-sx", id="auth-header-child", inner=SxExpr(orders_hdr)) + ")"),
)
header_rows = "(<> " + hdr + " " + auth_child + ")"
return full_page_sx(ctx, header_rows=header_rows,
filter=_orders_summary_sx(ctx),
aside=search_desktop_sx(ctx),
content=main)
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."""
return _orders_rows_sx(orders, page, total_pages, url_for_fn, qs_fn)
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
list_url = route_prefix() + url_for_fn("orders.list_orders")
rows = _orders_rows_sx(orders, page, total_pages, url_for_fn, qs_fn)
main = _orders_main_panel_sx(orders, rows)
auth_oob = _auth_header_sx(ctx, oob=True)
auth_child_oob = sx_call(
"oob-header-sx",
parent_id="auth-header-child",
row=SxExpr(_orders_header_sx(ctx, list_url)),
)
root_oob = root_header_sx(ctx, oob=True)
oobs = "(<> " + auth_oob + " " + auth_child_oob + " " + root_oob + ")"
return oob_page_sx(oobs=oobs,
filter=_orders_summary_sx(ctx),
aside=search_desktop_sx(ctx),
content=main)
# ---------------------------------------------------------------------------
# Public API: Single order detail
# ---------------------------------------------------------------------------
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)
main = _order_main_sx(order, calendar_entries)
filt = _order_filter_sx(order, list_url, recheck_url, pay_url, generate_csrf_token())
hdr = root_header_sx(ctx)
order_row = sx_call(
"menu-row-sx",
id="order-row", level=3, colour="sky",
link_href=detail_url, link_label=f"Order {order.id}", icon="fa fa-gbp",
)
order_child = sx_call(
"header-child-sx",
inner=SxExpr("(<> " + _auth_header_sx(ctx) + " " + sx_call("header-child-sx", id="auth-header-child", inner=SxExpr(
"(<> " + _orders_header_sx(ctx, list_url) + " " + sx_call("header-child-sx", id="orders-header-child", inner=SxExpr(order_row)) + ")"
)) + ")"),
)
header_rows = "(<> " + hdr + " " + order_child + ")"
return 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)
main = _order_main_sx(order, calendar_entries)
filt = _order_filter_sx(order, list_url, recheck_url, pay_url, generate_csrf_token())
order_row_oob = sx_call(
"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 = sx_call("oob-header-sx",
parent_id="orders-header-child",
row=SxExpr(order_row_oob))
root_oob = root_header_sx(ctx, oob=True)
oobs = "(<> " + orders_child_oob + " " + root_oob + ")"
return oob_page_sx(oobs=oobs, filter=filt, content=main)
# ---------------------------------------------------------------------------
# Public API: Checkout error
# ---------------------------------------------------------------------------
def _checkout_error_filter_sx() -> str:
return sx_call("checkout-error-header")
def _checkout_error_content_sx(error: str | None, order: Any | None) -> str:
err_msg = error or "Unexpected error while creating the hosted checkout session."
order_sx = None
if order:
order_sx = sx_call("checkout-error-order-id", oid=f"#{order.id}")
back_url = cart_url("/")
return sx_call(
"checkout-error-content",
msg=err_msg,
order=SxExpr(order_sx) if order_sx else None,
back_url=back_url,
)
async def render_checkout_error_page(ctx: dict, error: str | None = None, order: Any | None = None) -> str:
"""Full page: checkout error."""
hdr = root_header_sx(ctx)
filt = _checkout_error_filter_sx()
content = _checkout_error_content_sx(error, order)
return full_page_sx(ctx, header_rows=hdr, filter=filt, content=content)
# ---------------------------------------------------------------------------
# Page admin (/<page_slug>/admin/)
# ---------------------------------------------------------------------------
def _cart_page_admin_header_sx(ctx: dict, page_post: Any, *, oob: bool = False,
selected: str = "") -> str:
"""Build the page-level admin header row -- delegates to shared helper."""
slug = page_post.slug if page_post else ""
ctx = _ensure_post_ctx(ctx, page_post)
return post_admin_header_sx(ctx, slug, oob=oob, selected=selected)
def _cart_admin_main_panel_sx(ctx: dict) -> str:
"""Admin overview panel -- links to sub-admin pages."""
from quart import url_for
payments_href = url_for("page_admin.defpage_cart_payments")
return (
'(div :id "main-panel"'
' (div :class "flex items-center justify-between p-3 border-b"'
' (span :class "font-medium" (i :class "fa fa-credit-card text-purple-600 mr-1") " Payments")'
f' (a :href "{payments_href}" :class "text-sm underline" "configure")))'
)
def _cart_payments_main_panel_sx(ctx: dict) -> str:
"""Render SumUp payment config form."""
from quart import url_for
csrf_token = ctx.get("csrf_token")
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
page_config = ctx.get("page_config")
sumup_configured = bool(page_config and getattr(page_config, "sumup_api_key", None))
merchant_code = (getattr(page_config, "sumup_merchant_code", None) or "") if page_config else ""
checkout_prefix = (getattr(page_config, "sumup_checkout_prefix", None) or "") if page_config else ""
update_url = url_for("page_admin.update_sumup")
placeholder = "--------" if sumup_configured else "sup_sk_..."
input_cls = "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500"
return sx_call("cart-payments-panel",
update_url=update_url, csrf=csrf,
merchant_code=merchant_code, placeholder=placeholder,
input_cls=input_cls, sumup_configured=sumup_configured,
checkout_prefix=checkout_prefix)
def render_cart_payments_panel(ctx: dict) -> str:
"""Render the payments config panel for PUT response."""
return _cart_payments_main_panel_sx(ctx)

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 __future__ import annotations
from typing import Any from typing import Any
from markupsafe import escape
from shared.sx.parser import SxExpr
def setup_cart_pages() -> None: 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_layouts()
_register_cart_helpers()
_load_cart_page_files() _load_cart_page_files()
@@ -17,6 +19,280 @@ def _load_cart_page_files() -> None:
load_page_dir(os.path.dirname(__file__), "cart") 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 # Layouts
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -27,95 +303,51 @@ def _register_cart_layouts() -> None:
register_custom_layout("cart-admin", _cart_admin_full, _cart_admin_oob) register_custom_layout("cart-admin", _cart_admin_full, _cart_admin_oob)
def _cart_page_full(ctx: dict, **kw: Any) -> str: async def _cart_page_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, sx_call, SxExpr from shared.sx.helpers import root_header_sx, render_to_sx
from sx.sx_components import _cart_header_sx, _page_cart_header_sx from shared.sx.parser import SxExpr
page_post = ctx.get("page_post") page_post = ctx.get("page_post")
root_hdr = root_header_sx(ctx) root_hdr = await root_header_sx(ctx)
child = _cart_header_sx(ctx) child = await _cart_header_sx(ctx)
page_hdr = _page_cart_header_sx(ctx, page_post) page_hdr = await _page_cart_header_sx(ctx, page_post)
nested = sx_call( inner_child = await render_to_sx("header-child-sx", id="cart-header-child", inner=SxExpr(page_hdr))
nested = await render_to_sx(
"header-child-sx", "header-child-sx",
inner=SxExpr("(<> " + child + " " + sx_call("header-child-sx", id="cart-header-child", inner=SxExpr(page_hdr)) + ")"), inner=SxExpr("(<> " + child + " " + inner_child + ")"),
) )
return "(<> " + root_hdr + " " + nested + ")" return "(<> " + root_hdr + " " + nested + ")"
def _cart_page_oob(ctx: dict, **kw: Any) -> str: async def _cart_page_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, sx_call, SxExpr from shared.sx.helpers import root_header_sx, render_to_sx
from sx.sx_components import _cart_header_sx, _page_cart_header_sx from shared.sx.parser import SxExpr
page_post = ctx.get("page_post") page_post = ctx.get("page_post")
child_oob = sx_call("oob-header-sx", page_hdr = await _page_cart_header_sx(ctx, page_post)
child_oob = await render_to_sx("oob-header-sx",
parent_id="cart-header-child", parent_id="cart-header-child",
row=SxExpr(_page_cart_header_sx(ctx, page_post))) row=SxExpr(page_hdr))
cart_hdr_oob = _cart_header_sx(ctx, oob=True) cart_hdr_oob = await _cart_header_sx(ctx, oob=True)
root_hdr_oob = root_header_sx(ctx, oob=True) root_hdr_oob = await root_header_sx(ctx, oob=True)
return "(<> " + child_oob + " " + cart_hdr_oob + " " + root_hdr_oob + ")" return "(<> " + child_oob + " " + cart_hdr_oob + " " + root_hdr_oob + ")"
async def _cart_admin_full(ctx: dict, **kw: Any) -> str: async def _cart_admin_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx 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") page_post = ctx.get("page_post")
selected = kw.get("selected", "") selected = kw.get("selected", "")
root_hdr = root_header_sx(ctx) root_hdr = await root_header_sx(ctx)
post_hdr = await _post_header_sx(ctx, page_post) post_hdr = await _post_header_sx(ctx, page_post)
admin_hdr = _cart_page_admin_header_sx(ctx, page_post, selected=selected) admin_hdr = await _cart_page_admin_header_sx(ctx, page_post, selected=selected)
return "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")" return "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")"
async def _cart_admin_oob(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") page_post = ctx.get("page_post")
selected = kw.get("selected", "") selected = kw.get("selected", "")
return _cart_page_admin_header_sx(ctx, page_post, oob=True, selected=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,
})
def _h_overview_content():
from quart import g
page_groups = getattr(g, "overview_page_groups", [])
from sx.sx_components import _overview_main_panel_sx
# _overview_main_panel_sx needs ctx for url helpers — use g-based approach
# The function reads cart_url from ctx, which we can get from template context
from shared.sx.page import get_template_context
import asyncio
# Page helpers are sync — we pre-compute in before_request
return getattr(g, "overview_content", "")
def _h_page_cart_content():
from quart import g
return getattr(g, "page_cart_content", "")
def _h_cart_admin_content():
from sx.sx_components import _cart_admin_main_panel_sx
from shared.sx.page import get_template_context
# Sync helper — _cart_admin_main_panel_sx is sync, but needs ctx
# We can pre-compute in before_request, or use get_template_context_sync-like pattern
from quart import g
return getattr(g, "cart_admin_content", "")
def _h_cart_payments_content():
from quart import g
return getattr(g, "cart_payments_content", "")

View File

@@ -1,25 +1,43 @@
;; Cart app defpage declarations. ;; Cart app defpage declarations.
;; All data fetching via (service ...) IO primitives, no Python helpers.
(defpage cart-overview (defpage cart-overview
:path "/" :path "/"
:auth :public :auth :public
:layout :root :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 (defpage page-cart-view
:path "/" :path "/<page_slug>/"
:auth :public :auth :public
:layout :cart-page :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 (defpage cart-admin
:path "/" :path "/<page_slug>/admin/"
:auth :admin :auth :admin
:layout :cart-admin :layout :cart-admin
:content (cart-admin-content)) :content (~cart-admin-content))
(defpage cart-payments (defpage cart-payments
:path "/payments/" :path "/<page_slug>/admin/payments/"
:auth :admin :auth :admin
:layout (:cart-admin :selected "payments") :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/alembic:/app/blog/alembic:ro
- ./blog/app.py:/app/app.py - ./blog/app.py:/app/app.py
- ./blog/sx:/app/sx - ./blog/sx:/app/sx
- ./blog/sxc:/app/sxc
- ./blog/bp:/app/bp - ./blog/bp:/app/bp
- ./blog/services:/app/services - ./blog/services:/app/services
- ./blog/templates:/app/templates - ./blog/templates:/app/templates
@@ -84,6 +85,7 @@ services:
- ./market/alembic:/app/market/alembic:ro - ./market/alembic:/app/market/alembic:ro
- ./market/app.py:/app/app.py - ./market/app.py:/app/app.py
- ./market/sx:/app/sx - ./market/sx:/app/sx
- ./market/sxc:/app/sxc
- ./market/bp:/app/bp - ./market/bp:/app/bp
- ./market/services:/app/services - ./market/services:/app/services
- ./market/templates:/app/templates - ./market/templates:/app/templates
@@ -121,6 +123,7 @@ services:
- ./cart/alembic:/app/cart/alembic:ro - ./cart/alembic:/app/cart/alembic:ro
- ./cart/app.py:/app/app.py - ./cart/app.py:/app/app.py
- ./cart/sx:/app/sx - ./cart/sx:/app/sx
- ./cart/sxc:/app/sxc
- ./cart/bp:/app/bp - ./cart/bp:/app/bp
- ./cart/services:/app/services - ./cart/services:/app/services
- ./cart/templates:/app/templates - ./cart/templates:/app/templates
@@ -158,6 +161,7 @@ services:
- ./events/alembic:/app/events/alembic:ro - ./events/alembic:/app/events/alembic:ro
- ./events/app.py:/app/app.py - ./events/app.py:/app/app.py
- ./events/sx:/app/sx - ./events/sx:/app/sx
- ./events/sxc:/app/sxc
- ./events/bp:/app/bp - ./events/bp:/app/bp
- ./events/services:/app/services - ./events/services:/app/services
- ./events/templates:/app/templates - ./events/templates:/app/templates
@@ -195,6 +199,7 @@ services:
- ./federation/alembic:/app/federation/alembic:ro - ./federation/alembic:/app/federation/alembic:ro
- ./federation/app.py:/app/app.py - ./federation/app.py:/app/app.py
- ./federation/sx:/app/sx - ./federation/sx:/app/sx
- ./federation/sxc:/app/sxc
- ./federation/bp:/app/bp - ./federation/bp:/app/bp
- ./federation/services:/app/services - ./federation/services:/app/services
- ./federation/templates:/app/templates - ./federation/templates:/app/templates
@@ -232,6 +237,7 @@ services:
- ./account/alembic:/app/account/alembic:ro - ./account/alembic:/app/account/alembic:ro
- ./account/app.py:/app/app.py - ./account/app.py:/app/app.py
- ./account/sx:/app/sx - ./account/sx:/app/sx
- ./account/sxc:/app/sxc
- ./account/bp:/app/bp - ./account/bp:/app/bp
- ./account/services:/app/services - ./account/services:/app/services
- ./account/templates:/app/templates - ./account/templates:/app/templates
@@ -331,6 +337,7 @@ services:
- ./orders/alembic:/app/orders/alembic:ro - ./orders/alembic:/app/orders/alembic:ro
- ./orders/app.py:/app/app.py - ./orders/app.py:/app/app.py
- ./orders/sx:/app/sx - ./orders/sx:/app/sx
- ./orders/sxc:/app/sxc
- ./orders/bp:/app/bp - ./orders/bp:/app/bp
- ./orders/services:/app/services - ./orders/services:/app/services
- ./orders/templates:/app/templates - ./orders/templates:/app/templates

View File

@@ -358,3 +358,89 @@ Each service migrates independently, no coordination needed:
3. Enable SSR for bots (Phase 2) — per-page opt-in 3. Enable SSR for bots (Phase 2) — per-page opt-in
4. Client data primitives (Phase 4) — global once sx.js updated 4. Client data primitives (Phase 4) — global once sx.js updated
5. Data-only navigation (Phase 5) — automatic for any `defpage` route 5. Data-only navigation (Phase 5) — automatic for any `defpage` route
---
## Why: Architectural Rationale
The end state is: **sx.js is the only JavaScript in the browser.** All application code — components, pages, routing, event handling, data fetching — is expressed in sx, evaluated by the interpreter, with behavior mediated through bound primitives.
### Benefits
**Single language everywhere.** Components, pages, routing, event handling, data fetching — all sx. No context-switching between JS idioms and template syntax. One language for the entire frontend and the server rendering path.
**Portability.** The same source runs on any VM that implements the ~50-primitive interface. Today: Python + JS. Tomorrow: WASM, edge workers, native mobile, embedded devices. Coupled to a primitive contract, not to a specific runtime.
**Smaller wire transfer.** S-expressions are terser than equivalent JS. Combined with content-addressed caching (hash/localStorage), most navigations transfer zero code — just data.
**Inspectability.** The sx source is the running program — no build step, no source maps, no minification. View source shows exactly what executes. The AST is the structure the evaluator walks. Debugging is tracing a tree.
**Controlled surface area.** The only JS that runs is sx.js. Everything else is mediated through defined primitives. No npm supply chain. No third-party scripts with ambient DOM access. Components can only do what primitives allow — the capability surface is fully controlled.
**Hot-reloadable everything.** Components are data (cached AST). Swapping a definition is replacing a dict entry. No module system, no import graph, no HMR machinery. Already works for .sx file changes in dev mode — extends to behaviors too.
**AI-friendly.** S-expressions are trivially parseable and generatable. An LLM produces correct sx far more reliably than JS/JSX — fewer syntax edge cases, no semicolons/braces/arrow-function ambiguities. The codebase becomes more amenable to automated generation and transformation.
**Security boundary.** No `eval()`, no dynamic `<script>` injection, no prototype pollution. The sx evaluator is a sandbox — it only resolves symbols against the primitive table and component env. Auditing what any sx expression can do means auditing the primitive bindings.
### Performance and WASM
The tradeoff is interpreter overhead — a tree-walking interpreter is slower than native JS execution. For UI rendering (building DOM, handling events, fetching data), this is not the bottleneck — DOM operations dominate, and those are the same speed regardless of initiator.
If performance ever becomes a concern, WASM is the escape hatch at three levels:
1. **Evaluator in WASM.** Rewrite `sxEval` in Rust/Zig → WASM. The tight inner loop (symbol lookup, env traversal, function application) runs ~10-50x faster. DOM rendering stays in JS (it calls browser APIs regardless).
2. **Compile sx to WASM.** Ahead-of-time compiler: `.sx` → WASM modules. Each `defcomp` becomes a WASM function returning DOM instructions. Eliminates the interpreter entirely. The content-addressed cache stores compiled WASM blobs instead of sx source.
3. **Compute-heavy primitives in WASM.** Keep the sx interpreter in JS, bind specific primitives to WASM (image processing, crypto, data transformation). Most pragmatic and least disruptive — additive, no architecture change.
The primitive-binding model means the evaluator doesn't care what's behind a primitive. `(blur-image data radius)` could be a JS Canvas call today and a WASM JAX kernel tomorrow. The sx source doesn't change.
### Server-Driven by Default: The React Question
The sx system is architecturally aligned with HTMX/LiveView — server-driven UI — even though it does far more on the client (full s-expression evaluation, DOM rendering, morph reconciliation, component caching). The server is the single source of truth. Every UI state is a URL. Auth is enforced at render time. There are no state synchronization bugs because there is no client state to synchronize.
React's client-state model (`useState`, `useEffect`, Context, Suspense) exists because React was built for SPAs that need to feel like native apps — optimistic updates, offline capability, instant feedback without network latency. But it created an entire category of problems: state management libraries, hydration mismatches, cache invalidation, stale closures, memory leaks from forgotten cleanup, the `useEffect` footgun.
**The question is not "should sx have useState" — it's which specific interactions actually suffer from the server round-trip.**
For most of our apps, that's a very short list:
- Toggle a mobile nav panel
- Gallery image switching
- Quantity steppers
- Live search-as-you-type
These don't need a general-purpose reactive state system. They need **targeted client-side primitives** that handle those specific cases without abandoning the server-driven model.
**The dangerous path:** Add `useState` → need `useEffect` for cleanup → need Context to avoid prop drilling → need Suspense for async state → rebuild React inside sx → lose the simplicity that makes the server-driven model work.
**The careful path:** Keep server-driven as the default. Add explicit, targeted escape hatches for interactions that genuinely need client-side state. Make those escape hatches obviously different from the normal flow so they don't creep into everything.
#### What sx has vs React
| React feature | SX status | Verdict |
|---|---|---|
| Components + props | `defcomp` + `&key` | Done — cleaner than JSX |
| Fragments, conditionals, lists | `<>`, `if`/`when`/`cond`, `map` | Done — more expressive |
| Macros | `defmacro` | Done — React has nothing like this |
| OOB updates / portals | `sx-swap-oob` | Done — more powerful (server-driven) |
| DOM reconciliation | `_morphDOM` (id-keyed) | Done — works during SxEngine swaps |
| Reactive client state | None | **By design.** Server is source of truth. |
| Component lifecycle | None | Add targeted primitives if body.js behaviors move to sx |
| Context / providers | `_componentEnv` global | Sufficient for auth/theme; revisit if trees get deep |
| Suspense / loading | `sx-request` CSS class | Sufficient for server-driven; revisit for Phase 4 client data |
| Two-way data binding | None | Not needed — HTMX model (form POST → new HTML) works |
| Error boundaries | Global `sx:responseError` | Sufficient; per-component boundaries are a future nice-to-have |
| Keyed list reconciliation | id-based morph | Works; add `:key` prop support if list update bugs arise |
#### Targeted escape hatches (not a general state system)
For the few interactions that need client-side responsiveness, add **specific primitives** rather than a general framework:
- `(toggle! el "class")` — CSS class toggle, no server trip
- `(set-attr! el "attr" value)` — attribute manipulation
- `(on-event el "click" handler)` — declarative event binding within sx
- `(timer interval-ms handler)` — with automatic cleanup on DOM removal
These are imperative DOM operations exposed as primitives — not reactive state. They let components handle simple client-side interactions without importing React's entire mental model. The server-driven flow remains the default for anything involving data.

View File

@@ -9,7 +9,7 @@ from jinja2 import FileSystemLoader, ChoiceLoader
from shared.infrastructure.factory import create_base_app from shared.infrastructure.factory import create_base_app
from bp import register_all_events, register_calendar, register_calendars, register_markets, register_page, register_fragments, register_actions, register_data from bp import register_all_events, register_calendar, register_calendars, register_markets, register_page, register_actions, register_data
async def events_context() -> dict: async def events_context() -> dict:
@@ -112,7 +112,9 @@ def create_app() -> "Quart":
url_prefix="/<slug>/markets", url_prefix="/<slug>/markets",
) )
app.register_blueprint(register_fragments()) from shared.sx.handlers import auto_mount_fragment_handlers
auto_mount_fragment_handlers(app, "events")
app.register_blueprint(register_actions()) app.register_blueprint(register_actions())
app.register_blueprint(register_data()) app.register_blueprint(register_data())
@@ -171,19 +173,25 @@ def create_app() -> "Quart":
"markets": markets, "markets": markets,
} }
# Auto-mount all defpages with absolute paths
from shared.sx.pages import auto_mount_pages
auto_mount_pages(app, "events")
# Tickets blueprint — user-facing ticket views and QR codes # Tickets blueprint — user-facing ticket views and QR codes
from bp.tickets.routes import register as register_tickets from bp.tickets.routes import register as register_tickets
tickets_bp = register_tickets() tickets_bp = register_tickets()
from shared.sx.pages import mount_pages
mount_pages(tickets_bp, "events", names=["my-tickets", "ticket-detail"])
app.register_blueprint(tickets_bp) app.register_blueprint(tickets_bp)
# Ticket admin — check-in interface (admin only) # Ticket admin — check-in interface (admin only)
from bp.ticket_admin.routes import register as register_ticket_admin from bp.ticket_admin.routes import register as register_ticket_admin
ticket_admin_bp = register_ticket_admin() ticket_admin_bp = register_ticket_admin()
mount_pages(ticket_admin_bp, "events", names=["ticket-admin"])
app.register_blueprint(ticket_admin_bp) app.register_blueprint(ticket_admin_bp)
# --- Pass defpage helper data to template context for layouts ---
@app.context_processor
async def inject_events_data():
return getattr(g, '_defpage_ctx', {})
# --- oEmbed endpoint --- # --- oEmbed endpoint ---
@app.get("/oembed") @app.get("/oembed")
async def oembed(): async def oembed():

View File

@@ -3,6 +3,5 @@ from .calendar.routes import register as register_calendar
from .calendars.routes import register as register_calendars from .calendars.routes import register as register_calendars
from .markets.routes import register as register_markets from .markets.routes import register as register_markets
from .page.routes import register as register_page from .page.routes import register as register_page
from .fragments import register_fragments
from .actions import register_actions from .actions import register_actions
from .data import register_data from .data import register_data

View File

@@ -11,7 +11,7 @@ Routes:
""" """
from __future__ import annotations from __future__ import annotations
from quart import Blueprint, g, request, render_template, make_response from quart import Blueprint, g, request, make_response
from shared.browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
from shared.sx.helpers import sx_response from shared.sx.helpers import sx_response
@@ -126,7 +126,7 @@ def register() -> Blueprint:
frag_params["session_id"] = ident["session_id"] frag_params["session_id"] = ident["session_id"]
from sx.sx_components import render_ticket_widget from sx.sx_components import render_ticket_widget
widget_html = render_ticket_widget(entry, qty, "/all-tickets/adjust") widget_html = await render_ticket_widget(entry, qty, "/all-tickets/adjust")
mini_html = await fetch_fragment("cart", "cart-mini", params=frag_params, required=False) mini_html = await fetch_fragment("cart", "cart-mini", params=frag_params, required=False)
return sx_response(widget_html + (mini_html or "")) return sx_response(widget_html + (mini_html or ""))

View File

@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from quart import ( from quart import (
request, Blueprint, g Blueprint, g, request,
) )
@@ -15,23 +15,11 @@ from shared.sx.helpers import sx_response
def register(): def register():
bp = Blueprint("admin", __name__, url_prefix='/admin') bp = Blueprint("admin", __name__, url_prefix='/admin')
@bp.before_request
async def _prepare_page_data():
if "defpage_" not in (request.endpoint or ""):
return
from shared.sx.page import get_template_context
from sx.sx_components import _calendar_admin_main_panel_html
ctx = await get_template_context()
g.calendar_admin_content = _calendar_admin_main_panel_html(ctx)
from shared.sx.pages import mount_pages
mount_pages(bp, "events", names=["calendar-admin"])
@bp.get("/description/") @bp.get("/description/")
@require_admin @require_admin
async def calendar_description_edit(calendar_slug: str, **kwargs): async def calendar_description_edit(calendar_slug: str, **kwargs):
from sx.sx_components import render_calendar_description_edit from sx.sx_components import render_calendar_description_edit
html = render_calendar_description_edit(g.calendar) html = await render_calendar_description_edit(g.calendar)
return sx_response(html) return sx_response(html)
@@ -47,7 +35,7 @@ def register():
await g.s.flush() await g.s.flush()
from sx.sx_components import render_calendar_description from sx.sx_components import render_calendar_description
html = render_calendar_description(g.calendar, oob=True) html = await render_calendar_description(g.calendar, oob=True)
return sx_response(html) return sx_response(html)
@@ -55,7 +43,7 @@ def register():
@require_admin @require_admin
async def calendar_description_view(calendar_slug: str, **kwargs): async def calendar_description_view(calendar_slug: str, **kwargs):
from sx.sx_components import render_calendar_description from sx.sx_components import render_calendar_description
html = render_calendar_description(g.calendar) html = await render_calendar_description(g.calendar)
return sx_response(html) return sx_response(html)
return bp return bp

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime, timezone
from quart import ( from quart import (
request, render_template, make_response, Blueprint, g, abort, session as qsession request, make_response, Blueprint, g, abort, session as qsession
) )
@@ -201,7 +201,7 @@ def register():
from shared.sx.page import get_template_context from shared.sx.page import get_template_context
from sx.sx_components import _calendar_admin_main_panel_html from sx.sx_components import _calendar_admin_main_panel_html
ctx = await get_template_context() ctx = await get_template_context()
html = _calendar_admin_main_panel_html(ctx) html = await _calendar_admin_main_panel_html(ctx)
return sx_response(html) return sx_response(html)
@@ -220,7 +220,7 @@ def register():
from shared.sx.page import get_template_context from shared.sx.page import get_template_context
from sx.sx_components import render_calendars_list_panel from sx.sx_components import render_calendars_list_panel
ctx = await get_template_context() ctx = await get_template_context()
html = render_calendars_list_panel(ctx) html = await render_calendars_list_panel(ctx)
if post_data: if post_data:
from shared.services.entry_associations import get_associated_entries from shared.services.entry_associations import get_associated_entries
@@ -236,7 +236,7 @@ def register():
).scalars().all() ).scalars().all()
associated_entries = await get_associated_entries(post_id) associated_entries = await get_associated_entries(post_id)
nav_oob = render_post_nav_entries_oob(associated_entries, cals, post_data["post"]) nav_oob = await render_post_nav_entries_oob(associated_entries, cals, post_data["post"])
html = html + nav_oob html = html + nav_oob
return sx_response(html) return sx_response(html)

View File

@@ -3,7 +3,7 @@ from datetime import datetime, timezone
from decimal import Decimal from decimal import Decimal
from quart import ( from quart import (
request, render_template, make_response, request, make_response,
Blueprint, g, redirect, url_for, jsonify, Blueprint, g, redirect, url_for, jsonify,
) )
@@ -259,7 +259,7 @@ def register():
} }
from sx.sx_components import render_day_main_panel from sx.sx_components import render_day_main_panel
html = render_day_main_panel(ctx) html = await render_day_main_panel(ctx)
mini_html = await fetch_fragment("cart", "cart-mini", params=frag_params, required=False) mini_html = await fetch_fragment("cart", "cart-mini", params=frag_params, required=False)
return sx_response(html + (mini_html or "")) return sx_response(html + (mini_html or ""))
@@ -280,12 +280,12 @@ def register():
day_slots = list(result.scalars()) day_slots = list(result.scalars())
from sx.sx_components import render_entry_add_form from sx.sx_components import render_entry_add_form
return sx_response(render_entry_add_form(g.calendar, day, month, year, day_slots)) return sx_response(await render_entry_add_form(g.calendar, day, month, year, day_slots))
@bp.get("/add-button/") @bp.get("/add-button/")
async def add_button(day: int, month: int, year: int, **kwargs): async def add_button(day: int, month: int, year: int, **kwargs):
from sx.sx_components import render_entry_add_button from sx.sx_components import render_entry_add_button
return sx_response(render_entry_add_button(g.calendar, day, month, year)) return sx_response(await render_entry_add_button(g.calendar, day, month, year))

View File

@@ -1,23 +1,8 @@
from __future__ import annotations from __future__ import annotations
from quart import ( from quart import Blueprint
request, Blueprint, g
)
def register(): def register():
bp = Blueprint("admin", __name__, url_prefix='/admin') bp = Blueprint("admin", __name__, url_prefix='/admin')
@bp.before_request
async def _prepare_page_data():
if "defpage_" not in (request.endpoint or ""):
return
from shared.sx.page import get_template_context
from sx.sx_components import _entry_admin_main_panel_html
ctx = await get_template_context()
g.entry_admin_content = _entry_admin_main_panel_html(ctx)
from shared.sx.pages import mount_pages
mount_pages(bp, "events", names=["entry-admin"])
return bp return bp

View File

@@ -11,7 +11,7 @@ from shared.browser.app.redis_cacher import clear_cache
from sqlalchemy import select from sqlalchemy import select
from quart import ( from quart import (
request, render_template, make_response, Blueprint, g, jsonify request, make_response, Blueprint, g, jsonify
) )
from ..calendar_entries.services.entries import ( from ..calendar_entries.services.entries import (
svc_update_entry, svc_update_entry,
@@ -112,7 +112,7 @@ def register():
# Render OOB nav # Render OOB nav
from sx.sx_components import render_day_entries_nav_oob from sx.sx_components import render_day_entries_nav_oob
return render_day_entries_nav_oob(visible.confirmed_entries, calendar, day_date) return await render_day_entries_nav_oob(visible.confirmed_entries, calendar, day_date)
async def get_post_nav_oob(entry_id: int): async def get_post_nav_oob(entry_id: int):
"""Helper to generate OOB update for post entries nav when entry state changes""" """Helper to generate OOB update for post entries nav when entry state changes"""
@@ -149,7 +149,7 @@ def register():
# Render OOB nav for this post # Render OOB nav for this post
from sx.sx_components import render_post_nav_entries_oob from sx.sx_components import render_post_nav_entries_oob
nav_oob = render_post_nav_entries_oob(associated_entries, calendars, post) nav_oob = await render_post_nav_entries_oob(associated_entries, calendars, post)
nav_oobs.append(nav_oob) nav_oobs.append(nav_oob)
return "".join(nav_oobs) return "".join(nav_oobs)
@@ -238,19 +238,6 @@ def register():
"user_ticket_counts_by_type": user_ticket_counts_by_type, "user_ticket_counts_by_type": user_ticket_counts_by_type,
"container_nav": container_nav, "container_nav": container_nav,
} }
@bp.before_request
async def _prepare_page_data():
if "defpage_" not in (request.endpoint or ""):
return
from shared.sx.page import get_template_context
from sx.sx_components import _entry_main_panel_html, _entry_nav_html
ctx = await get_template_context()
g.entry_content = _entry_main_panel_html(ctx)
g.entry_menu = _entry_nav_html(ctx)
from shared.sx.pages import mount_pages
mount_pages(bp, "events", names=["entry-detail"])
@bp.get("/edit/") @bp.get("/edit/")
@require_admin @require_admin
async def get_edit(entry_id: int, **rest): async def get_edit(entry_id: int, **rest):
@@ -270,7 +257,7 @@ def register():
day_slots = list(result.scalars()) day_slots = list(result.scalars())
from sx.sx_components import render_entry_edit_form from sx.sx_components import render_entry_edit_form
return sx_response(render_entry_edit_form(g.entry, g.calendar, day, month, year, day_slots)) return sx_response(await render_entry_edit_form(g.entry, g.calendar, day, month, year, day_slots))
@bp.put("/") @bp.put("/")
@require_admin @require_admin
@@ -436,7 +423,7 @@ def register():
from sx.sx_components import _entry_main_panel_html from sx.sx_components import _entry_main_panel_html
tctx = await get_template_context() tctx = await get_template_context()
html = _entry_main_panel_html(tctx) html = await _entry_main_panel_html(tctx)
return sx_response(html + nav_oob) return sx_response(html + nav_oob)
@@ -462,7 +449,7 @@ def register():
# Re-read entry to get updated state # Re-read entry to get updated state
await g.s.refresh(g.entry) await g.s.refresh(g.entry)
from sx.sx_components import render_entry_optioned from sx.sx_components import render_entry_optioned
html = render_entry_optioned(g.entry, g.calendar, day, month, year) html = await render_entry_optioned(g.entry, g.calendar, day, month, year)
return sx_response(html + day_nav_oob + post_nav_oob) return sx_response(html + day_nav_oob + post_nav_oob)
@bp.post("/decline/") @bp.post("/decline/")
@@ -487,7 +474,7 @@ def register():
# Re-read entry to get updated state # Re-read entry to get updated state
await g.s.refresh(g.entry) await g.s.refresh(g.entry)
from sx.sx_components import render_entry_optioned from sx.sx_components import render_entry_optioned
html = render_entry_optioned(g.entry, g.calendar, day, month, year) html = await render_entry_optioned(g.entry, g.calendar, day, month, year)
return sx_response(html + day_nav_oob + post_nav_oob) return sx_response(html + day_nav_oob + post_nav_oob)
@bp.post("/provisional/") @bp.post("/provisional/")
@@ -512,7 +499,7 @@ def register():
# Re-read entry to get updated state # Re-read entry to get updated state
await g.s.refresh(g.entry) await g.s.refresh(g.entry)
from sx.sx_components import render_entry_optioned from sx.sx_components import render_entry_optioned
html = render_entry_optioned(g.entry, g.calendar, day, month, year) html = await render_entry_optioned(g.entry, g.calendar, day, month, year)
return sx_response(html + day_nav_oob + post_nav_oob) return sx_response(html + day_nav_oob + post_nav_oob)
@bp.post("/tickets/") @bp.post("/tickets/")
@@ -556,7 +543,7 @@ def register():
# Return just the tickets fragment (targeted by hx-target="#entry-tickets-...") # Return just the tickets fragment (targeted by hx-target="#entry-tickets-...")
await g.s.refresh(g.entry) await g.s.refresh(g.entry)
from sx.sx_components import render_entry_tickets_config from sx.sx_components import render_entry_tickets_config
html = render_entry_tickets_config(g.entry, g.calendar, request.view_args.get("day"), request.view_args.get("month"), request.view_args.get("year")) html = await render_entry_tickets_config(g.entry, g.calendar, request.view_args.get("day"), request.view_args.get("month"), request.view_args.get("year"))
return sx_response(html) return sx_response(html)
@bp.get("/posts/search/") @bp.get("/posts/search/")
@@ -572,7 +559,7 @@ def register():
va = request.view_args or {} va = request.view_args or {}
from sx.sx_components import render_post_search_results from sx.sx_components import render_post_search_results
return sx_response(render_post_search_results( return sx_response(await render_post_search_results(
search_posts, query, page, total_pages, search_posts, query, page, total_pages,
g.entry, g.calendar, g.entry, g.calendar,
va.get("day"), va.get("month"), va.get("year"), va.get("day"), va.get("month"), va.get("year"),
@@ -607,8 +594,8 @@ def register():
# Return updated posts list + OOB nav update # Return updated posts list + OOB nav update
from sx.sx_components import render_entry_posts_panel, render_entry_posts_nav_oob from sx.sx_components import render_entry_posts_panel, render_entry_posts_nav_oob
va = request.view_args or {} va = request.view_args or {}
html = render_entry_posts_panel(entry_posts, g.entry, g.calendar, va.get("day"), va.get("month"), va.get("year")) html = await render_entry_posts_panel(entry_posts, g.entry, g.calendar, va.get("day"), va.get("month"), va.get("year"))
nav_oob = render_entry_posts_nav_oob(entry_posts) nav_oob = await render_entry_posts_nav_oob(entry_posts)
return sx_response(html + nav_oob) return sx_response(html + nav_oob)
@bp.delete("/posts/<int:post_id>/") @bp.delete("/posts/<int:post_id>/")
@@ -629,8 +616,8 @@ def register():
# Return updated posts list + OOB nav update # Return updated posts list + OOB nav update
from sx.sx_components import render_entry_posts_panel, render_entry_posts_nav_oob from sx.sx_components import render_entry_posts_panel, render_entry_posts_nav_oob
va = request.view_args or {} va = request.view_args or {}
html = render_entry_posts_panel(entry_posts, g.entry, g.calendar, va.get("day"), va.get("month"), va.get("year")) html = await render_entry_posts_panel(entry_posts, g.entry, g.calendar, va.get("day"), va.get("month"), va.get("year"))
nav_oob = render_entry_posts_nav_oob(entry_posts) nav_oob = await render_entry_posts_nav_oob(entry_posts)
return sx_response(html + nav_oob) return sx_response(html + nav_oob)
return bp return bp

View File

@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from quart import ( from quart import (
request, render_template, make_response, Blueprint, g request, make_response, Blueprint, g
) )
from sqlalchemy import select from sqlalchemy import select
@@ -69,7 +69,7 @@ def register():
from shared.sx.page import get_template_context from shared.sx.page import get_template_context
from sx.sx_components import render_calendars_list_panel from sx.sx_components import render_calendars_list_panel
ctx = await get_template_context() ctx = await get_template_context()
html = render_calendars_list_panel(ctx) html = await render_calendars_list_panel(ctx)
# Blog-embedded mode: also update post nav # Blog-embedded mode: also update post nav
if post_data: if post_data:
@@ -85,7 +85,7 @@ def register():
).scalars().all() ).scalars().all()
associated_entries = await get_associated_entries(post_id) associated_entries = await get_associated_entries(post_id)
nav_oob = render_post_nav_entries_oob(associated_entries, cals, post_data["post"]) nav_oob = await render_post_nav_entries_oob(associated_entries, cals, post_data["post"])
html = html + nav_oob html = html + nav_oob
return sx_response(html) return sx_response(html)

View File

@@ -1,21 +1,8 @@
from __future__ import annotations from __future__ import annotations
from quart import ( from quart import Blueprint
request, Blueprint, g
)
def register(): def register():
bp = Blueprint("admin", __name__, url_prefix='/admin') bp = Blueprint("admin", __name__, url_prefix='/admin')
@bp.before_request
async def _prepare_page_data():
if "defpage_" not in (request.endpoint or ""):
return
from sx.sx_components import _day_admin_main_panel_html
g.day_admin_content = _day_admin_main_panel_html({})
from shared.sx.pages import mount_pages
mount_pages(bp, "events", names=["day-admin"])
return bp return bp

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import datetime, timezone, date, timedelta from datetime import datetime, timezone, date, timedelta
from quart import ( from quart import (
request, render_template, make_response, Blueprint, g, abort, session as qsession request, make_response, Blueprint, g, abort, session as qsession
) )
from bp.calendar.services import get_visible_entries_for_period from bp.calendar.services import get_visible_entries_for_period

View File

@@ -1 +0,0 @@
from .routes import register as register_fragments

View File

@@ -1,104 +0,0 @@
"""Events app fragment endpoints.
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
by other coop apps via the fragment client.
All handlers are defined declaratively in .sx files under
``events/sx/handlers/`` and dispatched via the sx handler registry.
container-cards and account-page remain as Python handlers because they
call domain service methods and return batched/conditional content, but
they use sx_call() for rendering (no Jinja templates).
"""
from __future__ import annotations
from quart import Blueprint, Response, g, request
from shared.infrastructure.fragments import FRAGMENT_HEADER
from shared.services.registry import services
from shared.sx.handlers import get_handler, execute_handler
def register():
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
_handlers: dict[str, object] = {}
# Fragment types that return HTML (comment-delimited batch)
_html_types = {"container-cards"}
@bp.before_request
async def _require_fragment_header():
if not request.headers.get(FRAGMENT_HEADER):
return Response("", status=403)
@bp.get("/<fragment_type>")
async def get_fragment(fragment_type: str):
# 1. Check Python handlers first
handler = _handlers.get(fragment_type)
if handler is not None:
result = await handler()
ct = "text/html" if fragment_type in _html_types else "text/sx"
return Response(result, status=200, content_type=ct)
# 2. Check sx handler registry
handler_def = get_handler("events", fragment_type)
if handler_def is not None:
result = await execute_handler(
handler_def, "events", args=dict(request.args),
)
return Response(result, status=200, content_type="text/sx")
return Response("", status=200, content_type="text/sx")
# --- container-cards fragment: entries for blog listing cards -----------
# Returns text/html with <!-- card-widget:POST_ID --> comment markers
# so the blog consumer can split per-post fragments.
async def _container_cards_handler():
from sx.sx_components import render_fragment_container_cards
post_ids_raw = request.args.get("post_ids", "")
post_slugs_raw = request.args.get("post_slugs", "")
post_ids = [int(x) for x in post_ids_raw.split(",") if x.strip()]
post_slugs = [x.strip() for x in post_slugs_raw.split(",") if x.strip()]
if not post_ids:
return ""
slug_map = {}
for i, pid in enumerate(post_ids):
slug_map[pid] = post_slugs[i] if i < len(post_slugs) else ""
batch = await services.calendar.confirmed_entries_for_posts(g.s, post_ids)
return render_fragment_container_cards(batch, post_ids, slug_map)
_handlers["container-cards"] = _container_cards_handler
# --- account-page fragment: tickets or bookings panel ------------------
# Returns text/sx — the account app embeds this as sx source.
async def _account_page_handler():
from sx.sx_components import (
render_fragment_account_tickets,
render_fragment_account_bookings,
)
slug = request.args.get("slug", "")
user_id = request.args.get("user_id", type=int)
if not user_id:
return ""
if slug == "tickets":
tickets = await services.calendar.user_tickets(g.s, user_id=user_id)
return render_fragment_account_tickets(tickets)
elif slug == "bookings":
bookings = await services.calendar.user_bookings(g.s, user_id=user_id)
return render_fragment_account_bookings(bookings)
return ""
_handlers["account-page"] = _account_page_handler
bp._fragment_handlers = _handlers
return bp

View File

@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from quart import ( from quart import (
request, render_template, make_response, Blueprint, g request, make_response, Blueprint, g
) )
from .services.markets import ( from .services.markets import (
@@ -21,18 +21,6 @@ def register():
async def inject_root(): async def inject_root():
return {} return {}
@bp.before_request
async def _prepare_page_data():
if "defpage_" not in (request.endpoint or ""):
return
from shared.sx.page import get_template_context
from sx.sx_components import _markets_main_panel_html
ctx = await get_template_context()
g.markets_content = _markets_main_panel_html(ctx)
from shared.sx.pages import mount_pages
mount_pages(bp, "events", names=["events-markets"])
@bp.post("/new/") @bp.post("/new/")
@require_admin @require_admin
async def create_market(**kwargs): async def create_market(**kwargs):
@@ -56,7 +44,7 @@ def register():
from shared.sx.page import get_template_context from shared.sx.page import get_template_context
from sx.sx_components import render_markets_list_panel from sx.sx_components import render_markets_list_panel
ctx = await get_template_context() ctx = await get_template_context()
return sx_response(render_markets_list_panel(ctx)) return sx_response(await render_markets_list_panel(ctx))
@bp.delete("/<market_slug>/") @bp.delete("/<market_slug>/")
@require_admin @require_admin
@@ -69,6 +57,6 @@ def register():
from shared.sx.page import get_template_context from shared.sx.page import get_template_context
from sx.sx_components import render_markets_list_panel from sx.sx_components import render_markets_list_panel
ctx = await get_template_context() ctx = await get_template_context()
return sx_response(render_markets_list_panel(ctx)) return sx_response(await render_markets_list_panel(ctx))
return bp return bp

View File

@@ -8,7 +8,7 @@ Routes:
""" """
from __future__ import annotations from __future__ import annotations
from quart import Blueprint, g, request, render_template, make_response from quart import Blueprint, g, request, make_response
from shared.browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
from shared.sx.helpers import sx_response from shared.sx.helpers import sx_response
@@ -107,7 +107,7 @@ def register() -> Blueprint:
frag_params["session_id"] = ident["session_id"] frag_params["session_id"] = ident["session_id"]
from sx.sx_components import render_ticket_widget from sx.sx_components import render_ticket_widget
widget_html = render_ticket_widget(entry, qty, f"/{g.post_slug}/tickets/adjust") widget_html = await render_ticket_widget(entry, qty, f"/{g.post_slug}/tickets/adjust")
mini_html = await fetch_fragment("cart", "cart-mini", params=frag_params, required=False) mini_html = await fetch_fragment("cart", "cart-mini", params=frag_params, required=False)
return sx_response(widget_html + (mini_html or "")) return sx_response(widget_html + (mini_html or ""))

View File

@@ -29,27 +29,6 @@ from shared.sx.helpers import sx_response
def register(): def register():
bp = Blueprint("slot", __name__, url_prefix='/<int:slot_id>') bp = Blueprint("slot", __name__, url_prefix='/<int:slot_id>')
@bp.before_request
async def _prepare_page_data():
if "defpage_" not in (request.endpoint or ""):
return
slot_id = (request.view_args or {}).get("slot_id")
slot = await svc_get_slot(g.s, slot_id) if slot_id else None
if not slot:
from quart import abort
abort(404)
g.slot = slot
calendar = getattr(g, "calendar", None)
from sx.sx_components import render_slot_main_panel
g.slot_content = render_slot_main_panel(slot, calendar)
@bp.context_processor
async def _inject_slot():
return {"slot": getattr(g, "slot", None)}
from shared.sx.pages import mount_pages
mount_pages(bp, "events", names=["slot-detail"])
@bp.get("/edit/") @bp.get("/edit/")
@require_admin @require_admin
async def get_edit(slot_id: int, **kwargs): async def get_edit(slot_id: int, **kwargs):
@@ -57,7 +36,7 @@ def register():
if not slot: if not slot:
return await make_response("Not found", 404) return await make_response("Not found", 404)
from sx.sx_components import render_slot_edit_form from sx.sx_components import render_slot_edit_form
return sx_response(render_slot_edit_form(slot, g.calendar)) return sx_response(await render_slot_edit_form(slot, g.calendar))
@bp.get("/view/") @bp.get("/view/")
@require_admin @require_admin
@@ -66,7 +45,7 @@ def register():
if not slot: if not slot:
return await make_response("Not found", 404) return await make_response("Not found", 404)
from sx.sx_components import render_slot_main_panel from sx.sx_components import render_slot_main_panel
return sx_response(render_slot_main_panel(slot, g.calendar)) return sx_response(await render_slot_main_panel(slot, g.calendar))
@bp.delete("/") @bp.delete("/")
@require_admin @require_admin
@@ -75,7 +54,7 @@ def register():
await svc_delete_slot(g.s, slot_id) await svc_delete_slot(g.s, slot_id)
slots = await svc_list_slots(g.s, g.calendar.id) slots = await svc_list_slots(g.s, g.calendar.id)
from sx.sx_components import render_slots_table from sx.sx_components import render_slots_table
return sx_response(render_slots_table(slots, g.calendar)) return sx_response(await render_slots_table(slots, g.calendar))
@bp.put("/") @bp.put("/")
@require_admin @require_admin
@@ -157,7 +136,7 @@ def register():
), 422 ), 422
from sx.sx_components import render_slot_main_panel from sx.sx_components import render_slot_main_panel
return sx_response(render_slot_main_panel(slot, g.calendar, oob=True)) return sx_response(await render_slot_main_panel(slot, g.calendar, oob=True))

View File

@@ -38,18 +38,6 @@ def register():
} }
return {"slots": []} return {"slots": []}
@bp.before_request
async def _prepare_page_data():
if "defpage_" not in (request.endpoint or ""):
return
calendar = getattr(g, "calendar", None)
slots = await svc_list_slots(g.s, calendar.id) if calendar else []
from sx.sx_components import render_slots_table
g.slots_content = render_slots_table(slots, calendar)
from shared.sx.pages import mount_pages
mount_pages(bp, "events", names=["slots-listing"])
@bp.post("/") @bp.post("/")
@require_admin @require_admin
@clear_cache(tag="calendars", tag_scope="all") @clear_cache(tag="calendars", tag_scope="all")
@@ -123,19 +111,19 @@ def register():
# Success → re-render the slots table # Success → re-render the slots table
slots = await svc_list_slots(g.s, g.calendar.id) slots = await svc_list_slots(g.s, g.calendar.id)
from sx.sx_components import render_slots_table from sx.sx_components import render_slots_table
return sx_response(render_slots_table(slots, g.calendar)) return sx_response(await render_slots_table(slots, g.calendar))
@bp.get("/add") @bp.get("/add")
@require_admin @require_admin
async def add_form(**kwargs): async def add_form(**kwargs):
from sx.sx_components import render_slot_add_form from sx.sx_components import render_slot_add_form
return sx_response(render_slot_add_form(g.calendar)) return sx_response(await render_slot_add_form(g.calendar))
@bp.get("/add-button") @bp.get("/add-button")
@require_admin @require_admin
async def add_button(**kwargs): async def add_button(**kwargs):
from sx.sx_components import render_slot_add_button from sx.sx_components import render_slot_add_button
return sx_response(render_slot_add_button(g.calendar)) return sx_response(await render_slot_add_button(g.calendar))
return bp return bp

View File

@@ -14,10 +14,10 @@ import logging
from quart import ( from quart import (
Blueprint, g, request, make_response, Blueprint, g, request, make_response,
) )
from sqlalchemy import select, func from sqlalchemy import select
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from models.calendars import CalendarEntry, Ticket, TicketType from models.calendars import CalendarEntry
from shared.browser.app.authz import require_admin from shared.browser.app.authz import require_admin
from shared.browser.app.redis_cacher import clear_cache from shared.browser.app.redis_cacher import clear_cache
from shared.sx.helpers import sx_response from shared.sx.helpers import sx_response
@@ -34,46 +34,6 @@ logger = logging.getLogger(__name__)
def register() -> Blueprint: def register() -> Blueprint:
bp = Blueprint("ticket_admin", __name__, url_prefix="/admin/tickets") bp = Blueprint("ticket_admin", __name__, url_prefix="/admin/tickets")
@bp.before_request
async def _prepare_page_data():
if "defpage_" not in (request.endpoint or ""):
return
# Get recent tickets
result = await g.s.execute(
select(Ticket)
.options(
selectinload(Ticket.entry).selectinload(CalendarEntry.calendar),
selectinload(Ticket.ticket_type),
)
.order_by(Ticket.created_at.desc())
.limit(50)
)
tickets = result.scalars().all()
# Stats
total = await g.s.scalar(select(func.count(Ticket.id)))
confirmed = await g.s.scalar(
select(func.count(Ticket.id)).where(Ticket.state == "confirmed")
)
checked_in = await g.s.scalar(
select(func.count(Ticket.id)).where(Ticket.state == "checked_in")
)
reserved = await g.s.scalar(
select(func.count(Ticket.id)).where(Ticket.state == "reserved")
)
stats = {
"total": total or 0,
"confirmed": confirmed or 0,
"checked_in": checked_in or 0,
"reserved": reserved or 0,
}
from shared.sx.page import get_template_context
from sx.sx_components import _ticket_admin_main_panel_html
ctx = await get_template_context()
g.ticket_admin_content = _ticket_admin_main_panel_html(ctx, tickets, stats)
@bp.get("/entry/<int:entry_id>/") @bp.get("/entry/<int:entry_id>/")
@require_admin @require_admin
async def entry_tickets(entry_id: int): async def entry_tickets(entry_id: int):
@@ -94,7 +54,7 @@ def register() -> Blueprint:
tickets = await get_tickets_for_entry(g.s, entry_id) tickets = await get_tickets_for_entry(g.s, entry_id)
from sx.sx_components import render_entry_tickets_admin from sx.sx_components import render_entry_tickets_admin
html = render_entry_tickets_admin(entry, tickets) html = await render_entry_tickets_admin(entry, tickets)
return sx_response(html) return sx_response(html)
@bp.get("/lookup/") @bp.get("/lookup/")
@@ -111,9 +71,9 @@ def register() -> Blueprint:
ticket = await get_ticket_by_code(g.s, code) ticket = await get_ticket_by_code(g.s, code)
from sx.sx_components import render_lookup_result from sx.sx_components import render_lookup_result
if not ticket: if not ticket:
return sx_response(render_lookup_result(None, "Ticket not found")) return sx_response(await render_lookup_result(None, "Ticket not found"))
return sx_response(render_lookup_result(ticket, None)) return sx_response(await render_lookup_result(ticket, None))
@bp.post("/<code>/checkin/") @bp.post("/<code>/checkin/")
@require_admin @require_admin
@@ -124,9 +84,9 @@ def register() -> Blueprint:
from sx.sx_components import render_checkin_result from sx.sx_components import render_checkin_result
if not success: if not success:
return sx_response(render_checkin_result(False, error, None)) return sx_response(await render_checkin_result(False, error, None))
ticket = await get_ticket_by_code(g.s, code) ticket = await get_ticket_by_code(g.s, code)
return sx_response(render_checkin_result(True, None, ticket)) return sx_response(await render_checkin_result(True, None, ticket))
return bp return bp

View File

@@ -22,32 +22,6 @@ from shared.sx.helpers import sx_response
def register(): def register():
bp = Blueprint("ticket_type", __name__, url_prefix='/<int:ticket_type_id>') bp = Blueprint("ticket_type", __name__, url_prefix='/<int:ticket_type_id>')
@bp.before_request
async def _prepare_page_data():
if "defpage_" not in (request.endpoint or ""):
return
ticket_type_id = (request.view_args or {}).get("ticket_type_id")
ticket_type = await svc_get_ticket_type(g.s, ticket_type_id) if ticket_type_id else None
if not ticket_type:
from quart import abort
abort(404)
g.ticket_type = ticket_type
entry = getattr(g, "entry", None)
calendar = getattr(g, "calendar", None)
va = request.view_args or {}
from sx.sx_components import render_ticket_type_main_panel
g.ticket_type_content = render_ticket_type_main_panel(
ticket_type, entry, calendar,
va.get("day"), va.get("month"), va.get("year"),
)
@bp.context_processor
async def _inject_ticket_type():
return {"ticket_type": getattr(g, "ticket_type", None)}
from shared.sx.pages import mount_pages
mount_pages(bp, "events", names=["ticket-type-detail"])
@bp.get("/edit/") @bp.get("/edit/")
@require_admin @require_admin
async def get_edit(ticket_type_id: int, **kwargs): async def get_edit(ticket_type_id: int, **kwargs):
@@ -58,7 +32,7 @@ def register():
from sx.sx_components import render_ticket_type_edit_form from sx.sx_components import render_ticket_type_edit_form
va = request.view_args or {} va = request.view_args or {}
return sx_response(render_ticket_type_edit_form( return sx_response(await render_ticket_type_edit_form(
ticket_type, g.entry, g.calendar, ticket_type, g.entry, g.calendar,
va.get("day"), va.get("month"), va.get("year"), va.get("day"), va.get("month"), va.get("year"),
)) ))
@@ -73,7 +47,7 @@ def register():
from sx.sx_components import render_ticket_type_main_panel from sx.sx_components import render_ticket_type_main_panel
va = request.view_args or {} va = request.view_args or {}
return sx_response(render_ticket_type_main_panel( return sx_response(await render_ticket_type_main_panel(
ticket_type, g.entry, g.calendar, ticket_type, g.entry, g.calendar,
va.get("day"), va.get("month"), va.get("year"), va.get("day"), va.get("month"), va.get("year"),
)) ))
@@ -140,7 +114,7 @@ def register():
# Return updated view with OOB flag # Return updated view with OOB flag
from sx.sx_components import render_ticket_type_main_panel from sx.sx_components import render_ticket_type_main_panel
va = request.view_args or {} va = request.view_args or {}
return sx_response(render_ticket_type_main_panel( return sx_response(await render_ticket_type_main_panel(
ticket_type, g.entry, g.calendar, ticket_type, g.entry, g.calendar,
va.get("day"), va.get("month"), va.get("year"), va.get("day"), va.get("month"), va.get("year"),
oob=True, oob=True,
@@ -159,7 +133,7 @@ def register():
ticket_types = await svc_list_ticket_types(g.s, g.entry.id) ticket_types = await svc_list_ticket_types(g.s, g.entry.id)
from sx.sx_components import render_ticket_types_table from sx.sx_components import render_ticket_types_table
va = request.view_args or {} va = request.view_args or {}
return sx_response(render_ticket_types_table( return sx_response(await render_ticket_types_table(
ticket_types, g.entry, g.calendar, ticket_types, g.entry, g.calendar,
va.get("day"), va.get("month"), va.get("year"), va.get("day"), va.get("month"), va.get("year"),
)) ))

View File

@@ -35,23 +35,6 @@ def register():
} }
return {"ticket_types": []} return {"ticket_types": []}
@bp.before_request
async def _prepare_page_data():
if "defpage_" not in (request.endpoint or ""):
return
entry = getattr(g, "entry", None)
calendar = getattr(g, "calendar", None)
ticket_types = await svc_list_ticket_types(g.s, entry.id) if entry else []
va = request.view_args or {}
from sx.sx_components import render_ticket_types_table
g.ticket_types_content = render_ticket_types_table(
ticket_types, entry, calendar,
va.get("day"), va.get("month"), va.get("year"),
)
from shared.sx.pages import mount_pages
mount_pages(bp, "events", names=["ticket-types-listing"])
@bp.post("/") @bp.post("/")
@require_admin @require_admin
@clear_cache(tag="calendars", tag_scope="all") @clear_cache(tag="calendars", tag_scope="all")
@@ -112,7 +95,7 @@ def register():
ticket_types = await svc_list_ticket_types(g.s, g.entry.id) ticket_types = await svc_list_ticket_types(g.s, g.entry.id)
from sx.sx_components import render_ticket_types_table from sx.sx_components import render_ticket_types_table
va = request.view_args or {} va = request.view_args or {}
return sx_response(render_ticket_types_table( return sx_response(await render_ticket_types_table(
ticket_types, g.entry, g.calendar, ticket_types, g.entry, g.calendar,
va.get("day"), va.get("month"), va.get("year"), va.get("day"), va.get("month"), va.get("year"),
)) ))
@@ -123,7 +106,7 @@ def register():
"""Show the add ticket type form.""" """Show the add ticket type form."""
from sx.sx_components import render_ticket_type_add_form from sx.sx_components import render_ticket_type_add_form
va = request.view_args or {} va = request.view_args or {}
return sx_response(render_ticket_type_add_form( return sx_response(await render_ticket_type_add_form(
g.entry, g.calendar, g.entry, g.calendar,
va.get("day"), va.get("month"), va.get("year"), va.get("day"), va.get("month"), va.get("year"),
)) ))
@@ -134,7 +117,7 @@ def register():
"""Show the add ticket type button.""" """Show the add ticket type button."""
from sx.sx_components import render_ticket_type_add_button from sx.sx_components import render_ticket_type_add_button
va = request.view_args or {} va = request.view_args or {}
return sx_response(render_ticket_type_add_button( return sx_response(await render_ticket_type_add_button(
g.entry, g.calendar, g.entry, g.calendar,
va.get("day"), va.get("month"), va.get("year"), va.get("day"), va.get("month"), va.get("year"),
)) ))

View File

@@ -24,8 +24,6 @@ from shared.sx.helpers import sx_response
from .services.tickets import ( from .services.tickets import (
create_ticket, create_ticket,
get_ticket_by_code,
get_user_tickets,
get_available_ticket_count, get_available_ticket_count,
get_tickets_for_entry, get_tickets_for_entry,
get_sold_ticket_count, get_sold_ticket_count,
@@ -39,44 +37,6 @@ logger = logging.getLogger(__name__)
def register() -> Blueprint: def register() -> Blueprint:
bp = Blueprint("tickets", __name__, url_prefix="/tickets") bp = Blueprint("tickets", __name__, url_prefix="/tickets")
@bp.before_request
async def _prepare_page_data():
ep = request.endpoint or ""
if "defpage_my_tickets" in ep:
ident = current_cart_identity()
tickets = await get_user_tickets(
g.s,
user_id=ident["user_id"],
session_id=ident["session_id"],
)
from shared.sx.page import get_template_context
from sx.sx_components import _tickets_main_panel_html
ctx = await get_template_context()
g.tickets_content = _tickets_main_panel_html(ctx, tickets)
elif "defpage_ticket_detail" in ep:
code = (request.view_args or {}).get("code")
ticket = await get_ticket_by_code(g.s, code) if code else None
if not ticket:
from quart import abort
abort(404)
# Verify ownership
ident = current_cart_identity()
if ident["user_id"] is not None:
if ticket.user_id != ident["user_id"]:
from quart import abort
abort(404)
elif ident["session_id"] is not None:
if ticket.session_id != ident["session_id"]:
from quart import abort
abort(404)
else:
from quart import abort
abort(404)
from shared.sx.page import get_template_context
from sx.sx_components import _ticket_detail_panel_html
ctx = await get_template_context()
g.ticket_detail_content = _ticket_detail_panel_html(ctx, ticket)
@bp.post("/buy/") @bp.post("/buy/")
@clear_cache(tag="calendars", tag_scope="all") @clear_cache(tag="calendars", tag_scope="all")
async def buy_tickets(): async def buy_tickets():
@@ -167,7 +127,7 @@ def register() -> Blueprint:
cart_count = summary.count + summary.calendar_count + summary.ticket_count cart_count = summary.count + summary.calendar_count + summary.ticket_count
from sx.sx_components import render_buy_result from sx.sx_components import render_buy_result
return sx_response(render_buy_result(entry, created, remaining, cart_count)) return sx_response(await render_buy_result(entry, created, remaining, cart_count))
@bp.post("/adjust/") @bp.post("/adjust/")
@clear_cache(tag="calendars", tag_scope="all") @clear_cache(tag="calendars", tag_scope="all")
@@ -290,7 +250,7 @@ def register() -> Blueprint:
cart_count = summary.count + summary.calendar_count + summary.ticket_count cart_count = summary.count + summary.calendar_count + summary.ticket_count
from sx.sx_components import render_adjust_response from sx.sx_components import render_adjust_response
return sx_response(render_adjust_response( return sx_response(await render_adjust_response(
entry, ticket_remaining, ticket_sold_count, entry, ticket_remaining, ticket_sold_count,
user_ticket_count, user_ticket_counts_by_type, cart_count, user_ticket_count, user_ticket_counts_by_type, cart_count,
)) ))

View File

@@ -0,0 +1,49 @@
;; Account-page fragment handler
;;
;; Renders tickets or bookings panel for the account dashboard.
;; slug=tickets → ticket list; slug=bookings → booking list.
(defhandler account-page (&key slug user_id)
(let ((uid (parse-int (or user_id "0"))))
(when (> uid 0)
(cond
(= slug "tickets")
(let ((tickets (service "calendar" "user-tickets" :user-id uid)))
(~events-frag-tickets-panel
:items (if (empty? tickets)
(~empty-state :message "No tickets yet."
:cls "text-sm text-stone-500")
(~events-frag-tickets-list
:items (<> (map (fn (t)
(~events-frag-ticket-item
:href (app-url "events"
(str "/tickets/" (get t "code") "/"))
:entry-name (get t "entry_name")
:date-str (format-date (get t "entry_start_at") "%d %b %Y, %H:%M")
:calendar-name (when (get t "calendar_name")
(span (str "\u00b7 " (get t "calendar_name"))))
:type-name (when (get t "ticket_type_name")
(span (str "\u00b7 " (get t "ticket_type_name"))))
:badge (~status-pill :status (or (get t "state") ""))))
tickets))))))
(= slug "bookings")
(let ((bookings (service "calendar" "user-bookings" :user-id uid)))
(~events-frag-bookings-panel
:items (if (empty? bookings)
(~empty-state :message "No bookings yet."
:cls "text-sm text-stone-500")
(~events-frag-bookings-list
:items (<> (map (fn (b)
(~events-frag-booking-item
:name (get b "name")
:date-str (str (format-date (get b "start_at") "%d %b %Y, %H:%M")
(if (get b "end_at")
(str " \u2013 " (format-date (get b "end_at") "%H:%M"))
""))
:calendar-name (when (get b "calendar_name")
(span (str "\u00b7 " (get b "calendar_name"))))
:cost-str (when (get b "cost")
(span (str "\u00b7 \u00a3" (get b "cost"))))
:badge (~status-pill :status (or (get b "state") ""))))
bookings))))))))))

View File

@@ -0,0 +1,38 @@
;; Container-cards fragment handler
;;
;; Returns HTML with <!-- card-widget:ID --> comment markers so the
;; blog consumer can split per-post fragments. Each post section
;; contains an events-frag-entries-widget with entry cards.
(defhandler container-cards (&key post_ids post_slugs)
(let ((ids (filter (fn (x) (> x 0))
(map parse-int
(filter (fn (s) (not (empty? s)))
(split (or post_ids "") ",")))))
(slugs (map trim
(split (or post_slugs "") ","))))
(when (not (empty? ids))
(let ((batch (service "calendar" "confirmed-entries-for-posts" :post-ids ids)))
(<> (map-indexed (fn (i pid)
(let ((entries (or (get batch pid) (list)))
(post-slug (or (nth slugs i) "")))
(<> (str "<!-- card-widget:" pid " -->")
(when (not (empty? entries))
(~events-frag-entries-widget
:cards (<> (map (fn (e)
(let ((time-str (str (format-date (get e "start_at") "%H:%M")
(if (get e "end_at")
(str " \u2013 " (format-date (get e "end_at") "%H:%M"))
""))))
(~events-frag-entry-card
:href (app-url "events"
(str "/" post-slug
"/" (get e "calendar_slug")
"/" (get e "start_at_year")
"/" (get e "start_at_month")
"/" (get e "start_at_day")
"/entries/" (get e "id") "/"))
:name (get e "name")
:date-str (format-date (get e "start_at") "%a, %b %d")
:time-str time-str))) entries))))
(str "<!-- /card-widget:" pid " -->")))) ids))))))

File diff suppressed because it is too large Load Diff

View File

@@ -44,11 +44,11 @@ async def _cal_admin_full(ctx: dict, **kw: Any) -> str:
) )
ctx = await _ensure_container_nav(ctx) ctx = await _ensure_container_nav(ctx)
slug = (ctx.get("post") or {}).get("slug", "") slug = (ctx.get("post") or {}).get("slug", "")
root_hdr = root_header_sx(ctx) root_hdr = await root_header_sx(ctx)
post_hdr = _post_header_sx(ctx) post_hdr = await _post_header_sx(ctx)
admin_hdr = post_admin_header_sx(ctx, slug, selected="calendars") admin_hdr = await post_admin_header_sx(ctx, slug, selected="calendars")
child = admin_hdr + _calendar_header_sx(ctx) + _calendar_admin_header_sx(ctx) child = admin_hdr + await _calendar_header_sx(ctx) + await _calendar_admin_header_sx(ctx)
return root_hdr + post_hdr + header_child_sx(child) return root_hdr + post_hdr + await header_child_sx(child)
async def _cal_admin_oob(ctx: dict, **kw: Any) -> str: async def _cal_admin_oob(ctx: dict, **kw: Any) -> str:
@@ -59,10 +59,10 @@ async def _cal_admin_oob(ctx: dict, **kw: Any) -> str:
) )
ctx = await _ensure_container_nav(ctx) ctx = await _ensure_container_nav(ctx)
slug = (ctx.get("post") or {}).get("slug", "") slug = (ctx.get("post") or {}).get("slug", "")
oobs = (post_admin_header_sx(ctx, slug, oob=True, selected="calendars") oobs = (await post_admin_header_sx(ctx, slug, oob=True, selected="calendars")
+ _calendar_header_sx(ctx, oob=True)) + await _calendar_header_sx(ctx, oob=True))
oobs += oob_header_sx("calendar-header-child", "calendar-admin-header-child", oobs += await oob_header_sx("calendar-header-child", "calendar-admin-header-child",
_calendar_admin_header_sx(ctx)) await _calendar_admin_header_sx(ctx))
oobs += _clear_deeper_oob("post-row", "post-header-child", oobs += _clear_deeper_oob("post-row", "post-header-child",
"post-admin-row", "post-admin-header-child", "post-admin-row", "post-admin-header-child",
"calendar-row", "calendar-header-child", "calendar-row", "calendar-header-child",
@@ -83,8 +83,8 @@ async def _slots_oob(ctx: dict, **kw: Any) -> str:
) )
ctx = await _ensure_container_nav({**ctx, "is_admin_section": True}) ctx = await _ensure_container_nav({**ctx, "is_admin_section": True})
slug = (ctx.get("post") or {}).get("slug", "") slug = (ctx.get("post") or {}).get("slug", "")
oobs = (post_admin_header_sx(ctx, slug, oob=True, selected="calendars") oobs = (await post_admin_header_sx(ctx, slug, oob=True, selected="calendars")
+ _calendar_admin_header_sx(ctx, oob=True)) + await _calendar_admin_header_sx(ctx, oob=True))
oobs += _clear_deeper_oob("post-row", "post-header-child", oobs += _clear_deeper_oob("post-row", "post-header-child",
"post-admin-row", "post-admin-header-child", "post-admin-row", "post-admin-header-child",
"calendar-row", "calendar-header-child", "calendar-row", "calendar-header-child",
@@ -102,12 +102,12 @@ async def _slot_full(ctx: dict, **kw: Any) -> str:
) )
ctx = await _ensure_container_nav({**ctx, "is_admin_section": True}) ctx = await _ensure_container_nav({**ctx, "is_admin_section": True})
slug = (ctx.get("post") or {}).get("slug", "") slug = (ctx.get("post") or {}).get("slug", "")
root_hdr = root_header_sx(ctx) root_hdr = await root_header_sx(ctx)
post_hdr = _post_header_sx(ctx) post_hdr = await _post_header_sx(ctx)
admin_hdr = post_admin_header_sx(ctx, slug, selected="calendars") admin_hdr = await post_admin_header_sx(ctx, slug, selected="calendars")
child = (admin_hdr + _calendar_header_sx(ctx) child = (admin_hdr + await _calendar_header_sx(ctx)
+ _calendar_admin_header_sx(ctx) + _slot_header_html(ctx)) + await _calendar_admin_header_sx(ctx) + await _slot_header_html(ctx))
return root_hdr + post_hdr + header_child_sx(child) return root_hdr + post_hdr + await header_child_sx(child)
async def _slot_oob(ctx: dict, **kw: Any) -> str: async def _slot_oob(ctx: dict, **kw: Any) -> str:
@@ -118,10 +118,10 @@ async def _slot_oob(ctx: dict, **kw: Any) -> str:
) )
ctx = await _ensure_container_nav({**ctx, "is_admin_section": True}) ctx = await _ensure_container_nav({**ctx, "is_admin_section": True})
slug = (ctx.get("post") or {}).get("slug", "") slug = (ctx.get("post") or {}).get("slug", "")
oobs = (post_admin_header_sx(ctx, slug, oob=True, selected="calendars") oobs = (await post_admin_header_sx(ctx, slug, oob=True, selected="calendars")
+ _calendar_admin_header_sx(ctx, oob=True)) + await _calendar_admin_header_sx(ctx, oob=True))
oobs += oob_header_sx("calendar-admin-header-child", "slot-header-child", oobs += await oob_header_sx("calendar-admin-header-child", "slot-header-child",
_slot_header_html(ctx)) await _slot_header_html(ctx))
oobs += _clear_deeper_oob("post-row", "post-header-child", oobs += _clear_deeper_oob("post-row", "post-header-child",
"post-admin-row", "post-admin-header-child", "post-admin-row", "post-admin-header-child",
"calendar-row", "calendar-header-child", "calendar-row", "calendar-header-child",
@@ -140,12 +140,12 @@ async def _day_admin_full(ctx: dict, **kw: Any) -> str:
) )
ctx = await _ensure_container_nav(ctx) ctx = await _ensure_container_nav(ctx)
slug = (ctx.get("post") or {}).get("slug", "") slug = (ctx.get("post") or {}).get("slug", "")
root_hdr = root_header_sx(ctx) root_hdr = await root_header_sx(ctx)
post_hdr = _post_header_sx(ctx) post_hdr = await _post_header_sx(ctx)
admin_hdr = post_admin_header_sx(ctx, slug, selected="calendars") admin_hdr = await post_admin_header_sx(ctx, slug, selected="calendars")
child = (admin_hdr + _calendar_header_sx(ctx) + _day_header_sx(ctx) child = (admin_hdr + await _calendar_header_sx(ctx) + await _day_header_sx(ctx)
+ _day_admin_header_sx(ctx)) + await _day_admin_header_sx(ctx))
return root_hdr + post_hdr + header_child_sx(child) return root_hdr + post_hdr + await header_child_sx(child)
async def _day_admin_oob(ctx: dict, **kw: Any) -> str: async def _day_admin_oob(ctx: dict, **kw: Any) -> str:
@@ -156,10 +156,10 @@ async def _day_admin_oob(ctx: dict, **kw: Any) -> str:
) )
ctx = await _ensure_container_nav(ctx) ctx = await _ensure_container_nav(ctx)
slug = (ctx.get("post") or {}).get("slug", "") slug = (ctx.get("post") or {}).get("slug", "")
oobs = (post_admin_header_sx(ctx, slug, oob=True, selected="calendars") oobs = (await post_admin_header_sx(ctx, slug, oob=True, selected="calendars")
+ _calendar_header_sx(ctx, oob=True)) + await _calendar_header_sx(ctx, oob=True))
oobs += oob_header_sx("day-header-child", "day-admin-header-child", oobs += await oob_header_sx("day-header-child", "day-admin-header-child",
_day_admin_header_sx(ctx)) await _day_admin_header_sx(ctx))
oobs += _clear_deeper_oob("post-row", "post-header-child", oobs += _clear_deeper_oob("post-row", "post-header-child",
"post-admin-row", "post-admin-header-child", "post-admin-row", "post-admin-header-child",
"calendar-row", "calendar-header-child", "calendar-row", "calendar-header-child",
@@ -170,26 +170,26 @@ async def _day_admin_oob(ctx: dict, **kw: Any) -> str:
# --- Entry layout (root + child(post + cal + day + entry), + menu) --- # --- Entry layout (root + child(post + cal + day + entry), + menu) ---
def _entry_full(ctx: dict, **kw: Any) -> str: async def _entry_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, header_child_sx from shared.sx.helpers import root_header_sx, header_child_sx
from sx.sx_components import ( from sx.sx_components import (
_post_header_sx, _calendar_header_sx, _post_header_sx, _calendar_header_sx,
_day_header_sx, _entry_header_html, _day_header_sx, _entry_header_html,
) )
root_hdr = root_header_sx(ctx) root_hdr = await root_header_sx(ctx)
child = (_post_header_sx(ctx) + _calendar_header_sx(ctx) child = (await _post_header_sx(ctx) + await _calendar_header_sx(ctx)
+ _day_header_sx(ctx) + _entry_header_html(ctx)) + await _day_header_sx(ctx) + await _entry_header_html(ctx))
return root_hdr + header_child_sx(child) return root_hdr + await header_child_sx(child)
def _entry_oob(ctx: dict, **kw: Any) -> str: async def _entry_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import oob_header_sx from shared.sx.helpers import oob_header_sx
from sx.sx_components import ( from sx.sx_components import (
_day_header_sx, _entry_header_html, _clear_deeper_oob, _day_header_sx, _entry_header_html, _clear_deeper_oob,
) )
oobs = _day_header_sx(ctx, oob=True) oobs = await _day_header_sx(ctx, oob=True)
oobs += oob_header_sx("day-header-child", "entry-header-child", oobs += await oob_header_sx("day-header-child", "entry-header-child",
_entry_header_html(ctx)) await _entry_header_html(ctx))
oobs += _clear_deeper_oob("post-row", "post-header-child", oobs += _clear_deeper_oob("post-row", "post-header-child",
"calendar-row", "calendar-header-child", "calendar-row", "calendar-header-child",
"day-row", "day-header-child", "day-row", "day-header-child",
@@ -208,12 +208,12 @@ async def _entry_admin_full(ctx: dict, **kw: Any) -> str:
) )
ctx = await _ensure_container_nav(ctx) ctx = await _ensure_container_nav(ctx)
slug = (ctx.get("post") or {}).get("slug", "") slug = (ctx.get("post") or {}).get("slug", "")
root_hdr = root_header_sx(ctx) root_hdr = await root_header_sx(ctx)
post_hdr = _post_header_sx(ctx) post_hdr = await _post_header_sx(ctx)
admin_hdr = post_admin_header_sx(ctx, slug, selected="calendars") admin_hdr = await post_admin_header_sx(ctx, slug, selected="calendars")
child = (admin_hdr + _calendar_header_sx(ctx) + _day_header_sx(ctx) child = (admin_hdr + await _calendar_header_sx(ctx) + await _day_header_sx(ctx)
+ _entry_header_html(ctx) + _entry_admin_header_html(ctx)) + await _entry_header_html(ctx) + await _entry_admin_header_html(ctx))
return root_hdr + post_hdr + header_child_sx(child) return root_hdr + post_hdr + await header_child_sx(child)
async def _entry_admin_oob(ctx: dict, **kw: Any) -> str: async def _entry_admin_oob(ctx: dict, **kw: Any) -> str:
@@ -224,10 +224,10 @@ async def _entry_admin_oob(ctx: dict, **kw: Any) -> str:
) )
ctx = await _ensure_container_nav(ctx) ctx = await _ensure_container_nav(ctx)
slug = (ctx.get("post") or {}).get("slug", "") slug = (ctx.get("post") or {}).get("slug", "")
oobs = (post_admin_header_sx(ctx, slug, oob=True, selected="calendars") oobs = (await post_admin_header_sx(ctx, slug, oob=True, selected="calendars")
+ _entry_header_html(ctx, oob=True)) + await _entry_header_html(ctx, oob=True))
oobs += oob_header_sx("entry-header-child", "entry-admin-header-child", oobs += await oob_header_sx("entry-header-child", "entry-admin-header-child",
_entry_admin_header_html(ctx)) await _entry_admin_header_html(ctx))
oobs += _clear_deeper_oob("post-row", "post-header-child", oobs += _clear_deeper_oob("post-row", "post-header-child",
"post-admin-row", "post-admin-header-child", "post-admin-row", "post-admin-header-child",
"calendar-row", "calendar-header-child", "calendar-row", "calendar-header-child",
@@ -239,78 +239,255 @@ async def _entry_admin_oob(ctx: dict, **kw: Any) -> str:
# --- Ticket types layout (extends entry admin with ticket-types header, + menu) --- # --- Ticket types layout (extends entry admin with ticket-types header, + menu) ---
def _ticket_types_full(ctx: dict, **kw: Any) -> str: async def _ticket_types_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, header_child_sx from shared.sx.helpers import root_header_sx, header_child_sx
from sx.sx_components import ( from sx.sx_components import (
_post_header_sx, _calendar_header_sx, _day_header_sx, _post_header_sx, _calendar_header_sx, _day_header_sx,
_entry_header_html, _entry_admin_header_html, _entry_header_html, _entry_admin_header_html,
_ticket_types_header_html, _ticket_types_header_html,
) )
root_hdr = root_header_sx(ctx) root_hdr = await root_header_sx(ctx)
child = (_post_header_sx(ctx) + _calendar_header_sx(ctx) child = (await _post_header_sx(ctx) + await _calendar_header_sx(ctx)
+ _day_header_sx(ctx) + _entry_header_html(ctx) + await _day_header_sx(ctx) + await _entry_header_html(ctx)
+ _entry_admin_header_html(ctx) + _ticket_types_header_html(ctx)) + await _entry_admin_header_html(ctx) + await _ticket_types_header_html(ctx))
return root_hdr + header_child_sx(child) return root_hdr + await header_child_sx(child)
def _ticket_types_oob(ctx: dict, **kw: Any) -> str: async def _ticket_types_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import oob_header_sx from shared.sx.helpers import oob_header_sx
from sx.sx_components import ( from sx.sx_components import (
_entry_admin_header_html, _ticket_types_header_html, _clear_deeper_oob, _entry_admin_header_html, _ticket_types_header_html, _clear_deeper_oob,
) )
oobs = _entry_admin_header_html(ctx, oob=True) oobs = await _entry_admin_header_html(ctx, oob=True)
oobs += oob_header_sx("entry-admin-header-child", "ticket_types-header-child", oobs += await oob_header_sx("entry-admin-header-child", "ticket_types-header-child",
_ticket_types_header_html(ctx)) await _ticket_types_header_html(ctx))
return oobs return oobs
# --- Ticket type detail layout (extends ticket types with ticket-type header, + menu) --- # --- Ticket type detail layout (extends ticket types with ticket-type header, + menu) ---
def _ticket_type_full(ctx: dict, **kw: Any) -> str: async def _ticket_type_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, header_child_sx from shared.sx.helpers import root_header_sx, header_child_sx
from sx.sx_components import ( from sx.sx_components import (
_post_header_sx, _calendar_header_sx, _day_header_sx, _post_header_sx, _calendar_header_sx, _day_header_sx,
_entry_header_html, _entry_admin_header_html, _entry_header_html, _entry_admin_header_html,
_ticket_types_header_html, _ticket_type_header_html, _ticket_types_header_html, _ticket_type_header_html,
) )
root_hdr = root_header_sx(ctx) root_hdr = await root_header_sx(ctx)
child = (_post_header_sx(ctx) + _calendar_header_sx(ctx) child = (await _post_header_sx(ctx) + await _calendar_header_sx(ctx)
+ _day_header_sx(ctx) + _entry_header_html(ctx) + await _day_header_sx(ctx) + await _entry_header_html(ctx)
+ _entry_admin_header_html(ctx) + _ticket_types_header_html(ctx) + await _entry_admin_header_html(ctx) + await _ticket_types_header_html(ctx)
+ _ticket_type_header_html(ctx)) + await _ticket_type_header_html(ctx))
return root_hdr + header_child_sx(child) return root_hdr + await header_child_sx(child)
def _ticket_type_oob(ctx: dict, **kw: Any) -> str: async def _ticket_type_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import oob_header_sx from shared.sx.helpers import oob_header_sx
from sx.sx_components import ( from sx.sx_components import (
_ticket_types_header_html, _ticket_type_header_html, _ticket_types_header_html, _ticket_type_header_html,
) )
oobs = _ticket_types_header_html(ctx, oob=True) oobs = await _ticket_types_header_html(ctx, oob=True)
oobs += oob_header_sx("ticket_types-header-child", "ticket_type-header-child", oobs += await oob_header_sx("ticket_types-header-child", "ticket_type-header-child",
_ticket_type_header_html(ctx)) await _ticket_type_header_html(ctx))
return oobs return oobs
# --- Markets layout (root + child(post + markets)) --- # --- Markets layout (root + child(post + markets)) ---
def _markets_full(ctx: dict, **kw: Any) -> str: async def _markets_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, header_child_sx from shared.sx.helpers import root_header_sx, header_child_sx
from sx.sx_components import _post_header_sx, _markets_header_sx from sx.sx_components import _post_header_sx, _markets_header_sx
root_hdr = root_header_sx(ctx) root_hdr = await root_header_sx(ctx)
child = _post_header_sx(ctx) + _markets_header_sx(ctx) child = await _post_header_sx(ctx) + await _markets_header_sx(ctx)
return root_hdr + header_child_sx(child) return root_hdr + await header_child_sx(child)
def _markets_oob(ctx: dict, **kw: Any) -> str: async def _markets_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import oob_header_sx from shared.sx.helpers import oob_header_sx
from sx.sx_components import _post_header_sx, _markets_header_sx from sx.sx_components import _post_header_sx, _markets_header_sx
oobs = _post_header_sx(ctx, oob=True) oobs = await _post_header_sx(ctx, oob=True)
oobs += oob_header_sx("post-header-child", "markets-header-child", oobs += await oob_header_sx("post-header-child", "markets-header-child",
_markets_header_sx(ctx)) await _markets_header_sx(ctx))
return oobs return oobs
# ---------------------------------------------------------------------------
# Shared hydration helpers
# ---------------------------------------------------------------------------
def _add_to_defpage_ctx(**kwargs: Any) -> None:
"""Add data to g._defpage_ctx for the app-level context_processor."""
from quart import g
if not hasattr(g, '_defpage_ctx'):
g._defpage_ctx = {}
g._defpage_ctx.update(kwargs)
async def _ensure_calendar(calendar_slug: str | None) -> None:
"""Load calendar into g.calendar if not already present."""
from quart import g, abort
if hasattr(g, 'calendar'):
_add_to_defpage_ctx(calendar=g.calendar)
return
from bp.calendar.services.calendar_view import (
get_calendar_by_post_and_slug, get_calendar_by_slug,
)
post_data = getattr(g, "post_data", None)
if post_data:
post_id = (post_data.get("post") or {}).get("id")
cal = await get_calendar_by_post_and_slug(g.s, post_id, calendar_slug)
else:
cal = await get_calendar_by_slug(g.s, calendar_slug)
if not cal:
abort(404)
g.calendar = cal
g.calendar_slug = calendar_slug
_add_to_defpage_ctx(calendar=cal)
async def _ensure_entry(entry_id: int | None) -> None:
"""Load calendar entry into g.entry if not already present."""
from quart import g, abort
if hasattr(g, 'entry'):
_add_to_defpage_ctx(entry=g.entry)
return
from sqlalchemy import select
from models.calendars import CalendarEntry
result = await g.s.execute(
select(CalendarEntry).where(
CalendarEntry.id == entry_id,
CalendarEntry.deleted_at.is_(None),
)
)
entry = result.scalar_one_or_none()
if entry is None:
abort(404)
g.entry = entry
_add_to_defpage_ctx(entry=entry)
async def _ensure_entry_context(entry_id: int | None) -> None:
"""Load full entry context (ticket data, posts) into g.* and _defpage_ctx."""
from quart import g
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from models.calendars import CalendarEntry
from bp.tickets.services.tickets import (
get_available_ticket_count,
get_sold_ticket_count,
get_user_reserved_count,
)
from shared.infrastructure.cart_identity import current_cart_identity
from bp.calendar_entry.services.post_associations import get_entry_posts
await _ensure_entry(entry_id)
# Reload with ticket_types eagerly loaded
stmt = (
select(CalendarEntry)
.where(CalendarEntry.id == entry_id, CalendarEntry.deleted_at.is_(None))
.options(selectinload(CalendarEntry.ticket_types))
)
result = await g.s.execute(stmt)
calendar_entry = result.scalar_one_or_none()
if calendar_entry and getattr(g, "calendar", None):
if calendar_entry.calendar_id != g.calendar.id:
calendar_entry = None
if calendar_entry:
await g.s.refresh(calendar_entry, ['slot'])
g.entry = calendar_entry
entry_posts = await get_entry_posts(g.s, calendar_entry.id)
ticket_remaining = await get_available_ticket_count(g.s, calendar_entry.id)
ticket_sold_count = await get_sold_ticket_count(g.s, calendar_entry.id)
ident = current_cart_identity()
user_ticket_count = await get_user_reserved_count(
g.s, calendar_entry.id,
user_id=ident["user_id"],
session_id=ident["session_id"],
)
user_ticket_counts_by_type = {}
if calendar_entry.ticket_types:
for tt in calendar_entry.ticket_types:
if tt.deleted_at is None:
user_ticket_counts_by_type[tt.id] = await get_user_reserved_count(
g.s, calendar_entry.id,
user_id=ident["user_id"],
session_id=ident["session_id"],
ticket_type_id=tt.id,
)
_add_to_defpage_ctx(
entry=calendar_entry,
entry_posts=entry_posts,
ticket_remaining=ticket_remaining,
ticket_sold_count=ticket_sold_count,
user_ticket_count=user_ticket_count,
user_ticket_counts_by_type=user_ticket_counts_by_type,
)
async def _ensure_day_data(year: int, month: int, day: int) -> None:
"""Load day-specific data for layout header functions."""
from quart import g, session as qsession
if hasattr(g, 'day_date'):
return
from datetime import date as date_cls, datetime, timezone, timedelta
from sqlalchemy import select
from bp.calendar.services import get_visible_entries_for_period
from models.calendars import CalendarSlot
calendar = getattr(g, "calendar", None)
if not calendar:
return
try:
day_date = date_cls(year, month, day)
except (ValueError, TypeError):
return
period_start = datetime(year, month, day, tzinfo=timezone.utc)
period_end = period_start + timedelta(days=1)
user = getattr(g, "user", None)
session_id = qsession.get("calendar_sid")
visible = await get_visible_entries_for_period(
sess=g.s,
calendar_id=calendar.id,
period_start=period_start,
period_end=period_end,
user=user,
session_id=session_id,
)
weekday_attr = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"][day_date.weekday()]
stmt = (
select(CalendarSlot)
.where(
CalendarSlot.calendar_id == calendar.id,
getattr(CalendarSlot, weekday_attr) == True, # noqa: E712
CalendarSlot.deleted_at.is_(None),
)
.order_by(CalendarSlot.time_start.asc(), CalendarSlot.id.asc())
)
result = await g.s.execute(stmt)
day_slots = list(result.scalars())
g.day_date = day_date
_add_to_defpage_ctx(
qsession=qsession,
day_date=day_date,
day=day,
year=year,
month=month,
day_entries=visible.merged_entries,
user_entries=visible.user_entries,
confirmed_entries=visible.confirmed_entries,
day_slots=day_slots,
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Page helpers # Page helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -336,71 +513,191 @@ def _register_events_helpers() -> None:
}) })
def _h_calendar_admin_content(): async def _h_calendar_admin_content(calendar_slug=None, **kw):
await _ensure_calendar(calendar_slug)
from shared.sx.page import get_template_context
from sx.sx_components import _calendar_admin_main_panel_html
ctx = await get_template_context()
return await _calendar_admin_main_panel_html(ctx)
async def _h_day_admin_content(calendar_slug=None, year=None, month=None, day=None, **kw):
await _ensure_calendar(calendar_slug)
if year is not None:
await _ensure_day_data(int(year), int(month), int(day))
from sx.sx_components import _day_admin_main_panel_html
return await _day_admin_main_panel_html({})
async def _h_slots_content(calendar_slug=None, **kw):
from quart import g from quart import g
return getattr(g, "calendar_admin_content", "") await _ensure_calendar(calendar_slug)
calendar = getattr(g, "calendar", None)
from bp.slots.services.slots import list_slots as svc_list_slots
slots = await svc_list_slots(g.s, calendar.id) if calendar else []
_add_to_defpage_ctx(slots=slots)
from sx.sx_components import render_slots_table
return await render_slots_table(slots, calendar)
def _h_day_admin_content(): async def _h_slot_content(calendar_slug=None, slot_id=None, **kw):
from quart import g, abort
await _ensure_calendar(calendar_slug)
from bp.slot.services.slot import get_slot as svc_get_slot
slot = await svc_get_slot(g.s, slot_id) if slot_id else None
if not slot:
abort(404)
g.slot = slot
_add_to_defpage_ctx(slot=slot)
calendar = getattr(g, "calendar", None)
from sx.sx_components import render_slot_main_panel
return await render_slot_main_panel(slot, calendar)
async def _h_entry_content(calendar_slug=None, entry_id=None, **kw):
await _ensure_calendar(calendar_slug)
await _ensure_entry_context(entry_id)
from shared.sx.page import get_template_context
from sx.sx_components import _entry_main_panel_html
ctx = await get_template_context()
return await _entry_main_panel_html(ctx)
async def _h_entry_menu(calendar_slug=None, entry_id=None, **kw):
await _ensure_calendar(calendar_slug)
await _ensure_entry_context(entry_id)
from shared.sx.page import get_template_context
from sx.sx_components import _entry_nav_html
ctx = await get_template_context()
return await _entry_nav_html(ctx)
async def _h_entry_admin_content(calendar_slug=None, entry_id=None, **kw):
await _ensure_calendar(calendar_slug)
await _ensure_entry_context(entry_id)
from shared.sx.page import get_template_context
from sx.sx_components import _entry_admin_main_panel_html
ctx = await get_template_context()
return await _entry_admin_main_panel_html(ctx)
async def _h_admin_menu():
from shared.sx.helpers import render_to_sx
return await render_to_sx("events-admin-placeholder-nav")
async def _h_ticket_types_content(calendar_slug=None, entry_id=None,
year=None, month=None, day=None, **kw):
from quart import g from quart import g
return getattr(g, "day_admin_content", "") await _ensure_calendar(calendar_slug)
await _ensure_entry(entry_id)
entry = getattr(g, "entry", None)
calendar = getattr(g, "calendar", None)
from bp.ticket_types.services.tickets import list_ticket_types as svc_list_ticket_types
ticket_types = await svc_list_ticket_types(g.s, entry.id) if entry else []
_add_to_defpage_ctx(ticket_types=ticket_types)
from sx.sx_components import render_ticket_types_table
return await render_ticket_types_table(ticket_types, entry, calendar, day, month, year)
def _h_slots_content(): async def _h_ticket_type_content(calendar_slug=None, entry_id=None,
ticket_type_id=None, year=None, month=None, day=None, **kw):
from quart import g, abort
await _ensure_calendar(calendar_slug)
await _ensure_entry(entry_id)
from bp.ticket_type.services.ticket import get_ticket_type as svc_get_ticket_type
ticket_type = await svc_get_ticket_type(g.s, ticket_type_id) if ticket_type_id else None
if not ticket_type:
abort(404)
g.ticket_type = ticket_type
_add_to_defpage_ctx(ticket_type=ticket_type)
entry = getattr(g, "entry", None)
calendar = getattr(g, "calendar", None)
from sx.sx_components import render_ticket_type_main_panel
return await render_ticket_type_main_panel(ticket_type, entry, calendar, day, month, year)
async def _h_tickets_content(**kw):
from quart import g from quart import g
return getattr(g, "slots_content", "") from shared.infrastructure.cart_identity import current_cart_identity
from bp.tickets.services.tickets import get_user_tickets
ident = current_cart_identity()
tickets = await get_user_tickets(
g.s,
user_id=ident["user_id"],
session_id=ident["session_id"],
)
from shared.sx.page import get_template_context
from sx.sx_components import _tickets_main_panel_html
ctx = await get_template_context()
return await _tickets_main_panel_html(ctx, tickets)
def _h_slot_content(): async def _h_ticket_detail_content(code=None, **kw):
from quart import g, abort
from shared.infrastructure.cart_identity import current_cart_identity
from bp.tickets.services.tickets import get_ticket_by_code
ticket = await get_ticket_by_code(g.s, code) if code else None
if not ticket:
abort(404)
# Verify ownership
ident = current_cart_identity()
if ident["user_id"] is not None:
if ticket.user_id != ident["user_id"]:
abort(404)
elif ident["session_id"] is not None:
if ticket.session_id != ident["session_id"]:
abort(404)
else:
abort(404)
from shared.sx.page import get_template_context
from sx.sx_components import _ticket_detail_panel_html
ctx = await get_template_context()
return await _ticket_detail_panel_html(ctx, ticket)
async def _h_ticket_admin_content(**kw):
from quart import g from quart import g
return getattr(g, "slot_content", "") from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
from models.calendars import CalendarEntry, Ticket
result = await g.s.execute(
select(Ticket)
.options(
selectinload(Ticket.entry).selectinload(CalendarEntry.calendar),
selectinload(Ticket.ticket_type),
)
.order_by(Ticket.created_at.desc())
.limit(50)
)
tickets = result.scalars().all()
total = await g.s.scalar(select(func.count(Ticket.id)))
confirmed = await g.s.scalar(
select(func.count(Ticket.id)).where(Ticket.state == "confirmed")
)
checked_in = await g.s.scalar(
select(func.count(Ticket.id)).where(Ticket.state == "checked_in")
)
reserved = await g.s.scalar(
select(func.count(Ticket.id)).where(Ticket.state == "reserved")
)
stats = {
"total": total or 0,
"confirmed": confirmed or 0,
"checked_in": checked_in or 0,
"reserved": reserved or 0,
}
from shared.sx.page import get_template_context
from sx.sx_components import _ticket_admin_main_panel_html
ctx = await get_template_context()
return await _ticket_admin_main_panel_html(ctx, tickets, stats)
def _h_entry_content(): async def _h_markets_content(**kw):
from quart import g from shared.sx.page import get_template_context
return getattr(g, "entry_content", "") from sx.sx_components import _markets_main_panel_html
ctx = await get_template_context()
return await _markets_main_panel_html(ctx)
def _h_entry_menu():
from quart import g
return getattr(g, "entry_menu", "")
def _h_entry_admin_content():
from quart import g
return getattr(g, "entry_admin_content", "")
def _h_admin_menu():
from shared.sx.helpers import sx_call
return sx_call("events-admin-placeholder-nav")
def _h_ticket_types_content():
from quart import g
return getattr(g, "ticket_types_content", "")
def _h_ticket_type_content():
from quart import g
return getattr(g, "ticket_type_content", "")
def _h_tickets_content():
from quart import g
return getattr(g, "tickets_content", "")
def _h_ticket_detail_content():
from quart import g
return getattr(g, "ticket_detail_content", "")
def _h_ticket_admin_content():
from quart import g
return getattr(g, "ticket_admin_content", "")
def _h_markets_content():
from quart import g
return getattr(g, "markets_content", "")

View File

@@ -1,89 +1,89 @@
;; Events pages — mounted on various nested blueprints ;; Events pages — auto-mounted with absolute paths
;; Calendar admin (mounted on calendar.admin bp) ;; Calendar admin
(defpage calendar-admin (defpage calendar-admin
:path "/" :path "/<slug>/<calendar_slug>/admin/"
:auth :admin :auth :admin
:layout :events-calendar-admin :layout :events-calendar-admin
:content (calendar-admin-content)) :content (calendar-admin-content calendar-slug))
;; Day admin (mounted on day.admin bp) ;; Day admin
(defpage day-admin (defpage day-admin
:path "/" :path "/<slug>/<calendar_slug>/day/<int:year>/<int:month>/<int:day>/admin/"
:auth :admin :auth :admin
:layout :events-day-admin :layout :events-day-admin
:content (day-admin-content)) :content (day-admin-content calendar-slug year month day))
;; Slots listing (mounted on slots bp) ;; Slots listing
(defpage slots-listing (defpage slots-listing
:path "/" :path "/<slug>/<calendar_slug>/slots/"
:auth :public :auth :public
:layout :events-slots :layout :events-slots
:content (slots-content)) :content (slots-content calendar-slug))
;; Slot detail (mounted on slot bp) ;; Slot detail
(defpage slot-detail (defpage slot-detail
:path "/" :path "/<slug>/<calendar_slug>/slots/<int:slot_id>/"
:auth :admin :auth :admin
:layout :events-slot :layout :events-slot
:content (slot-content)) :content (slot-content calendar-slug slot-id))
;; Entry detail (mounted on calendar_entry bp) ;; Entry detail
(defpage entry-detail (defpage entry-detail
:path "/" :path "/<slug>/<calendar_slug>/day/<int:year>/<int:month>/<int:day>/entries/<int:entry_id>/"
:auth :admin :auth :admin
:layout :events-entry :layout :events-entry
:content (entry-content) :content (entry-content calendar-slug entry-id)
:menu (entry-menu)) :menu (entry-menu calendar-slug entry-id))
;; Entry admin (mounted on calendar_entry.admin bp) ;; Entry admin
(defpage entry-admin (defpage entry-admin
:path "/" :path "/<slug>/<calendar_slug>/day/<int:year>/<int:month>/<int:day>/entries/<int:entry_id>/admin/"
:auth :admin :auth :admin
:layout :events-entry-admin :layout :events-entry-admin
:content (entry-admin-content) :content (entry-admin-content calendar-slug entry-id)
:menu (admin-menu)) :menu (admin-menu))
;; Ticket types listing (mounted on ticket_types bp) ;; Ticket types listing
(defpage ticket-types-listing (defpage ticket-types-listing
:path "/" :path "/<slug>/<calendar_slug>/day/<int:year>/<int:month>/<int:day>/entries/<int:entry_id>/ticket-types/"
:auth :public :auth :public
:layout :events-ticket-types :layout :events-ticket-types
:content (ticket-types-content) :content (ticket-types-content calendar-slug entry-id year month day)
:menu (admin-menu)) :menu (admin-menu))
;; Ticket type detail (mounted on ticket_type bp) ;; Ticket type detail
(defpage ticket-type-detail (defpage ticket-type-detail
:path "/" :path "/<slug>/<calendar_slug>/day/<int:year>/<int:month>/<int:day>/entries/<int:entry_id>/ticket-types/<int:ticket_type_id>/"
:auth :admin :auth :admin
:layout :events-ticket-type :layout :events-ticket-type
:content (ticket-type-content) :content (ticket-type-content calendar-slug entry-id ticket-type-id year month day)
:menu (admin-menu)) :menu (admin-menu))
;; My tickets (mounted on tickets bp) ;; My tickets
(defpage my-tickets (defpage my-tickets
:path "/" :path "/tickets/"
:auth :public :auth :public
:layout :root :layout :root
:content (tickets-content)) :content (tickets-content))
;; Ticket detail (mounted on tickets bp) ;; Ticket detail
(defpage ticket-detail (defpage ticket-detail
:path "/<code>/" :path "/tickets/<code>/"
:auth :public :auth :public
:layout :root :layout :root
:content (ticket-detail-content)) :content (ticket-detail-content code))
;; Ticket admin dashboard (mounted on ticket_admin bp) ;; Ticket admin dashboard
(defpage ticket-admin (defpage ticket-admin
:path "/" :path "/admin/tickets/"
:auth :admin :auth :admin
:layout :root :layout :root
:content (ticket-admin-content)) :content (ticket-admin-content))
;; Markets (mounted on markets bp) ;; Markets
(defpage events-markets (defpage events-markets
:path "/" :path "/<slug>/markets/"
:auth :public :auth :public
:layout :events-markets :layout :events-markets
:content (markets-content)) :content (markets-content))

View File

@@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import path_setup # noqa: F401 # adds shared/ to sys.path 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 pathlib import Path
from quart import g, request from quart import g, request
@@ -12,7 +11,6 @@ from shared.services.registry import services
from bp import ( from bp import (
register_identity_bp, register_identity_bp,
register_social_bp, register_social_bp,
register_fragments,
) )
@@ -84,7 +82,9 @@ def create_app() -> "Quart":
app.jinja_loader, 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 from sxc.pages import setup_federation_pages
setup_federation_pages() setup_federation_pages()
@@ -94,21 +94,24 @@ def create_app() -> "Quart":
app.register_blueprint(register_identity_bp()) app.register_blueprint(register_identity_bp())
social_bp = register_social_bp() social_bp = register_social_bp()
from shared.sx.pages import mount_pages
mount_pages(social_bp, "federation")
app.register_blueprint(social_bp) app.register_blueprint(social_bp)
app.register_blueprint(register_fragments()) from shared.sx.pages import auto_mount_pages
auto_mount_pages(app, "federation")
from shared.sx.handlers import auto_mount_fragment_handlers
auto_mount_fragment_handlers(app, "federation")
# --- home page --- # --- home page ---
@app.get("/") @app.get("/")
async def home(): async def home():
from quart import make_response from quart import make_response
from shared.sx.page import get_template_context 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() 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 await make_response(html)
return app return app

View File

@@ -1,3 +1,2 @@
from .identity.routes import register as register_identity_bp from .identity.routes import register as register_identity_bp
from .social.routes import register as register_social_bp from .social.routes import register as register_social_bp
from .fragments import register_fragments

View File

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

View File

@@ -1 +0,0 @@
from .routes import register as register_fragments

View File

@@ -1,36 +0,0 @@
"""Federation app fragment endpoints.
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
by other coop apps via the fragment client.
All handlers are defined declaratively in .sx files under
``federation/sx/handlers/`` and dispatched via the sx handler registry.
"""
from __future__ import annotations
from quart import Blueprint, Response, request
from shared.infrastructure.fragments import FRAGMENT_HEADER
from shared.sx.handlers import get_handler, execute_handler
def register():
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
@bp.before_request
async def _require_fragment_header():
if not request.headers.get(FRAGMENT_HEADER):
return Response("", status=403)
@bp.get("/<fragment_type>")
async def get_fragment(fragment_type: str):
handler_def = get_handler("federation", fragment_type)
if handler_def is not None:
result = await execute_handler(
handler_def, "federation", args=dict(request.args),
)
return Response(result, status=200, content_type="text/sx")
return Response("", status=200, content_type="text/sx")
return bp

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"): def register(url_prefix="/identity"):
bp = Blueprint("identity", __name__, url_prefix=url_prefix) bp = Blueprint("identity", __name__, url_prefix=url_prefix)
@@ -39,11 +66,7 @@ def register(url_prefix="/identity"):
if actor: if actor:
return redirect(url_for("activitypub.actor_profile", username=actor.preferred_username)) return redirect(url_for("activitypub.actor_profile", username=actor.preferred_username))
from shared.sx.page import get_template_context return await _render_choose_username(actor=actor)
from sx.sx_components import render_choose_username_page
ctx = await get_template_context()
ctx["actor"] = actor
return await render_choose_username_page(ctx)
@bp.post("/choose-username") @bp.post("/choose-username")
async def choose_username(): async def choose_username():
@@ -71,11 +94,7 @@ def register(url_prefix="/identity"):
error = "This username is already taken." error = "This username is already taken."
if error: if error:
from shared.sx.page import get_template_context return await _render_choose_username(error=error, username=username), 400
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
# Create ActorProfile with RSA keys # Create ActorProfile with RSA keys
display_name = g.user.name or username 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 quart import Blueprint, request, g, redirect, url_for, abort, Response
from shared.services.registry import services 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__) log = logging.getLogger(__name__)
@@ -32,102 +32,6 @@ def register(url_prefix="/social"):
actor = await services.federation.get_actor_by_user_id(g.s, g.user.id) actor = await services.federation.get_actor_by_user_id(g.s, g.user.id)
g._social_actor = actor g._social_actor = actor
@bp.before_request
async def _prepare_page_data():
"""Pre-render content for defpage routes."""
endpoint = request.endpoint or ""
if endpoint.endswith("defpage_home_timeline"):
actor = _require_actor()
items = await services.federation.get_home_timeline(g.s, actor.id)
from sx.sx_components import _timeline_content_sx
g.home_timeline_content = _timeline_content_sx(items, "home", actor)
elif endpoint.endswith("defpage_public_timeline"):
actor = getattr(g, "_social_actor", None)
items = await services.federation.get_public_timeline(g.s)
from sx.sx_components import _timeline_content_sx
g.public_timeline_content = _timeline_content_sx(items, "public", actor)
elif endpoint.endswith("defpage_compose_form"):
actor = _require_actor()
from sx.sx_components import _compose_content_sx
reply_to = request.args.get("reply_to")
g.compose_content = _compose_content_sx(actor, reply_to)
elif endpoint.endswith("defpage_search"):
actor = getattr(g, "_social_actor", None)
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}
from sx.sx_components import _search_content_sx
g.search_content = _search_content_sx(query, actors_list, total, 1, followed_urls, actor)
elif endpoint.endswith("defpage_following_list"):
actor = _require_actor()
actors_list, total = await services.federation.get_following(
g.s, actor.preferred_username,
)
from sx.sx_components import _following_content_sx
g.following_content = _following_content_sx(actors_list, total, actor)
elif endpoint.endswith("defpage_followers_list"):
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}
from sx.sx_components import _followers_content_sx
g.followers_content = _followers_content_sx(actors_list, total, followed_urls, actor)
elif endpoint.endswith("defpage_actor_timeline"):
actor = getattr(g, "_social_actor", None)
actor_id = request.view_args.get("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
from sx.sx_components import _actor_timeline_content_sx
g.actor_timeline_content = _actor_timeline_content_sx(remote_dto, items, is_following, actor)
elif endpoint.endswith("defpage_notifications"):
actor = _require_actor()
items = await services.federation.get_notifications(g.s, actor.id)
await services.federation.mark_notifications_read(g.s, actor.id)
from sx.sx_components import _notifications_content_sx
g.notifications_content = _notifications_content_sx(items)
# -- Timeline pagination --------------------------------------------------- # -- Timeline pagination ---------------------------------------------------
@bp.get("/timeline") @bp.get("/timeline")
@@ -143,8 +47,7 @@ def register(url_prefix="/social"):
items = await services.federation.get_home_timeline( items = await services.federation.get_home_timeline(
g.s, actor.id, before=before, 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) return sx_response(sx_src)
@bp.get("/public/timeline") @bp.get("/public/timeline")
@@ -158,8 +61,7 @@ def register(url_prefix="/social"):
pass pass
items = await services.federation.get_public_timeline(g.s, before=before) items = await services.federation.get_public_timeline(g.s, before=before)
actor = getattr(g, "_social_actor", None) 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) return sx_response(sx_src)
# -- Compose --------------------------------------------------------------- # -- Compose ---------------------------------------------------------------
@@ -170,7 +72,7 @@ def register(url_prefix="/social"):
form = await request.form form = await request.form
content = form.get("content", "").strip() content = form.get("content", "").strip()
if not content: if not content:
return redirect(url_for("social.defpage_compose_form")) return redirect(url_for("defpage_compose_form"))
visibility = form.get("visibility", "public") visibility = form.get("visibility", "public")
in_reply_to = form.get("in_reply_to") or None in_reply_to = form.get("in_reply_to") or None
@@ -181,18 +83,20 @@ def register(url_prefix="/social"):
visibility=visibility, visibility=visibility,
in_reply_to=in_reply_to, in_reply_to=in_reply_to,
) )
return redirect(url_for("social.defpage_home_timeline")) return redirect(url_for("defpage_home_timeline"))
@bp.post("/delete/<int:post_id>") @bp.post("/delete/<int:post_id>")
async def delete_post(post_id: int): async def delete_post(post_id: int):
actor = _require_actor() actor = _require_actor()
await services.federation.delete_local_post(g.s, actor.id, post_id) await services.federation.delete_local_post(g.s, actor.id, post_id)
return redirect(url_for("social.defpage_home_timeline")) return redirect(url_for("defpage_home_timeline"))
# -- Search + Follow ------------------------------------------------------- # -- Search + Follow -------------------------------------------------------
@bp.get("/search/page") @bp.get("/search/page")
async def search_page(): async def search_page():
from sxc.pages import _serialize_remote_actor, _serialize_actor
actor = getattr(g, "_social_actor", None) actor = getattr(g, "_social_actor", None)
query = request.args.get("q", "").strip() query = request.args.get("q", "").strip()
page = request.args.get("page", 1, type=int) page = request.args.get("page", 1, type=int)
@@ -208,8 +112,18 @@ def register(url_prefix="/social"):
g.s, actor.preferred_username, page=1, per_page=1000, g.s, actor.preferred_username, page=1, per_page=1000,
) )
followed_urls = {a.actor_url for a in following} 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) return sx_response(sx_src)
@bp.post("/follow") @bp.post("/follow")
@@ -223,7 +137,7 @@ def register(url_prefix="/social"):
) )
if request.headers.get("SX-Request") or request.headers.get("HX-Request"): if request.headers.get("SX-Request") or request.headers.get("HX-Request"):
return await _actor_card_response(actor, remote_actor_url, is_followed=True) return await _actor_card_response(actor, remote_actor_url, is_followed=True)
return redirect(request.referrer or url_for("social.defpage_search")) return redirect(request.referrer or url_for("defpage_search"))
@bp.post("/unfollow") @bp.post("/unfollow")
async def unfollow(): async def unfollow():
@@ -236,10 +150,12 @@ def register(url_prefix="/social"):
) )
if request.headers.get("SX-Request") or request.headers.get("HX-Request"): if request.headers.get("SX-Request") or request.headers.get("HX-Request"):
return await _actor_card_response(actor, remote_actor_url, is_followed=False) return await _actor_card_response(actor, remote_actor_url, is_followed=False)
return redirect(request.referrer or url_for("social.defpage_search")) return redirect(request.referrer or url_for("defpage_search"))
async def _actor_card_response(actor, remote_actor_url, is_followed): async def _actor_card_response(actor, remote_actor_url, is_followed):
"""Re-render a single actor card after follow/unfollow via HTMX.""" """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( remote_dto = await services.federation.get_or_fetch_remote_actor(
g.s, remote_actor_url, g.s, remote_actor_url,
) )
@@ -247,12 +163,12 @@ def register(url_prefix="/social"):
return Response("", status=200) return Response("", status=200)
followed_urls = {remote_actor_url} if is_followed else set() followed_urls = {remote_actor_url} if is_followed else set()
referer = request.referrer or "" referer = request.referrer or ""
if "/followers" in referer: list_type = "followers" if "/followers" in referer else "following"
list_type = "followers" actor_data = _serialize_actor(actor)
else: ad = _serialize_remote_actor(remote_dto)
list_type = "following" return sx_response(await render_to_sx("federation-actor-card-from-data",
from sx.sx_components import render_actor_card a=ad, actor=actor_data,
return sx_response(render_actor_card(remote_dto, actor, followed_urls, list_type=list_type)) followed_urls=list(followed_urls), list_type=list_type))
# -- Interactions ---------------------------------------------------------- # -- Interactions ----------------------------------------------------------
@@ -294,7 +210,9 @@ def register(url_prefix="/social"):
async def _interaction_buttons_response(actor, object_id, author_inbox): async def _interaction_buttons_response(actor, object_id, author_inbox):
"""Re-render interaction buttons after a like/boost action.""" """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 from sqlalchemy import select
svc = services.federation svc = services.federation
@@ -338,32 +256,72 @@ def register(url_prefix="/social"):
).limit(1) ).limit(1)
)).scalar()) )).scalar())
from sx.sx_components import render_interaction_buttons csrf = generate_csrf_token()
return sx_response(render_interaction_buttons( safe_id = object_id.replace("/", "_").replace(":", "_")
object_id=object_id, target = f"#interactions-{safe_id}"
author_inbox=author_inbox,
like_count=like_count, if liked_by_me:
boost_count=boost_count, like_action = url_for("social.unlike")
liked_by_me=liked_by_me, like_cls = "text-red-500 hover:text-red-600"
boosted_by_me=boosted_by_me, like_icon = "\u2665"
actor=actor, 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 -------------------------------------- # -- Following / Followers pagination --------------------------------------
@bp.get("/following/page") @bp.get("/following/page")
async def following_list_page(): async def following_list_page():
from sxc.pages import _serialize_remote_actor, _serialize_actor
actor = _require_actor() actor = _require_actor()
page = request.args.get("page", 1, type=int) page = request.args.get("page", 1, type=int)
actors_list, total = await services.federation.get_following( actors_list, total = await services.federation.get_following(
g.s, actor.preferred_username, page=page, g.s, actor.preferred_username, page=page,
) )
from sx.sx_components import render_following_items actor_dicts = [_serialize_remote_actor(a) for a in actors_list]
sx_src = await render_following_items(actors_list, page, actor) 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) return sx_response(sx_src)
@bp.get("/followers/page") @bp.get("/followers/page")
async def followers_list_page(): async def followers_list_page():
from sxc.pages import _serialize_remote_actor, _serialize_actor
actor = _require_actor() actor = _require_actor()
page = request.args.get("page", 1, type=int) page = request.args.get("page", 1, type=int)
actors_list, total = await services.federation.get_followers_paginated( actors_list, total = await services.federation.get_followers_paginated(
@@ -373,8 +331,17 @@ def register(url_prefix="/social"):
g.s, actor.preferred_username, page=1, per_page=1000, g.s, actor.preferred_username, page=1, per_page=1000,
) )
followed_urls = {a.actor_url for a in following} followed_urls = {a.actor_url for a in following}
from sx.sx_components import render_followers_items actor_dicts = [_serialize_remote_actor(a) for a in actors_list]
sx_src = await render_followers_items(actors_list, page, followed_urls, actor) 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) return sx_response(sx_src)
@bp.get("/actor/<int:id>/timeline") @bp.get("/actor/<int:id>/timeline")
@@ -390,8 +357,7 @@ def register(url_prefix="/social"):
items = await services.federation.get_actor_timeline( items = await services.federation.get_actor_timeline(
g.s, id, before=before, g.s, id, before=before,
) )
from sx.sx_components import render_actor_timeline_items sx_src = await _render_timeline_items(items, "actor", actor, id)
sx_src = await render_actor_timeline_items(items, id, actor)
return sx_response(sx_src) return sx_response(sx_src)
# -- Notifications --------------------------------------------------------- # -- Notifications ---------------------------------------------------------
@@ -414,6 +380,29 @@ def register(url_prefix="/social"):
async def mark_read(): async def mark_read():
actor = _require_actor() actor = _require_actor()
await services.federation.mark_notifications_read(g.s, actor.id) await services.federation.mark_notifications_read(g.s, actor.id)
return redirect(url_for("social.defpage_notifications")) return redirect(url_for("defpage_notifications"))
return bp 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 from shared.services.federation_impl import SqlFederationService
services.federation = 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

@@ -20,3 +20,50 @@
(defcomp ~federation-notifications-page (&key notifs) (defcomp ~federation-notifications-page (&key notifs)
(h1 :class "text-2xl font-bold mb-6" "Notifications") notifs) (h1 :class "text-2xl font-bold mb-6" "Notifications") notifs)
;; Assembled notification card — replaces Python _notification_sx
(defcomp ~federation-notification-from-data (&key notif)
(let* ((from-name (or (get notif "from_actor_name") "?"))
(from-username (or (get notif "from_actor_username") ""))
(from-domain (or (get notif "from_actor_domain") ""))
(from-icon (get notif "from_actor_icon"))
(ntype (or (get notif "notification_type") ""))
(preview (get notif "target_content_preview"))
(created (or (get notif "created_at_formatted") ""))
(read (get notif "read"))
(app-domain (or (get notif "app_domain") ""))
(border (if (not read) " border-l-4 border-l-stone-400" ""))
(initial (if (and (not from-icon) from-name)
(upper (slice from-name 0 1)) "?"))
(action-text (cond
((= ntype "follow") (str "followed you"
(if (and app-domain (!= app-domain "federation"))
(str " on " (escape app-domain)) "")))
((= ntype "like") "liked your post")
((= ntype "boost") "boosted your post")
((= ntype "mention") "mentioned you")
((= ntype "reply") "replied to your post")
(true ""))))
(~federation-notification-card
:cls (str "bg-white rounded-lg shadow-sm border border-stone-200 p-4" border)
:avatar (~avatar
:src from-icon
:cls (if from-icon "w-8 h-8 rounded-full"
"w-8 h-8 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xs")
:initial (when (not from-icon) initial))
:from-name (escape from-name)
:from-username (escape from-username)
:from-domain (if from-domain (str "@" (escape from-domain)) "")
:action-text action-text
:preview (when preview (~federation-notification-preview :preview (escape preview)))
:time created)))
;; Assembled notifications content — replaces Python _notifications_content_sx
(defcomp ~federation-notifications-content (&key notifications)
(~federation-notifications-page
:notifs (if (empty? notifications)
(~empty-state :message "No notifications yet." :cls "text-stone-500")
(~federation-notifications-list
:items (map (lambda (n)
(~federation-notification-from-data :notif n))
notifications)))))

View File

@@ -53,3 +53,40 @@
(defcomp ~federation-profile-summary-text (&key text) (defcomp ~federation-profile-summary-text (&key text)
(p :class "mt-2" text)) (p :class "mt-2" text))
;; Assembled actor timeline content — replaces Python _actor_timeline_content_sx
(defcomp ~federation-actor-timeline-content (&key remote-actor items is-following actor)
(let* ((display-name (or (get remote-actor "display_name") (get remote-actor "preferred_username") ""))
(icon-url (get remote-actor "icon_url"))
(summary (get remote-actor "summary"))
(actor-url (or (get remote-actor "actor_url") ""))
(csrf (csrf-token))
(initial (if (and (not icon-url) display-name)
(upper (slice display-name 0 1)) "?")))
(~federation-actor-timeline-layout
:header (~federation-actor-profile-header
:avatar (~avatar
:src icon-url
:cls (if icon-url "w-16 h-16 rounded-full"
"w-16 h-16 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xl")
:initial (when (not icon-url) initial))
:display-name (escape display-name)
:username (escape (or (get remote-actor "preferred_username") ""))
:domain (escape (or (get remote-actor "domain") ""))
:summary (when summary (~federation-profile-summary :summary summary))
:follow (when actor
(if is-following
(~federation-follow-form
:action (url-for "social.unfollow") :csrf csrf :actor-url actor-url
:label "Unfollow"
:cls "border border-stone-300 rounded px-4 py-2 hover:bg-stone-100")
(~federation-follow-form
:action (url-for "social.follow") :csrf csrf :actor-url actor-url
:label "Follow"
:cls "bg-stone-800 text-white rounded px-4 py-2 hover:bg-stone-700"))))
:timeline (~federation-timeline-items
:items items :timeline-type "actor" :actor actor
:next-url (when (not (empty? items))
(url-for "social.actor_timeline_page"
:id (get remote-actor "id")
:before (get (last items) "before_cursor")))))))

View File

@@ -60,3 +60,99 @@
(h1 :class "text-2xl font-bold mb-6" title " " (h1 :class "text-2xl font-bold mb-6" title " "
(span :class "text-stone-400 font-normal" count-str)) (span :class "text-stone-400 font-normal" count-str))
(div :id "actor-list" items)) (div :id "actor-list" items))
;; ---------------------------------------------------------------------------
;; Assembled actor card — replaces Python _actor_card_sx
;; ---------------------------------------------------------------------------
(defcomp ~federation-actor-card-from-data (&key a actor followed-urls list-type)
(let* ((display-name (or (get a "display_name") (get a "preferred_username") ""))
(username (or (get a "preferred_username") ""))
(domain (or (get a "domain") ""))
(icon-url (get a "icon_url"))
(actor-url (or (get a "actor_url") ""))
(summary (get a "summary"))
(aid (get a "id"))
(safe-id (replace (replace actor-url "/" "_") ":" "_"))
(initial (if (and (not icon-url) (or display-name username))
(upper (slice (or display-name username) 0 1)) "?"))
(csrf (csrf-token))
(is-followed (contains? (or followed-urls (list)) actor-url)))
(~federation-actor-card
:cls "bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-3 flex items-center gap-4"
:id (str "actor-" safe-id)
:avatar (~avatar
:src icon-url
:cls (if icon-url "w-12 h-12 rounded-full"
"w-12 h-12 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold")
:initial (when (not icon-url) initial))
:name (if (and (or (= list-type "following") (= list-type "search")) aid)
(~federation-actor-name-link
:href (url-for "social.defpage_actor_timeline" :id aid)
:name (escape display-name))
(~federation-actor-name-link-external
:href (str "https://" domain "/@" username)
:name (escape display-name)))
:username (escape username)
:domain (escape domain)
:summary (when summary (~federation-actor-summary :summary summary))
:button (when actor
(if (or (= list-type "following") is-followed)
(~federation-unfollow-button
:action (url-for "social.unfollow") :csrf csrf :actor-url actor-url)
(~federation-follow-button
:action (url-for "social.follow") :csrf csrf :actor-url actor-url
:label (if (= list-type "followers") "Follow Back" "Follow")))))))
;; Assembled search content — replaces Python _search_content_sx
(defcomp ~federation-search-content (&key query actors total followed-urls actor)
(~federation-search-page
:search-url (url-for "social.defpage_search")
:search-page-url (url-for "social.search_page")
:query (escape (or query ""))
:info (cond
((and query (> total 0))
(~federation-search-info
:cls "text-sm text-stone-500 mb-4"
:text (str total " result" (pluralize total) " for " (escape query))))
(query
(~federation-search-info
:cls "text-stone-500 mb-4"
:text (str "No results found for " (escape query))))
(true nil))
:results (when (not (empty? actors))
(<>
(map (lambda (a)
(~federation-actor-card-from-data
:a a :actor actor :followed-urls followed-urls :list-type "search"))
actors)
(when (>= (len actors) 20)
(~federation-scroll-sentinel
:url (url-for "social.search_page" :q query :page 2)))))))
;; Assembled following/followers content — replaces Python _following_content_sx etc.
(defcomp ~federation-following-content (&key actors total actor)
(~federation-actor-list-page
:title "Following" :count-str (str "(" total ")")
:items (when (not (empty? actors))
(<>
(map (lambda (a)
(~federation-actor-card-from-data
:a a :actor actor :followed-urls (list) :list-type "following"))
actors)
(when (>= (len actors) 20)
(~federation-scroll-sentinel
:url (url-for "social.following_list_page" :page 2)))))))
(defcomp ~federation-followers-content (&key actors total followed-urls actor)
(~federation-actor-list-page
:title "Followers" :count-str (str "(" total ")")
:items (when (not (empty? actors))
(<>
(map (lambda (a)
(~federation-actor-card-from-data
:a a :actor actor :followed-urls followed-urls :list-type "followers"))
actors)
(when (>= (len actors) 20)
(~federation-scroll-sentinel
:url (url-for "social.followers_list_page" :page 2)))))))

View File

@@ -110,3 +110,129 @@
(option :value "unlisted" "Unlisted") (option :value "unlisted" "Unlisted")
(option :value "followers" "Followers only")) (option :value "followers" "Followers only"))
(button :type "submit" :class "bg-stone-800 text-white px-6 py-2 rounded hover:bg-stone-700" "Publish")))) (button :type "submit" :class "bg-stone-800 text-white px-6 py-2 rounded hover:bg-stone-700" "Publish"))))
;; ---------------------------------------------------------------------------
;; Assembled social nav — replaces Python _social_nav_sx
;; ---------------------------------------------------------------------------
(defcomp ~federation-social-nav (&key actor)
(if (not actor)
(~federation-nav-choose-username :url (url-for "identity.choose_username_form"))
(let* ((rp (request-path))
(links (list
(dict :endpoint "social.defpage_home_timeline" :label "Timeline")
(dict :endpoint "social.defpage_public_timeline" :label "Public")
(dict :endpoint "social.defpage_compose_form" :label "Compose")
(dict :endpoint "social.defpage_following_list" :label "Following")
(dict :endpoint "social.defpage_followers_list" :label "Followers")
(dict :endpoint "social.defpage_search" :label "Search"))))
(~federation-nav-bar
:items (<>
(map (lambda (lnk)
(let* ((href (url-for (get lnk "endpoint")))
(bold (if (= rp href) " font-bold" "")))
(a :href href
:class (str "px-2 py-1 rounded hover:bg-stone-200" bold)
(get lnk "label"))))
links)
(let* ((notif-url (url-for "social.defpage_notifications"))
(notif-bold (if (= rp notif-url) " font-bold" "")))
(~federation-nav-notification-link
:href notif-url
:cls (str "px-2 py-1 rounded hover:bg-stone-200 relative" notif-bold)
:count-url (url-for "social.notification_count")))
(a :href (url-for "activitypub.actor_profile" :username (get actor "preferred_username"))
:class "px-2 py-1 rounded hover:bg-stone-200"
(str "@" (get actor "preferred_username"))))))))
;; ---------------------------------------------------------------------------
;; Assembled post card — replaces Python _post_card_sx
;; ---------------------------------------------------------------------------
(defcomp ~federation-post-card-from-data (&key item actor)
(let* ((boosted-by (get item "boosted_by"))
(actor-icon (get item "actor_icon"))
(actor-name (or (get item "actor_name") "?"))
(actor-username (or (get item "actor_username") ""))
(actor-domain (or (get item "actor_domain") ""))
(content (or (get item "content") ""))
(summary (get item "summary"))
(published (or (get item "published") ""))
(url (get item "url"))
(post-type (or (get item "post_type") ""))
(oid (or (get item "object_id") ""))
(safe-id (replace (replace oid "/" "_") ":" "_"))
(initial (if (and (not actor-icon) actor-name)
(upper (slice actor-name 0 1)) "?")))
(~federation-post-card
:boost (when boosted-by (~federation-boost-label :name (escape boosted-by)))
:avatar (~avatar
:src actor-icon
:cls (if actor-icon "w-10 h-10 rounded-full"
"w-10 h-10 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-sm")
:initial (when (not actor-icon) initial))
:actor-name (escape actor-name)
:actor-username (escape actor-username)
:domain (if actor-domain (str "@" (escape actor-domain)) "")
:time published
:content (if summary
(~federation-content :content content :summary (escape summary))
(~federation-content :content content))
:original (when (and url (= post-type "remote"))
(~federation-original-link :url url))
:interactions (when actor
(let* ((csrf (csrf-token))
(liked (get item "liked_by_me"))
(boosted-me (get item "boosted_by_me"))
(lcount (or (get item "like_count") 0))
(bcount (or (get item "boost_count") 0))
(ainbox (or (get item "author_inbox") ""))
(target (str "#interactions-" safe-id)))
(div :id (str "interactions-" safe-id)
(~federation-interaction-buttons
:like (~federation-like-form
:action (url-for (if liked "social.unlike" "social.like"))
:target target :oid oid :ainbox ainbox :csrf csrf
:cls (str "flex items-center gap-1 " (if liked "text-red-500 hover:text-red-600" "hover:text-red-500"))
:icon (if liked "\u2665" "\u2661") :count (str lcount))
:boost (~federation-boost-form
:action (url-for (if boosted-me "social.unboost" "social.boost"))
:target target :oid oid :ainbox ainbox :csrf csrf
:cls (str "flex items-center gap-1 " (if boosted-me "text-green-600 hover:text-green-700" "hover:text-green-600"))
:count (str bcount))
:reply (when oid
(~federation-reply-link
:url (url-for "social.defpage_compose_form" :reply-to oid))))))))))
;; ---------------------------------------------------------------------------
;; Assembled timeline items — replaces Python _timeline_items_sx
;; ---------------------------------------------------------------------------
(defcomp ~federation-timeline-items (&key items timeline-type actor next-url)
(<>
(map (lambda (item)
(~federation-post-card-from-data :item item :actor actor))
items)
(when next-url
(~federation-scroll-sentinel :url next-url))))
;; Assembled timeline content — replaces Python _timeline_content_sx
(defcomp ~federation-timeline-content (&key items timeline-type actor)
(let* ((label (if (= timeline-type "home") "Home" "Public")))
(~federation-timeline-page
:label label
:compose (when actor
(~federation-compose-button :url (url-for "social.defpage_compose_form")))
:timeline (~federation-timeline-items
:items items :timeline-type timeline-type :actor actor
:next-url (when (not (empty? items))
(url-for (str "social." timeline-type "_timeline_page")
:before (get (last items) "before_cursor")))))))
;; Assembled compose content — replaces Python _compose_content_sx
(defcomp ~federation-compose-content (&key reply-to)
(~federation-compose-form
:action (url-for "social.compose_submit")
:csrf (csrf-token)
:reply (when reply-to
(~federation-compose-reply :reply-to (escape reply-to)))))

View File

@@ -1,742 +0,0 @@
"""
Federation service s-expression page components.
Renders social timeline, compose, search, following/followers, notifications,
actor profiles, login, and username selection pages.
"""
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.parser import serialize
from shared.sx.helpers import (
sx_call, SxExpr,
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")
# ---------------------------------------------------------------------------
# Social header nav
# ---------------------------------------------------------------------------
def _social_nav_sx(actor: Any) -> str:
"""Build the social header nav bar content."""
from quart import url_for, request
if not actor:
choose_url = url_for("identity.choose_username_form")
return sx_call("federation-nav-choose-username", url=choose_url)
links = [
("social.defpage_home_timeline", "Timeline"),
("social.defpage_public_timeline", "Public"),
("social.defpage_compose_form", "Compose"),
("social.defpage_following_list", "Following"),
("social.defpage_followers_list", "Followers"),
("social.defpage_search", "Search"),
]
parts = []
for endpoint, label in links:
href = url_for(endpoint)
bold = " font-bold" if request.path == href else ""
cls = f"px-2 py-1 rounded hover:bg-stone-200{bold}"
parts.append(f'(a :href {serialize(href)} :class {serialize(cls)} {serialize(label)})')
# Notifications with live badge
notif_url = url_for("social.defpage_notifications")
notif_count_url = url_for("social.notification_count")
notif_bold = " font-bold" if request.path == notif_url else ""
parts.append(sx_call(
"federation-nav-notification-link",
href=notif_url,
cls=f"px-2 py-1 rounded hover:bg-stone-200 relative{notif_bold}",
count_url=notif_count_url,
))
# Profile link
profile_url = url_for("activitypub.actor_profile", username=actor.preferred_username)
parts.append(f'(a :href {serialize(profile_url)} :class "px-2 py-1 rounded hover:bg-stone-200" {serialize("@" + actor.preferred_username)})')
items_sx = "(<> " + " ".join(parts) + ")"
return sx_call("federation-nav-bar", items=SxExpr(items_sx))
def _social_header_sx(actor: Any) -> str:
"""Build the social section header row."""
nav_sx = _social_nav_sx(actor)
return sx_call("federation-social-header", nav=SxExpr(nav_sx))
def _social_page(ctx: dict, actor: Any, *, content: str,
title: str = "Rose Ash", meta_html: str = "") -> str:
"""Render a social page with header and content."""
hdr = root_header_sx(ctx)
social_hdr = _social_header_sx(actor)
child = header_child_sx(social_hdr)
header_rows = "(<> " + hdr + " " + child + ")"
return full_page_sx(ctx, header_rows=header_rows, content=content,
meta_html=meta_html or f'<title>{escape(title)}</title>')
# ---------------------------------------------------------------------------
# Post card
# ---------------------------------------------------------------------------
def _interaction_buttons_sx(item: Any, actor: Any) -> str:
"""Render like/boost/reply buttons for a post."""
from shared.browser.app.csrf import generate_csrf_token
from quart import url_for
oid = getattr(item, "object_id", "") or ""
ainbox = getattr(item, "author_inbox", "") or ""
lcount = getattr(item, "like_count", 0) or 0
bcount = getattr(item, "boost_count", 0) or 0
liked = getattr(item, "liked_by_me", False)
boosted = getattr(item, "boosted_by_me", False)
csrf = generate_csrf_token()
safe_id = oid.replace("/", "_").replace(":", "_")
target = f"#interactions-{safe_id}"
if liked:
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:
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=oid) if oid else ""
reply_sx = sx_call("federation-reply-link", url=reply_url) if reply_url else ""
like_form = sx_call(
"federation-like-form",
action=like_action, target=target, oid=oid, ainbox=ainbox,
csrf=csrf, cls=f"flex items-center gap-1 {like_cls}",
icon=like_icon, count=str(lcount),
)
boost_form = sx_call(
"federation-boost-form",
action=boost_action, target=target, oid=oid, ainbox=ainbox,
csrf=csrf, cls=f"flex items-center gap-1 {boost_cls}",
count=str(bcount),
)
return sx_call(
"federation-interaction-buttons",
like=SxExpr(like_form),
boost=SxExpr(boost_form),
reply=SxExpr(reply_sx) if reply_sx else None,
)
def _post_card_sx(item: Any, actor: Any) -> str:
"""Render a single timeline post card."""
boosted_by = getattr(item, "boosted_by", None)
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 = getattr(item, "published", None)
url = getattr(item, "url", None)
post_type = getattr(item, "post_type", "")
boost_sx = sx_call(
"federation-boost-label", name=str(escape(boosted_by)),
) if boosted_by else ""
initial = actor_name[0].upper() if (not actor_icon and actor_name) else "?"
avatar = sx_call(
"avatar", src=actor_icon or None,
cls="w-10 h-10 rounded-full" if actor_icon else "w-10 h-10 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-sm",
initial=None if actor_icon else initial,
)
domain_str = f"@{escape(actor_domain)}" if actor_domain else ""
time_str = published.strftime("%b %d, %H:%M") if published else ""
if summary:
content_sx = sx_call(
"federation-content",
content=content, summary=str(escape(summary)),
)
else:
content_sx = sx_call("federation-content", content=content)
original_sx = ""
if url and post_type == "remote":
original_sx = sx_call("federation-original-link", url=url)
interactions_sx = ""
if actor:
oid = getattr(item, "object_id", "") or ""
safe_id = oid.replace("/", "_").replace(":", "_")
interactions_sx = f'(div :id {serialize(f"interactions-{safe_id}")} {_interaction_buttons_sx(item, actor)})'
return sx_call(
"federation-post-card",
boost=SxExpr(boost_sx) if boost_sx else None,
avatar=SxExpr(avatar),
actor_name=str(escape(actor_name)),
actor_username=str(escape(actor_username)),
domain=domain_str, time=time_str,
content=SxExpr(content_sx),
original=SxExpr(original_sx) if original_sx else None,
interactions=SxExpr(interactions_sx) if interactions_sx else None,
)
# ---------------------------------------------------------------------------
# Timeline items (pagination fragment)
# ---------------------------------------------------------------------------
def _timeline_items_sx(items: list, timeline_type: str, actor: Any,
actor_id: int | None = None) -> str:
"""Render timeline items with infinite scroll sentinel."""
from quart import url_for
parts = [_post_card_sx(item, actor) for item in items]
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)
parts.append(sx_call("federation-scroll-sentinel", url=next_url))
return "(<> " + " ".join(parts) + ")" if parts else ""
# ---------------------------------------------------------------------------
# Search results (pagination fragment)
# ---------------------------------------------------------------------------
def _actor_card_sx(a: Any, actor: Any, followed_urls: set,
*, list_type: str = "search") -> str:
"""Render a single actor card with follow/unfollow button."""
from shared.browser.app.csrf import generate_csrf_token
from quart import url_for
csrf = generate_csrf_token()
display_name = getattr(a, "display_name", None) or getattr(a, "preferred_username", "")
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)
aid = getattr(a, "id", None)
safe_id = actor_url.replace("/", "_").replace(":", "_")
initial = (display_name or username)[0].upper() if (not icon_url and (display_name or username)) else "?"
avatar = sx_call(
"avatar", src=icon_url or None,
cls="w-12 h-12 rounded-full" if icon_url else "w-12 h-12 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold",
initial=None if icon_url else initial,
)
# Name link
if (list_type in ("following", "search")) and aid:
name_sx = sx_call(
"federation-actor-name-link",
href=url_for("social.defpage_actor_timeline", id=aid),
name=str(escape(display_name)),
)
else:
name_sx = sx_call(
"federation-actor-name-link-external",
href=f"https://{domain}/@{username}",
name=str(escape(display_name)),
)
summary_sx = sx_call("federation-actor-summary", summary=summary) if summary else ""
# Follow/unfollow button
button_sx = ""
if actor:
is_followed = actor_url in (followed_urls or set())
if list_type == "following" or is_followed:
button_sx = sx_call(
"federation-unfollow-button",
action=url_for("social.unfollow"), csrf=csrf, actor_url=actor_url,
)
else:
label = "Follow Back" if list_type == "followers" else "Follow"
button_sx = sx_call(
"federation-follow-button",
action=url_for("social.follow"), csrf=csrf, actor_url=actor_url, label=label,
)
return sx_call(
"federation-actor-card",
cls="bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-3 flex items-center gap-4",
id=f"actor-{safe_id}",
avatar=SxExpr(avatar),
name=SxExpr(name_sx),
username=str(escape(username)), domain=str(escape(domain)),
summary=SxExpr(summary_sx) if summary_sx else None,
button=SxExpr(button_sx) if button_sx else None,
)
def _search_results_sx(actors: list, query: str, page: int,
followed_urls: set, actor: Any) -> str:
"""Render search results with pagination sentinel."""
from quart import url_for
parts = [_actor_card_sx(a, actor, followed_urls, list_type="search") for a in actors]
if len(actors) >= 20:
next_url = url_for("social.search_page", q=query, page=page + 1)
parts.append(sx_call("federation-scroll-sentinel", url=next_url))
return "(<> " + " ".join(parts) + ")" if parts else ""
def _actor_list_items_sx(actors: list, page: int, list_type: str,
followed_urls: set, actor: Any) -> str:
"""Render actor list items (following/followers) with pagination sentinel."""
from quart import url_for
parts = [_actor_card_sx(a, actor, followed_urls, list_type=list_type) for a in actors]
if len(actors) >= 20:
next_url = url_for(f"social.{list_type}_list_page", page=page + 1)
parts.append(sx_call("federation-scroll-sentinel", url=next_url))
return "(<> " + " ".join(parts) + ")" if parts else ""
# ---------------------------------------------------------------------------
# Notification card
# ---------------------------------------------------------------------------
def _notification_sx(notif: Any) -> str:
"""Render a single notification."""
from_name = getattr(notif, "from_actor_name", "?")
from_username = getattr(notif, "from_actor_username", "")
from_domain = getattr(notif, "from_actor_domain", "")
from_icon = getattr(notif, "from_actor_icon", None)
ntype = getattr(notif, "notification_type", "")
preview = getattr(notif, "target_content_preview", None)
created = getattr(notif, "created_at", None)
read = getattr(notif, "read", True)
app_domain = getattr(notif, "app_domain", "")
border = " border-l-4 border-l-stone-400" if not read else ""
initial = from_name[0].upper() if (not from_icon and from_name) else "?"
avatar = sx_call(
"avatar", src=from_icon or None,
cls="w-8 h-8 rounded-full" if from_icon else "w-8 h-8 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xs",
initial=None if from_icon else initial,
)
domain_str = f"@{escape(from_domain)}" if from_domain else ""
type_map = {
"follow": "followed you",
"like": "liked your post",
"boost": "boosted your post",
"mention": "mentioned you",
"reply": "replied to your post",
}
action = type_map.get(ntype, "")
if ntype == "follow" and app_domain and app_domain != "federation":
action += f" on {escape(app_domain)}"
preview_sx = sx_call(
"federation-notification-preview", preview=str(escape(preview)),
) if preview else ""
time_str = created.strftime("%b %d, %H:%M") if created else ""
return sx_call(
"federation-notification-card",
cls=f"bg-white rounded-lg shadow-sm border border-stone-200 p-4{border}",
avatar=SxExpr(avatar),
from_name=str(escape(from_name)),
from_username=str(escape(from_username)),
from_domain=domain_str, action_text=action,
preview=SxExpr(preview_sx) if preview_sx else None,
time=time_str,
)
# ---------------------------------------------------------------------------
# Public API: Home page
# ---------------------------------------------------------------------------
async def render_federation_home(ctx: dict) -> str:
"""Full page: federation home (minimal)."""
hdr = root_header_sx(ctx)
return full_page_sx(ctx, header_rows=hdr)
# ---------------------------------------------------------------------------
# Public API: Login
# ---------------------------------------------------------------------------
async def render_login_page(ctx: dict) -> str:
"""Full page: federation login form."""
from shared.browser.app.csrf import generate_csrf_token
from quart import url_for
error = ctx.get("error", "")
email = ctx.get("email", "")
action = url_for("auth.start_login")
csrf = generate_csrf_token()
error_sx = sx_call("auth-error-banner", error=error) if error else ""
content = sx_call(
"auth-login-form",
error=SxExpr(error_sx) if error_sx else None,
action=action, csrf_token=csrf,
email=str(escape(email)),
)
return _social_page(ctx, None, content=content,
title="Login \u2014 Rose Ash")
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")
error_sx = sx_call(
"auth-check-email-error", error=str(escape(email_error)),
) if email_error else ""
content = sx_call(
"auth-check-email",
email=str(escape(email)),
error=SxExpr(error_sx) if error_sx else None,
)
return _social_page(ctx, None, content=content,
title="Check your email \u2014 Rose Ash")
# ---------------------------------------------------------------------------
# Content builders (used by defpage before_request)
# ---------------------------------------------------------------------------
def _timeline_content_sx(items: list, timeline_type: str, actor: Any) -> str:
"""Build timeline content SX string."""
from quart import url_for
label = "Home" if timeline_type == "home" else "Public"
compose_sx = ""
if actor:
compose_url = url_for("social.defpage_compose_form")
compose_sx = sx_call("federation-compose-button", url=compose_url)
timeline_sx = _timeline_items_sx(items, timeline_type, actor)
return sx_call(
"federation-timeline-page",
label=label,
compose=SxExpr(compose_sx) if compose_sx else None,
timeline=SxExpr(timeline_sx) if timeline_sx else None,
)
async def render_timeline_items(items: list, timeline_type: str,
actor: Any, actor_id: int | None = None) -> str:
"""Pagination fragment: timeline items."""
return _timeline_items_sx(items, timeline_type, actor, actor_id)
def _compose_content_sx(actor: Any, reply_to: str | None) -> str:
"""Build compose form content SX string."""
from shared.browser.app.csrf import generate_csrf_token
from quart import url_for
csrf = generate_csrf_token()
action = url_for("social.compose_submit")
reply_sx = ""
if reply_to:
reply_sx = sx_call(
"federation-compose-reply",
reply_to=str(escape(reply_to)),
)
return sx_call(
"federation-compose-form",
action=action, csrf=csrf,
reply=SxExpr(reply_sx) if reply_sx else None,
)
def _search_content_sx(query: str, actors: list, total: int,
page: int, followed_urls: set, actor: Any) -> str:
"""Build search page content SX string."""
from quart import url_for
search_url = url_for("social.defpage_search")
search_page_url = url_for("social.search_page")
results_sx = _search_results_sx(actors, query, page, followed_urls, actor)
info_sx = ""
if query and total:
s = "s" if total != 1 else ""
info_sx = sx_call(
"federation-search-info",
cls="text-sm text-stone-500 mb-4",
text=f"{total} result{s} for <strong>{escape(query)}</strong>",
)
elif query:
info_sx = sx_call(
"federation-search-info",
cls="text-stone-500 mb-4",
text=f"No results found for <strong>{escape(query)}</strong>",
)
return sx_call(
"federation-search-page",
search_url=search_url, search_page_url=search_page_url,
query=str(escape(query)),
info=SxExpr(info_sx) if info_sx else None,
results=SxExpr(results_sx) if results_sx else None,
)
async def render_search_results(actors: list, query: str, page: int,
followed_urls: set, actor: Any) -> str:
"""Pagination fragment: search results."""
return _search_results_sx(actors, query, page, followed_urls, actor)
def _following_content_sx(actors: list, total: int, actor: Any) -> str:
"""Build following list content SX string."""
items_sx = _actor_list_items_sx(actors, 1, "following", set(), actor)
return sx_call(
"federation-actor-list-page",
title="Following", count_str=f"({total})",
items=SxExpr(items_sx) if items_sx else None,
)
async def render_following_items(actors: list, page: int, actor: Any) -> str:
"""Pagination fragment: following items."""
return _actor_list_items_sx(actors, page, "following", set(), actor)
def _followers_content_sx(actors: list, total: int,
followed_urls: set, actor: Any) -> str:
"""Build followers list content SX string."""
items_sx = _actor_list_items_sx(actors, 1, "followers", followed_urls, actor)
return sx_call(
"federation-actor-list-page",
title="Followers", count_str=f"({total})",
items=SxExpr(items_sx) if items_sx else None,
)
async def render_followers_items(actors: list, page: int,
followed_urls: set, actor: Any) -> str:
"""Pagination fragment: followers items."""
return _actor_list_items_sx(actors, page, "followers", followed_urls, actor)
def _actor_timeline_content_sx(remote_actor: Any, items: list,
is_following: bool, actor: Any) -> str:
"""Build actor timeline content SX string."""
from shared.browser.app.csrf import generate_csrf_token
from quart import url_for
csrf = generate_csrf_token()
display_name = remote_actor.display_name or remote_actor.preferred_username
icon_url = getattr(remote_actor, "icon_url", None)
summary = getattr(remote_actor, "summary", None)
actor_url = getattr(remote_actor, "actor_url", "")
initial = display_name[0].upper() if (not icon_url and display_name) else "?"
avatar = sx_call(
"avatar", src=icon_url or None,
cls="w-16 h-16 rounded-full" if icon_url else "w-16 h-16 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xl",
initial=None if icon_url else initial,
)
summary_sx = sx_call("federation-profile-summary", summary=summary) if summary else ""
follow_sx = ""
if actor:
if is_following:
follow_sx = sx_call(
"federation-follow-form",
action=url_for("social.unfollow"), csrf=csrf, actor_url=actor_url,
label="Unfollow",
cls="border border-stone-300 rounded px-4 py-2 hover:bg-stone-100",
)
else:
follow_sx = sx_call(
"federation-follow-form",
action=url_for("social.follow"), csrf=csrf, actor_url=actor_url,
label="Follow",
cls="bg-stone-800 text-white rounded px-4 py-2 hover:bg-stone-700",
)
timeline_sx = _timeline_items_sx(items, "actor", actor, remote_actor.id)
header_sx = sx_call(
"federation-actor-profile-header",
avatar=SxExpr(avatar),
display_name=str(escape(display_name)),
username=str(escape(remote_actor.preferred_username)),
domain=str(escape(remote_actor.domain)),
summary=SxExpr(summary_sx) if summary_sx else None,
follow=SxExpr(follow_sx) if follow_sx else None,
)
return sx_call(
"federation-actor-timeline-layout",
header=SxExpr(header_sx),
timeline=SxExpr(timeline_sx) if timeline_sx else None,
)
async def render_actor_timeline_items(items: list, actor_id: int,
actor: Any) -> str:
"""Pagination fragment: actor timeline items."""
return _timeline_items_sx(items, "actor", actor, actor_id)
def _notifications_content_sx(notifications: list) -> str:
"""Build notifications content SX string."""
if not notifications:
notif_sx = sx_call("empty-state", message="No notifications yet.",
cls="text-stone-500")
else:
items_sx = "(<> " + " ".join(_notification_sx(n) for n in notifications) + ")"
notif_sx = sx_call(
"federation-notifications-list",
items=SxExpr(items_sx),
)
return sx_call("federation-notifications-page", notifs=SxExpr(notif_sx))
# ---------------------------------------------------------------------------
# Public API: Choose username
# ---------------------------------------------------------------------------
async def render_choose_username_page(ctx: dict) -> str:
"""Full page: choose username form."""
from shared.browser.app.csrf import generate_csrf_token
from quart import url_for
from shared.config import config
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 = sx_call("auth-error-banner", error=error) if error else ""
content = sx_call(
"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 _social_page(ctx, actor, content=content,
title="Choose Username \u2014 Rose Ash")
# ---------------------------------------------------------------------------
# Public API: Actor profile
# ---------------------------------------------------------------------------
async def render_profile_page(ctx: dict, actor: Any, activities: list,
total: int) -> str:
"""Full page: actor profile."""
from shared.config import config
ap_domain = config().get("ap_domain", "rose-ash.com")
display_name = actor.display_name or actor.preferred_username
summary_sx = sx_call(
"federation-profile-summary-text", text=str(escape(actor.summary)),
) if actor.summary else ""
activities_sx = ""
if activities:
parts = []
for a in activities:
published = a.published.strftime("%Y-%m-%d %H:%M") if a.published else ""
obj_type_sx = sx_call(
"federation-activity-obj-type", obj_type=a.object_type,
) if a.object_type else ""
parts.append(sx_call(
"federation-activity-card",
activity_type=a.activity_type, published=published,
obj_type=SxExpr(obj_type_sx) if obj_type_sx else None,
))
items_sx = "(<> " + " ".join(parts) + ")"
activities_sx = sx_call("federation-activities-list", items=SxExpr(items_sx))
else:
activities_sx = sx_call("federation-activities-empty")
content = sx_call(
"federation-profile-page",
display_name=str(escape(display_name)),
username=str(escape(actor.preferred_username)),
domain=str(escape(ap_domain)),
summary=SxExpr(summary_sx) if summary_sx else None,
activities_heading=f"Activities ({total})",
activities=SxExpr(activities_sx),
)
return _social_page(ctx, actor, content=content,
title=f"@{actor.preferred_username} \u2014 Rose Ash")
# ---------------------------------------------------------------------------
# Public API: POST handler fragment renderers
# ---------------------------------------------------------------------------
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 HTMX POST response."""
from types import SimpleNamespace
item = SimpleNamespace(
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,
)
return _interaction_buttons_sx(item, actor)
def render_actor_card(actor_dto: Any, actor: Any, followed_urls: set,
*, list_type: str = "following") -> str:
"""Render a single actor card fragment for HTMX POST response."""
return _actor_card_sx(actor_dto, actor, 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 __future__ import annotations
from typing import Any from typing import Any
def setup_federation_pages() -> None: 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_layouts()
_register_federation_helpers()
_load_federation_page_files() _load_federation_page_files()
@@ -26,84 +25,83 @@ def _register_federation_layouts() -> None:
register_custom_layout("social", _social_full, _social_oob) register_custom_layout("social", _social_full, _social_oob)
def _social_full(ctx: dict, **kw: Any) -> str: async def _social_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, header_child_sx from shared.sx.helpers import root_header_sx, header_child_sx, render_to_sx
from sx.sx_components import _social_header_sx from shared.sx.parser import SxExpr
actor = ctx.get("actor") actor = ctx.get("actor")
root_hdr = root_header_sx(ctx) actor_data = _serialize_actor(actor) if actor else None
social_hdr = _social_header_sx(actor) nav = await render_to_sx("federation-social-nav", actor=actor_data)
child = header_child_sx(social_hdr) social_hdr = await render_to_sx("federation-social-header", nav=SxExpr(nav))
root_hdr = await root_header_sx(ctx)
child = await header_child_sx(social_hdr)
return "(<> " + root_hdr + " " + child + ")" return "(<> " + root_hdr + " " + child + ")"
def _social_oob(ctx: dict, **kw: Any) -> str: async def _social_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, sx_call, SxExpr from shared.sx.helpers import root_header_sx, render_to_sx
from sx.sx_components import _social_header_sx from shared.sx.parser import SxExpr
actor = ctx.get("actor") actor = ctx.get("actor")
social_hdr = _social_header_sx(actor) actor_data = _serialize_actor(actor) if actor else None
child_oob = sx_call("oob-header-sx", nav = await render_to_sx("federation-social-nav", actor=actor_data)
social_hdr = await render_to_sx("federation-social-header", nav=SxExpr(nav))
child_oob = await render_to_sx("oob-header-sx",
parent_id="root-header-child", parent_id="root-header-child",
row=SxExpr(social_hdr)) row=SxExpr(social_hdr))
root_hdr_oob = root_header_sx(ctx, oob=True) root_hdr_oob = await root_header_sx(ctx, oob=True)
return "(<> " + child_oob + " " + root_hdr_oob + ")" return "(<> " + child_oob + " " + root_hdr_oob + ")"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Page helpers # Serializers and helpers — still used by layouts and route handlers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _register_federation_helpers() -> None: def _serialize_actor(actor) -> dict | None:
from shared.sx.pages import register_page_helpers """Serialize an actor profile to a dict for sx defcomps."""
from services.federation_page import _serialize_actor as _impl
register_page_helpers("federation", { return _impl(actor)
"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 _h_home_timeline_content(): def _serialize_timeline_item(item) -> dict:
"""Serialize a timeline item DTO to a dict for sx defcomps."""
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."""
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():
"""Return current user's actor or None."""
from quart import g from quart import g
return getattr(g, "home_timeline_content", "") return getattr(g, "_social_actor", None)
def _h_public_timeline_content(): def _require_actor():
from quart import g """Return current user's actor or abort 403."""
return getattr(g, "public_timeline_content", "") from quart import abort
actor = _get_actor()
if not actor:
def _h_compose_content(): abort(403, "You need to choose a federation username first")
from quart import g return actor
return getattr(g, "compose_content", "")
def _h_search_content():
from quart import g
return getattr(g, "search_content", "")
def _h_following_content():
from quart import g
return getattr(g, "following_content", "")
def _h_followers_content():
from quart import g
return getattr(g, "followers_content", "")
def _h_actor_timeline_content():
from quart import g
return getattr(g, "actor_timeline_content", "")
def _h_notifications_content():
from quart import g
return getattr(g, "notifications_content", "")

View File

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

View File

@@ -1,32 +0,0 @@
{% extends "_types/social/index.html" %}
{% block title %}@{{ actor.preferred_username }} — Rose Ash{% endblock %}
{% block social_content %}
<div class="py-8">
<div class="bg-white rounded-lg shadow p-6 mb-6">
<h1 class="text-2xl font-bold">{{ actor.display_name or actor.preferred_username }}</h1>
<p class="text-stone-500">@{{ actor.preferred_username }}@{{ config.get('ap_domain', 'rose-ash.com') }}</p>
{% if actor.summary %}
<p class="mt-2">{{ actor.summary }}</p>
{% endif %}
</div>
<h2 class="text-xl font-bold mb-4">Activities ({{ total }})</h2>
{% if activities %}
<div class="space-y-4">
{% for a in activities %}
<div class="bg-white rounded-lg shadow p-4">
<div class="flex justify-between items-start">
<span class="font-medium">{{ a.activity_type }}</span>
<span class="text-sm text-stone-400">{{ a.published.strftime('%Y-%m-%d %H:%M') if a.published }}</span>
</div>
{% if a.object_type %}
<span class="text-sm text-stone-500">{{ a.object_type }}</span>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<p class="text-stone-500">No activities yet.</p>
{% endif %}
</div>
{% endblock %}

View File

@@ -31,7 +31,7 @@ def register() -> Blueprint:
async def _is_liked(): async def _is_liked():
"""Check if a user has liked a specific target.""" """Check if a user has liked a specific target."""
from sqlalchemy import select from sqlalchemy import select
from likes.models.like import Like from models.like import Like
user_id = request.args.get("user_id", type=int) user_id = request.args.get("user_id", type=int)
target_type = request.args.get("target_type", "") target_type = request.args.get("target_type", "")
@@ -62,7 +62,7 @@ def register() -> Blueprint:
async def _liked_slugs(): async def _liked_slugs():
"""Return all liked target_slugs for a user + target_type.""" """Return all liked target_slugs for a user + target_type."""
from sqlalchemy import select from sqlalchemy import select
from likes.models.like import Like from models.like import Like
user_id = request.args.get("user_id", type=int) user_id = request.args.get("user_id", type=int)
target_type = request.args.get("target_type", "") target_type = request.args.get("target_type", "")
@@ -86,7 +86,7 @@ def register() -> Blueprint:
async def _liked_ids(): async def _liked_ids():
"""Return all liked target_ids for a user + target_type.""" """Return all liked target_ids for a user + target_type."""
from sqlalchemy import select from sqlalchemy import select
from likes.models.like import Like from models.like import Like
user_id = request.args.get("user_id", type=int) user_id = request.args.get("user_id", type=int)
target_type = request.args.get("target_type", "") target_type = request.args.get("target_type", "")

View File

@@ -11,7 +11,7 @@ from sqlalchemy import select
from shared.infrastructure.factory import create_base_app from shared.infrastructure.factory import create_base_app
from shared.config import config from shared.config import config
from bp import register_market_bp, register_all_markets, register_page_markets, register_page_admin, register_fragments, register_actions, register_data from bp import register_market_bp, register_all_markets, register_page_markets, register_page_admin, register_actions, register_data
async def market_context() -> dict: async def market_context() -> dict:
@@ -103,21 +103,16 @@ def create_app() -> "Quart":
from sxc.pages import setup_market_pages from sxc.pages import setup_market_pages
setup_market_pages() setup_market_pages()
from shared.sx.pages import mount_pages
# All markets: / — global view across all pages # All markets: / — global view across all pages
all_markets_bp = register_all_markets() all_markets_bp = register_all_markets()
mount_pages(all_markets_bp, "market", names=["all-markets-index"])
app.register_blueprint(all_markets_bp, url_prefix="/") app.register_blueprint(all_markets_bp, url_prefix="/")
# Page markets: /<slug>/ — markets for a single page # Page markets: /<slug>/ — markets for a single page
page_markets_bp = register_page_markets() page_markets_bp = register_page_markets()
mount_pages(page_markets_bp, "market", names=["page-markets-index"])
app.register_blueprint(page_markets_bp, url_prefix="/<slug>") app.register_blueprint(page_markets_bp, url_prefix="/<slug>")
# Page admin: /<slug>/admin/ — post-level admin for markets # Page admin: /<slug>/admin/ — post-level admin for markets
page_admin_bp = register_page_admin() page_admin_bp = register_page_admin()
mount_pages(page_admin_bp, "market", names=["page-admin"])
app.register_blueprint(page_admin_bp, url_prefix="/<slug>/admin") app.register_blueprint(page_admin_bp, url_prefix="/<slug>/admin")
# Market blueprint nested under post slug: /<page_slug>/<market_slug>/ # Market blueprint nested under post slug: /<page_slug>/<market_slug>/
@@ -131,10 +126,16 @@ def create_app() -> "Quart":
url_prefix="/<page_slug>/<market_slug>", url_prefix="/<page_slug>/<market_slug>",
) )
app.register_blueprint(register_fragments()) from shared.sx.handlers import auto_mount_fragment_handlers
auto_mount_fragment_handlers(app, "market")
app.register_blueprint(register_actions()) app.register_blueprint(register_actions())
app.register_blueprint(register_data()) app.register_blueprint(register_data())
# Auto-mount all defpages with absolute paths
from shared.sx.pages import auto_mount_pages
auto_mount_pages(app, "market")
# --- Auto-inject slugs into url_for() calls --- # --- Auto-inject slugs into url_for() calls ---
@app.url_value_preprocessor @app.url_value_preprocessor
def pull_slugs(endpoint, values): def pull_slugs(endpoint, values):

View File

@@ -3,6 +3,5 @@ from .product.routes import register as register_product
from .all_markets.routes import register as register_all_markets from .all_markets.routes import register as register_all_markets
from .page_markets.routes import register as register_page_markets from .page_markets.routes import register as register_page_markets
from .page_admin.routes import register as register_page_admin from .page_admin.routes import register as register_page_admin
from .fragments import register_fragments
from .actions import register_actions from .actions import register_actions
from .data import register_data from .data import register_data

View File

@@ -41,19 +41,6 @@ async def _load_markets(page, per_page=20):
def register() -> Blueprint: def register() -> Blueprint:
bp = Blueprint("all_markets", __name__) bp = Blueprint("all_markets", __name__)
@bp.before_request
async def _prepare_page_data():
"""Load all-markets data for defpage routes."""
endpoint = request.endpoint or ""
if not endpoint.endswith("defpage_all_markets_index"):
return
page = int(request.args.get("page", 1))
markets, has_more, page_info = await _load_markets(page)
g.all_markets_data = {
"markets": markets, "has_more": has_more,
"page_info": page_info, "page": page,
}
@bp.get("/all-markets") @bp.get("/all-markets")
async def markets_fragment(): async def markets_fragment():
page = int(request.args.get("page", 1)) page = int(request.args.get("page", 1))

View File

@@ -29,10 +29,6 @@ def register():
register_product(), register_product(),
) )
# Mount defpage for market home (GET /)
from shared.sx.pages import mount_pages
mount_pages(browse_bp, "market", names=["market-home"])
@browse_bp.get("/all/") @browse_bp.get("/all/")
@cache_page(tag="browse") @cache_page(tag="browse")
async def browse_all(): async def browse_all():

View File

@@ -1 +0,0 @@
from .routes import register as register_fragments

View File

@@ -1,36 +0,0 @@
"""Market app fragment endpoints.
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
by other coop apps via the fragment client.
All handlers are defined declaratively in .sx files under
``market/sx/handlers/`` and dispatched via the sx handler registry.
"""
from __future__ import annotations
from quart import Blueprint, Response, request
from shared.infrastructure.fragments import FRAGMENT_HEADER
from shared.sx.handlers import get_handler, execute_handler
def register():
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
@bp.before_request
async def _require_fragment_header():
if not request.headers.get(FRAGMENT_HEADER):
return Response("", status=403)
@bp.get("/<fragment_type>")
async def get_fragment(fragment_type: str):
handler_def = get_handler("market", fragment_type)
if handler_def is not None:
result = await execute_handler(
handler_def, "market", args=dict(request.args),
)
return Response(result, status=200, content_type="text/sx")
return Response("", status=200, content_type="text/sx")
return bp

View File

@@ -5,9 +5,4 @@ from quart import Blueprint
def register(): def register():
bp = Blueprint("admin", __name__, url_prefix='/admin') bp = Blueprint("admin", __name__, url_prefix='/admin')
# Mount defpage for market admin (GET /)
from shared.sx.pages import mount_pages
mount_pages(bp, "market", names=["market-admin"])
return bp return bp

Some files were not shown because too many files have changed in this diff Show More