8 Commits
exorcism ... sx

Author SHA1 Message Date
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
124 changed files with 4129 additions and 2115 deletions

View File

@@ -8,7 +8,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:
@@ -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

@@ -8,15 +8,12 @@ from __future__ import annotations
from quart import ( from quart import (
Blueprint, Blueprint,
request, 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.infrastructure.fragments import fetch_fragment, fetch_fragments
from shared.sx.helpers import sx_response from shared.sx.helpers import sx_response
@@ -25,8 +22,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 +30,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"):

View File

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

View File

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

View File

@@ -75,31 +75,60 @@ def _register_account_helpers() -> None:
}) })
def _h_account_content(): def _h_account_content(**kw):
from sx.sx_components import _account_main_panel_sx from sx.sx_components import _account_main_panel_sx
return _account_main_panel_sx({}) return _account_main_panel_sx({})
def _h_newsletters_content(): async def _h_newsletters_content(**kw):
from quart import g from quart import g
d = getattr(g, "newsletters_data", None) from sqlalchemy import select
if not d: from shared.models import UserNewsletter
from shared.models.ghost_membership_entities import GhostNewsletter
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,
})
if not newsletter_list:
from shared.sx.helpers import sx_call from shared.sx.helpers import sx_call
return sx_call("account-newsletter-empty") return sx_call("account-newsletter-empty")
from shared.sx.page import get_template_context_sync
from sx.sx_components import _newsletters_panel_sx from sx.sx_components import _newsletters_panel_sx
# Build a minimal ctx with account_url
ctx = {"account_url": getattr(g, "_account_url", None)} ctx = {"account_url": getattr(g, "_account_url", None)}
if ctx["account_url"] is None: if ctx["account_url"] is None:
from shared.infrastructure.urls import account_url from shared.infrastructure.urls import account_url
ctx["account_url"] = account_url ctx["account_url"] = account_url
return _newsletters_panel_sx(ctx, d) return _newsletters_panel_sx(ctx, newsletter_list)
def _h_fragment_content(): async def _h_fragment_content(slug=None, **kw):
from quart import g from quart import g, abort
frag = getattr(g, "fragment_page_data", None) from shared.infrastructure.fragments import fetch_fragment
if not frag:
if not slug or not g.get("user"):
return "" return ""
fragment_html = await fetch_fragment(
"events", "account-page",
params={"slug": slug, "user_id": str(g.user.id)},
)
if not fragment_html:
abort(404)
from sx.sx_components import _fragment_content from sx.sx_components import _fragment_content
return _fragment_content(frag) return _fragment_content(fragment_html)

View File

@@ -28,4 +28,4 @@
:path "/<slug>/" :path "/<slug>/"
:auth :login :auth :login
:layout :account :layout :account
:content (fragment-content)) :content (fragment-content slug))

View File

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

View File

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

View File

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

View File

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

View File

@@ -53,16 +53,6 @@ def register(url_prefix, title):
@blogs_bp.before_request @blogs_bp.before_request
async def route(): async def route():
g.makeqs_factory = makeqs_factory g.makeqs_factory = makeqs_factory
ep = request.endpoint or ""
if "defpage_new_post" in ep:
from sx.sx_components import render_editor_panel
g.editor_content = render_editor_panel()
elif "defpage_new_page" in ep:
from sx.sx_components import render_editor_panel
g.editor_page_content = render_editor_panel(is_page=True)
from shared.sx.pages import mount_pages
mount_pages(blogs_bp, "blog", names=["new-post", "new-page"])
@blogs_bp.context_processor @blogs_bp.context_processor
async def inject_root(): async def inject_root():
@@ -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/")
@@ -335,7 +325,7 @@ def register(url_prefix, title):
await invalidate_tag_cache("blog") await invalidate_tag_cache("blog")
# Redirect to the page admin # Redirect to the page admin
return redirect(host_url(url_for("blog.post.admin.defpage_post_edit", slug=page.slug))) return redirect(host_url(url_for("defpage_post_edit", slug=page.slug)))
@blogs_bp.get("/drafts/") @blogs_bp.get("/drafts/")

View File

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

View File

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

View File

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

View File

