Auto-mount defpages: eliminate Python route stubs across all 9 services

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>
This commit is contained in:
2026-03-03 19:03:15 +00:00
parent 4ba63bda17
commit 293f7713d6
63 changed files with 1340 additions and 1216 deletions

View File

@@ -81,10 +81,11 @@ def create_app() -> "Quart":
app.register_blueprint(register_auth_bp())
account_bp = register_account_bp()
from shared.sx.pages import mount_pages
mount_pages(account_bp, "account")
app.register_blueprint(account_bp)
from shared.sx.pages import auto_mount_pages
auto_mount_pages(app, "account")
app.register_blueprint(register_fragments())
from bp.actions.routes import register as register_actions

View File

@@ -8,15 +8,12 @@ from __future__ import annotations
from quart import (
Blueprint,
request,
redirect,
g,
)
from sqlalchemy import select
from shared.models import UserNewsletter
from shared.models.ghost_membership_entities import GhostNewsletter
from shared.infrastructure.urls import login_url
from shared.infrastructure.fragments import fetch_fragment, fetch_fragments
from shared.infrastructure.fragments import fetch_fragments
from shared.sx.helpers import sx_response
@@ -25,8 +22,7 @@ def register(url_prefix="/"):
@account_bp.before_request
async def _prepare_page_data():
"""Fetch account_nav fragments and load data for defpage routes."""
# Fetch account nav items for layout (was in context_processor)
"""Fetch account_nav fragments for layout."""
events_nav, cart_nav, artdag_nav = await fetch_fragments([
("events", "account-nav-item", {}),
("cart", "account-nav-item", {}),
@@ -34,48 +30,6 @@ def register(url_prefix="/"):
], required=False)
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/")
async def toggle_newsletter(newsletter_id: int):
if not g.get("user"):

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

View File

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

View File

@@ -162,6 +162,23 @@ def create_app() -> "Quart":
)
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 ---
@app.get("/__rules")
async def dump_rules():

View File

@@ -3,13 +3,9 @@ from __future__ import annotations
#from quart import Blueprint, g
from quart import (
render_template,
make_response,
Blueprint,
redirect,
url_for,
request,
jsonify
)
from shared.browser.app.redis_cacher import clear_all_cache
from shared.browser.app.authz import require_admin
@@ -27,23 +23,6 @@ def register(url_prefix):
"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/")
@require_admin
async def cache_clear():
@@ -54,7 +33,7 @@ def register(url_prefix):
html = render_comp("cache-cleared", time_str=now.strftime("%H:%M:%S"))
return sx_response(html)
return redirect(url_for("settings.defpage_cache_page"))
return redirect(url_for("defpage_cache_page"))
return bp

View File

@@ -2,8 +2,6 @@ from __future__ import annotations
import re
from quart import (
render_template,
make_response,
Blueprint,
redirect,
url_for,
@@ -13,9 +11,7 @@ from quart import (
from sqlalchemy import select, delete
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.sx.helpers import sx_response
from models.tag_group import TagGroup, TagGroupTag
from models.ghost_content import Tag
@@ -46,60 +42,13 @@ async def _unassigned_tags(session):
def register():
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("/")
@require_admin
async def create():
form = await request.form
name = (form.get("name") or "").strip()
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)
feature_image = (form.get("feature_image") or "").strip() or None
@@ -115,14 +64,14 @@ def register():
await g.s.flush()
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>/")
@require_admin
async def save(id: int):
tg = await g.s.get(TagGroup, id)
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
name = (form.get("name") or "").strip()
@@ -153,7 +102,7 @@ def register():
await g.s.flush()
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/")
@require_admin
@@ -163,6 +112,6 @@ def register():
await g.s.delete(tg)
await g.s.flush()
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

View File

@@ -53,16 +53,6 @@ def register(url_prefix, title):
@blogs_bp.before_request
async def route():
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
async def inject_root():
@@ -277,7 +267,7 @@ def register(url_prefix, title):
await invalidate_tag_cache("blog")
# 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/")
@@ -335,7 +325,7 @@ def register(url_prefix, title):
await invalidate_tag_cache("blog")
# 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/")

View File

@@ -12,7 +12,6 @@ from .services.menu_items import (
search_pages,
MenuItemError,
)
from shared.browser.app.utils.htmx import is_htmx_request
from shared.sx.helpers import sx_response
def register():
@@ -23,20 +22,6 @@ def register():
from sx.sx_components import render_menu_items_nav_oob
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/")
@require_admin
async def new_menu_item():

View File

@@ -10,7 +10,6 @@ from quart import (
url_for,
)
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.utils import host_url
@@ -55,155 +54,6 @@ def _post_to_edit_dict(post) -> dict:
def register():
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/")
@require_admin
async def update_features(slug: str):
@@ -468,7 +318,7 @@ def register():
except OptimisticLockError:
from urllib.parse import quote
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.")
)
@@ -479,7 +329,7 @@ def register():
await invalidate_tag_cache("post.post_detail")
# 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/")
@require_post_author
@@ -504,11 +354,11 @@ def register():
try:
lexical_doc = json.loads(lexical_raw)
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)
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
is_admin = bool((g.get("rights") or {}).get("admin"))
@@ -544,7 +394,7 @@ def register():
)
except OptimisticLockError:
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.")
)
@@ -560,7 +410,7 @@ def register():
await invalidate_tag_cache("post.post_detail")
# 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:
redirect_url += "&publish_requested=1"
return redirect(redirect_url)

