From 293f7713d67ae0d479a2178711f43ab94d21a799 Mon Sep 17 00:00:00 2001 From: giles Date: Tue, 3 Mar 2026 19:03:15 +0000 Subject: [PATCH] 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 --- account/app.py | 5 +- account/bp/account/routes.py | 50 +-- account/sxc/pages/__init__.py | 53 ++- account/sxc/pages/account.sx | 2 +- blog/app.py | 17 + blog/bp/admin/routes.py | 23 +- blog/bp/blog/admin/routes.py | 61 +--- blog/bp/blog/routes.py | 14 +- blog/bp/menu_items/routes.py | 15 - blog/bp/post/admin/routes.py | 162 +-------- blog/bp/snippets/routes.py | 20 +- blog/sxc/pages/__init__.py | 418 +++++++++++++++++++---- blog/sxc/pages/blog.sx | 42 +-- cart/app.py | 15 +- cart/bp/cart/global_routes.py | 8 +- cart/bp/cart/overview_routes.py | 17 +- cart/bp/cart/page_routes.py | 24 +- cart/bp/orders/routes.py | 2 +- cart/bp/page_admin/routes.py | 17 - cart/sx/sx_components.py | 2 +- cart/sxc/pages/__init__.py | 56 +-- cart/sxc/pages/cart.sx | 6 +- events/app.py | 12 +- events/bp/all_events/routes.py | 2 +- events/bp/calendar/admin/routes.py | 14 +- events/bp/calendar/routes.py | 2 +- events/bp/calendar_entries/routes.py | 2 +- events/bp/calendar_entry/admin/routes.py | 17 +- events/bp/calendar_entry/routes.py | 15 +- events/bp/calendars/routes.py | 2 +- events/bp/day/admin/routes.py | 15 +- events/bp/day/routes.py | 2 +- events/bp/markets/routes.py | 14 +- events/bp/page/routes.py | 2 +- events/bp/slot/routes.py | 21 -- events/bp/slots/routes.py | 12 - events/bp/ticket_admin/routes.py | 44 +-- events/bp/ticket_type/routes.py | 26 -- events/bp/ticket_types/routes.py | 17 - events/bp/tickets/routes.py | 40 --- events/sx/sx_components.py | 22 +- events/sxc/pages/__init__.py | 383 ++++++++++++++++++--- events/sxc/pages/events.sx | 70 ++-- federation/app.py | 5 +- federation/bp/social/routes.py | 108 +----- federation/sxc/pages/__init__.py | 133 ++++++-- federation/sxc/pages/social.sx | 18 +- market/app.py | 9 +- market/bp/all_markets/routes.py | 13 - market/bp/browse/routes.py | 4 - market/bp/market/admin/routes.py | 5 - market/bp/market/routes.py | 2 +- market/bp/page_admin/routes.py | 11 - market/bp/page_markets/routes.py | 14 - market/sxc/pages/__init__.py | 82 +++-- market/sxc/pages/market.sx | 16 +- orders/app.py | 7 +- orders/bp/orders/routes.py | 109 +----- orders/sxc/pages/__init__.py | 159 ++++++++- orders/sxc/pages/orders.sx | 8 +- shared/sx/async_eval.py | 71 +++- shared/sx/pages.py | 12 + sx/app.py | 7 +- 63 files changed, 1340 insertions(+), 1216 deletions(-) diff --git a/account/app.py b/account/app.py index c5c375b..639262e 100644 --- a/account/app.py +++ b/account/app.py @@ -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 diff --git a/account/bp/account/routes.py b/account/bp/account/routes.py index 9c18757..835353f 100644 --- a/account/bp/account/routes.py +++ b/account/bp/account/routes.py @@ -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//toggle/") async def toggle_newsletter(newsletter_id: int): if not g.get("user"): diff --git a/account/sxc/pages/__init__.py b/account/sxc/pages/__init__.py index 6e3e8c6..269ad2d 100644 --- a/account/sxc/pages/__init__.py +++ b/account/sxc/pages/__init__.py @@ -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) diff --git a/account/sxc/pages/account.sx b/account/sxc/pages/account.sx index c175285..1e3b3ca 100644 --- a/account/sxc/pages/account.sx +++ b/account/sxc/pages/account.sx @@ -28,4 +28,4 @@ :path "//" :auth :login :layout :account - :content (fragment-content)) + :content (fragment-content slug)) diff --git a/blog/app.py b/blog/app.py index bba7428..9fe803b 100644 --- a/blog/app.py +++ b/blog/app.py @@ -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(): diff --git a/blog/bp/admin/routes.py b/blog/bp/admin/routes.py index c69642a..958bdab 100644 --- a/blog/bp/admin/routes.py +++ b/blog/bp/admin/routes.py @@ -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 diff --git a/blog/bp/blog/admin/routes.py b/blog/bp/blog/admin/routes.py index 2465b63..64909e2 100644 --- a/blog/bp/blog/admin/routes.py +++ b/blog/bp/blog/admin/routes.py @@ -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("//") @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("//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 diff --git a/blog/bp/blog/routes.py b/blog/bp/blog/routes.py index 57fec85..1a3001f 100644 --- a/blog/bp/blog/routes.py +++ b/blog/bp/blog/routes.py @@ -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/") diff --git a/blog/bp/menu_items/routes.py b/blog/bp/menu_items/routes.py index 56d94d4..e43381d 100644 --- a/blog/bp/menu_items/routes.py +++ b/blog/bp/menu_items/routes.py @@ -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(): diff --git a/blog/bp/post/admin/routes.py b/blog/bp/post/admin/routes.py index 475f664..227b658 100644 --- a/blog/bp/post/admin/routes.py +++ b/blog/bp/post/admin/routes.py @@ -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"] = "Error rendering sx" - 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"] = "Error rendering lexical" - 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) diff --git a/blog/bp/snippets/routes.py b/blog/bp/snippets/routes.py index a5c7f22..f64d00b 100644 --- a/blog/bp/snippets/routes.py +++ b/blog/bp/snippets/routes.py @@ -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("//") @require_login async def delete_snippet(snippet_id: int): diff --git a/blog/sxc/pages/__init__.py b/blog/sxc/pages/__init__.py index 19f1395..0536ba1 100644 --- a/blog/sxc/pages/__init__.py +++ b/blog/sxc/pages/__init__.py @@ -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"] = "Error rendering sx" + 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"] = "Error rendering lexical" + 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) diff --git a/blog/sxc/pages/blog.sx b/blog/sxc/pages/blog.sx index 9f87393..21a816e 100644 --- a/blog/sxc/pages/blog.sx +++ b/blog/sxc/pages/blog.sx @@ -15,54 +15,54 @@ :layout :blog :content (editor-page-content)) -; --- Post admin pages (nested under //admin/) --- +; --- Post admin pages (absolute paths under //admin/) --- (defpage post-admin - :path "/" + :path "//admin/" :auth :admin :layout (:post-admin :selected "admin") - :content (post-admin-content)) + :content (post-admin-content slug)) (defpage post-data - :path "/data/" + :path "//admin/data/" :auth :admin :layout (:post-admin :selected "data") - :content (post-data-content)) + :content (post-data-content slug)) (defpage post-preview - :path "/preview/" + :path "//admin/preview/" :auth :admin :layout (:post-admin :selected "preview") - :content (post-preview-content)) + :content (post-preview-content slug)) (defpage post-entries - :path "/entries/" + :path "//admin/entries/" :auth :admin :layout (:post-admin :selected "entries") - :content (post-entries-content)) + :content (post-entries-content slug)) (defpage post-settings - :path "/settings/" + :path "//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 "//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 "//" + :path "/settings/tag-groups//" :auth :admin :layout :blog-tag-group-edit - :content (tag-group-edit-content)) + :content (tag-group-edit-content id)) diff --git a/cart/app.py b/cart/app.py index 2b50759..5ae5140 100644 --- a/cart/app.py +++ b/cart/app.py @@ -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 //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="//admin") - # Page cart at // (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="/") + # Auto-mount all defpages with absolute paths + from shared.sx.pages import auto_mount_pages + auto_mount_pages(app, "cart") + return app diff --git a/cart/bp/cart/global_routes.py b/cart/bp/cart/global_routes.py index c0819d0..ef41637 100644 --- a/cart/bp/cart/global_routes.py +++ b/cart/bp/cart/global_routes.py @@ -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//") 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) diff --git a/cart/bp/cart/overview_routes.py b/cart/bp/cart/overview_routes.py index a6b0d52..392902d 100644 --- a/cart/bp/cart/overview_routes.py +++ b/cart/bp/cart/overview_routes.py @@ -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 diff --git a/cart/bp/cart/page_routes.py b/cart/bp/cart/page_routes.py index 0cbb067..d1d2633 100644 --- a/cart/bp/cart/page_routes.py +++ b/cart/bp/cart/page_routes.py @@ -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() diff --git a/cart/bp/orders/routes.py b/cart/bp/orders/routes.py index 41d989b..abb3e6a 100644 --- a/cart/bp/orders/routes.py +++ b/cart/bp/orders/routes.py @@ -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 diff --git a/cart/bp/page_admin/routes.py b/cart/bp/page_admin/routes.py index d8455fc..f77492c 100644 --- a/cart/bp/page_admin/routes.py +++ b/cart/bp/page_admin/routes.py @@ -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): diff --git a/cart/sx/sx_components.py b/cart/sx/sx_components.py index ec10db3..293ffcf 100644 --- a/cart/sx/sx_components.py +++ b/cart/sx/sx_components.py @@ -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"' diff --git a/cart/sxc/pages/__init__.py b/cart/sxc/pages/__init__.py index fdac60d..710e6ed 100644 --- a/cart/sxc/pages/__init__.py +++ b/cart/sxc/pages/__init__.py @@ -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) diff --git a/cart/sxc/pages/cart.sx b/cart/sxc/pages/cart.sx index 23ec20b..05ada99 100644 --- a/cart/sxc/pages/cart.sx +++ b/cart/sxc/pages/cart.sx @@ -7,19 +7,19 @@ :content (overview-content)) (defpage page-cart-view - :path "/" + :path "//" :auth :public :layout :cart-page :content (page-cart-content)) (defpage cart-admin - :path "/" + :path "//admin/" :auth :admin :layout :cart-admin :content (cart-admin-content)) (defpage cart-payments - :path "/payments/" + :path "//admin/payments/" :auth :admin :layout (:cart-admin :selected "payments") :content (cart-payments-content)) diff --git a/events/app.py b/events/app.py index df8190d..58a5ce0 100644 --- a/events/app.py +++ b/events/app.py @@ -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(): diff --git a/events/bp/all_events/routes.py b/events/bp/all_events/routes.py index 1f091ac..b657b32 100644 --- a/events/bp/all_events/routes.py +++ b/events/bp/all_events/routes.py @@ -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 diff --git a/events/bp/calendar/admin/routes.py b/events/bp/calendar/admin/routes.py index f1c30ac..e7fcc01 100644 --- a/events/bp/calendar/admin/routes.py +++ b/events/bp/calendar/admin/routes.py @@ -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): diff --git a/events/bp/calendar/routes.py b/events/bp/calendar/routes.py index 4cffc4d..aa07ad9 100644 --- a/events/bp/calendar/routes.py +++ b/events/bp/calendar/routes.py @@ -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 ) diff --git a/events/bp/calendar_entries/routes.py b/events/bp/calendar_entries/routes.py index 84faff0..2ba164f 100644 --- a/events/bp/calendar_entries/routes.py +++ b/events/bp/calendar_entries/routes.py @@ -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, ) diff --git a/events/bp/calendar_entry/admin/routes.py b/events/bp/calendar_entry/admin/routes.py index ecb2fa9..6963b91 100644 --- a/events/bp/calendar_entry/admin/routes.py +++ b/events/bp/calendar_entry/admin/routes.py @@ -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 diff --git a/events/bp/calendar_entry/routes.py b/events/bp/calendar_entry/routes.py index 5de1a59..4139966 100644 --- a/events/bp/calendar_entry/routes.py +++ b/events/bp/calendar_entry/routes.py @@ -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): diff --git a/events/bp/calendars/routes.py b/events/bp/calendars/routes.py index cec8afc..350a939 100644 --- a/events/bp/calendars/routes.py +++ b/events/bp/calendars/routes.py @@ -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 diff --git a/events/bp/day/admin/routes.py b/events/bp/day/admin/routes.py index 4347787..6963b91 100644 --- a/events/bp/day/admin/routes.py +++ b/events/bp/day/admin/routes.py @@ -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 diff --git a/events/bp/day/routes.py b/events/bp/day/routes.py index ad78eb4..f8412d8 100644 --- a/events/bp/day/routes.py +++ b/events/bp/day/routes.py @@ -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 diff --git a/events/bp/markets/routes.py b/events/bp/markets/routes.py index 59e95b9..2335a86 100644 --- a/events/bp/markets/routes.py +++ b/events/bp/markets/routes.py @@ -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): diff --git a/events/bp/page/routes.py b/events/bp/page/routes.py index 8ee7571..bb1a700 100644 --- a/events/bp/page/routes.py +++ b/events/bp/page/routes.py @@ -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 diff --git a/events/bp/slot/routes.py b/events/bp/slot/routes.py index 7052623..496e892 100644 --- a/events/bp/slot/routes.py +++ b/events/bp/slot/routes.py @@ -29,27 +29,6 @@ from shared.sx.helpers import sx_response def register(): bp = Blueprint("slot", __name__, url_prefix='/') - @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): diff --git a/events/bp/slots/routes.py b/events/bp/slots/routes.py index 88b7cba..e8443d0 100644 --- a/events/bp/slots/routes.py +++ b/events/bp/slots/routes.py @@ -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") diff --git a/events/bp/ticket_admin/routes.py b/events/bp/ticket_admin/routes.py index 7188bdb..993c969 100644 --- a/events/bp/ticket_admin/routes.py +++ b/events/bp/ticket_admin/routes.py @@ -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//") @require_admin async def entry_tickets(entry_id: int): diff --git a/events/bp/ticket_type/routes.py b/events/bp/ticket_type/routes.py index 9909162..09e1191 100644 --- a/events/bp/ticket_type/routes.py +++ b/events/bp/ticket_type/routes.py @@ -22,32 +22,6 @@ from shared.sx.helpers import sx_response def register(): bp = Blueprint("ticket_type", __name__, url_prefix='/') - @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): diff --git a/events/bp/ticket_types/routes.py b/events/bp/ticket_types/routes.py index 3cb856d..2163c34 100644 --- a/events/bp/ticket_types/routes.py +++ b/events/bp/ticket_types/routes.py @@ -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") diff --git a/events/bp/tickets/routes.py b/events/bp/tickets/routes.py index 8a8f48a..54e8586 100644 --- a/events/bp/tickets/routes.py +++ b/events/bp/tickets/routes.py @@ -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(): diff --git a/events/sx/sx_components.py b/events/sx/sx_components.py index ecfe2c2..8326e25 100644 --- a/events/sx/sx_components.py +++ b/events/sx/sx_components.py @@ -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)) diff --git a/events/sxc/pages/__init__.py b/events/sxc/pages/__init__.py index b36d87e..87ecc43 100644 --- a/events/sxc/pages/__init__.py +++ b/events/sxc/pages/__init__.py @@ -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) diff --git a/events/sxc/pages/events.sx b/events/sxc/pages/events.sx index d47ecb0..6ad48b0 100644 --- a/events/sxc/pages/events.sx +++ b/events/sxc/pages/events.sx @@ -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 "///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 "///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 "///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 "///slots//" :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 "///day////entries//" :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 "///day////entries//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 "///day////entries//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 "///day////entries//ticket-types//" :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 "//" + :path "/tickets//" :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 "//markets/" :auth :public :layout :events-markets :content (markets-content)) diff --git a/federation/app.py b/federation/app.py index 5a40971..60cd5a2 100644 --- a/federation/app.py +++ b/federation/app.py @@ -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 --- diff --git a/federation/bp/social/routes.py b/federation/bp/social/routes.py index 3a7fe4b..2450eb8 100644 --- a/federation/bp/social/routes.py +++ b/federation/bp/social/routes.py @@ -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/") 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 diff --git a/federation/sxc/pages/__init__.py b/federation/sxc/pages/__init__.py index be22680..4d30f0c 100644 --- a/federation/sxc/pages/__init__.py +++ b/federation/sxc/pages/__init__.py @@ -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) diff --git a/federation/sxc/pages/social.sx b/federation/sxc/pages/social.sx index cb1a557..fafed16 100644 --- a/federation/sxc/pages/social.sx +++ b/federation/sxc/pages/social.sx @@ -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/" + :path "/social/actor/" :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)) diff --git a/market/app.py b/market/app.py index 23f5d5f..a65ccac 100644 --- a/market/app.py +++ b/market/app.py @@ -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: // — 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="/") # Page admin: //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="//admin") # Market blueprint nested under post 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): diff --git a/market/bp/all_markets/routes.py b/market/bp/all_markets/routes.py index e7c2716..f6bc8c3 100644 --- a/market/bp/all_markets/routes.py +++ b/market/bp/all_markets/routes.py @@ -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)) diff --git a/market/bp/browse/routes.py b/market/bp/browse/routes.py index 0629b5f..f6fe2bf 100644 --- a/market/bp/browse/routes.py +++ b/market/bp/browse/routes.py @@ -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(): diff --git a/market/bp/market/admin/routes.py b/market/bp/market/admin/routes.py index 6172ca2..6963b91 100644 --- a/market/bp/market/admin/routes.py +++ b/market/bp/market/admin/routes.py @@ -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 diff --git a/market/bp/market/routes.py b/market/bp/market/routes.py index 2eefecc..0cc5981 100644 --- a/market/bp/market/routes.py +++ b/market/bp/market/routes.py @@ -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 diff --git a/market/bp/page_admin/routes.py b/market/bp/page_admin/routes.py index 59f8507..93873ad 100644 --- a/market/bp/page_admin/routes.py +++ b/market/bp/page_admin/routes.py @@ -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): diff --git a/market/bp/page_markets/routes.py b/market/bp/page_markets/routes.py index aada33f..10d1f2f 100644 --- a/market/bp/page_markets/routes.py +++ b/market/bp/page_markets/routes.py @@ -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"] diff --git a/market/sxc/pages/__init__.py b/market/sxc/pages/__init__.py index 03c1c70..5f96bdf 100644 --- a/market/sxc/pages/__init__.py +++ b/market/sxc/pages/__init__.py @@ -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"' diff --git a/market/sxc/pages/market.sx b/market/sxc/pages/market.sx index 22c5727..65070fd 100644 --- a/market/sxc/pages/market.sx +++ b/market/sxc/pages/market.sx @@ -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 /) -;; page-admin: / (on page_admin bp, mounted at //admin) -;; market-home: / (on browse bp, mounted at //) -;; market-admin: / (on admin bp, mounted at ///admin) +;; page-markets-index: // — markets for a single page +;; page-admin: //admin/ — post-level admin for markets +;; market-home: /// — market landing page +;; market-admin: ///admin/ — market admin (defpage all-markets-index :path "/" @@ -13,25 +13,25 @@ :content (all-markets-content)) (defpage page-markets-index - :path "/" + :path "//" :auth :public :layout :post :content (page-markets-content)) (defpage page-admin - :path "/" + :path "//admin/" :auth :admin :layout (:post-admin :selected "markets") :content (page-admin-content)) (defpage market-home - :path "/" + :path "///" :auth :public :layout :market :content (market-home-content)) (defpage market-admin - :path "/" + :path "///admin/" :auth :admin :layout (:market-admin :selected "markets") :content (market-admin-content)) diff --git a/orders/app.py b/orders/app.py index d293f6d..a782056 100644 --- a/orders/app.py +++ b/orders/app.py @@ -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()) diff --git a/orders/bp/orders/routes.py b/orders/bp/orders/routes.py index 529ecfb..2967d56 100644 --- a/orders/bp/orders/routes.py +++ b/orders/bp/orders/routes.py @@ -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.""" diff --git a/orders/sxc/pages/__init__.py b/orders/sxc/pages/__init__.py index 802317d..b1ea93b 100644 --- a/orders/sxc/pages/__init__.py +++ b/orders/sxc/pages/__init__.py @@ -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 "/" diff --git a/orders/sxc/pages/orders.sx b/orders/sxc/pages/orders.sx index a28e25b..4e32cff 100644 --- a/orders/sxc/pages/orders.sx +++ b/orders/sxc/pages/orders.sx @@ -21,7 +21,7 @@ :path "//" :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)) diff --git a/shared/sx/async_eval.py b/shared/sx/async_eval.py index 4388ecd..ef8a5d8 100644 --- a/shared/sx/async_eval.py +++ b/shared/sx/async_eval.py @@ -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 diff --git a/shared/sx/pages.py b/shared/sx/pages.py index 2149816..41b8600 100644 --- a/shared/sx/pages.py +++ b/shared/sx/pages.py @@ -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. diff --git a/sx/app.py b/sx/app.py index c688def..390e490 100644 --- a/sx/app.py +++ b/sx/app.py @@ -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