@@ -12,7 +12,6 @@ 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():
@@ -23,20 +22,6 @@ def register():
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 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
async def new_menu_item(): async def new_menu_item():

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):
@@ -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)

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

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
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -110,48 +200,48 @@ def _sub_settings_oob(ctx: dict, row_id: str, child_id: str,
def _cache_full(ctx: dict, **kw: Any) -> str: def _cache_full(ctx: dict, **kw: Any) -> str:
return _sub_settings_full(ctx, "cache-row", "cache-header-child", return _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: def _cache_oob(ctx: dict, **kw: Any) -> str:
return _sub_settings_oob(ctx, "cache-row", "cache-header-child", return _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: def _snippets_full(ctx: dict, **kw: Any) -> str:
return _sub_settings_full(ctx, "snippets-row", "snippets-header-child", return _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: def _snippets_oob(ctx: dict, **kw: Any) -> str:
return _sub_settings_oob(ctx, "snippets-row", "snippets-header-child", return _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: def _menu_items_full(ctx: dict, **kw: Any) -> str:
return _sub_settings_full(ctx, "menu_items-row", "menu_items-header-child", return _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: def _menu_items_oob(ctx: dict, **kw: Any) -> str:
return _sub_settings_oob(ctx, "menu_items-row", "menu_items-header-child", return _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: def _tag_groups_full(ctx: dict, **kw: Any) -> str:
return _sub_settings_full(ctx, "tag-groups-row", "tag-groups-header-child", return _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: def _tag_groups_oob(ctx: dict, **kw: Any) -> str:
return _sub_settings_oob(ctx, "tag-groups-row", "tag-groups-header-child", return _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 ---
@@ -165,7 +255,7 @@ def _tag_group_edit_full(ctx: dict, **kw: Any) -> str:
root_hdr = root_header_sx(ctx) root_hdr = root_header_sx(ctx)
settings_hdr = _settings_header_sx(ctx) settings_hdr = _settings_header_sx(ctx)
sub_hdr = _sub_settings_header_sx("tag-groups-row", "tag-groups-header-child", sub_hdr = _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 + ")"
@@ -178,14 +268,14 @@ def _tag_group_edit_oob(ctx: dict, **kw: Any) -> str:
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 = _settings_header_sx(ctx, oob=True)
sub_hdr = _sub_settings_header_sx("tag-groups-row", "tag-groups-header-child", sub_hdr = _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 = oob_header_sx("root-settings-header-child", "tag-groups-header-child", sub_hdr)
return "(<> " + settings_hdr_oob + " " + sub_oob + ")" return "(<> " + settings_hdr_oob + " " + sub_oob + ")"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Page helpers (sync functions available in .sx defpage expressions) # Page helpers (async functions available in .sx defpage expressions)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _register_blog_helpers() -> None: def _register_blog_helpers() -> None:
@@ -208,71 +298,277 @@ def _register_blog_helpers() -> None:
}) })
def _h_editor_content(): # --- Editor helpers ---
async def _h_editor_content(**kw):
from sx.sx_components import render_editor_panel
return render_editor_panel()
async def _h_editor_page_content(**kw):
from sx.sx_components import render_editor_panel
return 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 _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 _post_entries_content_sx(tctx)
def _h_post_data_content(): async def _h_post_settings_content(slug=None, **kw):
await _ensure_post_data(slug)
from quart import g, request
from models.ghost_content import Post
from sqlalchemy import select as sa_select
from sqlalchemy.orm import selectinload
from bp.post.admin.routes import _post_to_edit_dict
post_id = g.post_data["post"]["id"]
post = (await g.s.execute(
sa_select(Post)
.where(Post.id == post_id)
.options(selectinload(Post.tags))
)).scalar_one_or_none()
ghost_post = _post_to_edit_dict(post) if post else {}
save_success = request.args.get("saved") == "1"
from shared.sx.page import get_template_context
from sx.sx_components import _post_settings_content_sx
tctx = await get_template_context()
tctx["ghost_post"] = ghost_post
tctx["save_success"] = save_success
return _post_settings_content_sx(tctx)
async def _h_post_edit_content(slug=None, **kw):
await _ensure_post_data(slug)
from quart import g, request
from models.ghost_content import Post
from sqlalchemy import select as sa_select
from sqlalchemy.orm import selectinload
from shared.infrastructure.data_client import fetch_data
from bp.post.admin.routes import _post_to_edit_dict
post_id = g.post_data["post"]["id"]
post = (await g.s.execute(
sa_select(Post)
.where(Post.id == post_id)
.options(selectinload(Post.tags))
)).scalar_one_or_none()
ghost_post = _post_to_edit_dict(post) if post else {}
save_success = request.args.get("saved") == "1"
save_error = request.args.get("error", "")
raw_newsletters = await fetch_data("account", "newsletters", required=False) or []
from types import SimpleNamespace
newsletters = [SimpleNamespace(**nl) for nl in raw_newsletters]
from shared.sx.page import get_template_context
from sx.sx_components import _post_edit_content_sx
tctx = await get_template_context()
tctx["ghost_post"] = ghost_post
tctx["save_success"] = save_success
tctx["save_error"] = save_error
tctx["newsletters"] = newsletters
return _post_edit_content_sx(tctx)
# --- Settings helpers ---
async def _h_settings_content(**kw):
from shared.sx.page import get_template_context
from sx.sx_components import _settings_main_panel_sx
tctx = await get_template_context()
return _settings_main_panel_sx(tctx)
async def _h_cache_content(**kw):
from shared.sx.page import get_template_context
from sx.sx_components import _cache_main_panel_sx
tctx = await get_template_context()
return _cache_main_panel_sx(tctx)
# --- Snippets helper ---
async def _h_snippets_content(**kw):
from quart import g from quart import g
return getattr(g, "post_data_content", "") from sqlalchemy import select, or_
from models import Snippet
uid = g.user.id
is_admin = g.rights.get("admin")
filters = [Snippet.user_id == uid, Snippet.visibility == "shared"]
if is_admin:
filters.append(Snippet.visibility == "admin")
rows = (await g.s.execute(
select(Snippet).where(or_(*filters)).order_by(Snippet.name)
)).scalars().all()
from shared.sx.page import get_template_context
from sx.sx_components import _snippets_main_panel_sx
tctx = await get_template_context()
tctx["snippets"] = rows
tctx["is_admin"] = is_admin
return _snippets_main_panel_sx(tctx)
def _h_post_preview_content(): # --- Menu Items helper ---
async def _h_menu_items_content(**kw):
from quart import g from quart import g
return getattr(g, "post_preview_content", "") from bp.menu_items.services.menu_items import get_all_menu_items
menu_items = await get_all_menu_items(g.s)
from shared.sx.page import get_template_context
from sx.sx_components import _menu_items_main_panel_sx
tctx = await get_template_context()
tctx["menu_items"] = menu_items
return _menu_items_main_panel_sx(tctx)
def _h_post_entries_content(): # --- Tag Groups helpers ---
async def _h_tag_groups_content(**kw):
from quart import g from quart import g
return getattr(g, "post_entries_content", "") from sqlalchemy import select
from models.tag_group import TagGroup
from bp.blog.admin.routes import _unassigned_tags
groups = list(
(await g.s.execute(
select(TagGroup).order_by(TagGroup.sort_order, TagGroup.name)
)).scalars()
)
unassigned = await _unassigned_tags(g.s)
from shared.sx.page import get_template_context
from sx.sx_components import _tag_groups_main_panel_sx
tctx = await get_template_context()
tctx.update({"groups": groups, "unassigned_tags": unassigned})
return _tag_groups_main_panel_sx(tctx)
def _h_post_settings_content(): async def _h_tag_group_edit_content(id=None, **kw):
from quart import g from quart import g, abort
return getattr(g, "post_settings_content", "") from sqlalchemy import select
from models.tag_group import TagGroup, TagGroupTag
from models.ghost_content import Tag
def _h_post_edit_content(): tg = await g.s.get(TagGroup, id)
from quart import g if not tg:
return getattr(g, "post_edit_content", "") abort(404)
assigned_rows = list(
(await g.s.execute(
def _h_settings_content(): select(TagGroupTag.tag_id).where(TagGroupTag.tag_group_id == id)
from quart import g )).scalars()
return getattr(g, "settings_content", "") )
all_tags = list(
(await g.s.execute(
def _h_cache_content(): select(Tag).where(
from quart import g Tag.deleted_at.is_(None),
return getattr(g, "cache_content", "") (Tag.visibility == "public") | (Tag.visibility.is_(None)),
).order_by(Tag.name)
)).scalars()
def _h_snippets_content(): )
from quart import g from shared.sx.page import get_template_context
return getattr(g, "snippets_content", "") from sx.sx_components import _tag_groups_edit_main_panel_sx
tctx = await get_template_context()
tctx.update({
def _h_menu_items_content(): "group": tg,
from quart import g "all_tags": all_tags,
return getattr(g, "menu_items_content", "") "assigned_tag_ids": set(assigned_rows),
})
return _tag_groups_edit_main_panel_sx(tctx)
def _h_tag_groups_content():
from quart import g
return getattr(g, "tag_groups_content", "")
def _h_tag_group_edit_content():
from quart import g
return getattr(g, "tag_group_edit_content", "")

View File

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

View File

@@ -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,9 @@ 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()) 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 +186,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 +195,22 @@ def create_app() -> "Quart":
url_prefix="/", url_prefix="/",
) )
# Cart overview at GET / # Cart overview blueprint (no defpage routes, just action endpoints)
overview_bp = register_cart_overview(url_prefix="/") overview_bp = register_cart_overview(url_prefix="/")
mount_pages(overview_bp, "cart", names=["cart-overview"])
app.register_blueprint(overview_bp, url_prefix="/") app.register_blueprint(overview_bp, url_prefix="/")
# Page admin at /<page_slug>/admin/ (before page_cart catch-all) # Page admin (PUT /payments/ etc.)
admin_bp = register_page_admin() admin_bp = register_page_admin()
mount_pages(admin_bp, "cart", names=["cart-admin", "cart-payments"])
app.register_blueprint(admin_bp, url_prefix="/<page_slug>/admin") app.register_blueprint(admin_bp, url_prefix="/<page_slug>/admin")
# Page cart at /<page_slug>/ (dynamic, matched last) # Page cart (POST /checkout/ etc.)
page_cart_bp = register_page_cart(url_prefix="/") page_cart_bp = register_page_cart(url_prefix="/")
mount_pages(page_cart_bp, "cart", names=["page-cart-view"])
app.register_blueprint(page_cart_bp, url_prefix="/<page_slug>") app.register_blueprint(page_cart_bp, url_prefix="/<page_slug>")
# Auto-mount all defpages with absolute paths
from shared.sx.pages import auto_mount_pages
auto_mount_pages(app, "cart")
return app return app

View File

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

View File

@@ -56,9 +56,9 @@ def register(url_prefix: str) -> Blueprint:
if request.headers.get("SX-Request") == "true" or request.headers.get("HX-Request") == "true": if request.headers.get("SX-Request") == "true" or request.headers.get("HX-Request") == "true":
# Redirect to overview for HTMX # Redirect to overview for HTMX
return redirect(url_for("cart_overview.defpage_cart_overview")) return redirect(url_for("defpage_cart_overview"))
return redirect(url_for("cart_overview.defpage_cart_overview")) return redirect(url_for("defpage_cart_overview"))
@bp.post("/quantity/<int:product_id>/") @bp.post("/quantity/<int:product_id>/")
async def update_quantity(product_id: int): async def update_quantity(product_id: int):
@@ -137,7 +137,7 @@ def register(url_prefix: str) -> Blueprint:
tickets = await get_ticket_cart_entries(g.s) tickets = await get_ticket_cart_entries(g.s)
if not cart and not calendar_entries and not tickets: if not cart and not calendar_entries and not tickets:
return redirect(url_for("cart_overview.defpage_cart_overview")) return redirect(url_for("defpage_cart_overview"))
product_total = total(cart) or 0 product_total = total(cart) or 0
calendar_amount = calendar_total(calendar_entries) or 0 calendar_amount = calendar_total(calendar_entries) or 0
@@ -145,7 +145,7 @@ 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)

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

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

@@ -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

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

View File

@@ -771,7 +771,7 @@ def _cart_page_admin_header_sx(ctx: dict, page_post: Any, *, oob: bool = False,
def _cart_admin_main_panel_sx(ctx: dict) -> str: def _cart_admin_main_panel_sx(ctx: dict) -> str:
"""Admin overview panel -- links to sub-admin pages.""" """Admin overview panel -- links to sub-admin pages."""
from quart import url_for from quart import url_for
payments_href = url_for("page_admin.defpage_cart_payments") payments_href = url_for("defpage_cart_payments")
return ( return (
'(div :id "main-panel"' '(div :id "main-panel"'
' (div :class "flex items-center justify-between p-3 border-b"' ' (div :class "flex items-center justify-between p-3 border-b"'

View File

@@ -90,32 +90,48 @@ def _register_cart_helpers() -> None:
}) })
def _h_overview_content(): async def _h_overview_content(**kw):
from quart import g from quart import g
page_groups = getattr(g, "overview_page_groups", []) from shared.sx.page import get_template_context
from sx.sx_components import _overview_main_panel_sx from sx.sx_components import _overview_main_panel_sx
# _overview_main_panel_sx needs ctx for url helpers — use g-based approach from bp.cart.services import get_cart_grouped_by_page
# The function reads cart_url from ctx, which we can get from template context page_groups = await get_cart_grouped_by_page(g.s)
from shared.sx.page import get_template_context ctx = await get_template_context()
import asyncio return _overview_main_panel_sx(page_groups, ctx)
# Page helpers are sync — we pre-compute in before_request
return getattr(g, "overview_content", "")
def _h_page_cart_content(): async def _h_page_cart_content(page_slug=None, **kw):
from quart import g from quart import g
return getattr(g, "page_cart_content", "") from shared.sx.page import get_template_context
from sx.sx_components import _page_cart_main_panel_sx
from bp.cart.services import total, calendar_total, ticket_total
from bp.cart.services.page_cart import (
get_cart_for_page, get_calendar_entries_for_page, get_tickets_for_page,
)
from bp.cart.services.ticket_groups import group_tickets
post = g.page_post
cart = await get_cart_for_page(g.s, post.id)
cal_entries = await get_calendar_entries_for_page(g.s, post.id)
page_tickets = await get_tickets_for_page(g.s, post.id)
ticket_groups = group_tickets(page_tickets)
ctx = await get_template_context()
return _page_cart_main_panel_sx(
ctx, cart, cal_entries, page_tickets, ticket_groups,
total, calendar_total, ticket_total,
)
def _h_cart_admin_content(): async def _h_cart_admin_content(page_slug=None, **kw):
from shared.sx.page import get_template_context
from sx.sx_components import _cart_admin_main_panel_sx from sx.sx_components import _cart_admin_main_panel_sx
ctx = await get_template_context()
return _cart_admin_main_panel_sx(ctx)
async def _h_cart_payments_content(page_slug=None, **kw):
from shared.sx.page import get_template_context from shared.sx.page import get_template_context
# Sync helper — _cart_admin_main_panel_sx is sync, but needs ctx from sx.sx_components import _cart_payments_main_panel_sx
# We can pre-compute in before_request, or use get_template_context_sync-like pattern ctx = await get_template_context()
from quart import g return _cart_payments_main_panel_sx(ctx)
return getattr(g, "cart_admin_content", "")
def _h_cart_payments_content():
from quart import g
return getattr(g, "cart_payments_content", "")

View File

@@ -7,19 +7,19 @@
:content (overview-content)) :content (overview-content))
(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)) :content (page-cart-content))
(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)) :content (cart-payments-content))

View File

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

View File

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

View File

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

View File