View File

@@ -1,11 +1,9 @@
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.orm import selectinload
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 models import Snippet
@@ -32,22 +30,6 @@ async def _visible_snippets(session):
def register():
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>/")
@require_login
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")
# ---------------------------------------------------------------------------
# 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
# ---------------------------------------------------------------------------
@@ -110,48 +200,48 @@ def _sub_settings_oob(ctx: dict, row_id: str, child_id: str,
def _cache_full(ctx: dict, **kw: Any) -> str:
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:
return _sub_settings_oob(ctx, "cache-row", "cache-header-child",
"settings.defpage_cache_page", "refresh", "Cache")
"defpage_cache_page", "refresh", "Cache")
# --- Snippets ---
def _snippets_full(ctx: dict, **kw: Any) -> str:
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:
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 ---
def _menu_items_full(ctx: dict, **kw: Any) -> str:
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:
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 ---
def _tag_groups_full(ctx: dict, **kw: Any) -> str:
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:
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 ---
@@ -165,7 +255,7 @@ def _tag_group_edit_full(ctx: dict, **kw: Any) -> str:
root_hdr = root_header_sx(ctx)
settings_hdr = _settings_header_sx(ctx)
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)
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
settings_hdr_oob = _settings_header_sx(ctx, oob=True)
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)
sub_oob = oob_header_sx("root-settings-header-child", "tag-groups-header-child", sub_hdr)
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:
@@ -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
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
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
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
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
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
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():
from quart import g
return getattr(g, "post_settings_content", "")
def _h_post_edit_content():
from quart import g
return getattr(g, "post_edit_content", "")
def _h_settings_content():
from quart import g
return getattr(g, "settings_content", "")
def _h_cache_content():
from quart import g
return getattr(g, "cache_content", "")
def _h_snippets_content():
from quart import g
return getattr(g, "snippets_content", "")
def _h_menu_items_content():
from quart import g
return getattr(g, "menu_items_content", "")
def _h_tag_groups_content():
from quart import g
return getattr(g, "tag_groups_content", "")
def _h_tag_group_edit_content():
from quart import g
return getattr(g, "tag_group_edit_content", "")
async def _h_tag_group_edit_content(id=None, **kw):
from quart import g, abort
from sqlalchemy import select
from models.tag_group import TagGroup, TagGroupTag
from models.ghost_content import Tag
tg = await g.s.get(TagGroup, id)
if not tg:
abort(404)
assigned_rows = list(
(await g.s.execute(
select(TagGroupTag.tag_id).where(TagGroupTag.tag_group_id == 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),
})
return _tag_groups_edit_main_panel_sx(tctx)

View File

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

View File

@@ -185,8 +185,6 @@ def create_app() -> "Quart":
from sxc.pages import setup_cart_pages
setup_cart_pages()
from shared.sx.pages import mount_pages
# --- Blueprint registration ---
# Static prefixes first, dynamic (page_slug) last
@@ -196,21 +194,22 @@ def create_app() -> "Quart":
url_prefix="/",
)
# Cart overview at GET /
# Cart overview blueprint (no defpage routes, just action endpoints)
overview_bp = register_cart_overview(url_prefix="/")
mount_pages(overview_bp, "cart", names=["cart-overview"])
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()
mount_pages(admin_bp, "cart", names=["cart-admin", "cart-payments"])
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="/")
mount_pages(page_cart_bp, "cart", names=["page-cart-view"])
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

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":
# 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>/")
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)
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
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
if cart_total <= 0:
return redirect(url_for("cart_overview.defpage_cart_overview"))
return redirect(url_for("defpage_cart_overview"))
try:
page_config = await resolve_page_config(g.s, cart, calendar_entries, tickets)

View File

@@ -3,24 +3,9 @@
from __future__ import annotations
from quart import Blueprint, g, request
from .services import get_cart_grouped_by_page
from quart import Blueprint
def register(url_prefix: str) -> Blueprint:
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

