32 Commits

Author SHA1 Message Date
984e2ebed0 Fix cart load_service_components: use os.path instead of Path
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m20s
Avoid UnboundLocalError with Path by using os.path directly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 08:57:27 +00:00
d80894dbf5 Fix cart load_service_components path
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
The old sx_components.py used os.path.dirname(__file__) to resolve
the app root. When it was deleted, the replacement call in app.py
used the string "cart" which resolves to /app/cart/ (alembic only),
not /app/ where the sx/ directory lives. Use Path(__file__).parent.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 08:56:04 +00:00
8e16cc459a Fix Like model import path in SqlLikesService
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m57s
Inside the likes container the model is at models.like not
likes.models.like — the container's Python path is /app.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 08:52:51 +00:00
336a4ad9a1 Lazy-import Like model in SqlLikesService
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
The module-level import of likes.models.like.Like caused ImportError
in non-likes services that register SqlLikesService. Move the import
into a lazy helper called per-method.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 08:51:11 +00:00
d6f3250a77 Fix dev_watcher sentinel path for container permissions
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m15s
The sentinel was written to shared/_reload_sentinel.py but shared/ is
volume-mounted as root:root, so appuser can't create files there.
Move sentinel to /app/_reload_sentinel.py which is owned by appuser
and still under Hypercorn's --reload watch path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 08:37:29 +00:00
486ab834de Fix datetime serialization in _dto_to_dict
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m10s
Use dto_to_dict() from shared/contracts/dtos.py for dataclass
serialization instead of raw dataclasses.asdict(). This ensures
datetimes are converted to ISO format strings (not RFC 2822 from
jsonify), matching what dto_from_dict() expects on the receiving end.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 08:28:47 +00:00
41e803335a Fix _dto_to_dict for slots=True dataclasses
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m7s
The defquery conversion routes inter-service results through
_dto_to_dict which checked __dict__ (absent on slots dataclasses),
producing {"value": obj} instead of proper field dicts. This broke
TicketDTO deserialization in the cart app. Check __dataclass_fields__
first and use dataclasses.asdict() for correct serialization.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 08:24:40 +00:00
1f36987f77 Replace inter-service _handlers dicts with declarative sx defquery/defaction
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m5s
The inter-service data layer (fetch_data/call_action) was the least
structured part of the codebase — Python _handlers dicts with ad-hoc
param extraction scattered across 16 route files. This replaces them
with declarative .sx query/action definitions that make the entire
inter-service protocol self-describing and greppable.

Infrastructure:
- defquery/defaction special forms in the sx evaluator
- Query/action registry with load, lookup, and schema introspection
- Query executor using async_eval with I/O primitives
- Blueprint factories (create_data_blueprint/create_action_blueprint)
  with sx-first dispatch and Python fallback
- /internal/schema endpoint on every service
- parse-datetime and split-ids primitives for type coercion

Service extractions:
- LikesService (toggle, is_liked, liked_slugs, liked_ids)
- PageConfigService (ensure, get_by_container, get_by_id, get_batch, update)
- RelationsService (wraps module-level functions)
- AccountDataService (user_by_email, newsletters)
- CartItemsService, MarketDataService (raw SQLAlchemy lookups)

50 of 54 handlers converted to sx, 4 Python fallbacks remain
(ghost-sync/push-member, clear-cart-for-order, create-order).
Net: -1,383 lines Python, +251 lines modified.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 08:13:50 +00:00
e53e8cc1f7 Eliminate blog settings page helpers — pure .sx defpages with service data
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m32s
Convert 6 blog settings pages (settings-home, cache, snippets, menu-items,
tag-groups, tag-group-edit) from Python page helpers to .sx defpages with
(service "blog-page" ...) IO primitives. Create data-driven defcomps that
handle iteration via (map ...) instead of Python loops.

Post-related page helpers (editor, post-admin/data/preview/entries/settings/edit)
remain as Python helpers — they depend on _ensure_post_data and sx_components
rendering functions that need separate conversion.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 07:50:24 +00:00
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
220 changed files with 10382 additions and 8106 deletions

4
account/actions.sx Normal file
View File

@@ -0,0 +1,4 @@
;; Account service — inter-service action endpoints
;;
;; ghost-sync-member and ghost-push-member use local service imports —
;; remain as Python fallbacks.

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

@@ -1,63 +1,33 @@
"""Account app action endpoints. """Account app action endpoints.
Exposes write operations at ``/internal/actions/<action_name>`` for All actions remain as Python fallbacks (local service imports).
cross-app callers (blog webhooks) via the internal action client.
""" """
from __future__ import annotations from __future__ import annotations
from quart import Blueprint, g, jsonify, request from quart import Blueprint, g, request
from shared.infrastructure.actions import ACTION_HEADER from shared.infrastructure.query_blueprint import create_action_blueprint
def register() -> Blueprint: def register() -> Blueprint:
bp = Blueprint("actions", __name__, url_prefix="/internal/actions") bp, _handlers = create_action_blueprint("account")
@bp.before_request
async def _require_action_header():
if not request.headers.get(ACTION_HEADER):
return jsonify({"error": "forbidden"}), 403
from shared.infrastructure.internal_auth import validate_internal_request
if not validate_internal_request():
return jsonify({"error": "forbidden"}), 403
_handlers: dict[str, object] = {}
@bp.post("/<action_name>")
async def handle_action(action_name: str):
handler = _handlers.get(action_name)
if handler is None:
return jsonify({"error": "unknown action"}), 404
try:
result = await handler()
return jsonify(result)
except Exception as exc:
import logging
logging.getLogger(__name__).exception("Action %s failed", action_name)
return jsonify({"error": str(exc)}), 500
# --- ghost-sync-member ---
async def _ghost_sync_member(): async def _ghost_sync_member():
"""Sync a single Ghost member into db_account."""
data = await request.get_json() data = await request.get_json()
ghost_id = data.get("ghost_id") ghost_id = data.get("ghost_id")
if not ghost_id: if not ghost_id:
return {"error": "ghost_id required"}, 400 return {"error": "ghost_id required"}, 400
from services.ghost_membership import sync_single_member from services.ghost_membership import sync_single_member
await sync_single_member(g.s, ghost_id) await sync_single_member(g.s, ghost_id)
return {"ok": True} return {"ok": True}
_handlers["ghost-sync-member"] = _ghost_sync_member _handlers["ghost-sync-member"] = _ghost_sync_member
# --- ghost-push-member ---
async def _ghost_push_member(): async def _ghost_push_member():
"""Push a local user's membership data to Ghost."""
data = await request.get_json() data = await request.get_json()
user_id = data.get("user_id") user_id = data.get("user_id")
if not user_id: if not user_id:
return {"error": "user_id required"}, 400 return {"error": "user_id required"}, 400
from services.ghost_membership import sync_member_to_ghost from services.ghost_membership import sync_member_to_ghost
result_id = await sync_member_to_ghost(g.s, int(user_id)) result_id = await sync_member_to_ghost(g.s, int(user_id))
return {"ok": True, "ghost_id": result_id} return {"ok": True, "ghost_id": result_id}

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,67 +1,14 @@
"""Account app data endpoints. """Account app data endpoints.
Exposes read-only JSON queries at ``/internal/data/<query_name>`` for All queries are defined in ``account/queries.sx``.
cross-app callers via the internal data client.
""" """
from __future__ import annotations from __future__ import annotations
from quart import Blueprint, g, jsonify, request from quart import Blueprint
from shared.infrastructure.data_client import DATA_HEADER from shared.infrastructure.query_blueprint import create_data_blueprint
from sqlalchemy import select
from shared.models import User
def register() -> Blueprint: def register() -> Blueprint:
bp = Blueprint("data", __name__, url_prefix="/internal/data") bp, _handlers = create_data_blueprint("account")
@bp.before_request
async def _require_data_header():
if not request.headers.get(DATA_HEADER):
return jsonify({"error": "forbidden"}), 403
from shared.infrastructure.internal_auth import validate_internal_request
if not validate_internal_request():
return jsonify({"error": "forbidden"}), 403
_handlers: dict[str, object] = {}
@bp.get("/<query_name>")
async def handle_query(query_name: str):
handler = _handlers.get(query_name)
if handler is None:
return jsonify({"error": "unknown query"}), 404
result = await handler()
return jsonify(result)
# --- user-by-email ---
async def _user_by_email():
"""Return user_id for a given email address."""
email = request.args.get("email", "").strip().lower()
if not email:
return None
result = await g.s.execute(
select(User.id).where(User.email.ilike(email))
)
row = result.first()
if not row:
return None
return {"user_id": row[0]}
_handlers["user-by-email"] = _user_by_email
# --- newsletters ---
async def _newsletters():
"""Return all Ghost newsletters (for blog post editor)."""
from shared.models.ghost_membership_entities import GhostNewsletter
result = await g.s.execute(
select(GhostNewsletter.id, GhostNewsletter.ghost_id, GhostNewsletter.name, GhostNewsletter.slug)
.order_by(GhostNewsletter.name)
)
return [
{"id": row[0], "ghost_id": row[1], "name": row[2], "slug": row[3]}
for row in result.all()
]
_handlers["newsletters"] = _newsletters
return bp return 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