@@ -11,7 +11,7 @@ Routes:
""" """
from __future__ import annotations from __future__ import annotations
from quart import Blueprint, g, request, render_template, make_response from quart import Blueprint, g, request, make_response
from shared.browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
from shared.sx.helpers import sx_response from shared.sx.helpers import sx_response

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,18 +15,6 @@ 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):

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

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

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

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

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

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

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

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

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

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

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

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():

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

View File

@@ -191,11 +191,11 @@ def _calendar_nav_sx(ctx: dict) -> str:
select_colours = ctx.get("select_colours", "") select_colours = ctx.get("select_colours", "")
parts = [] parts = []
slots_href = url_for("calendar.slots.defpage_slots_listing", calendar_slug=cal_slug) slots_href = url_for("defpage_slots_listing", calendar_slug=cal_slug)
parts.append(sx_call("nav-link", href=slots_href, icon="fa fa-clock", parts.append(sx_call("nav-link", href=slots_href, icon="fa fa-clock",
label="Slots", select_colours=select_colours)) label="Slots", select_colours=select_colours))
if is_admin: if is_admin:
admin_href = url_for("calendar.admin.defpage_calendar_admin", calendar_slug=cal_slug) admin_href = url_for("defpage_calendar_admin", calendar_slug=cal_slug)
parts.append(sx_call("nav-link", href=admin_href, icon="fa fa-cog", parts.append(sx_call("nav-link", href=admin_href, icon="fa fa-cog",
select_colours=select_colours)) select_colours=select_colours))
return "".join(parts) return "".join(parts)
@@ -319,7 +319,7 @@ def _calendar_admin_header_sx(ctx: dict, *, oob: bool = False) -> str:
nav_parts = [] nav_parts = []
if cal_slug: if cal_slug:
for endpoint, label in [ for endpoint, label in [
("calendar.slots.defpage_slots_listing", "slots"), ("defpage_slots_listing", "slots"),
("calendar.admin.calendar_description_edit", "description"), ("calendar.admin.calendar_description_edit", "description"),
]: ]:
href = url_for(endpoint, calendar_slug=cal_slug) href = url_for(endpoint, calendar_slug=cal_slug)
@@ -339,7 +339,7 @@ def _calendar_admin_header_sx(ctx: dict, *, oob: bool = False) -> str:
def _markets_header_sx(ctx: dict, *, oob: bool = False) -> str: def _markets_header_sx(ctx: dict, *, oob: bool = False) -> str:
"""Build the markets section header row.""" """Build the markets section header row."""
from quart import url_for from quart import url_for
link_href = url_for("markets.defpage_events_markets") link_href = url_for("defpage_events_markets")
return sx_call("menu-row-sx", id="markets-row", level=3, return sx_call("menu-row-sx", id="markets-row", level=3,
link_href=link_href, link_href=link_href,
link_label_content=SxExpr(sx_call("events-markets-label")), link_label_content=SxExpr(sx_call("events-markets-label")),
@@ -594,7 +594,7 @@ def _day_row_html(ctx: dict, entry) -> str:
# Slot/Time # Slot/Time
slot = getattr(entry, "slot", None) slot = getattr(entry, "slot", None)
if slot: if slot:
slot_href = url_for("calendar.slots.slot.defpage_slot_detail", calendar_slug=cal_slug, slot_id=slot.id) slot_href = url_for("defpage_slot_detail", calendar_slug=cal_slug, slot_id=slot.id)
time_start = slot.time_start.strftime("%H:%M") if slot.time_start else "" time_start = slot.time_start.strftime("%H:%M") if slot.time_start else ""
time_end = f" \u2192 {slot.time_end.strftime('%H:%M')}" if slot.time_end else "" time_end = f" \u2192 {slot.time_end.strftime('%H:%M')}" if slot.time_end else ""
slot_html = sx_call("events-day-row-slot", slot_html = sx_call("events-day-row-slot",
@@ -774,7 +774,7 @@ def _tickets_main_panel_html(ctx: dict, tickets: list) -> str:
ticket_cards = [] ticket_cards = []
if tickets: if tickets:
for ticket in tickets: for ticket in tickets:
href = url_for("tickets.defpage_ticket_detail", code=ticket.code) href = url_for("defpage_ticket_detail", code=ticket.code)
entry = getattr(ticket, "entry", None) entry = getattr(ticket, "entry", None)
entry_name = entry.name if entry else "Unknown event" entry_name = entry.name if entry else "Unknown event"
tt = getattr(ticket, "ticket_type", None) tt = getattr(ticket, "ticket_type", None)
@@ -819,7 +819,7 @@ def _ticket_detail_panel_html(ctx: dict, ticket) -> str:
bg_map = {"confirmed": "bg-emerald-50", "checked_in": "bg-blue-50", "reserved": "bg-amber-50"} bg_map = {"confirmed": "bg-emerald-50", "checked_in": "bg-blue-50", "reserved": "bg-amber-50"}
header_bg = bg_map.get(state, "bg-stone-50") header_bg = bg_map.get(state, "bg-stone-50")
entry_name = entry.name if entry else "Ticket" entry_name = entry.name if entry else "Ticket"
back_href = url_for("tickets.defpage_my_tickets") back_href = url_for("defpage_my_tickets")
# Badge with larger sizing # Badge with larger sizing
badge = _ticket_state_badge_html(state).replace('px-2 py-0.5 text-xs', 'px-3 py-1 text-sm') badge = _ticket_state_badge_html(state).replace('px-2 py-0.5 text-xs', 'px-3 py-1 text-sm')
@@ -2165,7 +2165,7 @@ def render_slots_table(slots, calendar) -> str:
rows_html = "" rows_html = ""
if slots: if slots:
for s in slots: for s in slots:
slot_href = url_for("calendar.slots.slot.defpage_slot_detail", calendar_slug=cal_slug, slot_id=s.id) slot_href = url_for("defpage_slot_detail", calendar_slug=cal_slug, slot_id=s.id)
del_url = url_for("calendar.slots.slot.slot_delete", calendar_slug=cal_slug, slot_id=s.id) del_url = url_for("calendar.slots.slot.slot_delete", calendar_slug=cal_slug, slot_id=s.id)
desc = getattr(s, "description", "") or "" desc = getattr(s, "description", "") or ""
@@ -2309,7 +2309,7 @@ def render_buy_result(entry, created_tickets, remaining, cart_count) -> str:
tickets_html = "" tickets_html = ""
for ticket in created_tickets: for ticket in created_tickets:
href = url_for("tickets.defpage_ticket_detail", code=ticket.code) href = url_for("defpage_ticket_detail", code=ticket.code)
tickets_html += sx_call("events-buy-result-ticket", tickets_html += sx_call("events-buy-result-ticket",
href=href, code_short=ticket.code[:12] + "...") href=href, code_short=ticket.code[:12] + "...")
@@ -2319,7 +2319,7 @@ def render_buy_result(entry, created_tickets, remaining, cart_count) -> str:
remaining_html = sx_call("events-buy-result-remaining", remaining_html = sx_call("events-buy-result-remaining",
text=f"{remaining} ticket{r_suffix} remaining") text=f"{remaining} ticket{r_suffix} remaining")
my_href = url_for("tickets.defpage_my_tickets") my_href = url_for("defpage_my_tickets")
return cart_html + sx_call("events-buy-result", return cart_html + sx_call("events-buy-result",
entry_id=str(entry.id), entry_id=str(entry.id),
@@ -2411,7 +2411,7 @@ def _ticket_adjust_controls(csrf, adjust_url, target, entry_id, count, *, ticket
return _adj_form(1, sx_call("events-adjust-cart-plus"), return _adj_form(1, sx_call("events-adjust-cart-plus"),
extra_cls="flex items-center") extra_cls="flex items-center")
my_tickets_href = url_for("tickets.defpage_my_tickets") my_tickets_href = url_for("defpage_my_tickets")
minus = _adj_form(count - 1, sx_call("events-adjust-minus")) minus = _adj_form(count - 1, sx_call("events-adjust-minus"))
cart_icon = sx_call("events-adjust-cart-icon", cart_icon = sx_call("events-adjust-cart-icon",
href=my_tickets_href, count=str(count)) href=my_tickets_href, count=str(count))

View File

@@ -311,6 +311,183 @@ def _markets_oob(ctx: dict, **kw: Any) -> str:
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,39 +513,72 @@ 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 _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 _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 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 from quart import g, abort
return getattr(g, "day_admin_content", "") 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 render_slot_main_panel(slot, calendar)
def _h_slots_content(): async def _h_entry_content(calendar_slug=None, entry_id=None, **kw):
from quart import g await _ensure_calendar(calendar_slug)
return getattr(g, "slots_content", "") 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 _entry_main_panel_html(ctx)
def _h_slot_content(): async def _h_entry_menu(calendar_slug=None, entry_id=None, **kw):
from quart import g await _ensure_calendar(calendar_slug)
return getattr(g, "slot_content", "") 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 _entry_nav_html(ctx)
def _h_entry_content(): async def _h_entry_admin_content(calendar_slug=None, entry_id=None, **kw):
from quart import g await _ensure_calendar(calendar_slug)
return getattr(g, "entry_content", "") await _ensure_entry_context(entry_id)
from shared.sx.page import get_template_context
from sx.sx_components import _entry_admin_main_panel_html
def _h_entry_menu(): ctx = await get_template_context()
from quart import g return _entry_admin_main_panel_html(ctx)
return getattr(g, "entry_menu", "")
def _h_entry_admin_content():
from quart import g
return getattr(g, "entry_admin_content", "")
def _h_admin_menu(): def _h_admin_menu():
@@ -376,31 +586,118 @@ def _h_admin_menu():
return sx_call("events-admin-placeholder-nav") return sx_call("events-admin-placeholder-nav")
def _h_ticket_types_content(): 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, "ticket_types_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 render_ticket_types_table(ticket_types, entry, calendar, day, month, year)
def _h_ticket_type_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 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, "ticket_type_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 _tickets_main_panel_html(ctx, tickets)
def _h_tickets_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 _ticket_detail_panel_html(ctx, ticket)
async def _h_ticket_admin_content(**kw):
from quart import g from quart import g
return getattr(g, "tickets_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 _ticket_admin_main_panel_html(ctx, tickets, stats)
def _h_ticket_detail_content(): async def _h_markets_content(**kw):
from quart import g from shared.sx.page import get_template_context
return getattr(g, "ticket_detail_content", "") from sx.sx_components import _markets_main_panel_html
ctx = await get_template_context()
return _markets_main_panel_html(ctx)
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

@@ -12,7 +12,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,
) )
@@ -94,11 +93,13 @@ 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("/")

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

@@ -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

@@ -32,102 +32,6 @@ def register(url_prefix="/social"):
actor = await services.federation.get_actor_by_user_id(g.s, g.user.id) actor = await services.federation.get_actor_by_user_id(g.s, g.user.id)
g._social_actor = actor g._social_actor = actor
@bp.before_request
async def _prepare_page_data():
"""Pre-render content for defpage routes."""
endpoint = request.endpoint or ""
if endpoint.endswith("defpage_home_timeline"):
actor = _require_actor()
items = await services.federation.get_home_timeline(g.s, actor.id)
from sx.sx_components import _timeline_content_sx
g.home_timeline_content = _timeline_content_sx(items, "home", actor)
elif endpoint.endswith("defpage_public_timeline"):
actor = getattr(g, "_social_actor", None)
items = await services.federation.get_public_timeline(g.s)
from sx.sx_components import _timeline_content_sx
g.public_timeline_content = _timeline_content_sx(items, "public", actor)
elif endpoint.endswith("defpage_compose_form"):
actor = _require_actor()
from sx.sx_components import _compose_content_sx
reply_to = request.args.get("reply_to")
g.compose_content = _compose_content_sx(actor, reply_to)
elif endpoint.endswith("defpage_search"):
actor = getattr(g, "_social_actor", None)
query = request.args.get("q", "").strip()
actors_list = []
total = 0
followed_urls: set[str] = set()
if query:
actors_list, total = await services.federation.search_actors(g.s, query)
if actor:
following, _ = await services.federation.get_following(
g.s, actor.preferred_username, page=1, per_page=1000,
)
followed_urls = {a.actor_url for a in following}
from sx.sx_components import _search_content_sx
g.search_content = _search_content_sx(query, actors_list, total, 1, followed_urls, actor)
elif endpoint.endswith("defpage_following_list"):
actor = _require_actor()
actors_list, total = await services.federation.get_following(
g.s, actor.preferred_username,
)
from sx.sx_components import _following_content_sx
g.following_content = _following_content_sx(actors_list, total, actor)
elif endpoint.endswith("defpage_followers_list"):
actor = _require_actor()
actors_list, total = await services.federation.get_followers_paginated(
g.s, actor.preferred_username,
)
following, _ = await services.federation.get_following(
g.s, actor.preferred_username, page=1, per_page=1000,
)
followed_urls = {a.actor_url for a in following}
from sx.sx_components import _followers_content_sx
g.followers_content = _followers_content_sx(actors_list, total, followed_urls, actor)
elif endpoint.endswith("defpage_actor_timeline"):
actor = getattr(g, "_social_actor", None)
actor_id = request.view_args.get("id")
from shared.models.federation import RemoteActor
from sqlalchemy import select as sa_select
remote = (
await g.s.execute(
sa_select(RemoteActor).where(RemoteActor.id == actor_id)
)
).scalar_one_or_none()
if not remote:
abort(404)
from shared.services.federation_impl import _remote_actor_to_dto
remote_dto = _remote_actor_to_dto(remote)
items = await services.federation.get_actor_timeline(g.s, actor_id)
is_following = False
if actor:
from shared.models.federation import APFollowing
existing = (
await g.s.execute(
sa_select(APFollowing).where(
APFollowing.actor_profile_id == actor.id,
APFollowing.remote_actor_id == actor_id,
)
)
).scalar_one_or_none()
is_following = existing is not None
from sx.sx_components import _actor_timeline_content_sx
g.actor_timeline_content = _actor_timeline_content_sx(remote_dto, items, is_following, actor)
elif endpoint.endswith("defpage_notifications"):
actor = _require_actor()
items = await services.federation.get_notifications(g.s, actor.id)
await services.federation.mark_notifications_read(g.s, actor.id)
from sx.sx_components import _notifications_content_sx
g.notifications_content = _notifications_content_sx(items)
# -- Timeline pagination --------------------------------------------------- # -- Timeline pagination ---------------------------------------------------
@bp.get("/timeline") @bp.get("/timeline")
@@ -170,7 +74,7 @@ def register(url_prefix="/social"):
form = await request.form form = await request.form
content = form.get("content", "").strip() content = form.get("content", "").strip()
if not content: if not content:
return redirect(url_for("social.defpage_compose_form")) return redirect(url_for("defpage_compose_form"))
visibility = form.get("visibility", "public") visibility = form.get("visibility", "public")
in_reply_to = form.get("in_reply_to") or None in_reply_to = form.get("in_reply_to") or None
@@ -181,13 +85,13 @@ def register(url_prefix="/social"):
visibility=visibility, visibility=visibility,
in_reply_to=in_reply_to, in_reply_to=in_reply_to,
) )
return redirect(url_for("social.defpage_home_timeline")) return redirect(url_for("defpage_home_timeline"))
@bp.post("/delete/<int:post_id>") @bp.post("/delete/<int:post_id>")
async def delete_post(post_id: int): async def delete_post(post_id: int):
actor = _require_actor() actor = _require_actor()
await services.federation.delete_local_post(g.s, actor.id, post_id) await services.federation.delete_local_post(g.s, actor.id, post_id)
return redirect(url_for("social.defpage_home_timeline")) return redirect(url_for("defpage_home_timeline"))
# -- Search + Follow ------------------------------------------------------- # -- Search + Follow -------------------------------------------------------
@@ -223,7 +127,7 @@ def register(url_prefix="/social"):
) )
if request.headers.get("SX-Request") or request.headers.get("HX-Request"): if request.headers.get("SX-Request") or request.headers.get("HX-Request"):
return await _actor_card_response(actor, remote_actor_url, is_followed=True) return await _actor_card_response(actor, remote_actor_url, is_followed=True)
return redirect(request.referrer or url_for("social.defpage_search")) return redirect(request.referrer or url_for("defpage_search"))
@bp.post("/unfollow") @bp.post("/unfollow")
async def unfollow(): async def unfollow():
@@ -236,7 +140,7 @@ def register(url_prefix="/social"):
) )
if request.headers.get("SX-Request") or request.headers.get("HX-Request"): if request.headers.get("SX-Request") or request.headers.get("HX-Request"):
return await _actor_card_response(actor, remote_actor_url, is_followed=False) return await _actor_card_response(actor, remote_actor_url, is_followed=False)
return redirect(request.referrer or url_for("social.defpage_search")) return redirect(request.referrer or url_for("defpage_search"))
async def _actor_card_response(actor, remote_actor_url, is_followed): async def _actor_card_response(actor, remote_actor_url, is_followed):
"""Re-render a single actor card after follow/unfollow via HTMX.""" """Re-render a single actor card after follow/unfollow via HTMX."""
@@ -414,6 +318,6 @@ def register(url_prefix="/social"):
async def mark_read(): async def mark_read():
actor = _require_actor() actor = _require_actor()
await services.federation.mark_notifications_read(g.s, actor.id) await services.federation.mark_notifications_read(g.s, actor.id)
return redirect(url_for("social.defpage_notifications")) return redirect(url_for("defpage_notifications"))
return bp return bp