View File

@@ -19,26 +19,6 @@ from .services import current_cart_identity
def register(url_prefix: str) -> Blueprint:
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/")
async def page_checkout():
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)
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
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
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()

View File

@@ -1,6 +1,6 @@
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.orm import selectinload

View File

@@ -13,23 +13,6 @@ from shared.sx.helpers import sx_response
def register():
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/")
@require_admin
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:
"""Admin overview panel -- links to sub-admin pages."""
from quart import url_for
payments_href = url_for("page_admin.defpage_cart_payments")
payments_href = url_for("defpage_cart_payments")
return (
'(div :id "main-panel"'
' (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
page_groups = getattr(g, "overview_page_groups", [])
from shared.sx.page import get_template_context
from sx.sx_components import _overview_main_panel_sx
# _overview_main_panel_sx needs ctx for url helpers — use g-based approach
# The function reads cart_url from ctx, which we can get from template context
from shared.sx.page import get_template_context
import asyncio
# Page helpers are sync — we pre-compute in before_request
return getattr(g, "overview_content", "")
from bp.cart.services import get_cart_grouped_by_page
page_groups = await get_cart_grouped_by_page(g.s)
ctx = await get_template_context()
return _overview_main_panel_sx(page_groups, ctx)
def _h_page_cart_content():
async def _h_page_cart_content(page_slug=None, **kw):
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
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
# Sync helper — _cart_admin_main_panel_sx is sync, but needs ctx
# We can pre-compute in before_request, or use get_template_context_sync-like pattern
from quart import g
return getattr(g, "cart_admin_content", "")
def _h_cart_payments_content():
from quart import g
return getattr(g, "cart_payments_content", "")
from sx.sx_components import _cart_payments_main_panel_sx
ctx = await get_template_context()
return _cart_payments_main_panel_sx(ctx)

View File

@@ -7,19 +7,19 @@
:content (overview-content))
(defpage page-cart-view
:path "/"
:path "/<page_slug>/"
:auth :public
:layout :cart-page
:content (page-cart-content))
(defpage cart-admin
:path "/"
:path "/<page_slug>/admin/"
:auth :admin
:layout :cart-admin
:content (cart-admin-content))
(defpage cart-payments
:path "/payments/"
:path "/<page_slug>/admin/payments/"
:auth :admin
:layout (:cart-admin :selected "payments")
:content (cart-payments-content))

View File

@@ -171,19 +171,25 @@ def create_app() -> "Quart":
"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
from bp.tickets.routes import register as 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)
# Ticket admin — check-in interface (admin only)
from bp.ticket_admin.routes import register as register_ticket_admin
ticket_admin_bp = register_ticket_admin()
mount_pages(ticket_admin_bp, "events", names=["ticket-admin"])
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 ---
@app.get("/oembed")
async def oembed():

View File

@@ -11,7 +11,7 @@ Routes:
"""
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.sx.helpers import sx_response

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
from quart import (
request, Blueprint, g
Blueprint, g, request,
)
@@ -15,18 +15,6 @@ from shared.sx.helpers import sx_response
def register():
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/")
@require_admin
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 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 quart import (
request, render_template, make_response,
request, make_response,
Blueprint, g, redirect, url_for, jsonify,
)

View File

@@ -1,23 +1,8 @@
from __future__ import annotations
from quart import (
request, Blueprint, g
)
from quart import Blueprint
def register():
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

View File

@@ -11,7 +11,7 @@ from shared.browser.app.redis_cacher import clear_cache
from sqlalchemy import select
from quart import (
request, render_template, make_response, Blueprint, g, jsonify
request, make_response, Blueprint, g, jsonify
)
from ..calendar_entries.services.entries import (
svc_update_entry,
@@ -238,19 +238,6 @@ def register():
"user_ticket_counts_by_type": user_ticket_counts_by_type,
"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/")
@require_admin
async def get_edit(entry_id: int, **rest):

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
from quart import (
request, render_template, make_response, Blueprint, g
request, make_response, Blueprint, g
)
from sqlalchemy import select

View File

@@ -1,21 +1,8 @@
from __future__ import annotations
from quart import (
request, Blueprint, g
)
from quart import Blueprint
def register():
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

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import datetime, timezone, date, timedelta
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

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
from quart import (
request, render_template, make_response, Blueprint, g
request, make_response, Blueprint, g
)
from .services.markets import (
@@ -21,18 +21,6 @@ def register():
async def inject_root():
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/")
@require_admin
async def create_market(**kwargs):

View File

@@ -8,7 +8,7 @@ Routes:
"""
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.sx.helpers import sx_response

View File

@@ -29,27 +29,6 @@ from shared.sx.helpers import sx_response
def register():
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/")
@require_admin
async def get_edit(slot_id: int, **kwargs):