9
account/queries.sx Normal file
View File

@@ -0,0 +1,9 @@
;; Account service — inter-service data queries
(defquery user-by-email (&key email)
"Return user_id for a given email address."
(service "account" "user-by-email" :email email))
(defquery newsletters ()
"Return all Ghost newsletters."
(service "account" "newsletters"))

View File

@@ -3,9 +3,10 @@ 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
from .account_page import AccountPageService
services.register("account_page", AccountPageService())
Account is a consumer-only dashboard app. It has no own domain. from shared.services.account_impl import SqlAccountDataService
All cross-app data comes via fragments and HTTP data endpoints. services.register("account", SqlAccountDataService())
"""
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)))

12
blog/actions.sx Normal file
View File

@@ -0,0 +1,12 @@
;; Blog service — inter-service action endpoints
(defaction update-page-config (&key container-type container-id
features sumup-merchant-code
sumup-checkout-prefix sumup-api-key)
"Create or update a PageConfig with features and SumUp settings."
(service "page-config" "update"
:container-type container-type :container-id container-id
:features features
:sumup-merchant-code sumup-merchant-code
:sumup-checkout-prefix sumup-checkout-prefix
:sumup-api-key sumup-api-key))

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

@@ -1,96 +1,14 @@
"""Blog app action endpoints. """Blog app action endpoints.
Exposes write operations at ``/internal/actions/<action_name>`` for All actions are defined in ``blog/actions.sx``.
cross-app callers via the internal action client.
""" """
from __future__ import annotations from __future__ import annotations
from quart import Blueprint, g, jsonify, request from quart import Blueprint
from shared.infrastructure.actions import ACTION_HEADER from shared.infrastructure.query_blueprint import create_action_blueprint
def register() -> Blueprint: def register() -> Blueprint:
bp = Blueprint("actions", __name__, url_prefix="/internal/actions") bp, _handlers = create_action_blueprint("blog")
@bp.before_request
async def _require_action_header():
if not request.headers.get(ACTION_HEADER):
return jsonify({"error": "forbidden"}), 403
from shared.infrastructure.internal_auth import validate_internal_request
if not validate_internal_request():
return jsonify({"error": "forbidden"}), 403
_handlers: dict[str, object] = {}
@bp.post("/<action_name>")
async def handle_action(action_name: str):
handler = _handlers.get(action_name)
if handler is None:
return jsonify({"error": "unknown action"}), 404
result = await handler()
return jsonify(result or {"ok": True})
# --- update-page-config ---
async def _update_page_config():
"""Create or update a PageConfig (page_configs now lives in db_blog)."""
from shared.models.page_config import PageConfig
from sqlalchemy import select
from sqlalchemy.orm.attributes import flag_modified
data = await request.get_json(force=True)
container_type = data.get("container_type", "page")
container_id = data.get("container_id")
if container_id is None:
return {"error": "container_id required"}, 400
pc = (await g.s.execute(
select(PageConfig).where(
PageConfig.container_type == container_type,
PageConfig.container_id == container_id,
)
)).scalar_one_or_none()
if pc is None:
pc = PageConfig(
container_type=container_type,
container_id=container_id,
features=data.get("features", {}),
)
g.s.add(pc)
await g.s.flush()
if "features" in data:
features = dict(pc.features or {})
for key, val in data["features"].items():
if isinstance(val, bool):
features[key] = val
elif val in ("true", "1", "on"):
features[key] = True
elif val in ("false", "0", "off", None):
features[key] = False
pc.features = features
flag_modified(pc, "features")
if "sumup_merchant_code" in data:
pc.sumup_merchant_code = data["sumup_merchant_code"] or None
if "sumup_checkout_prefix" in data:
pc.sumup_checkout_prefix = data["sumup_checkout_prefix"] or None
if "sumup_api_key" in data:
pc.sumup_api_key = data["sumup_api_key"] or None
await g.s.flush()
return {
"id": pc.id,
"container_type": pc.container_type,
"container_id": pc.container_id,
"features": pc.features or {},
"sumup_merchant_code": pc.sumup_merchant_code,
"sumup_checkout_prefix": pc.sumup_checkout_prefix,
"sumup_configured": bool(pc.sumup_api_key),
}
_handlers["update-page-config"] = _update_page_config
return bp return bp

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,185 +1,14 @@
"""Blog app data endpoints. """Blog app data endpoints.
Exposes read-only JSON queries at ``/internal/data/<query_name>`` for All queries are defined in ``blog/queries.sx``.
cross-app callers via the internal data client.
""" """
from __future__ import annotations from __future__ import annotations
from quart import Blueprint, g, jsonify, request from quart import Blueprint
from shared.infrastructure.data_client import DATA_HEADER from shared.infrastructure.query_blueprint import create_data_blueprint
from shared.contracts.dtos import dto_to_dict
from services import blog_service
def register() -> Blueprint: def register() -> Blueprint:
bp = Blueprint("data", __name__, url_prefix="/internal/data") bp, _handlers = create_data_blueprint("blog")
@bp.before_request
async def _require_data_header():
if not request.headers.get(DATA_HEADER):
return jsonify({"error": "forbidden"}), 403
from shared.infrastructure.internal_auth import validate_internal_request
if not validate_internal_request():
return jsonify({"error": "forbidden"}), 403
_handlers: dict[str, object] = {}
@bp.get("/<query_name>")
async def handle_query(query_name: str):
handler = _handlers.get(query_name)
if handler is None:
return jsonify({"error": "unknown query"}), 404
result = await handler()
return jsonify(result)
# --- post-by-slug ---
async def _post_by_slug():
slug = request.args.get("slug", "")
post = await blog_service.get_post_by_slug(g.s, slug)
if not post:
return None
return dto_to_dict(post)
_handlers["post-by-slug"] = _post_by_slug
# --- post-by-id ---
async def _post_by_id():
post_id = int(request.args.get("id", 0))
post = await blog_service.get_post_by_id(g.s, post_id)
if not post:
return None
return dto_to_dict(post)
_handlers["post-by-id"] = _post_by_id
# --- posts-by-ids ---
async def _posts_by_ids():
ids_raw = request.args.get("ids", "")
if not ids_raw:
return []
ids = [int(x.strip()) for x in ids_raw.split(",") if x.strip()]
posts = await blog_service.get_posts_by_ids(g.s, ids)
return [dto_to_dict(p) for p in posts]
_handlers["posts-by-ids"] = _posts_by_ids
# --- search-posts ---
async def _search_posts():
query = request.args.get("query", "")
page = int(request.args.get("page", 1))
per_page = int(request.args.get("per_page", 10))
posts, total = await blog_service.search_posts(g.s, query, page, per_page)
return {"posts": [dto_to_dict(p) for p in posts], "total": total}
_handlers["search-posts"] = _search_posts
# --- page-config-ensure ---
async def _page_config_ensure():
"""Get or create a PageConfig for a container_type + container_id."""
from sqlalchemy import select
from shared.models.page_config import PageConfig
container_type = request.args.get("container_type", "page")
container_id = request.args.get("container_id", type=int)
if container_id is None:
return {"error": "container_id required"}, 400
row = (await g.s.execute(
select(PageConfig).where(
PageConfig.container_type == container_type,
PageConfig.container_id == container_id,
)
)).scalar_one_or_none()
if row is None:
row = PageConfig(
container_type=container_type,
container_id=container_id,
features={},
)
g.s.add(row)
await g.s.flush()
return {
"id": row.id,
"container_type": row.container_type,
"container_id": row.container_id,
}
_handlers["page-config-ensure"] = _page_config_ensure
# --- page-config ---
async def _page_config():
"""Return a single PageConfig by container_type + container_id."""
from sqlalchemy import select
from shared.models.page_config import PageConfig
ct = request.args.get("container_type", "page")
cid = request.args.get("container_id", type=int)
if cid is None:
return None
pc = (await g.s.execute(
select(PageConfig).where(
PageConfig.container_type == ct,
PageConfig.container_id == cid,
)
)).scalar_one_or_none()
if not pc:
return None
return _page_config_dict(pc)
_handlers["page-config"] = _page_config
# --- page-config-by-id ---
async def _page_config_by_id():
"""Return a single PageConfig by its primary key."""
from shared.models.page_config import PageConfig
pc_id = request.args.get("id", type=int)
if pc_id is None:
return None
pc = await g.s.get(PageConfig, pc_id)
if not pc:
return None
return _page_config_dict(pc)
_handlers["page-config-by-id"] = _page_config_by_id
# --- page-configs-batch ---
async def _page_configs_batch():
"""Return PageConfigs for multiple container_ids (comma-separated)."""
from sqlalchemy import select
from shared.models.page_config import PageConfig
ct = request.args.get("container_type", "page")
ids_raw = request.args.get("ids", "")
if not ids_raw:
return []
ids = [int(x.strip()) for x in ids_raw.split(",") if x.strip()]
if not ids:
return []
result = await g.s.execute(
select(PageConfig).where(
PageConfig.container_type == ct,
PageConfig.container_id.in_(ids),
)
)
return [_page_config_dict(pc) for pc in result.scalars().all()]
_handlers["page-configs-batch"] = _page_configs_batch
return bp return bp
def _page_config_dict(pc) -> dict:
"""Serialize PageConfig to a JSON-safe dict."""
return {
"id": pc.id,
"container_type": pc.container_type,
"container_id": pc.container_id,
"features": pc.features or {},
"sumup_merchant_code": pc.sumup_merchant_code,
"sumup_api_key": pc.sumup_api_key,
"sumup_checkout_prefix": pc.sumup_checkout_prefix,
}

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

38
blog/queries.sx Normal file
View File

@@ -0,0 +1,38 @@
;; Blog service — inter-service data queries
(defquery post-by-slug (&key slug)
"Fetch a single blog post by its URL slug."
(service "blog" "get-post-by-slug" :slug slug))
(defquery post-by-id (&key id)
"Fetch a single blog post by its primary key."
(service "blog" "get-post-by-id" :id id))
(defquery posts-by-ids (&key ids)
"Fetch multiple blog posts by comma-separated IDs."
(service "blog" "get-posts-by-ids" :ids (split-ids ids)))
(defquery search-posts (&key query page per-page)
"Search blog posts by text query, paginated."
(let ((result (service "blog" "search-posts"
:query query :page page :per-page per-page)))
{"posts" (nth result 0) "total" (nth result 1)}))
(defquery page-config-ensure (&key container-type container-id)
"Get or create a PageConfig for a container."
(service "page-config" "ensure"
:container-type container-type :container-id container-id))
(defquery page-config (&key container-type container-id)
"Return a single PageConfig by container type + id."
(service "page-config" "get-by-container"
:container-type container-type :container-id container-id))
(defquery page-config-by-id (&key id)
"Return a single PageConfig by primary key."
(service "page-config" "get-by-id" :id id))
(defquery page-configs-batch (&key container-type ids)
"Return PageConfigs for multiple container IDs (comma-separated)."
(service "page-config" "get-batch"
:container-type container-type :ids (split-ids ids)))

View File

@@ -71,8 +71,16 @@ def register_domain_services() -> None:
Blog owns: Post, Tag, Author, PostAuthor, PostTag. Blog owns: Post, Tag, Author, PostAuthor, PostTag.
Cross-app calls go over HTTP via call_action() / fetch_data(). Cross-app calls go over HTTP via call_action() / fetch_data().
""" """
# Federation needed for AP shared infrastructure (activitypub blueprint)
from shared.services.registry import services from shared.services.registry import services
services.register("blog", blog_service)
from shared.services.page_config_impl import SqlPageConfigService
services.register("page_config", SqlPageConfigService())
# Federation needed for AP shared infrastructure (activitypub blueprint)
if not services.has("federation"): if not services.has("federation"):
from shared.services.federation_impl import SqlFederationService from shared.services.federation_impl import SqlFederationService
services.federation = SqlFederationService() services.federation = SqlFederationService()
from .blog_page import BlogPageService
services.register("blog_page", BlogPageService())

234
blog/services/blog_page.py Normal file
View File

@@ -0,0 +1,234 @@
"""Blog page data service — provides serialized dicts for .sx defpages."""
from __future__ import annotations
class BlogPageService:
"""Service for blog page data, callable via (service "blog-page" ...)."""
async def cache_data(self, session, **kw):
from quart import url_for as qurl
from shared.browser.app.csrf import generate_csrf_token
return {
"clear_url": qurl("settings.cache_clear"),
"csrf": generate_csrf_token(),
}
async def snippets_data(self, session, **kw):
from quart import g, url_for as qurl
from sqlalchemy import select, or_
from models import Snippet
from shared.browser.app.csrf import generate_csrf_token
uid = g.user.id
is_admin = g.rights.get("admin")
csrf = generate_csrf_token()
filters = [Snippet.user_id == uid, Snippet.visibility == "shared"]
if is_admin:
filters.append(Snippet.visibility == "admin")
rows = (await session.execute(
select(Snippet).where(or_(*filters)).order_by(Snippet.name)
)).scalars().all()
snippets = []
for s in rows:
s_id = s.id
s_vis = s.visibility or "private"
s_uid = s.user_id
owner = "You" if s_uid == uid else f"User #{s_uid}"
can_delete = s_uid == uid or is_admin
d = {
"id": s_id,
"name": s.name or "",
"visibility": s_vis,
"owner": owner,
"can_delete": can_delete,
}
if is_admin:
d["patch_url"] = qurl("snippets.patch_visibility", snippet_id=s_id)
if can_delete:
d["delete_url"] = qurl("snippets.delete_snippet", snippet_id=s_id)
snippets.append(d)
return {
"snippets": snippets,
"is_admin": bool(is_admin),
"csrf": csrf,
}
async def menu_items_data(self, session, **kw):
from quart import url_for as qurl
from bp.menu_items.services.menu_items import get_all_menu_items
from shared.browser.app.csrf import generate_csrf_token
menu_items = await get_all_menu_items(session)
csrf = generate_csrf_token()
items = []
for mi in menu_items:
i_id = mi.id
label = mi.label or ""
fi = getattr(mi, "feature_image", None)
sort = mi.position or 0
items.append({
"id": i_id,
"label": label,
"url": mi.url or "",
"sort_order": sort,
"feature_image": fi,
"edit_url": qurl("menu_items.edit_menu_item", item_id=i_id),
"delete_url": qurl("menu_items.delete_menu_item_route", item_id=i_id),
})
return {
"menu_items": items,
"new_url": qurl("menu_items.new_menu_item"),
"csrf": csrf,
}
async def tag_groups_data(self, session, **kw):
from quart import url_for as qurl
from sqlalchemy import select
from models.tag_group import TagGroup
from bp.blog.admin.routes import _unassigned_tags
from shared.browser.app.csrf import generate_csrf_token
groups_rows = list(
(await session.execute(
select(TagGroup).order_by(TagGroup.sort_order, TagGroup.name)
)).scalars()
)
unassigned = await _unassigned_tags(session)
groups = []
for g in groups_rows:
groups.append({
"id": g.id,
"name": g.name or "",
"slug": getattr(g, "slug", "") or "",
"feature_image": getattr(g, "feature_image", None),
"colour": getattr(g, "colour", None),
"sort_order": getattr(g, "sort_order", 0) or 0,
"edit_href": qurl("blog.tag_groups_admin.defpage_tag_group_edit", id=g.id),
})
unassigned_tags = []
for t in unassigned:
unassigned_tags.append({
"name": getattr(t, "name", "") if hasattr(t, "name") else t.get("name", ""),
})
return {
"groups": groups,
"unassigned_tags": unassigned_tags,
"create_url": qurl("blog.tag_groups_admin.create"),
"csrf": generate_csrf_token(),
}
async def tag_group_edit_data(self, session, *, id=None, **kw):
from quart import abort, url_for as qurl
from sqlalchemy import select
from models.tag_group import TagGroup, TagGroupTag
from models.ghost_content import Tag
from shared.browser.app.csrf import generate_csrf_token
tg = await session.get(TagGroup, id)
if not tg:
abort(404)
assigned_rows = list(
(await session.execute(
select(TagGroupTag.tag_id).where(TagGroupTag.tag_group_id == id)
)).scalars()
)
assigned_set = set(assigned_rows)
all_tags_rows = list(
(await session.execute(
select(Tag).where(
Tag.deleted_at.is_(None),
(Tag.visibility == "public") | (Tag.visibility.is_(None)),
).order_by(Tag.name)
)).scalars()
)
all_tags = []
for t in all_tags_rows:
all_tags.append({
"id": t.id,
"name": getattr(t, "name", "") or "",
"feature_image": getattr(t, "feature_image", None),
"checked": t.id in assigned_set,
})
return {
"group": {
"id": tg.id,
"name": tg.name or "",
"colour": getattr(tg, "colour", "") or "",
"sort_order": getattr(tg, "sort_order", 0) or 0,
"feature_image": getattr(tg, "feature_image", "") or "",
},
"all_tags": all_tags,
"save_url": qurl("blog.tag_groups_admin.save", id=tg.id),
"delete_url": qurl("blog.tag_groups_admin.delete_group", id=tg.id),
"csrf": generate_csrf_token(),
}
async def post_admin_data(self, session, *, slug=None, **kw):
"""Post admin panel — just needs post loaded into context."""
from quart import g
from sqlalchemy import select
from shared.models.page_config import PageConfig
# _ensure_post_data is called by before_request in defpage context
post = (g.post_data or {}).get("post", {})
features = {}
sumup_configured = False
if post.get("is_page"):
pc = (await session.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)
return {
"features": features,
"sumup_configured": sumup_configured,
}
async def preview_data(self, session, *, slug=None, **kw):
"""Build preview data with prettified/rendered content."""
from quart import g
from models.ghost_content import Post
from sqlalchemy import select as sa_select
post_id = g.post_data["post"]["id"]
post = (await session.execute(
sa_select(Post).where(Post.id == post_id)
)).scalar_one_or_none()
result = {}
sx_content = getattr(post, "sx_content", None) or ""
if sx_content:
from shared.sx.prettify import sx_to_pretty_sx
result["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
result["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)
result["sx_rendered"] = sx_html_render(parsed, dict(_COMPONENT_ENV))
except Exception:
result["sx_rendered"] = "<em>Error rendering sx</em>"
if lexical_raw:
from bp.blog.ghost.lexical_renderer import render_lexical
try:
result["lex_rendered"] = render_lexical(lexical_raw)
except Exception:
result["lex_rendered"] = "<em>Error rendering lexical</em>"
return result

View File

@@ -169,3 +169,117 @@
(details :class "border rounded bg-white" (details :class "border rounded bg-white"
(summary :class "cursor-pointer px-4 py-3 font-medium text-sm bg-stone-100 hover:bg-stone-200 select-none" title) (summary :class "cursor-pointer px-4 py-3 font-medium text-sm bg-stone-100 hover:bg-stone-200 select-none" title)
(div :class "p-4 overflow-x-auto text-xs" content))) (div :class "p-4 overflow-x-auto text-xs" content)))
;; ---------------------------------------------------------------------------
;; Data-driven content defcomps (called from defpages with service data)
;; ---------------------------------------------------------------------------
;; Snippets — receives serialized snippet dicts from service
(defcomp ~blog-snippets-content (&key snippets is-admin csrf)
(~blog-snippets-panel
:list (if (empty? (or snippets (list)))
(~empty-state :icon "fa fa-puzzle-piece"
:message "No snippets yet. Create one from the blog editor.")
(~blog-snippets-list
:rows (map (lambda (s)
(let* ((badge-colours (dict
"private" "bg-stone-200 text-stone-700"
"shared" "bg-blue-100 text-blue-700"
"admin" "bg-amber-100 text-amber-700"))
(vis (or (get s "visibility") "private"))
(badge-cls (or (get badge-colours vis) "bg-stone-200 text-stone-700"))
(name (get s "name"))
(owner (get s "owner"))
(can-delete (get s "can_delete")))
(~blog-snippet-row
:name name :owner owner :badge-cls badge-cls :visibility vis
:extra (<>
(when is-admin
(~blog-snippet-visibility-select
:patch-url (get s "patch_url")
:hx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}")
:options (<>
(~blog-snippet-option :value "private" :selected (= vis "private") :label "private")
(~blog-snippet-option :value "shared" :selected (= vis "shared") :label "shared")
(~blog-snippet-option :value "admin" :selected (= vis "admin") :label "admin"))))
(when can-delete
(~delete-btn
:url (get s "delete_url")
:trigger-target "#snippets-list"
:title "Delete snippet?"
:text (str "Delete \u201c" name "\u201d?")
:sx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}")
:cls "px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800 flex-shrink-0"))))))
(or snippets (list)))))))
;; Menu Items — receives serialized menu item dicts from service
(defcomp ~blog-menu-items-content (&key menu-items new-url csrf)
(~blog-menu-items-panel
:new-url new-url
:list (if (empty? (or menu-items (list)))
(~empty-state :icon "fa fa-inbox"
:message "No menu items yet. Add one to get started!")
(~blog-menu-items-list
:rows (map (lambda (mi)
(~blog-menu-item-row
:img (~img-or-placeholder
:src (get mi "feature_image") :alt (get mi "label")
:size-cls "w-12 h-12 rounded-full object-cover flex-shrink-0")
:label (get mi "label")
:slug (get mi "url")
:sort-order (str (or (get mi "sort_order") 0))
:edit-url (get mi "edit_url")
:delete-url (get mi "delete_url")
:confirm-text (str "Remove " (get mi "label") " from the menu?")
:hx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}")))
(or menu-items (list)))))))
;; Tag Groups — receives serialized tag group data from service
(defcomp ~blog-tag-groups-content (&key groups unassigned-tags create-url csrf)
(~blog-tag-groups-main
:form (~blog-tag-groups-create-form :create-url create-url :csrf csrf)
:groups (if (empty? (or groups (list)))
(~empty-state :icon "fa fa-tags" :message "No tag groups yet.")
(~blog-tag-groups-list
:items (map (lambda (g)
(let* ((fi (get g "feature_image"))
(colour (get g "colour"))
(name (get g "name"))
(initial (slice (or name "?") 0 1))
(icon (if fi
(~blog-tag-group-icon-image :src fi :name name)
(~blog-tag-group-icon-color
:style (if colour (str "background:" colour) "background:#e7e5e4")
:initial initial))))
(~blog-tag-group-li
:icon icon
:edit-href (get g "edit_href")
:name name
:slug (or (get g "slug") "")
:sort-order (or (get g "sort_order") 0))))
(or groups (list)))))
:unassigned (when (not (empty? (or unassigned-tags (list))))
(~blog-unassigned-tags
:heading (str (len (or unassigned-tags (list))) " Unassigned Tags")
:spans (map (lambda (t)
(~blog-unassigned-tag :name (get t "name")))
(or unassigned-tags (list)))))))
;; Tag Group Edit — receives serialized tag group + tags from service
(defcomp ~blog-tag-group-edit-content (&key group all-tags save-url delete-url csrf)
(~blog-tag-group-edit-main
:edit-form (~blog-tag-group-edit-form
:save-url save-url :csrf csrf
:name (get group "name")
:colour (get group "colour")
:sort-order (get group "sort_order")
:feature-image (get group "feature_image")
:tags (map (lambda (t)
(~blog-tag-checkbox
:tag-id (get t "id")
:checked (get t "checked")
:img (when (get t "feature_image")
(~blog-tag-checkbox-image :src (get t "feature_image")))
:name (get t "name")))
(or all-tags (list))))
:delete-form (~blog-tag-group-delete-form :delete-url delete-url :csrf csrf)))

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:
@@ -199,80 +289,179 @@ def _register_blog_helpers() -> None:
"post-entries-content": _h_post_entries_content, "post-entries-content": _h_post_entries_content,
"post-settings-content": _h_post_settings_content, "post-settings-content": _h_post_settings_content,
"post-edit-content": _h_post_edit_content, "post-edit-content": _h_post_edit_content,
"settings-content": _h_settings_content,
"cache-content": _h_cache_content,
"snippets-content": _h_snippets_content,
"menu-items-content": _h_menu_items_content,
"tag-groups-content": _h_tag_groups_content,
"tag-group-edit-content": _h_tag_group_edit_content,
}) })
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):
from quart import g await _ensure_post_data(slug)
return getattr(g, "post_data_content", "") 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)
def _h_post_preview_content(): async def _h_post_edit_content(slug=None, **kw):
from quart import g await _ensure_post_data(slug)
return getattr(g, "post_preview_content", "") 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)
def _h_post_entries_content():
from quart import g
return getattr(g, "post_entries_content", "")
def _h_post_settings_content():
from quart import g
return getattr(g, "post_settings_content", "")
def _h_post_edit_content():
from quart import g
return getattr(g, "post_edit_content", "")
def _h_settings_content():
from quart import g
return getattr(g, "settings_content", "")
def _h_cache_content():
from quart import g
return getattr(g, "cache_content", "")
def _h_snippets_content():
from quart import g
return getattr(g, "snippets_content", "")
def _h_menu_items_content():
from quart import g
return getattr(g, "menu_items_content", "")
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,84 +15,95 @@
: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 (div :class "max-w-2xl mx-auto px-4 py-6"))
(defpage cache-page (defpage cache-page
:path "/cache/" :path "/settings/cache/"
:auth :admin :auth :admin
:layout :blog-cache :layout :blog-cache
:content (cache-content)) :data (service "blog-page" "cache-data")
:content (~blog-cache-panel :clear-url clear-url :csrf csrf))
; --- 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)) :data (service "blog-page" "snippets-data")
:content (~blog-snippets-content
:snippets snippets :is-admin is-admin :csrf csrf))
; --- 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)) :data (service "blog-page" "menu-items-data")
:content (~blog-menu-items-content
:menu-items menu-items :new-url new-url :csrf csrf))
; --- 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)) :data (service "blog-page" "tag-groups-data")
:content (~blog-tag-groups-content
:groups groups :unassigned-tags unassigned-tags
:create-url create-url :csrf csrf))
(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)) :data (service "blog-page" "tag-group-edit-data" :id id)
:content (~blog-tag-group-edit-content
:group group :all-tags all-tags
:save-url save-url :delete-url delete-url :csrf csrf))

10
cart/actions.sx Normal file
View File

@@ -0,0 +1,10 @@
;; Cart service — inter-service action endpoints
(defaction adopt-cart-for-user (&key user-id session-id)
"Transfer anonymous cart items to a logged-in user."
(do
(service "cart" "adopt-cart-for-user"
:user-id user-id :session-id session-id)
{"ok" true}))
;; clear-cart-for-order: remains as Python fallback (complex object construction)

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,12 @@ 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()) import os as _os
load_service_components(_os.path.dirname(_os.path.abspath(__file__)), service_name="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 +189,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 +198,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

@@ -1,64 +1,26 @@
"""Cart app action endpoints. """Cart app action endpoints.
Exposes write operations at ``/internal/actions/<action_name>`` for adopt-cart-for-user is defined in ``cart/actions.sx``.
cross-app callers (login handler) via the internal action client. clear-cart-for-order remains as a Python fallback (complex object construction).
""" """
from __future__ import annotations from __future__ import annotations
from quart import Blueprint, g, jsonify, request from quart import Blueprint, g, request
from shared.infrastructure.actions import ACTION_HEADER from shared.infrastructure.query_blueprint import create_action_blueprint
from shared.services.registry import services
def register() -> Blueprint: def register() -> Blueprint:
bp = Blueprint("actions", __name__, url_prefix="/internal/actions") bp, _handlers = create_action_blueprint("cart")
@bp.before_request
async def _require_action_header():
if not request.headers.get(ACTION_HEADER):
return jsonify({"error": "forbidden"}), 403
from shared.infrastructure.internal_auth import validate_internal_request
if not validate_internal_request():
return jsonify({"error": "forbidden"}), 403
_handlers: dict[str, object] = {}
@bp.post("/<action_name>")
async def handle_action(action_name: str):
handler = _handlers.get(action_name)
if handler is None:
return jsonify({"error": "unknown action"}), 404
try:
result = await handler()
return jsonify(result)
except Exception as exc:
import logging
logging.getLogger(__name__).exception("Action %s failed", action_name)
return jsonify({"error": str(exc)}), 500
# --- adopt-cart-for-user ---
async def _adopt_cart():
data = await request.get_json()
await services.cart.adopt_cart_for_user(
g.s, data["user_id"], data["session_id"],
)
return {"ok": True}
_handlers["adopt-cart-for-user"] = _adopt_cart
# --- clear-cart-for-order ---
async def _clear_cart_for_order(): async def _clear_cart_for_order():
"""Soft-delete cart items after an order is paid. Called by orders service."""
from bp.cart.services.clear_cart_for_order import clear_cart_for_order from bp.cart.services.clear_cart_for_order import clear_cart_for_order
from shared.models.order import Order
data = await request.get_json() data = await request.get_json()
user_id = data.get("user_id") user_id = data.get("user_id")
session_id = data.get("session_id") session_id = data.get("session_id")
page_post_id = data.get("page_post_id") page_post_id = data.get("page_post_id")
# Build a minimal order-like object with the fields clear_cart_for_order needs
order = type("_Order", (), { order = type("_Order", (), {
"user_id": user_id, "user_id": user_id,
"session_id": session_id, "session_id": session_id,

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,79 +1,14 @@
"""Cart app data endpoints. """Cart app data endpoints.
Exposes read-only JSON queries at ``/internal/data/<query_name>`` for All queries are defined in ``cart/queries.sx``.
cross-app callers via the internal data client.
""" """
from __future__ import annotations from __future__ import annotations
from quart import Blueprint, g, jsonify, request from quart import Blueprint
from shared.infrastructure.data_client import DATA_HEADER from shared.infrastructure.query_blueprint import create_data_blueprint
from shared.contracts.dtos import dto_to_dict
from shared.services.registry import services
def register() -> Blueprint: def register() -> Blueprint:
bp = Blueprint("data", __name__, url_prefix="/internal/data") bp, _handlers = create_data_blueprint("cart")
@bp.before_request
async def _require_data_header():
if not request.headers.get(DATA_HEADER):
return jsonify({"error": "forbidden"}), 403
from shared.infrastructure.internal_auth import validate_internal_request
if not validate_internal_request():
return jsonify({"error": "forbidden"}), 403
_handlers: dict[str, object] = {}
@bp.get("/<query_name>")
async def handle_query(query_name: str):
handler = _handlers.get(query_name)
if handler is None:
return jsonify({"error": "unknown query"}), 404
result = await handler()
return jsonify(result)
# --- cart-summary ---
async def _cart_summary():
user_id = request.args.get("user_id", type=int)
session_id = request.args.get("session_id")
page_slug = request.args.get("page_slug")
summary = await services.cart.cart_summary(
g.s, user_id=user_id, session_id=session_id, page_slug=page_slug,
)
return dto_to_dict(summary)
_handlers["cart-summary"] = _cart_summary
# --- cart-items (product slugs + quantities for template rendering) ---
async def _cart_items():
from sqlalchemy import select
from shared.models.market import CartItem
user_id = request.args.get("user_id", type=int)
session_id = request.args.get("session_id")
filters = [CartItem.deleted_at.is_(None)]
if user_id is not None:
filters.append(CartItem.user_id == user_id)
elif session_id is not None:
filters.append(CartItem.session_id == session_id)
else:
return []
result = await g.s.execute(
select(CartItem).where(*filters)
)
items = result.scalars().all()
return [
{
"product_id": item.product_id,
"product_slug": item.product_slug,
"quantity": item.quantity,
}
for item in items
]
_handlers["cart-items"] = _cart_items
return bp return bp

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

11
cart/queries.sx Normal file
View File

@@ -0,0 +1,11 @@
;; Cart service — inter-service data queries
(defquery cart-summary (&key user-id session-id page-slug)
"Cart summary for a user or session, optionally filtered by page."
(service "cart" "cart-summary"
:user-id user-id :session-id session-id :page-slug page-slug))
(defquery cart-items (&key user-id session-id)
"Product slugs and quantities in the cart."
(service "cart-data" "cart-items"
:user-id user-id :session-id session-id))

View File

@@ -12,3 +12,9 @@ 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 shared.services.cart_items_impl import SqlCartItemsService
services.register("cart_data", SqlCartItemsService())
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.

65
events/actions.sx Normal file
View File

@@ -0,0 +1,65 @@
;; Events service — inter-service action endpoints
;;
;; Each defaction replaces a Python handler in bp/actions/routes.py.
;; The (service ...) primitive calls the registered CalendarService method
;; with g.s (async session) + keyword args.
(defaction adjust-ticket-quantity (&key entry-id count user-id session-id ticket-type-id)
"Add or remove tickets for a calendar entry."
(do
(service "calendar" "adjust-ticket-quantity"
:entry-id entry-id :count count
:user-id user-id :session-id session-id
:ticket-type-id ticket-type-id)
{"ok" true}))
(defaction claim-entries-for-order (&key order-id user-id session-id page-post-id)
"Claim pending calendar entries for an order."
(do
(service "calendar" "claim-entries-for-order"
:order-id order-id :user-id user-id
:session-id session-id :page-post-id page-post-id)
{"ok" true}))
(defaction claim-tickets-for-order (&key order-id user-id session-id page-post-id)
"Claim pending tickets for an order."
(do
(service "calendar" "claim-tickets-for-order"
:order-id order-id :user-id user-id
:session-id session-id :page-post-id page-post-id)
{"ok" true}))
(defaction confirm-entries-for-order (&key order-id user-id session-id)
"Confirm calendar entries after payment."
(do
(service "calendar" "confirm-entries-for-order"
:order-id order-id :user-id user-id :session-id session-id)
{"ok" true}))
(defaction confirm-tickets-for-order (&key order-id)
"Confirm tickets after payment."
(do
(service "calendar" "confirm-tickets-for-order" :order-id order-id)
{"ok" true}))
(defaction toggle-entry-post (&key entry-id content-type content-id)
"Toggle association between a calendar entry and a content item."
(let ((is-associated (service "calendar" "toggle-entry-post"
:entry-id entry-id
:content-type content-type
:content-id content-id)))
{"is_associated" is-associated}))
(defaction adopt-entries-for-user (&key user-id session-id)
"Transfer anonymous calendar entries to a logged-in user."
(do
(service "calendar" "adopt-entries-for-user"
:user-id user-id :session-id session-id)
{"ok" true}))
(defaction adopt-tickets-for-user (&key user-id session-id)
"Transfer anonymous tickets to a logged-in user."
(do
(service "calendar" "adopt-tickets-for-user"
:user-id user-id :session-id session-id)
{"ok" true}))

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

@@ -1,139 +1,15 @@
"""Events app action endpoints. """Events app action endpoints.
Exposes write operations at ``/internal/actions/<action_name>`` for All actions are defined declaratively in ``events/actions.sx`` and
cross-app callers (cart, blog) via the internal action client. dispatched via the sx query registry. No Python fallbacks needed.
""" """
from __future__ import annotations from __future__ import annotations
from quart import Blueprint, g, jsonify, request from shared.infrastructure.query_blueprint import create_action_blueprint
from shared.infrastructure.actions import ACTION_HEADER from quart import Blueprint
from shared.services.registry import services
def register() -> Blueprint: def register() -> Blueprint:
bp = Blueprint("actions", __name__, url_prefix="/internal/actions") bp, _handlers = create_action_blueprint("events")
@bp.before_request
async def _require_action_header():
if not request.headers.get(ACTION_HEADER):
return jsonify({"error": "forbidden"}), 403
from shared.infrastructure.internal_auth import validate_internal_request
if not validate_internal_request():
return jsonify({"error": "forbidden"}), 403
_handlers: dict[str, object] = {}
@bp.post("/<action_name>")
async def handle_action(action_name: str):
handler = _handlers.get(action_name)
if handler is None:
return jsonify({"error": "unknown action"}), 404
try:
result = await handler()
return jsonify(result)
except Exception as exc:
import logging
logging.getLogger(__name__).exception("Action %s failed", action_name)
return jsonify({"error": str(exc)}), 500
# --- adjust-ticket-quantity ---
async def _adjust_ticket_quantity():
data = await request.get_json()
await services.calendar.adjust_ticket_quantity(
g.s,
data["entry_id"],
data["count"],
user_id=data.get("user_id"),
session_id=data.get("session_id"),
ticket_type_id=data.get("ticket_type_id"),
)
return {"ok": True}
_handlers["adjust-ticket-quantity"] = _adjust_ticket_quantity
# --- claim-entries-for-order ---
async def _claim_entries():
data = await request.get_json()
await services.calendar.claim_entries_for_order(
g.s,
data["order_id"],
data.get("user_id"),
data.get("session_id"),
data.get("page_post_id"),
)
return {"ok": True}
_handlers["claim-entries-for-order"] = _claim_entries
# --- claim-tickets-for-order ---
async def _claim_tickets():
data = await request.get_json()
await services.calendar.claim_tickets_for_order(
g.s,
data["order_id"],
data.get("user_id"),
data.get("session_id"),
data.get("page_post_id"),
)
return {"ok": True}
_handlers["claim-tickets-for-order"] = _claim_tickets
# --- confirm-entries-for-order ---
async def _confirm_entries():
data = await request.get_json()
await services.calendar.confirm_entries_for_order(
g.s,
data["order_id"],
data.get("user_id"),
data.get("session_id"),
)
return {"ok": True}
_handlers["confirm-entries-for-order"] = _confirm_entries
# --- confirm-tickets-for-order ---
async def _confirm_tickets():
data = await request.get_json()
await services.calendar.confirm_tickets_for_order(
g.s, data["order_id"],
)
return {"ok": True}
_handlers["confirm-tickets-for-order"] = _confirm_tickets
# --- toggle-entry-post ---
async def _toggle_entry_post():
data = await request.get_json()
is_associated = await services.calendar.toggle_entry_post(
g.s,
data["entry_id"],
data["content_type"],
data["content_id"],
)
return {"is_associated": is_associated}
_handlers["toggle-entry-post"] = _toggle_entry_post
# --- adopt-entries-for-user ---
async def _adopt_entries():
data = await request.get_json()
await services.calendar.adopt_entries_for_user(
g.s, data["user_id"], data["session_id"],
)
return {"ok": True}
_handlers["adopt-entries-for-user"] = _adopt_entries
# --- adopt-tickets-for-user ---
async def _adopt_tickets():
data = await request.get_json()
await services.calendar.adopt_tickets_for_user(
g.s, data["user_id"], data["session_id"],
)
return {"ok": True}
_handlers["adopt-tickets-for-user"] = _adopt_tickets
return bp return bp

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,148 +1,14 @@
"""Events app data endpoints. """Events app data endpoints.
Exposes read-only JSON queries at ``/internal/data/<query_name>`` for All queries are defined in ``events/queries.sx``.
cross-app callers via the internal data client.
""" """
from __future__ import annotations from __future__ import annotations
from quart import Blueprint, g, jsonify, request from quart import Blueprint
from shared.infrastructure.data_client import DATA_HEADER from shared.infrastructure.query_blueprint import create_data_blueprint
from shared.contracts.dtos import dto_to_dict
from shared.services.registry import services
def register() -> Blueprint: def register() -> Blueprint:
bp = Blueprint("data", __name__, url_prefix="/internal/data") bp, _handlers = create_data_blueprint("events")
@bp.before_request
async def _require_data_header():
if not request.headers.get(DATA_HEADER):
return jsonify({"error": "forbidden"}), 403
from shared.infrastructure.internal_auth import validate_internal_request
if not validate_internal_request():
return jsonify({"error": "forbidden"}), 403
_handlers: dict[str, object] = {}
@bp.get("/<query_name>")
async def handle_query(query_name: str):
handler = _handlers.get(query_name)
if handler is None:
return jsonify({"error": "unknown query"}), 404
result = await handler()
return jsonify(result)
# --- pending-entries ---
async def _pending_entries():
user_id = request.args.get("user_id", type=int)
session_id = request.args.get("session_id")
entries = await services.calendar.pending_entries(
g.s, user_id=user_id, session_id=session_id,
)
return [dto_to_dict(e) for e in entries]
_handlers["pending-entries"] = _pending_entries
# --- pending-tickets ---
async def _pending_tickets():
user_id = request.args.get("user_id", type=int)
session_id = request.args.get("session_id")
tickets = await services.calendar.pending_tickets(
g.s, user_id=user_id, session_id=session_id,
)
return [dto_to_dict(t) for t in tickets]
_handlers["pending-tickets"] = _pending_tickets
# --- entries-for-page ---
async def _entries_for_page():
page_id = request.args.get("page_id", type=int)
user_id = request.args.get("user_id", type=int)
session_id = request.args.get("session_id")
entries = await services.calendar.entries_for_page(
g.s, page_id, user_id=user_id, session_id=session_id,
)
return [dto_to_dict(e) for e in entries]
_handlers["entries-for-page"] = _entries_for_page
# --- tickets-for-page ---
async def _tickets_for_page():
page_id = request.args.get("page_id", type=int)
user_id = request.args.get("user_id", type=int)
session_id = request.args.get("session_id")
tickets = await services.calendar.tickets_for_page(
g.s, page_id, user_id=user_id, session_id=session_id,
)
return [dto_to_dict(t) for t in tickets]
_handlers["tickets-for-page"] = _tickets_for_page
# --- entries-for-order ---
async def _entries_for_order():
order_id = request.args.get("order_id", type=int)
entries = await services.calendar.get_entries_for_order(g.s, order_id)
return [dto_to_dict(e) for e in entries]
_handlers["entries-for-order"] = _entries_for_order
# --- tickets-for-order ---
async def _tickets_for_order():
order_id = request.args.get("order_id", type=int)
tickets = await services.calendar.get_tickets_for_order(g.s, order_id)
return [dto_to_dict(t) for t in tickets]
_handlers["tickets-for-order"] = _tickets_for_order
# --- entry-ids-for-content ---
async def _entry_ids_for_content():
content_type = request.args.get("content_type", "")
content_id = request.args.get("content_id", type=int)
ids = await services.calendar.entry_ids_for_content(g.s, content_type, content_id)
return list(ids)
_handlers["entry-ids-for-content"] = _entry_ids_for_content
# --- associated-entries ---
async def _associated_entries():
content_type = request.args.get("content_type", "")
content_id = request.args.get("content_id", type=int)
page = request.args.get("page", 1, type=int)
entries, has_more = await services.calendar.associated_entries(
g.s, content_type, content_id, page,
)
return {"entries": [dto_to_dict(e) for e in entries], "has_more": has_more}
_handlers["associated-entries"] = _associated_entries
# --- calendars-for-container ---
async def _calendars_for_container():
container_type = request.args.get("type", "")
container_id = request.args.get("id", type=int)
calendars = await services.calendar.calendars_for_container(
g.s, container_type, container_id,
)
return [dto_to_dict(c) for c in calendars]
_handlers["calendars-for-container"] = _calendars_for_container
# --- visible-entries-for-period ---
async def _visible_entries_for_period():
from datetime import datetime
calendar_id = request.args.get("calendar_id", type=int)
period_start = datetime.fromisoformat(request.args.get("period_start", ""))
period_end = datetime.fromisoformat(request.args.get("period_end", ""))
user_id = request.args.get("user_id", type=int)
session_id = request.args.get("session_id")
# is_admin determined server-side, never from client params
is_admin = False
entries = await services.calendar.visible_entries_for_period(
g.s, calendar_id, period_start, period_end,
user_id=user_id, is_admin=is_admin, session_id=session_id,
)
return [dto_to_dict(e) for e in entries]
_handlers["visible-entries-for-period"] = _visible_entries_for_period
return bp return bp

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,
)) ))

57
events/queries.sx Normal file
View File

@@ -0,0 +1,57 @@
;; Events service — inter-service data queries
;;
;; Each defquery replaces a Python handler in bp/data/routes.py.
;; The (service ...) primitive calls the registered CalendarService method
;; with g.s (async session) + keyword args, and auto-converts DTOs to dicts.
(defquery pending-entries (&key user-id session-id)
"Calendar entries in pending state for a user or session."
(service "calendar" "pending-entries"
:user-id user-id :session-id session-id))
(defquery pending-tickets (&key user-id session-id)
"Tickets in pending state for a user or session."
(service "calendar" "pending-tickets"
:user-id user-id :session-id session-id))
(defquery entries-for-page (&key page-id user-id session-id)
"Calendar entries for a specific page."
(service "calendar" "entries-for-page"
:page-id page-id :user-id user-id :session-id session-id))
(defquery tickets-for-page (&key page-id user-id session-id)
"Tickets for a specific page."
(service "calendar" "tickets-for-page"
:page-id page-id :user-id user-id :session-id session-id))
(defquery entries-for-order (&key order-id)
"Calendar entries claimed by an order."
(service "calendar" "get-entries-for-order" :order-id order-id))
(defquery tickets-for-order (&key order-id)
"Tickets claimed by an order."
(service "calendar" "get-tickets-for-order" :order-id order-id))
(defquery entry-ids-for-content (&key content-type content-id)
"Entry IDs associated with a content item."
(service "calendar" "entry-ids-for-content"
:content-type content-type :content-id content-id))
(defquery associated-entries (&key content-type content-id page)
"Entries associated with content, paginated."
(let ((result (service "calendar" "associated-entries"
:content-type content-type :content-id content-id :page page)))
{"entries" (nth result 0) "has_more" (nth result 1)}))
(defquery calendars-for-container (&key type id)
"Calendars attached to a container (page, marketplace, etc)."
(service "calendar" "calendars-for-container"
:container-type type :container-id id))
(defquery visible-entries-for-period (&key calendar-id period-start period-end user-id session-id)
"Visible entries within a date range for a calendar."
(service "calendar" "visible-entries-for-period"
:calendar-id calendar-id
:period-start (parse-datetime period-start)
:period-end (parse-datetime period-end)
:user-id user-id :is-admin false :session-id session-id))

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

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