View File

@@ -69,41 +69,130 @@ def _register_federation_helpers() -> None:
}) })
def _h_home_timeline_content(): def _get_actor():
"""Return current user's actor or None."""
from quart import g from quart import g
return getattr(g, "home_timeline_content", "") return getattr(g, "_social_actor", None)
def _h_public_timeline_content(): def _require_actor():
"""Return current user's actor or abort 403."""
from quart import abort
actor = _get_actor()
if not actor:
abort(403, "You need to choose a federation username first")
return actor
async def _h_home_timeline_content(**kw):
from quart import g from quart import g
return getattr(g, "public_timeline_content", "") from shared.services.registry import services
actor = _require_actor()
items = await services.federation.get_home_timeline(g.s, actor.id)
from sx.sx_components import _timeline_content_sx
return _timeline_content_sx(items, "home", actor)
def _h_compose_content(): async def _h_public_timeline_content(**kw):
from quart import g from quart import g
return getattr(g, "compose_content", "") from shared.services.registry import services
actor = _get_actor()
items = await services.federation.get_public_timeline(g.s)
from sx.sx_components import _timeline_content_sx
return _timeline_content_sx(items, "public", actor)
def _h_search_content(): async def _h_compose_content(**kw):
from quart import request
actor = _require_actor()
from sx.sx_components import _compose_content_sx
reply_to = request.args.get("reply_to")
return _compose_content_sx(actor, reply_to)
async def _h_search_content(**kw):
from quart import g, request
from shared.services.registry import services
actor = _get_actor()
query = request.args.get("q", "").strip()
actors_list = []
total = 0
followed_urls: set[str] = set()
if query:
actors_list, total = await services.federation.search_actors(g.s, query)
if actor:
following, _ = await services.federation.get_following(
g.s, actor.preferred_username, page=1, per_page=1000,
)
followed_urls = {a.actor_url for a in following}
from sx.sx_components import _search_content_sx
return _search_content_sx(query, actors_list, total, 1, followed_urls, actor)
async def _h_following_content(**kw):
from quart import g from quart import g
return getattr(g, "search_content", "") from shared.services.registry import services
actor = _require_actor()
actors_list, total = await services.federation.get_following(
g.s, actor.preferred_username,
)
from sx.sx_components import _following_content_sx
return _following_content_sx(actors_list, total, actor)
def _h_following_content(): async def _h_followers_content(**kw):
from quart import g from quart import g
return getattr(g, "following_content", "") from shared.services.registry import services
actor = _require_actor()
actors_list, total = await services.federation.get_followers_paginated(
g.s, actor.preferred_username,
)
following, _ = await services.federation.get_following(
g.s, actor.preferred_username, page=1, per_page=1000,
)
followed_urls = {a.actor_url for a in following}
from sx.sx_components import _followers_content_sx
return _followers_content_sx(actors_list, total, followed_urls, actor)
def _h_followers_content(): async def _h_actor_timeline_content(id=None, **kw):
from quart import g, abort
from shared.services.registry import services
actor = _get_actor()
actor_id = id
from shared.models.federation import RemoteActor
from sqlalchemy import select as sa_select
remote = (
await g.s.execute(
sa_select(RemoteActor).where(RemoteActor.id == actor_id)
)
).scalar_one_or_none()
if not remote:
abort(404)
from shared.services.federation_impl import _remote_actor_to_dto
remote_dto = _remote_actor_to_dto(remote)
items = await services.federation.get_actor_timeline(g.s, actor_id)
is_following = False
if actor:
from shared.models.federation import APFollowing
existing = (
await g.s.execute(
sa_select(APFollowing).where(
APFollowing.actor_profile_id == actor.id,
APFollowing.remote_actor_id == actor_id,
)
)
).scalar_one_or_none()
is_following = existing is not None
from sx.sx_components import _actor_timeline_content_sx
return _actor_timeline_content_sx(remote_dto, items, is_following, actor)
async def _h_notifications_content(**kw):
from quart import g from quart import g
return getattr(g, "followers_content", "") from shared.services.registry import services
actor = _require_actor()
items = await services.federation.get_notifications(g.s, actor.id)
def _h_actor_timeline_content(): await services.federation.mark_notifications_read(g.s, actor.id)
from quart import g from sx.sx_components import _notifications_content_sx
return getattr(g, "actor_timeline_content", "") return _notifications_content_sx(items)
def _h_notifications_content():
from quart import g
return getattr(g, "notifications_content", "")