View File

@@ -38,18 +38,6 @@ def register():
}
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("/")
@require_admin
@clear_cache(tag="calendars", tag_scope="all")

View File

@@ -14,10 +14,10 @@ import logging
from quart import (
Blueprint, g, request, make_response,
)
from sqlalchemy import select, func
from sqlalchemy import select
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.redis_cacher import clear_cache
from shared.sx.helpers import sx_response
@@ -34,46 +34,6 @@ logger = logging.getLogger(__name__)
def register() -> Blueprint:
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>/")
@require_admin
async def entry_tickets(entry_id: int):

View File

@@ -22,32 +22,6 @@ from shared.sx.helpers import sx_response
def register():
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/")
@require_admin
async def get_edit(ticket_type_id: int, **kwargs):

View File

@@ -35,23 +35,6 @@ def register():
}
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("/")
@require_admin
@clear_cache(tag="calendars", tag_scope="all")

View File

@@ -24,8 +24,6 @@ from shared.sx.helpers import sx_response
from .services.tickets import (
create_ticket,
get_ticket_by_code,
get_user_tickets,
get_available_ticket_count,
get_tickets_for_entry,
get_sold_ticket_count,
@@ -39,44 +37,6 @@ logger = logging.getLogger(__name__)
def register() -> Blueprint:
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/")
@clear_cache(tag="calendars", tag_scope="all")
async def buy_tickets():

View File

@@ -191,11 +191,11 @@ def _calendar_nav_sx(ctx: dict) -> str:
select_colours = ctx.get("select_colours", "")
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",
label="Slots", select_colours=select_colours))
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",
select_colours=select_colours))
return "".join(parts)
@@ -319,7 +319,7 @@ def _calendar_admin_header_sx(ctx: dict, *, oob: bool = False) -> str:
nav_parts = []
if cal_slug:
for endpoint, label in [
("calendar.slots.defpage_slots_listing", "slots"),
("defpage_slots_listing", "slots"),
("calendar.admin.calendar_description_edit", "description"),
]:
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:
"""Build the markets section header row."""
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,
link_href=link_href,
link_label_content=SxExpr(sx_call("events-markets-label")),
@@ -594,7 +594,7 @@ def _day_row_html(ctx: dict, entry) -> str:
# Slot/Time
slot = getattr(entry, "slot", None)
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_end = f" \u2192 {slot.time_end.strftime('%H:%M')}" if slot.time_end else ""
slot_html = sx_call("events-day-row-slot",
@@ -774,7 +774,7 @@ def _tickets_main_panel_html(ctx: dict, tickets: list) -> str:
ticket_cards = []
if 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_name = entry.name if entry else "Unknown event"
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"}
header_bg = bg_map.get(state, "bg-stone-50")
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 = _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 = ""
if 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)
desc = getattr(s, "description", "") or ""
@@ -2309,7 +2309,7 @@ def render_buy_result(entry, created_tickets, remaining, cart_count) -> str:
tickets_html = ""
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",
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",
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",
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"),
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"))
cart_icon = sx_call("events-adjust-cart-icon",
href=my_tickets_href, count=str(count))

View File