View File

@@ -1,49 +1,49 @@
;; Federation social pages ;; Federation social pages
(defpage home-timeline (defpage home-timeline
:path "/" :path "/social/"
:auth :login :auth :login
:layout :social :layout :social
:content (home-timeline-content)) :content (home-timeline-content))
(defpage public-timeline (defpage public-timeline
:path "/public" :path "/social/public"
:auth :public :auth :public
:layout :social :layout :social
:content (public-timeline-content)) :content (public-timeline-content))
(defpage compose-form (defpage compose-form
:path "/compose" :path "/social/compose"
:auth :login :auth :login
:layout :social :layout :social
:content (compose-content)) :content (compose-content))
(defpage search (defpage search
:path "/search" :path "/social/search"
:auth :public :auth :public
:layout :social :layout :social
:content (search-content)) :content (search-content))
(defpage following-list (defpage following-list
:path "/following" :path "/social/following"
:auth :login :auth :login
:layout :social :layout :social
:content (following-content)) :content (following-content))
(defpage followers-list (defpage followers-list
:path "/followers" :path "/social/followers"
:auth :login :auth :login
:layout :social :layout :social
:content (followers-content)) :content (followers-content))
(defpage actor-timeline (defpage actor-timeline
:path "/actor/<int:id>" :path "/social/actor/<int:id>"
:auth :public :auth :public
:layout :social :layout :social
:content (actor-timeline-content)) :content (actor-timeline-content id))
(defpage notifications (defpage notifications
:path "/notifications" :path "/social/notifications"
:auth :login :auth :login
:layout :social :layout :social
:content (notifications-content)) :content (notifications-content))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from quart import Blueprint, g, render_template, make_response, url_for from quart import Blueprint, g, make_response, url_for
from ..browse.routes import register as register_browse_bp from ..browse.routes import register as register_browse_bp

View File

@@ -26,17 +26,6 @@ def _slugify(value: str, max_len: int = 255) -> str:
def register(): def register():
bp = Blueprint("page_admin", __name__) bp = Blueprint("page_admin", __name__)
@bp.before_request
async def _prepare_page_data():
"""Pre-render page admin content for defpage (async helper)."""
endpoint = request.endpoint or ""
if request.method != "GET" or not endpoint.endswith("defpage_page_admin"):
return
from shared.sx.page import get_template_context
from sx.sx_components import _markets_admin_panel_sx
ctx = await get_template_context()
g.page_admin_content = await _markets_admin_panel_sx(ctx)
@bp.post("/new/") @bp.post("/new/")
@require_admin @require_admin
async def create_market(**kwargs): async def create_market(**kwargs):

View File

@@ -23,20 +23,6 @@ async def _load_markets(post_id, page, per_page=20):
def register() -> Blueprint: def register() -> Blueprint:
bp = Blueprint("page_markets", __name__) bp = Blueprint("page_markets", __name__)
@bp.before_request
async def _prepare_page_data():
"""Load page-markets data for defpage routes."""
endpoint = request.endpoint or ""
if not endpoint.endswith("defpage_page_markets_index"):
return
post = g.post_data["post"]
page = int(request.args.get("page", 1))
markets, has_more = await _load_markets(post["id"], page)
g.page_markets_data = {
"markets": markets, "has_more": has_more,
"page": page, "post_slug": post.get("slug", ""),
}
@bp.get("/page-markets") @bp.get("/page-markets")
async def markets_fragment(): async def markets_fragment():
post = g.post_data["post"] post = g.post_data["post"]

View File

@@ -98,67 +98,77 @@ def _register_market_helpers() -> None:
}) })
def _h_all_markets_content(): async def _h_all_markets_content(**kw):
from quart import g, url_for, request from quart import g, url_for, request
from shared.utils import route_prefix from shared.utils import route_prefix
from shared.services.registry import services
from shared.infrastructure.data_client import fetch_data
from shared.contracts.dtos import PostDTO, dto_from_dict
data = getattr(g, "all_markets_data", None) page = int(request.args.get("page", 1))
if not data: markets, has_more = await services.market.list_marketplaces(
g.s, page=page, per_page=20,
)
page_info = {}
if markets:
post_ids = list({m.container_id for m in markets if m.container_type == "page"})
if post_ids:
raw_posts = await fetch_data("blog", "posts-by-ids",
params={"ids": ",".join(str(i) for i in post_ids)},
required=False) or []
for raw_p in raw_posts:
p = dto_from_dict(PostDTO, raw_p)
page_info[p.id] = {"title": p.title, "slug": p.slug}
if not markets:
from sx.sx_components import _no_markets_sx from sx.sx_components import _no_markets_sx
return _no_markets_sx() return _no_markets_sx()
markets = data["markets"]
has_more = data["has_more"]
page_info = data["page_info"]
page = data["page"]
prefix = route_prefix() prefix = route_prefix()
next_url = prefix + url_for("all_markets.markets_fragment", page=page + 1) next_url = prefix + url_for("all_markets.markets_fragment", page=page + 1)
from sx.sx_components import _market_cards_sx, _markets_grid, _no_markets_sx from sx.sx_components import _market_cards_sx, _markets_grid
if markets: cards = _market_cards_sx(markets, page_info, page, has_more, next_url)
cards = _market_cards_sx(markets, page_info, page, has_more, next_url) content = _markets_grid(cards)
content = _markets_grid(cards)
else:
content = _no_markets_sx()
return "(<> " + content + " " + '(div :class "pb-8")' + ")" return "(<> " + content + " " + '(div :class "pb-8")' + ")"
def _h_page_markets_content(): async def _h_page_markets_content(slug=None, **kw):
from quart import g, url_for from quart import g, url_for, request
from shared.utils import route_prefix from shared.utils import route_prefix
from shared.services.registry import services
data = getattr(g, "page_markets_data", None) post = g.post_data["post"]
if not data: page = int(request.args.get("page", 1))
markets, has_more = await services.market.list_marketplaces(
g.s, "page", post["id"], page=page, per_page=20,
)
post_slug = post.get("slug", "")
if not markets:
from sx.sx_components import _no_markets_sx from sx.sx_components import _no_markets_sx
return _no_markets_sx("No markets for this page") return _no_markets_sx("No markets for this page")
markets = data["markets"]
has_more = data["has_more"]
page = data["page"]
post_slug = data.get("post_slug", "")
prefix = route_prefix() prefix = route_prefix()
next_url = prefix + url_for("page_markets.markets_fragment", page=page + 1) next_url = prefix + url_for("page_markets.markets_fragment", page=page + 1)
from sx.sx_components import _market_cards_sx, _markets_grid, _no_markets_sx from sx.sx_components import _market_cards_sx, _markets_grid
if markets: cards = _market_cards_sx(markets, {}, page, has_more, next_url,
cards = _market_cards_sx(markets, {}, page, has_more, next_url, show_page_badge=False, post_slug=post_slug)
show_page_badge=False, post_slug=post_slug) content = _markets_grid(cards)
content = _markets_grid(cards)
else:
content = _no_markets_sx("No markets for this page")
return "(<> " + content + " " + '(div :class "pb-8")' + ")" return "(<> " + content + " " + '(div :class "pb-8")' + ")"
def _h_page_admin_content(): async def _h_page_admin_content(slug=None, **kw):
# Content pre-rendered by before_request (async _markets_admin_panel_sx) from shared.sx.page import get_template_context
from quart import g from sx.sx_components import _markets_admin_panel_sx
content = getattr(g, "page_admin_content", "") ctx = await get_template_context()
content = await _markets_admin_panel_sx(ctx)
return '(div :id "main-panel" ' + content + ')' return '(div :id "main-panel" ' + content + ')'
def _h_market_home_content(): def _h_market_home_content(page_slug=None, market_slug=None, **kw):
from quart import g from quart import g
post_data = getattr(g, "post_data", {}) post_data = getattr(g, "post_data", {})
post = post_data.get("post", {}) post = post_data.get("post", {})
@@ -166,5 +176,5 @@ def _h_market_home_content():
return _market_landing_content_sx(post) return _market_landing_content_sx(post)
def _h_market_admin_content(): def _h_market_admin_content(page_slug=None, market_slug=None, **kw):
return '"market admin"' return '"market admin"'

View File

@@ -1,10 +1,10 @@
;; Market app defpage declarations. ;; Market app defpage declarations.
;; ;;
;; all-markets-index: / — global view across all pages ;; all-markets-index: / — global view across all pages
;; page-markets-index: / (on page_markets bp, mounted at /<slug>) ;; page-markets-index: /<slug>/ — markets for a single page
;; page-admin: / (on page_admin bp, mounted at /<slug>/admin) ;; page-admin: /<slug>/admin/ — post-level admin for markets
;; market-home: / (on browse bp, mounted at /<page_slug>/<market_slug>) ;; market-home: /<page_slug>/<market_slug>/ — market landing page
;; market-admin: / (on admin bp, mounted at /<page_slug>/<market_slug>/admin) ;; market-admin: /<page_slug>/<market_slug>/admin/ — market admin
(defpage all-markets-index (defpage all-markets-index
:path "/" :path "/"
@@ -13,25 +13,25 @@
:content (all-markets-content)) :content (all-markets-content))
(defpage page-markets-index (defpage page-markets-index
:path "/" :path "/<slug>/"
:auth :public :auth :public
:layout :post :layout :post
:content (page-markets-content)) :content (page-markets-content))
(defpage page-admin (defpage page-admin
:path "/" :path "/<slug>/admin/"
:auth :admin :auth :admin
:layout (:post-admin :selected "markets") :layout (:post-admin :selected "markets")
:content (page-admin-content)) :content (page-admin-content))
(defpage market-home (defpage market-home
:path "/" :path "/<page_slug>/<market_slug>/"
:auth :public :auth :public
:layout :market :layout :market
:content (market-home-content)) :content (market-home-content))
(defpage market-admin (defpage market-admin
:path "/" :path "/<page_slug>/<market_slug>/admin/"
:auth :admin :auth :admin
:layout (:market-admin :selected "markets") :layout (:market-admin :selected "markets")
:content (market-admin-content)) :content (market-admin-content))

View File

@@ -14,7 +14,6 @@ from bp import (
register_orders, register_orders,
register_order, register_order,
register_checkout, register_checkout,
register_fragments,
register_actions, register_actions,
register_data, register_data,
) )
@@ -77,16 +76,19 @@ def create_app() -> "Quart":
from sxc.pages import setup_orders_pages from sxc.pages import setup_orders_pages
setup_orders_pages() setup_orders_pages()
app.register_blueprint(register_fragments()) from shared.sx.handlers import auto_mount_fragment_handlers
auto_mount_fragment_handlers(app, "orders")
app.register_blueprint(register_actions()) app.register_blueprint(register_actions())
app.register_blueprint(register_data()) app.register_blueprint(register_data())
# Orders list at / (defpage routes mounted below) # Orders list at /
bp = register_orders(url_prefix="/") bp = register_orders(url_prefix="/")
from shared.sx.pages import mount_pages
mount_pages(bp, "orders")
app.register_blueprint(bp) app.register_blueprint(bp)
from shared.sx.pages import auto_mount_pages
auto_mount_pages(app, "orders")
# Checkout webhook + return # Checkout webhook + return
app.register_blueprint(register_checkout()) app.register_blueprint(register_checkout())

View File

@@ -3,4 +3,3 @@ from .orders.routes import register as register_orders
from .checkout.routes import register as register_checkout from .checkout.routes import register as register_checkout
from .data.routes import register as register_data from .data.routes import register as register_data
from .actions.routes import register as register_actions from .actions.routes import register as register_actions
from .fragments.routes import register as register_fragments

View File