@@ -311,6 +311,183 @@ def _markets_oob(ctx: dict, **kw: Any) -> str:
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
# ---------------------------------------------------------------------------
@@ -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
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():
from quart import g
return getattr(g, "day_admin_content", "")
async def _h_slot_content(calendar_slug=None, slot_id=None, **kw):
from quart import g, abort
await _ensure_calendar(calendar_slug)
from bp.slot.services.slot import get_slot as svc_get_slot
slot = await svc_get_slot(g.s, slot_id) if slot_id else None
if not slot:
abort(404)
g.slot = slot
_add_to_defpage_ctx(slot=slot)
calendar = getattr(g, "calendar", None)
from sx.sx_components import render_slot_main_panel
return render_slot_main_panel(slot, calendar)
def _h_slots_content():
from quart import g
return getattr(g, "slots_content", "")
async def _h_entry_content(calendar_slug=None, entry_id=None, **kw):
await _ensure_calendar(calendar_slug)
await _ensure_entry_context(entry_id)
from shared.sx.page import get_template_context
from sx.sx_components import _entry_main_panel_html
ctx = await get_template_context()
return _entry_main_panel_html(ctx)
def _h_slot_content():
from quart import g
return getattr(g, "slot_content", "")
async def _h_entry_menu(calendar_slug=None, entry_id=None, **kw):
await _ensure_calendar(calendar_slug)
await _ensure_entry_context(entry_id)
from shared.sx.page import get_template_context
from sx.sx_components import _entry_nav_html
ctx = await get_template_context()
return _entry_nav_html(ctx)
def _h_entry_content():
from quart import g
return getattr(g, "entry_content", "")
def _h_entry_menu():
from quart import g
return getattr(g, "entry_menu", "")
def _h_entry_admin_content():
from quart import g
return getattr(g, "entry_admin_content", "")
async def _h_entry_admin_content(calendar_slug=None, entry_id=None, **kw):
await _ensure_calendar(calendar_slug)
await _ensure_entry_context(entry_id)
from shared.sx.page import get_template_context
from sx.sx_components import _entry_admin_main_panel_html
ctx = await get_template_context()
return _entry_admin_main_panel_html(ctx)
def _h_admin_menu():
@@ -376,31 +586,118 @@ def _h_admin_menu():
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
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
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
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():
from quart import g
return getattr(g, "ticket_detail_content", "")
def _h_ticket_admin_content():
from quart import g
return getattr(g, "ticket_admin_content", "")
def _h_markets_content():
from quart import g
return getattr(g, "markets_content", "")
async def _h_markets_content(**kw):
from shared.sx.page import get_template_context
from sx.sx_components import _markets_main_panel_html
ctx = await get_template_context()
return _markets_main_panel_html(ctx)

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
:path "/"
:path "/<slug>/<calendar_slug>/admin/"
:auth :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
:path "/"
:path "/<slug>/<calendar_slug>/day/<int:year>/<int:month>/<int:day>/admin/"
:auth :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
:path "/"
:path "/<slug>/<calendar_slug>/slots/"
:auth :public
:layout :events-slots
:content (slots-content))
:content (slots-content calendar-slug))
;; Slot detail (mounted on slot bp)
;; Slot detail
(defpage slot-detail
:path "/"
:path "/<slug>/<calendar_slug>/slots/<int:slot_id>/"
:auth :admin
: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
:path "/"
:path "/<slug>/<calendar_slug>/day/<int:year>/<int:month>/<int:day>/entries/<int:entry_id>/"
:auth :admin
:layout :events-entry
:content (entry-content)
:menu (entry-menu))
:content (entry-content calendar-slug entry-id)
:menu (entry-menu calendar-slug entry-id))
;; Entry admin (mounted on calendar_entry.admin bp)
;; Entry admin
(defpage entry-admin
:path "/"
:path "/<slug>/<calendar_slug>/day/<int:year>/<int:month>/<int:day>/entries/<int:entry_id>/admin/"
:auth :admin
:layout :events-entry-admin
:content (entry-admin-content)
:content (entry-admin-content calendar-slug entry-id)
:menu (admin-menu))
;; Ticket types listing (mounted on ticket_types bp)
;; 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
:layout :events-ticket-types
:content (ticket-types-content)
:content (ticket-types-content calendar-slug entry-id year month day)
:menu (admin-menu))
;; Ticket type detail (mounted on ticket_type bp)
;; 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
: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))
;; My tickets (mounted on tickets bp)
;; My tickets
(defpage my-tickets
:path "/"
:path "/tickets/"
:auth :public
:layout :root
:content (tickets-content))
;; Ticket detail (mounted on tickets bp)
;; Ticket detail
(defpage ticket-detail
:path "/<code>/"
:path "/tickets/<code>/"
:auth :public
: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
:path "/"
:path "/admin/tickets/"
:auth :admin
:layout :root
:content (ticket-admin-content))
;; Markets (mounted on markets bp)
;; Markets
(defpage events-markets
:path "/"
:path "/<slug>/markets/"
:auth :public
:layout :events-markets
:content (markets-content))

View File

@@ -94,10 +94,11 @@ def create_app() -> "Quart":
app.register_blueprint(register_identity_bp())
social_bp = register_social_bp()
from shared.sx.pages import mount_pages
mount_pages(social_bp, "federation")
app.register_blueprint(social_bp)
from shared.sx.pages import auto_mount_pages
auto_mount_pages(app, "federation")
app.register_blueprint(register_fragments())
# --- home page ---

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)
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 ---------------------------------------------------
@bp.get("/timeline")
@@ -170,7 +74,7 @@ def register(url_prefix="/social"):
form = await request.form
content = form.get("content", "").strip()
if not content:
return redirect(url_for("social.defpage_compose_form"))
return redirect(url_for("defpage_compose_form"))
visibility = form.get("visibility", "public")
in_reply_to = form.get("in_reply_to") or None
@@ -181,13 +85,13 @@ def register(url_prefix="/social"):
visibility=visibility,
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>")
async def delete_post(post_id: int):
actor = _require_actor()
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 -------------------------------------------------------
@@ -223,7 +127,7 @@ def register(url_prefix="/social"):
)
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 redirect(request.referrer or url_for("social.defpage_search"))
return redirect(request.referrer or url_for("defpage_search"))
@bp.post("/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"):
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):
"""Re-render a single actor card after follow/unfollow via HTMX."""
@@ -414,6 +318,6 @@ def register(url_prefix="/social"):
async def mark_read():
actor = _require_actor()
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

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
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
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
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
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
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
return getattr(g, "followers_content", "")
def _h_actor_timeline_content():
from quart import g
return getattr(g, "actor_timeline_content", "")
def _h_notifications_content():
from quart import g
return getattr(g, "notifications_content", "")
from shared.services.registry import services
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
return _notifications_content_sx(items)