@@ -1,36 +0,0 @@
"""Orders 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
``orders/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("orders", fragment_type)
if handler_def is not None:
result = await execute_handler(
handler_def, "orders", 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

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from quart import Blueprint, g, redirect, url_for, make_response, request from quart import Blueprint, g, redirect, url_for, request
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
@@ -8,7 +8,6 @@ from shared.models.order import Order, OrderItem
from shared.infrastructure.http_utils import vary as _vary, current_url_without_page as _current_url_without_page from shared.infrastructure.http_utils import vary as _vary, current_url_without_page as _current_url_without_page
from shared.infrastructure.cart_identity import current_cart_identity from shared.infrastructure.cart_identity import current_cart_identity
from shared.browser.app.utils.htmx import is_htmx_request
from bp.order.routes import register as register_order from bp.order.routes import register as register_order
from .filters.qs import makeqs_factory, decode from .filters.qs import makeqs_factory, decode
@@ -31,112 +30,6 @@ def register(url_prefix: str) -> Blueprint:
if not ident["user_id"] and not ident["session_id"]: if not ident["user_id"] and not ident["session_id"]:
return redirect(url_for("auth.login_form")) return redirect(url_for("auth.login_form"))
@bp.before_request
async def _prepare_page_data():
"""Load data for defpage routes into g.*."""
if request.method != "GET":
return
endpoint = request.endpoint or ""
# Orders list page
if endpoint.endswith("defpage_orders_list"):
ident = current_cart_identity()
if ident["user_id"]:
owner_clause = Order.user_id == ident["user_id"]
elif ident["session_id"]:
owner_clause = Order.session_id == ident["session_id"]
else:
return
q = decode()
page, search = q.page, q.search
if page < 1:
page = 1
where_clause = _search_clause(search) if search else None
count_stmt = select(func.count()).select_from(Order).where(owner_clause)
if where_clause is not None:
count_stmt = count_stmt.where(where_clause)
total_count_result = await g.s.execute(count_stmt)
total_count = total_count_result.scalar_one() or 0
total_pages = max(1, (total_count + ORDERS_PER_PAGE - 1) // ORDERS_PER_PAGE)
if page > total_pages:
page = total_pages
offset = (page - 1) * ORDERS_PER_PAGE
stmt = (
select(Order)
.where(owner_clause)
.order_by(Order.created_at.desc())
.offset(offset)
.limit(ORDERS_PER_PAGE)
)
if where_clause is not None:
stmt = stmt.where(where_clause)
result = await g.s.execute(stmt)
orders = result.scalars().all()
from shared.utils import route_prefix
pfx = route_prefix()
qs_fn = makeqs_factory()
g.orders_page_data = {
"orders": orders,
"page": page,
"total_pages": total_pages,
"search": search,
"search_count": total_count,
"url_for_fn": url_for,
"qs_fn": qs_fn,
"list_url": pfx + url_for("orders.defpage_orders_list"),
}
# Order detail page
elif endpoint.endswith("defpage_order_detail"):
order_id = request.view_args.get("order_id")
if order_id is None:
return
ident = current_cart_identity()
if ident["user_id"]:
owner = Order.user_id == ident["user_id"]
elif ident["session_id"]:
owner = Order.session_id == ident["session_id"]
else:
from quart import abort
abort(404)
return
result = await g.s.execute(
select(Order)
.options(selectinload(Order.items))
.where(Order.id == order_id, owner)
)
order = result.scalar_one_or_none()
if not order:
from quart import abort
abort(404)
return
from shared.utils import route_prefix
from shared.browser.app.csrf import generate_csrf_token
pfx = route_prefix()
g.order_detail_data = {
"order": order,
"calendar_entries": None,
"detail_url": pfx + url_for("orders.defpage_order_detail", order_id=order.id),
"list_url": pfx + url_for("orders.defpage_orders_list"),
"recheck_url": pfx + url_for("orders.order.order_recheck", order_id=order.id),
"pay_url": pfx + url_for("orders.order.order_pay", order_id=order.id),
"csrf_token": generate_csrf_token(),
}
@bp.get("/rows") @bp.get("/rows")
async def orders_rows(): async def orders_rows():
"""Pagination endpoint — returns order rows for page > 1.""" """Pagination endpoint — returns order rows for page > 1."""

View File

@@ -119,7 +119,143 @@ def _register_orders_helpers() -> None:
}) })
def _h_orders_list_content(): async def _ensure_orders_list():
"""Fetch orders list data and store in g.orders_page_data."""
from quart import g, url_for
if hasattr(g, "orders_page_data"):
return
from sqlalchemy import select, func, or_, cast, String, exists
from shared.models.order import Order, OrderItem
from shared.infrastructure.cart_identity import current_cart_identity
from shared.utils import route_prefix
ORDERS_PER_PAGE = 10
ident = current_cart_identity()
if ident["user_id"]:
owner_clause = Order.user_id == ident["user_id"]
elif ident["session_id"]:
owner_clause = Order.session_id == ident["session_id"]
else:
g.orders_page_data = None
return
from bp.orders.filters.qs import makeqs_factory, decode
q = decode()
page, search = q.page, q.search
if page < 1:
page = 1
where_clause = None
if search:
term = f"%{search.strip()}%"
conditions = [
Order.status.ilike(term),
Order.currency.ilike(term),
Order.sumup_checkout_id.ilike(term),
Order.sumup_status.ilike(term),
Order.description.ilike(term),
]
conditions.append(
exists(
select(1).select_from(OrderItem)
.where(OrderItem.order_id == Order.id,
or_(OrderItem.product_title.ilike(term),
OrderItem.product_slug.ilike(term)))
)
)
try:
search_id = int(search)
except (TypeError, ValueError):
search_id = None
if search_id is not None:
conditions.append(Order.id == search_id)
else:
conditions.append(cast(Order.id, String).ilike(term))
where_clause = or_(*conditions)
count_stmt = select(func.count()).select_from(Order).where(owner_clause)
if where_clause is not None:
count_stmt = count_stmt.where(where_clause)
total_count_result = await g.s.execute(count_stmt)
total_count = total_count_result.scalar_one() or 0
total_pages = max(1, (total_count + ORDERS_PER_PAGE - 1) // ORDERS_PER_PAGE)
if page > total_pages:
page = total_pages
offset = (page - 1) * ORDERS_PER_PAGE
stmt = (
select(Order).where(owner_clause)
.order_by(Order.created_at.desc())
.offset(offset).limit(ORDERS_PER_PAGE)
)
if where_clause is not None:
stmt = stmt.where(where_clause)
result = await g.s.execute(stmt)
orders = result.scalars().all()
pfx = route_prefix()
qs_fn = makeqs_factory()
g.orders_page_data = {
"orders": orders,
"page": page,
"total_pages": total_pages,
"search": search,
"search_count": total_count,
"url_for_fn": url_for,
"qs_fn": qs_fn,
"list_url": pfx + url_for("defpage_orders_list"),
}
async def _ensure_order_detail(order_id):
"""Fetch order detail data and store in g.order_detail_data."""
from quart import g, url_for, abort
if hasattr(g, "order_detail_data"):
return
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from shared.models.order import Order
from shared.infrastructure.cart_identity import current_cart_identity
from shared.utils import route_prefix
from shared.browser.app.csrf import generate_csrf_token
if order_id is None:
abort(404)
ident = current_cart_identity()
if ident["user_id"]:
owner = Order.user_id == ident["user_id"]
elif ident["session_id"]:
owner = Order.session_id == ident["session_id"]
else:
abort(404)
return
result = await g.s.execute(
select(Order).options(selectinload(Order.items))
.where(Order.id == order_id, owner)
)
order = result.scalar_one_or_none()
if not order:
abort(404)
return
pfx = route_prefix()
g.order_detail_data = {
"order": order,
"calendar_entries": None,
"detail_url": pfx + url_for("defpage_order_detail", order_id=order.id),
"list_url": pfx + url_for("defpage_orders_list"),
"recheck_url": pfx + url_for("orders.order.order_recheck", order_id=order.id),
"pay_url": pfx + url_for("orders.order.order_pay", order_id=order.id),
"csrf_token": generate_csrf_token(),
}
async def _h_orders_list_content(**kw):
await _ensure_orders_list()
from quart import g from quart import g
d = getattr(g, "orders_page_data", None) d = getattr(g, "orders_page_data", None)
if not d: if not d:
@@ -131,7 +267,8 @@ def _h_orders_list_content():
return _orders_main_panel_sx(d["orders"], rows) return _orders_main_panel_sx(d["orders"], rows)
def _h_orders_list_filter(): async def _h_orders_list_filter(**kw):
await _ensure_orders_list()
from quart import g from quart import g
from shared.sx.helpers import sx_call, SxExpr from shared.sx.helpers import sx_call, SxExpr
from shared.sx.page import SEARCH_HEADERS_MOBILE from shared.sx.page import SEARCH_HEADERS_MOBILE
@@ -148,7 +285,8 @@ def _h_orders_list_filter():
return sx_call("order-list-header", search_mobile=SxExpr(search_mobile)) return sx_call("order-list-header", search_mobile=SxExpr(search_mobile))
def _h_orders_list_aside(): async def _h_orders_list_aside(**kw):
await _ensure_orders_list()
from quart import g from quart import g
from shared.sx.helpers import sx_call from shared.sx.helpers import sx_call
from shared.sx.page import SEARCH_HEADERS_DESKTOP from shared.sx.page import SEARCH_HEADERS_DESKTOP
@@ -164,13 +302,15 @@ def _h_orders_list_aside():
) )
def _h_orders_list_url(): async def _h_orders_list_url(**kw):
await _ensure_orders_list()
from quart import g from quart import g
d = getattr(g, "orders_page_data", None) d = getattr(g, "orders_page_data", None)
return d["list_url"] if d else "/" return d["list_url"] if d else "/"
def _h_order_detail_content(): async def _h_order_detail_content(order_id=None, **kw):
await _ensure_order_detail(order_id)
from quart import g from quart import g
d = getattr(g, "order_detail_data", None) d = getattr(g, "order_detail_data", None)
if not d: if not d:
@@ -179,7 +319,8 @@ def _h_order_detail_content():
return _order_main_sx(d["order"], d["calendar_entries"]) return _order_main_sx(d["order"], d["calendar_entries"])
def _h_order_detail_filter(): async def _h_order_detail_filter(order_id=None, **kw):
await _ensure_order_detail(order_id)
from quart import g from quart import g
d = getattr(g, "order_detail_data", None) d = getattr(g, "order_detail_data", None)
if not d: if not d:
@@ -189,13 +330,15 @@ def _h_order_detail_filter():
d["pay_url"], d["csrf_token"]) d["pay_url"], d["csrf_token"])
def _h_order_detail_url(): async def _h_order_detail_url(order_id=None, **kw):
await _ensure_order_detail(order_id)
from quart import g from quart import g
d = getattr(g, "order_detail_data", None) d = getattr(g, "order_detail_data", None)
return d["detail_url"] if d else "/" return d["detail_url"] if d else "/"
def _h_order_list_url_from_detail(): async def _h_order_list_url_from_detail(order_id=None, **kw):
await _ensure_order_detail(order_id)
from quart import g from quart import g
d = getattr(g, "order_detail_data", None) d = getattr(g, "order_detail_data", None)
return d["list_url"] if d else "/" return d["list_url"] if d else "/"

View File

@@ -21,7 +21,7 @@
:path "/<int:order_id>/" :path "/<int:order_id>/"
:auth :public :auth :public
:layout (:order-detail :layout (:order-detail
:list-url (order-list-url-from-detail) :list-url (order-list-url-from-detail order-id)
:detail-url (order-detail-url)) :detail-url (order-detail-url order-id))
:filter (order-detail-filter) :filter (order-detail-filter order-id)
:content (order-detail-content)) :content (order-detail-content order-id))

View File

@@ -4,7 +4,7 @@ import sx.sx_components as sx_components # noqa: F401 # ensure Hypercorn --rel
from shared.infrastructure.factory import create_base_app from shared.infrastructure.factory import create_base_app
from bp import register_actions, register_data, register_fragments from bp import register_actions, register_data
from services import register_domain_services from services import register_domain_services
@@ -16,7 +16,9 @@ def create_app() -> "Quart":
app.register_blueprint(register_actions()) app.register_blueprint(register_actions())
app.register_blueprint(register_data()) app.register_blueprint(register_data())
app.register_blueprint(register_fragments())
from shared.sx.handlers import auto_mount_fragment_handlers
auto_mount_fragment_handlers(app, "relations")
return app return app

View File

@@ -1,3 +1,2 @@
from .data.routes import register as register_data from .data.routes import register as register_data
from .actions.routes import register as register_actions from .actions.routes import register as register_actions
from .fragments.routes import register as register_fragments

View File

@@ -1,36 +0,0 @@
"""Relations 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
``relations/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("relations", fragment_type)
if handler_def is not None:
result = await execute_handler(
handler_def, "relations", 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

@@ -1,42 +0,0 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='social-lite-row', oob=oob) %}
<div class="w-full flex flex-row items-center gap-2 flex-wrap">
{% if actor %}
<nav class="flex gap-3 text-sm items-center flex-wrap">
<a href="{{ url_for('ap_social.search') }}"
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('ap_social.search') %}font-bold{% endif %}">
Search
</a>
<a href="{{ url_for('ap_social.following_list') }}"
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('ap_social.following_list') %}font-bold{% endif %}">
Following
</a>
<a href="{{ url_for('ap_social.followers_list') }}"
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('ap_social.followers_list') %}font-bold{% endif %}">
Followers
</a>
<a href="{{ url_for('activitypub.actor_profile', username=actor.preferred_username) }}"
class="px-2 py-1 rounded hover:bg-stone-200">
@{{ actor.preferred_username }}
</a>
<a href="{{ federation_url('/social/') }}"
class="px-2 py-1 rounded hover:bg-stone-200 text-stone-500">
Hub
</a>
</nav>
{% else %}
<nav class="flex gap-3 text-sm items-center">
<a href="{{ url_for('ap_social.search') }}"
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('ap_social.search') %}font-bold{% endif %}">
Search
</a>
<a href="{{ federation_url('/social/') }}"
class="px-2 py-1 rounded hover:bg-stone-200 text-stone-500">
Hub
</a>
</nav>
{% endif %}
</div>
{% endcall %}
{% endmacro %}

View File

@@ -1,10 +0,0 @@
{% extends '_types/root/_index.html' %}
{% block meta %}{% endblock %}
{% block root_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row('social-lite-header-child', '_types/social_lite/header/_header.html') %}
{% endcall %}
{% endblock %}
{% block content %}
{% block social_content %}{% endblock %}
{% endblock %}

View File

@@ -1,63 +0,0 @@
{% for a in actors %}
<article class="bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-3 flex items-center gap-4"
id="actor-{{ a.actor_url | replace('/', '_') | replace(':', '_') }}">
{% if a.icon_url %}
<img src="{{ a.icon_url }}" alt="" class="w-12 h-12 rounded-full">
{% else %}
<div class="w-12 h-12 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold">
{{ (a.display_name or a.preferred_username)[0] | upper }}
</div>
{% endif %}
<div class="flex-1 min-w-0">
{% if list_type == "following" and a.id %}
<a href="{{ url_for('ap_social.actor_timeline', id=a.id) }}" class="font-semibold text-stone-900 hover:underline">
{{ a.display_name or a.preferred_username }}
</a>
{% else %}
<a href="https://{{ a.domain }}/@{{ a.preferred_username }}" target="_blank" rel="noopener" class="font-semibold text-stone-900 hover:underline">
{{ a.display_name or a.preferred_username }}
</a>
{% endif %}
<div class="text-sm text-stone-500">@{{ a.preferred_username }}@{{ a.domain }}</div>
{% if a.summary %}
<div class="text-sm text-stone-600 mt-1 truncate">{{ a.summary | striptags }}</div>
{% endif %}
</div>
{% if actor %}
<div class="flex-shrink-0">
{% if list_type == "following" or a.actor_url in (followed_urls or []) %}
<form method="post" action="{{ url_for('ap_social.unfollow') }}"
sx-post="{{ url_for('ap_social.unfollow') }}"
sx-target="closest article"
sx-swap="outerHTML">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="actor_url" value="{{ a.actor_url }}">
<button type="submit" class="text-sm border border-stone-300 rounded px-3 py-1 hover:bg-stone-100">
Unfollow
</button>
</form>
{% else %}
<form method="post" action="{{ url_for('ap_social.follow') }}"
sx-post="{{ url_for('ap_social.follow') }}"
sx-target="closest article"
sx-swap="outerHTML">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="actor_url" value="{{ a.actor_url }}">
<button type="submit" class="text-sm bg-stone-800 text-white rounded px-3 py-1 hover:bg-stone-700">
Follow Back
</button>
</form>
{% endif %}
</div>
{% endif %}
</article>
{% endfor %}
{% if actors | length >= 20 %}
<div sx-get="{{ url_for('ap_social.' ~ list_type ~ '_list_page', page=page + 1) }}"
sx-trigger="revealed"
sx-swap="outerHTML">
</div>
{% endif %}