View File

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

View File

@@ -103,21 +103,16 @@ def create_app() -> "Quart":
from sxc.pages import setup_market_pages
setup_market_pages()
from shared.sx.pages import mount_pages
# All markets: / — global view across all pages
all_markets_bp = register_all_markets()
mount_pages(all_markets_bp, "market", names=["all-markets-index"])
app.register_blueprint(all_markets_bp, url_prefix="/")
# Page markets: /<slug>/ — markets for a single page
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>")
# Page admin: /<slug>/admin/ — post-level admin for markets
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")
# Market blueprint nested under post slug: /<page_slug>/<market_slug>/
@@ -135,6 +130,10 @@ def create_app() -> "Quart":
app.register_blueprint(register_actions())
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 ---
@app.url_value_preprocessor
def pull_slugs(endpoint, values):

View File

@@ -41,19 +41,6 @@ async def _load_markets(page, per_page=20):
def register() -> Blueprint:
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")
async def markets_fragment():
page = int(request.args.get("page", 1))

View File

@@ -29,10 +29,6 @@ def register():
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/")
@cache_page(tag="browse")
async def browse_all():

View File

@@ -5,9 +5,4 @@ from quart import Blueprint
def register():
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

View File

@@ -1,6 +1,6 @@
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

View File

@@ -26,17 +26,6 @@ def _slugify(value: str, max_len: int = 255) -> str:
def register():
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/")
@require_admin
async def create_market(**kwargs):

View File

@@ -23,20 +23,6 @@ async def _load_markets(post_id, page, per_page=20):
def register() -> Blueprint:
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")
async def markets_fragment():
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 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)
if not data:
page = int(request.args.get("page", 1))
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
return _no_markets_sx()
markets = data["markets"]
has_more = data["has_more"]
page_info = data["page_info"]
page = data["page"]
prefix = route_prefix()
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
if markets:
cards = _market_cards_sx(markets, page_info, page, has_more, next_url)
content = _markets_grid(cards)
else:
content = _no_markets_sx()
from sx.sx_components import _market_cards_sx, _markets_grid
cards = _market_cards_sx(markets, page_info, page, has_more, next_url)
content = _markets_grid(cards)
return "(<> " + content + " " + '(div :class "pb-8")' + ")"
def _h_page_markets_content():
from quart import g, url_for
async def _h_page_markets_content(slug=None, **kw):
from quart import g, url_for, request
from shared.utils import route_prefix
from shared.services.registry import services
data = getattr(g, "page_markets_data", None)
if not data:
post = g.post_data["post"]
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
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()
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
if markets:
cards = _market_cards_sx(markets, {}, page, has_more, next_url,
show_page_badge=False, post_slug=post_slug)
content = _markets_grid(cards)
else:
content = _no_markets_sx("No markets for this page")
from sx.sx_components import _market_cards_sx, _markets_grid
cards = _market_cards_sx(markets, {}, page, has_more, next_url,
show_page_badge=False, post_slug=post_slug)
content = _markets_grid(cards)
return "(<> " + content + " " + '(div :class "pb-8")' + ")"
def _h_page_admin_content():
# Content pre-rendered by before_request (async _markets_admin_panel_sx)
from quart import g
content = getattr(g, "page_admin_content", "")
async def _h_page_admin_content(slug=None, **kw):
from shared.sx.page import get_template_context
from sx.sx_components import _markets_admin_panel_sx
ctx = await get_template_context()
content = await _markets_admin_panel_sx(ctx)
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
post_data = getattr(g, "post_data", {})
post = post_data.get("post", {})
@@ -166,5 +176,5 @@ def _h_market_home_content():
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"'

View File

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

View File

@@ -81,12 +81,13 @@ def create_app() -> "Quart":
app.register_blueprint(register_actions())
app.register_blueprint(register_data())
# Orders list at / (defpage routes mounted below)
# Orders list at /
bp = register_orders(url_prefix="/")
from shared.sx.pages import mount_pages
mount_pages(bp, "orders")
app.register_blueprint(bp)
from shared.sx.pages import auto_mount_pages
auto_mount_pages(app, "orders")
# Checkout webhook + return
app.register_blueprint(register_checkout())