View File

@@ -1,53 +0,0 @@
<article class="bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-4">
{% if item.boosted_by %}
<div class="text-sm text-stone-500 mb-2">
Boosted by {{ item.boosted_by }}
</div>
{% endif %}
<div class="flex items-start gap-3">
{% if item.actor_icon %}
<img src="{{ item.actor_icon }}" alt="" class="w-10 h-10 rounded-full">
{% else %}
<div class="w-10 h-10 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-sm">
{{ item.actor_name[0] | upper if item.actor_name else '?' }}
</div>
{% endif %}
<div class="flex-1 min-w-0">
<div class="flex items-baseline gap-2">
<span class="font-semibold text-stone-900">{{ item.actor_name }}</span>
<span class="text-sm text-stone-500">
@{{ item.actor_username }}{% if item.actor_domain %}@{{ item.actor_domain }}{% endif %}
</span>
<span class="text-sm text-stone-400 ml-auto">
{% if item.published %}
{{ item.published.strftime('%b %d, %H:%M') }}
{% endif %}
</span>
</div>
{% if item.summary %}
<details class="mt-2">
<summary class="text-stone-500 cursor-pointer">CW: {{ item.summary }}</summary>
<div class="mt-2 prose prose-sm prose-stone max-w-none">{{ item.content | safe }}</div>
</details>
{% else %}
<div class="mt-2 prose prose-sm prose-stone max-w-none">{{ item.content | safe }}</div>
{% endif %}
<div class="mt-2 flex gap-3 text-sm text-stone-400">
{% if item.url and item.post_type == "remote" %}
<a href="{{ item.url }}" target="_blank" rel="noopener" class="hover:underline">
original
</a>
{% endif %}
{% if item.object_id %}
<a href="{{ federation_url('/social/') }}" class="hover:underline">
View on Hub
</a>
{% endif %}
</div>
</div>
</div>
</article>

View File

@@ -1,61 +0,0 @@
{% for a in actors %}
<article class="bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-3 flex items-center gap-4"
id="actor-{{ a.actor_url | replace('/', '_') | replace(':', '_') }}">
{% if a.icon_url %}
<img src="{{ a.icon_url }}" alt="" class="w-12 h-12 rounded-full">
{% else %}
<div class="w-12 h-12 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold">
{{ (a.display_name or a.preferred_username)[0] | upper }}
</div>
{% endif %}
<div class="flex-1 min-w-0">
{% if a.id %}
<a href="{{ url_for('ap_social.actor_timeline', id=a.id) }}" class="font-semibold text-stone-900 hover:underline">
{{ a.display_name or a.preferred_username }}
</a>
{% else %}
<span class="font-semibold text-stone-900">{{ a.display_name or a.preferred_username }}</span>
{% endif %}
<div class="text-sm text-stone-500">@{{ a.preferred_username }}@{{ a.domain }}</div>
{% if a.summary %}
<div class="text-sm text-stone-600 mt-1 truncate">{{ a.summary | striptags }}</div>
{% endif %}
</div>
{% if actor %}
<div class="flex-shrink-0">
{% if a.actor_url in (followed_urls or []) %}
<form method="post" action="{{ url_for('ap_social.unfollow') }}"
sx-post="{{ url_for('ap_social.unfollow') }}"
sx-target="closest article"
sx-swap="outerHTML">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="actor_url" value="{{ a.actor_url }}">
<button type="submit" class="text-sm border border-stone-300 rounded px-3 py-1 hover:bg-stone-100">
Unfollow
</button>
</form>
{% else %}
<form method="post" action="{{ url_for('ap_social.follow') }}"
sx-post="{{ url_for('ap_social.follow') }}"
sx-target="closest article"
sx-swap="outerHTML">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="actor_url" value="{{ a.actor_url }}">
<button type="submit" class="text-sm bg-stone-800 text-white rounded px-3 py-1 hover:bg-stone-700">
Follow
</button>
</form>
{% endif %}
</div>
{% endif %}
</article>
{% endfor %}
{% if actors | length >= 20 %}
<div sx-get="{{ url_for('ap_social.search_page', q=query, page=page + 1) }}"
sx-trigger="revealed"
sx-swap="outerHTML">
</div>
{% endif %}

View File

@@ -1,13 +0,0 @@
{% for item in items %}
{% include "social/_post_card.html" %}
{% endfor %}
{% if items %}
{% set last = items[-1] %}
{% if timeline_type == "actor" %}
<div sx-get="{{ url_for('ap_social.actor_timeline_page', id=actor_id, before=last.published.isoformat()) }}"
sx-trigger="revealed"
sx-swap="outerHTML">
</div>
{% endif %}
{% endif %}

View File

@@ -1,53 +0,0 @@
{% extends "_types/social_lite/index.html" %}
{% block title %}{{ remote_actor.display_name or remote_actor.preferred_username }} — Rose Ash{% endblock %}
{% block social_content %}
<div class="bg-white rounded-lg shadow-sm border border-stone-200 p-6 mb-6">
<div class="flex items-center gap-4">
{% if remote_actor.icon_url %}
<img src="{{ remote_actor.icon_url }}" alt="" class="w-16 h-16 rounded-full">
{% else %}
<div class="w-16 h-16 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xl">
{{ (remote_actor.display_name or remote_actor.preferred_username)[0] | upper }}
</div>
{% endif %}
<div class="flex-1">
<h1 class="text-xl font-bold">{{ remote_actor.display_name or remote_actor.preferred_username }}</h1>
<div class="text-stone-500">@{{ remote_actor.preferred_username }}@{{ remote_actor.domain }}</div>
{% if remote_actor.summary %}
<div class="text-sm text-stone-600 mt-2">{{ remote_actor.summary | safe }}</div>
{% endif %}
</div>
{% if actor %}
<div class="flex-shrink-0">
{% if is_following %}
<form method="post" action="{{ url_for('ap_social.unfollow') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="actor_url" value="{{ remote_actor.actor_url }}">
<button type="submit" class="border border-stone-300 rounded px-4 py-2 hover:bg-stone-100">
Unfollow
</button>
</form>
{% else %}
<form method="post" action="{{ url_for('ap_social.follow') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="actor_url" value="{{ remote_actor.actor_url }}">
<button type="submit" class="bg-stone-800 text-white rounded px-4 py-2 hover:bg-stone-700">
Follow
</button>
</form>
{% endif %}
</div>
{% endif %}
</div>
</div>
<div id="timeline">
{% set timeline_type = "actor" %}
{% set actor_id = remote_actor.id %}
{% include "social/_timeline_items.html" %}
</div>
{% endblock %}

View File

@@ -1,12 +0,0 @@
{% extends "_types/social_lite/index.html" %}
{% block title %}Followers — Rose Ash{% endblock %}
{% block social_content %}
<h1 class="text-2xl font-bold mb-6">Followers <span class="text-stone-400 font-normal">({{ total }})</span></h1>
<div id="actor-list">
{% set list_type = "followers" %}
{% include "social/_actor_list_items.html" %}
</div>
{% endblock %}

View File

@@ -1,13 +0,0 @@
{% extends "_types/social_lite/index.html" %}
{% block title %}Following — Rose Ash{% endblock %}
{% block social_content %}
<h1 class="text-2xl font-bold mb-6">Following <span class="text-stone-400 font-normal">({{ total }})</span></h1>
<div id="actor-list">
{% set list_type = "following" %}
{% set followed_urls = [] %}
{% include "social/_actor_list_items.html" %}
</div>
{% endblock %}

View File

@@ -1,33 +0,0 @@
{% extends "_types/social_lite/index.html" %}
{% block title %}Social — Rose Ash{% endblock %}
{% block social_content %}
<h1 class="text-2xl font-bold mb-6">Social</h1>
{% if actor %}
<div class="space-y-3">
<a href="{{ url_for('ap_social.search') }}" class="block bg-white rounded-lg shadow-sm border border-stone-200 p-4 hover:bg-stone-50">
<div class="font-semibold">Search</div>
<div class="text-sm text-stone-500">Find and follow accounts on the fediverse</div>
</a>
<a href="{{ url_for('ap_social.following_list') }}" class="block bg-white rounded-lg shadow-sm border border-stone-200 p-4 hover:bg-stone-50">
<div class="font-semibold">Following</div>
<div class="text-sm text-stone-500">Accounts you follow</div>
</a>
<a href="{{ url_for('ap_social.followers_list') }}" class="block bg-white rounded-lg shadow-sm border border-stone-200 p-4 hover:bg-stone-50">
<div class="font-semibold">Followers</div>
<div class="text-sm text-stone-500">Accounts following you here</div>
</a>
<a href="{{ federation_url('/social/') }}" class="block bg-white rounded-lg shadow-sm border border-stone-200 p-4 hover:bg-stone-50">
<div class="font-semibold">Hub</div>
<div class="text-sm text-stone-500">Full social experience — timeline, compose, notifications</div>
</a>
</div>
{% else %}
<p class="text-stone-500">
<a href="{{ url_for('ap_social.search') }}" class="underline">Search</a> for accounts on the fediverse, or visit the
<a href="{{ federation_url('/social/') }}" class="underline">Hub</a> to get started.
</p>
{% endif %}
{% endblock %}

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