View File

@@ -1,6 +1,6 @@
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.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.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 .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"]:
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")
async def orders_rows():
"""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
d = getattr(g, "orders_page_data", None)
if not d:
@@ -131,7 +267,8 @@ def _h_orders_list_content():
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 shared.sx.helpers import sx_call, SxExpr
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))
def _h_orders_list_aside():
async def _h_orders_list_aside(**kw):
await _ensure_orders_list()
from quart import g
from shared.sx.helpers import sx_call
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
d = getattr(g, "orders_page_data", None)
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
d = getattr(g, "order_detail_data", None)
if not d:
@@ -179,7 +319,8 @@ def _h_order_detail_content():
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
d = getattr(g, "order_detail_data", None)
if not d:
@@ -189,13 +330,15 @@ def _h_order_detail_filter():
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
d = getattr(g, "order_detail_data", None)
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
d = getattr(g, "order_detail_data", None)
return d["list_url"] if d else "/"

View File

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

View File

@@ -19,6 +19,7 @@ Usage::
from __future__ import annotations
import inspect
from typing import Any
from .types import Component, Keyword, Lambda, Macro, NIL, Symbol
@@ -114,7 +115,10 @@ async def async_eval(expr: Any, env: dict[str, Any], ctx: RequestContext) -> Any
args = [await async_eval(a, env, ctx) for a in expr[1:]]
if callable(fn) and not isinstance(fn, (Lambda, Component)):
return fn(*args)
result = fn(*args)
if inspect.iscoroutine(result):
return await result
return result
if isinstance(fn, Lambda):
return await _async_call_lambda(fn, args, env, ctx)
if isinstance(fn, Component):
@@ -369,6 +373,8 @@ async def _asf_thread_first(expr, env, ctx):
args = [result]
if callable(fn) and not isinstance(fn, (Lambda, Component)):
result = fn(*args)
if inspect.iscoroutine(result):
result = await result
elif isinstance(fn, Lambda):
result = await _async_call_lambda(fn, args, env, ctx)
else:
@@ -418,7 +424,8 @@ async def _aho_map(expr, env, ctx):
if isinstance(fn, Lambda):
results.append(await _async_call_lambda(fn, [item], env, ctx))
elif callable(fn):
results.append(fn(item))
r = fn(item)
results.append(await r if inspect.iscoroutine(r) else r)
else:
raise EvalError(f"map requires callable, got {type(fn).__name__}")
return results
@@ -432,7 +439,8 @@ async def _aho_map_indexed(expr, env, ctx):
if isinstance(fn, Lambda):
results.append(await _async_call_lambda(fn, [i, item], env, ctx))
elif callable(fn):
results.append(fn(i, item))
r = fn(i, item)
results.append(await r if inspect.iscoroutine(r) else r)
else:
raise EvalError(f"map-indexed requires callable, got {type(fn).__name__}")
return results
@@ -447,6 +455,8 @@ async def _aho_filter(expr, env, ctx):
val = await _async_call_lambda(fn, [item], env, ctx)
elif callable(fn):
val = fn(item)
if inspect.iscoroutine(val):
val = await val
else:
raise EvalError(f"filter requires callable, got {type(fn).__name__}")
if val:
@@ -459,7 +469,12 @@ async def _aho_reduce(expr, env, ctx):
acc = await async_eval(expr[2], env, ctx)
coll = await async_eval(expr[3], env, ctx)
for item in coll:
acc = await _async_call_lambda(fn, [acc, item], env, ctx) if isinstance(fn, Lambda) else fn(acc, item)
if isinstance(fn, Lambda):
acc = await _async_call_lambda(fn, [acc, item], env, ctx)
else:
acc = fn(acc, item)
if inspect.iscoroutine(acc):
acc = await acc
return acc
@@ -467,7 +482,12 @@ async def _aho_some(expr, env, ctx):
fn = await async_eval(expr[1], env, ctx)
coll = await async_eval(expr[2], env, ctx)
for item in coll:
result = await _async_call_lambda(fn, [item], env, ctx) if isinstance(fn, Lambda) else fn(item)
if isinstance(fn, Lambda):
result = await _async_call_lambda(fn, [item], env, ctx)
else:
result = fn(item)
if inspect.iscoroutine(result):
result = await result
if result:
return result
return NIL
@@ -477,7 +497,13 @@ async def _aho_every(expr, env, ctx):
fn = await async_eval(expr[1], env, ctx)
coll = await async_eval(expr[2], env, ctx)
for item in coll:
if not (await _async_call_lambda(fn, [item], env, ctx) if isinstance(fn, Lambda) else fn(item)):
if isinstance(fn, Lambda):
val = await _async_call_lambda(fn, [item], env, ctx)
else:
val = fn(item)
if inspect.iscoroutine(val):
val = await val
if not val:
return False
return True
@@ -489,7 +515,9 @@ async def _aho_for_each(expr, env, ctx):
if isinstance(fn, Lambda):
await _async_call_lambda(fn, [item], env, ctx)
elif callable(fn):
fn(item)
r = fn(item)
if inspect.iscoroutine(r):
await r
return NIL
@@ -782,7 +810,10 @@ async def _arsf_map(expr, env, ctx):
if isinstance(fn, Lambda):
parts.append(await _arender_lambda(fn, (item,), env, ctx))
elif callable(fn):
parts.append(await _arender(fn(item), env, ctx))
r = fn(item)
if inspect.iscoroutine(r):
r = await r
parts.append(await _arender(r, env, ctx))
else:
parts.append(await _arender(item, env, ctx))
return "".join(parts)
@@ -796,7 +827,10 @@ async def _arsf_map_indexed(expr, env, ctx):
if isinstance(fn, Lambda):
parts.append(await _arender_lambda(fn, (i, item), env, ctx))
elif callable(fn):
parts.append(await _arender(fn(i, item), env, ctx))
r = fn(i, item)
if inspect.iscoroutine(r):
r = await r
parts.append(await _arender(r, env, ctx))
else:
parts.append(await _arender(item, env, ctx))
return "".join(parts)
@@ -815,7 +849,10 @@ async def _arsf_for_each(expr, env, ctx):
if isinstance(fn, Lambda):
parts.append(await _arender_lambda(fn, (item,), env, ctx))
elif callable(fn):
parts.append(await _arender(fn(item), env, ctx))
r = fn(item)
if inspect.iscoroutine(r):
r = await r
parts.append(await _arender(r, env, ctx))
else:
parts.append(await _arender(item, env, ctx))
return "".join(parts)
@@ -956,7 +993,10 @@ async def _aser(expr: Any, env: dict[str, Any], ctx: RequestContext) -> Any:
args = [await async_eval(a, env, ctx) for a in expr[1:]]
if callable(fn) and not isinstance(fn, (Lambda, Component)):
return fn(*args)
result = fn(*args)
if inspect.iscoroutine(result):
return await result
return result
if isinstance(fn, Lambda):
return await _async_call_lambda(fn, args, env, ctx)
if isinstance(fn, Component):
@@ -1151,7 +1191,8 @@ async def _asho_ser_map(expr, env, ctx):
local[p] = v
results.append(await _aser(fn.body, local, ctx))
elif callable(fn):
results.append(fn(item))
r = fn(item)
results.append(await r if inspect.iscoroutine(r) else r)
else:
raise EvalError(f"map requires callable, got {type(fn).__name__}")
return results
@@ -1169,7 +1210,8 @@ async def _asho_ser_map_indexed(expr, env, ctx):
local[fn.params[1]] = item
results.append(await _aser(fn.body, local, ctx))
elif callable(fn):
results.append(fn(i, item))
r = fn(i, item)
results.append(await r if inspect.iscoroutine(r) else r)
else:
raise EvalError(f"map-indexed requires callable, got {type(fn).__name__}")
return results
@@ -1191,7 +1233,8 @@ async def _asho_ser_for_each(expr, env, ctx):
local[fn.params[0]] = item
results.append(await _aser(fn.body, local, ctx))
elif callable(fn):
results.append(fn(item))
r = fn(item)
results.append(await r if inspect.iscoroutine(r) else r)
return results

View File

@@ -290,6 +290,18 @@ async def execute_page(
# Blueprint mounting
# ---------------------------------------------------------------------------
def auto_mount_pages(app: Any, service_name: str) -> None:
"""Auto-mount all registered defpages for a service directly on the app.
Pages must have absolute paths (from the service URL root).
Called once per service in app.py after setup_*_pages().
"""
pages = get_all_pages(service_name)
for page_def in pages.values():
_mount_one_page(app, service_name, page_def)
logger.info("Auto-mounted %d defpages for %s", len(pages), service_name)
def mount_pages(bp: Any, service_name: str,
names: set[str] | list[str] | None = None) -> None:
"""Mount registered PageDef routes onto a Quart Blueprint.

View File

@@ -54,12 +54,11 @@ def create_app() -> "Quart":
setup_sx_pages()
bp = register_pages(url_prefix="/")
from shared.sx.pages import mount_pages
mount_pages(bp, "sx")
app.register_blueprint(bp)
from shared.sx.pages import auto_mount_pages
auto_mount_pages(app, "sx")
return app