8 Commits
exorcism ... sx

Author SHA1 Message Date
03f0929fdf Fix SX nav morphing, retry error modal, and aria-selected CSS extraction
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 12m18s
- Re-read verb URL from element attributes at execution time so morphed
  nav links navigate to the correct destination
- Reset retry backoff on fresh requests; skip error modal when sx-retry
  handles the failure
- Strip attribute selectors in CSS registry so aria-selected:* classes
  resolve correctly for on-demand CSS
- Add @css annotations for dynamic aria-selected variant classes
- Add SX docs integration test suite (102 tests)

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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -8,7 +8,7 @@ from jinja2 import FileSystemLoader, ChoiceLoader
from shared.infrastructure.factory import create_base_app
from bp import register_account_bp, register_auth_bp, register_fragments
from bp import register_account_bp, register_auth_bp
async def account_context() -> dict:
@@ -81,11 +81,13 @@ 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)
app.register_blueprint(register_fragments())
from shared.sx.pages import auto_mount_pages
auto_mount_pages(app, "account")
from shared.sx.handlers import auto_mount_fragment_handlers
auto_mount_fragment_handlers(app, "account")
from bp.actions.routes import register as register_actions
app.register_blueprint(register_actions())

View File

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

View File

@@ -8,15 +8,12 @@ from __future__ import annotations
from quart import (
Blueprint,
request,
redirect,
g,
)
from sqlalchemy import select
from shared.models import UserNewsletter
from shared.models.ghost_membership_entities import GhostNewsletter
from shared.infrastructure.urls import login_url
from shared.infrastructure.fragments import fetch_fragment, fetch_fragments
from shared.infrastructure.fragments import fetch_fragments
from shared.sx.helpers import sx_response
@@ -25,8 +22,7 @@ def register(url_prefix="/"):
@account_bp.before_request
async def _prepare_page_data():
"""Fetch account_nav fragments and load data for defpage routes."""
# Fetch account nav items for layout (was in context_processor)
"""Fetch account_nav fragments for layout."""
events_nav, cart_nav, artdag_nav = await fetch_fragments([
("events", "account-nav-item", {}),
("cart", "account-nav-item", {}),
@@ -34,48 +30,6 @@ def register(url_prefix="/"):
], required=False)
g.account_nav = events_nav + cart_nav + artdag_nav
if request.method != "GET":
return
endpoint = request.endpoint or ""
# Newsletters page — load newsletter data
if endpoint.endswith("defpage_newsletters"):
result = await g.s.execute(
select(GhostNewsletter).order_by(GhostNewsletter.name)
)
all_newsletters = result.scalars().all()
sub_result = await g.s.execute(
select(UserNewsletter).where(
UserNewsletter.user_id == g.user.id,
)
)
user_subs = {un.newsletter_id: un for un in sub_result.scalars().all()}
newsletter_list = []
for nl in all_newsletters:
un = user_subs.get(nl.id)
newsletter_list.append({
"newsletter": nl,
"un": un,
"subscribed": un.subscribed if un else False,
})
g.newsletters_data = newsletter_list
# Fragment page — load fragment from events service
elif endpoint.endswith("defpage_fragment_page"):
slug = request.view_args.get("slug")
if slug and g.get("user"):
fragment_html = await fetch_fragment(
"events", "account-page",
params={"slug": slug, "user_id": str(g.user.id)},
)
if not fragment_html:
from quart import abort
abort(404)
g.fragment_page_data = fragment_html
@account_bp.post("/newsletter/<int:newsletter_id>/toggle/")
async def toggle_newsletter(newsletter_id: int):
if not g.get("user"):

View File

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

View File

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

View File

@@ -75,31 +75,60 @@ def _register_account_helpers() -> None:
})
def _h_account_content():
def _h_account_content(**kw):
from sx.sx_components import _account_main_panel_sx
return _account_main_panel_sx({})
def _h_newsletters_content():
async def _h_newsletters_content(**kw):
from quart import g
d = getattr(g, "newsletters_data", None)
if not d:
from sqlalchemy import select
from shared.models import UserNewsletter
from shared.models.ghost_membership_entities import GhostNewsletter
result = await g.s.execute(
select(GhostNewsletter).order_by(GhostNewsletter.name)
)
all_newsletters = result.scalars().all()
sub_result = await g.s.execute(
select(UserNewsletter).where(
UserNewsletter.user_id == g.user.id,
)
)
user_subs = {un.newsletter_id: un for un in sub_result.scalars().all()}
newsletter_list = []
for nl in all_newsletters:
un = user_subs.get(nl.id)
newsletter_list.append({
"newsletter": nl,
"un": un,
"subscribed": un.subscribed if un else False,
})
if not newsletter_list:
from shared.sx.helpers import sx_call
return sx_call("account-newsletter-empty")
from shared.sx.page import get_template_context_sync
from sx.sx_components import _newsletters_panel_sx
# Build a minimal ctx with account_url
ctx = {"account_url": getattr(g, "_account_url", None)}
if ctx["account_url"] is None:
from shared.infrastructure.urls import account_url
ctx["account_url"] = account_url
return _newsletters_panel_sx(ctx, d)
return _newsletters_panel_sx(ctx, newsletter_list)
def _h_fragment_content():
from quart import g
frag = getattr(g, "fragment_page_data", None)
if not frag:
async def _h_fragment_content(slug=None, **kw):
from quart import g, abort
from shared.infrastructure.fragments import fetch_fragment
if not slug or not g.get("user"):
return ""
fragment_html = await fetch_fragment(
"events", "account-page",
params={"slug": slug, "user_id": str(g.user.id)},
)
if not fragment_html:
abort(404)
from sx.sx_components import _fragment_content
return _fragment_content(frag)
return _fragment_content(fragment_html)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,7 +12,6 @@ from .services.menu_items import (
search_pages,
MenuItemError,
)
from shared.browser.app.utils.htmx import is_htmx_request
from shared.sx.helpers import sx_response
def register():
@@ -23,20 +22,6 @@ def register():
from sx.sx_components import render_menu_items_nav_oob
return render_menu_items_nav_oob(menu_items)
@bp.before_request
async def _prepare_page_data():
if "defpage_" not in (request.endpoint or ""):
return
menu_items = await get_all_menu_items(g.s)
from shared.sx.page import get_template_context
from sx.sx_components import _menu_items_main_panel_sx
tctx = await get_template_context()
tctx["menu_items"] = menu_items
g.menu_items_content = _menu_items_main_panel_sx(tctx)
from shared.sx.pages import mount_pages
mount_pages(bp, "blog", names=["menu-items-page"])
@bp.get("/new/")
@require_admin
async def new_menu_item():

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,7 +17,6 @@ from bp import (
register_page_cart,
register_cart_global,
register_page_admin,
register_fragments,
register_actions,
register_data,
register_inbox,
@@ -141,7 +140,9 @@ def create_app() -> "Quart":
app.jinja_env.globals["cart_quantity_url"] = lambda product_id: f"/quantity/{product_id}/"
app.jinja_env.globals["cart_delete_url"] = lambda product_id: f"/delete/{product_id}/"
app.register_blueprint(register_fragments())
from shared.sx.handlers import auto_mount_fragment_handlers
auto_mount_fragment_handlers(app, "cart")
app.register_blueprint(register_actions())
app.register_blueprint(register_data())
app.register_blueprint(register_inbox())
@@ -185,8 +186,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 +195,22 @@ def create_app() -> "Quart":
url_prefix="/",
)
# Cart overview at GET /
# Cart overview blueprint (no defpage routes, just action endpoints)
overview_bp = register_cart_overview(url_prefix="/")
mount_pages(overview_bp, "cart", names=["cart-overview"])
app.register_blueprint(overview_bp, url_prefix="/")
# Page admin at /<page_slug>/admin/ (before page_cart catch-all)
# Page admin (PUT /payments/ etc.)
admin_bp = register_page_admin()
mount_pages(admin_bp, "cart", names=["cart-admin", "cart-payments"])
app.register_blueprint(admin_bp, url_prefix="/<page_slug>/admin")
# Page cart at /<page_slug>/ (dynamic, matched last)
# Page cart (POST /checkout/ etc.)
page_cart_bp = register_page_cart(url_prefix="/")
mount_pages(page_cart_bp, "cart", names=["page-cart-view"])
app.register_blueprint(page_cart_bp, url_prefix="/<page_slug>")
# Auto-mount all defpages with absolute paths
from shared.sx.pages import auto_mount_pages
auto_mount_pages(app, "cart")
return app

View File

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

View File

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

View File

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

View File

@@ -19,26 +19,6 @@ from .services import current_cart_identity
def register(url_prefix: str) -> Blueprint:
bp = Blueprint("page_cart", __name__, url_prefix=url_prefix)
@bp.before_request
async def _prepare_page_data():
"""Load page cart data for defpage route."""
endpoint = request.endpoint or ""
if not endpoint.endswith("defpage_page_cart_view"):
return
post = g.page_post
cart = await get_cart_for_page(g.s, post.id)
cal_entries = await get_calendar_entries_for_page(g.s, post.id)
page_tickets = await get_tickets_for_page(g.s, post.id)
ticket_groups = group_tickets(page_tickets)
from shared.sx.page import get_template_context
from sx.sx_components import _page_cart_main_panel_sx
ctx = await get_template_context()
g.page_cart_content = _page_cart_main_panel_sx(
ctx, cart, cal_entries, page_tickets, ticket_groups,
total, calendar_total, ticket_total,
)
@bp.post("/checkout/")
async def page_checkout():
post = g.page_post
@@ -48,7 +28,7 @@ def register(url_prefix: str) -> Blueprint:
page_tickets = await get_tickets_for_page(g.s, post.id)
if not cart and not cal_entries and not page_tickets:
return redirect(url_for("page_cart.defpage_page_cart_view"))
return redirect(url_for("defpage_page_cart_view"))
product_total_val = total(cart) or 0
calendar_amount = calendar_total(cal_entries) or 0
@@ -56,7 +36,7 @@ def register(url_prefix: str) -> Blueprint:
cart_total = product_total_val + calendar_amount + ticket_amount
if cart_total <= 0:
return redirect(url_for("page_cart.defpage_page_cart_view"))
return redirect(url_for("defpage_page_cart_view"))
ident = current_cart_identity()

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
from __future__ import annotations
from quart import Blueprint, g, render_template, redirect, url_for, make_response
from quart import Blueprint, g, redirect, url_for, make_response
from sqlalchemy import select, func, or_, cast, String, exists
from sqlalchemy.orm import selectinload

View File

@@ -13,23 +13,6 @@ from shared.sx.helpers import sx_response
def register():
bp = Blueprint("page_admin", __name__)
@bp.before_request
async def _prepare_page_data():
"""Pre-render admin content for defpage routes."""
endpoint = request.endpoint or ""
if request.method != "GET":
return
if endpoint.endswith("defpage_cart_admin"):
from shared.sx.page import get_template_context
from sx.sx_components import _cart_admin_main_panel_sx
ctx = await get_template_context()
g.cart_admin_content = _cart_admin_main_panel_sx(ctx)
elif endpoint.endswith("defpage_cart_payments"):
from shared.sx.page import get_template_context
from sx.sx_components import _cart_payments_main_panel_sx
ctx = await get_template_context()
g.cart_payments_content = _cart_payments_main_panel_sx(ctx)
@bp.put("/payments/")
@require_admin
async def update_sumup(**kwargs):

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import datetime, timezone
from quart import (
request, render_template, make_response, Blueprint, g, abort, session as qsession
request, make_response, Blueprint, g, abort, session as qsession
)

View File

@@ -3,7 +3,7 @@ from datetime import datetime, timezone
from decimal import Decimal
from quart import (
request, render_template, make_response,
request, make_response,
Blueprint, g, redirect, url_for, jsonify,
)

View File

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

View File

@@ -11,7 +11,7 @@ from shared.browser.app.redis_cacher import clear_cache
from sqlalchemy import select
from quart import (
request, render_template, make_response, Blueprint, g, jsonify
request, make_response, Blueprint, g, jsonify
)
from ..calendar_entries.services.entries import (
svc_update_entry,
@@ -238,19 +238,6 @@ def register():
"user_ticket_counts_by_type": user_ticket_counts_by_type,
"container_nav": container_nav,
}
@bp.before_request
async def _prepare_page_data():
if "defpage_" not in (request.endpoint or ""):
return
from shared.sx.page import get_template_context
from sx.sx_components import _entry_main_panel_html, _entry_nav_html
ctx = await get_template_context()
g.entry_content = _entry_main_panel_html(ctx)
g.entry_menu = _entry_nav_html(ctx)
from shared.sx.pages import mount_pages
mount_pages(bp, "events", names=["entry-detail"])
@bp.get("/edit/")
@require_admin
async def get_edit(entry_id: int, **rest):

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -29,27 +29,6 @@ from shared.sx.helpers import sx_response
def register():
bp = Blueprint("slot", __name__, url_prefix='/<int:slot_id>')
@bp.before_request
async def _prepare_page_data():
if "defpage_" not in (request.endpoint or ""):
return
slot_id = (request.view_args or {}).get("slot_id")
slot = await svc_get_slot(g.s, slot_id) if slot_id else None
if not slot:
from quart import abort
abort(404)
g.slot = slot
calendar = getattr(g, "calendar", None)
from sx.sx_components import render_slot_main_panel
g.slot_content = render_slot_main_panel(slot, calendar)
@bp.context_processor
async def _inject_slot():
return {"slot": getattr(g, "slot", None)}
from shared.sx.pages import mount_pages
mount_pages(bp, "events", names=["slot-detail"])
@bp.get("/edit/")
@require_admin
async def get_edit(slot_id: int, **kwargs):

View File

@@ -38,18 +38,6 @@ def register():
}
return {"slots": []}
@bp.before_request
async def _prepare_page_data():
if "defpage_" not in (request.endpoint or ""):
return
calendar = getattr(g, "calendar", None)
slots = await svc_list_slots(g.s, calendar.id) if calendar else []
from sx.sx_components import render_slots_table
g.slots_content = render_slots_table(slots, calendar)
from shared.sx.pages import mount_pages
mount_pages(bp, "events", names=["slots-listing"])
@bp.post("/")
@require_admin
@clear_cache(tag="calendars", tag_scope="all")

View File

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

View File

@@ -22,32 +22,6 @@ from shared.sx.helpers import sx_response
def register():
bp = Blueprint("ticket_type", __name__, url_prefix='/<int:ticket_type_id>')
@bp.before_request
async def _prepare_page_data():
if "defpage_" not in (request.endpoint or ""):
return
ticket_type_id = (request.view_args or {}).get("ticket_type_id")
ticket_type = await svc_get_ticket_type(g.s, ticket_type_id) if ticket_type_id else None
if not ticket_type:
from quart import abort
abort(404)
g.ticket_type = ticket_type
entry = getattr(g, "entry", None)
calendar = getattr(g, "calendar", None)
va = request.view_args or {}
from sx.sx_components import render_ticket_type_main_panel
g.ticket_type_content = render_ticket_type_main_panel(
ticket_type, entry, calendar,
va.get("day"), va.get("month"), va.get("year"),
)
@bp.context_processor
async def _inject_ticket_type():
return {"ticket_type": getattr(g, "ticket_type", None)}
from shared.sx.pages import mount_pages
mount_pages(bp, "events", names=["ticket-type-detail"])
@bp.get("/edit/")
@require_admin
async def get_edit(ticket_type_id: int, **kwargs):

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -311,6 +311,183 @@ def _markets_oob(ctx: dict, **kw: Any) -> str:
return oobs
# ---------------------------------------------------------------------------
# Shared hydration helpers
# ---------------------------------------------------------------------------
def _add_to_defpage_ctx(**kwargs: Any) -> None:
"""Add data to g._defpage_ctx for the app-level context_processor."""
from quart import g
if not hasattr(g, '_defpage_ctx'):
g._defpage_ctx = {}
g._defpage_ctx.update(kwargs)
async def _ensure_calendar(calendar_slug: str | None) -> None:
"""Load calendar into g.calendar if not already present."""
from quart import g, abort
if hasattr(g, 'calendar'):
_add_to_defpage_ctx(calendar=g.calendar)
return
from bp.calendar.services.calendar_view import (
get_calendar_by_post_and_slug, get_calendar_by_slug,
)
post_data = getattr(g, "post_data", None)
if post_data:
post_id = (post_data.get("post") or {}).get("id")
cal = await get_calendar_by_post_and_slug(g.s, post_id, calendar_slug)
else:
cal = await get_calendar_by_slug(g.s, calendar_slug)
if not cal:
abort(404)
g.calendar = cal
g.calendar_slug = calendar_slug
_add_to_defpage_ctx(calendar=cal)
async def _ensure_entry(entry_id: int | None) -> None:
"""Load calendar entry into g.entry if not already present."""
from quart import g, abort
if hasattr(g, 'entry'):
_add_to_defpage_ctx(entry=g.entry)
return
from sqlalchemy import select
from models.calendars import CalendarEntry
result = await g.s.execute(
select(CalendarEntry).where(
CalendarEntry.id == entry_id,
CalendarEntry.deleted_at.is_(None),
)
)
entry = result.scalar_one_or_none()
if entry is None:
abort(404)
g.entry = entry
_add_to_defpage_ctx(entry=entry)
async def _ensure_entry_context(entry_id: int | None) -> None:
"""Load full entry context (ticket data, posts) into g.* and _defpage_ctx."""
from quart import g
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from models.calendars import CalendarEntry
from bp.tickets.services.tickets import (
get_available_ticket_count,
get_sold_ticket_count,
get_user_reserved_count,
)
from shared.infrastructure.cart_identity import current_cart_identity
from bp.calendar_entry.services.post_associations import get_entry_posts
await _ensure_entry(entry_id)
# Reload with ticket_types eagerly loaded
stmt = (
select(CalendarEntry)
.where(CalendarEntry.id == entry_id, CalendarEntry.deleted_at.is_(None))
.options(selectinload(CalendarEntry.ticket_types))
)
result = await g.s.execute(stmt)
calendar_entry = result.scalar_one_or_none()
if calendar_entry and getattr(g, "calendar", None):
if calendar_entry.calendar_id != g.calendar.id:
calendar_entry = None
if calendar_entry:
await g.s.refresh(calendar_entry, ['slot'])
g.entry = calendar_entry
entry_posts = await get_entry_posts(g.s, calendar_entry.id)
ticket_remaining = await get_available_ticket_count(g.s, calendar_entry.id)
ticket_sold_count = await get_sold_ticket_count(g.s, calendar_entry.id)
ident = current_cart_identity()
user_ticket_count = await get_user_reserved_count(
g.s, calendar_entry.id,
user_id=ident["user_id"],
session_id=ident["session_id"],
)
user_ticket_counts_by_type = {}
if calendar_entry.ticket_types:
for tt in calendar_entry.ticket_types:
if tt.deleted_at is None:
user_ticket_counts_by_type[tt.id] = await get_user_reserved_count(
g.s, calendar_entry.id,
user_id=ident["user_id"],
session_id=ident["session_id"],
ticket_type_id=tt.id,
)
_add_to_defpage_ctx(
entry=calendar_entry,
entry_posts=entry_posts,
ticket_remaining=ticket_remaining,
ticket_sold_count=ticket_sold_count,
user_ticket_count=user_ticket_count,
user_ticket_counts_by_type=user_ticket_counts_by_type,
)
async def _ensure_day_data(year: int, month: int, day: int) -> None:
"""Load day-specific data for layout header functions."""
from quart import g, session as qsession
if hasattr(g, 'day_date'):
return
from datetime import date as date_cls, datetime, timezone, timedelta
from sqlalchemy import select
from bp.calendar.services import get_visible_entries_for_period
from models.calendars import CalendarSlot
calendar = getattr(g, "calendar", None)
if not calendar:
return
try:
day_date = date_cls(year, month, day)
except (ValueError, TypeError):
return
period_start = datetime(year, month, day, tzinfo=timezone.utc)
period_end = period_start + timedelta(days=1)
user = getattr(g, "user", None)
session_id = qsession.get("calendar_sid")
visible = await get_visible_entries_for_period(
sess=g.s,
calendar_id=calendar.id,
period_start=period_start,
period_end=period_end,
user=user,
session_id=session_id,
)
weekday_attr = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"][day_date.weekday()]
stmt = (
select(CalendarSlot)
.where(
CalendarSlot.calendar_id == calendar.id,
getattr(CalendarSlot, weekday_attr) == True, # noqa: E712
CalendarSlot.deleted_at.is_(None),
)
.order_by(CalendarSlot.time_start.asc(), CalendarSlot.id.asc())
)
result = await g.s.execute(stmt)
day_slots = list(result.scalars())
g.day_date = day_date
_add_to_defpage_ctx(
qsession=qsession,
day_date=day_date,
day=day,
year=year,
month=month,
day_entries=visible.merged_entries,
user_entries=visible.user_entries,
confirmed_entries=visible.confirmed_entries,
day_slots=day_slots,
)
# ---------------------------------------------------------------------------
# Page helpers
# ---------------------------------------------------------------------------
@@ -336,39 +513,72 @@ def _register_events_helpers() -> None:
})
def _h_calendar_admin_content():
async def _h_calendar_admin_content(calendar_slug=None, **kw):
await _ensure_calendar(calendar_slug)
from shared.sx.page import get_template_context
from sx.sx_components import _calendar_admin_main_panel_html
ctx = await get_template_context()
return _calendar_admin_main_panel_html(ctx)
async def _h_day_admin_content(calendar_slug=None, year=None, month=None, day=None, **kw):
await _ensure_calendar(calendar_slug)
if year is not None:
await _ensure_day_data(int(year), int(month), int(day))
from sx.sx_components import _day_admin_main_panel_html
return _day_admin_main_panel_html({})
async def _h_slots_content(calendar_slug=None, **kw):
from quart import g
return getattr(g, "calendar_admin_content", "")
await _ensure_calendar(calendar_slug)
calendar = getattr(g, "calendar", None)
from bp.slots.services.slots import list_slots as svc_list_slots
slots = await svc_list_slots(g.s, calendar.id) if calendar else []
_add_to_defpage_ctx(slots=slots)
from sx.sx_components import render_slots_table
return render_slots_table(slots, calendar)
def _h_day_admin_content():
from quart import g
return getattr(g, "day_admin_content", "")
async def _h_slot_content(calendar_slug=None, slot_id=None, **kw):
from quart import g, abort
await _ensure_calendar(calendar_slug)
from bp.slot.services.slot import get_slot as svc_get_slot
slot = await svc_get_slot(g.s, slot_id) if slot_id else None
if not slot:
abort(404)
g.slot = slot
_add_to_defpage_ctx(slot=slot)
calendar = getattr(g, "calendar", None)
from sx.sx_components import render_slot_main_panel
return render_slot_main_panel(slot, calendar)
def _h_slots_content():
from quart import g
return getattr(g, "slots_content", "")
async def _h_entry_content(calendar_slug=None, entry_id=None, **kw):
await _ensure_calendar(calendar_slug)
await _ensure_entry_context(entry_id)
from shared.sx.page import get_template_context
from sx.sx_components import _entry_main_panel_html
ctx = await get_template_context()
return _entry_main_panel_html(ctx)
def _h_slot_content():
from quart import g
return getattr(g, "slot_content", "")
async def _h_entry_menu(calendar_slug=None, entry_id=None, **kw):
await _ensure_calendar(calendar_slug)
await _ensure_entry_context(entry_id)
from shared.sx.page import get_template_context
from sx.sx_components import _entry_nav_html
ctx = await get_template_context()
return _entry_nav_html(ctx)
def _h_entry_content():
from quart import g
return getattr(g, "entry_content", "")
def _h_entry_menu():
from quart import g
return getattr(g, "entry_menu", "")
def _h_entry_admin_content():
from quart import g
return getattr(g, "entry_admin_content", "")
async def _h_entry_admin_content(calendar_slug=None, entry_id=None, **kw):
await _ensure_calendar(calendar_slug)
await _ensure_entry_context(entry_id)
from shared.sx.page import get_template_context
from sx.sx_components import _entry_admin_main_panel_html
ctx = await get_template_context()
return _entry_admin_main_panel_html(ctx)
def _h_admin_menu():
@@ -376,31 +586,118 @@ def _h_admin_menu():
return sx_call("events-admin-placeholder-nav")
def _h_ticket_types_content():
async def _h_ticket_types_content(calendar_slug=None, entry_id=None,
year=None, month=None, day=None, **kw):
from quart import g
return getattr(g, "ticket_types_content", "")
await _ensure_calendar(calendar_slug)
await _ensure_entry(entry_id)
entry = getattr(g, "entry", None)
calendar = getattr(g, "calendar", None)
from bp.ticket_types.services.tickets import list_ticket_types as svc_list_ticket_types
ticket_types = await svc_list_ticket_types(g.s, entry.id) if entry else []
_add_to_defpage_ctx(ticket_types=ticket_types)
from sx.sx_components import render_ticket_types_table
return render_ticket_types_table(ticket_types, entry, calendar, day, month, year)
def _h_ticket_type_content():
async def _h_ticket_type_content(calendar_slug=None, entry_id=None,
ticket_type_id=None, year=None, month=None, day=None, **kw):
from quart import g, abort
await _ensure_calendar(calendar_slug)
await _ensure_entry(entry_id)
from bp.ticket_type.services.ticket import get_ticket_type as svc_get_ticket_type
ticket_type = await svc_get_ticket_type(g.s, ticket_type_id) if ticket_type_id else None
if not ticket_type:
abort(404)
g.ticket_type = ticket_type
_add_to_defpage_ctx(ticket_type=ticket_type)
entry = getattr(g, "entry", None)
calendar = getattr(g, "calendar", None)
from sx.sx_components import render_ticket_type_main_panel
return render_ticket_type_main_panel(ticket_type, entry, calendar, day, month, year)
async def _h_tickets_content(**kw):
from quart import g
return getattr(g, "ticket_type_content", "")
from shared.infrastructure.cart_identity import current_cart_identity
from bp.tickets.services.tickets import get_user_tickets
ident = current_cart_identity()
tickets = await get_user_tickets(
g.s,
user_id=ident["user_id"],
session_id=ident["session_id"],
)
from shared.sx.page import get_template_context
from sx.sx_components import _tickets_main_panel_html
ctx = await get_template_context()
return _tickets_main_panel_html(ctx, tickets)
def _h_tickets_content():
async def _h_ticket_detail_content(code=None, **kw):
from quart import g, abort
from shared.infrastructure.cart_identity import current_cart_identity
from bp.tickets.services.tickets import get_ticket_by_code
ticket = await get_ticket_by_code(g.s, code) if code else None
if not ticket:
abort(404)
# Verify ownership
ident = current_cart_identity()
if ident["user_id"] is not None:
if ticket.user_id != ident["user_id"]:
abort(404)
elif ident["session_id"] is not None:
if ticket.session_id != ident["session_id"]:
abort(404)
else:
abort(404)
from shared.sx.page import get_template_context
from sx.sx_components import _ticket_detail_panel_html
ctx = await get_template_context()
return _ticket_detail_panel_html(ctx, ticket)
async def _h_ticket_admin_content(**kw):
from quart import g
return getattr(g, "tickets_content", "")
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
from models.calendars import CalendarEntry, Ticket
result = await g.s.execute(
select(Ticket)
.options(
selectinload(Ticket.entry).selectinload(CalendarEntry.calendar),
selectinload(Ticket.ticket_type),
)
.order_by(Ticket.created_at.desc())
.limit(50)
)
tickets = result.scalars().all()
total = await g.s.scalar(select(func.count(Ticket.id)))
confirmed = await g.s.scalar(
select(func.count(Ticket.id)).where(Ticket.state == "confirmed")
)
checked_in = await g.s.scalar(
select(func.count(Ticket.id)).where(Ticket.state == "checked_in")
)
reserved = await g.s.scalar(
select(func.count(Ticket.id)).where(Ticket.state == "reserved")
)
stats = {
"total": total or 0,
"confirmed": confirmed or 0,
"checked_in": checked_in or 0,
"reserved": reserved or 0,
}
from shared.sx.page import get_template_context
from sx.sx_components import _ticket_admin_main_panel_html
ctx = await get_template_context()
return _ticket_admin_main_panel_html(ctx, tickets, stats)
def _h_ticket_detail_content():
from quart import g
return getattr(g, "ticket_detail_content", "")
def _h_ticket_admin_content():
from quart import g
return getattr(g, "ticket_admin_content", "")
def _h_markets_content():
from quart import g
return getattr(g, "markets_content", "")
async def _h_markets_content(**kw):
from shared.sx.page import get_template_context
from sx.sx_components import _markets_main_panel_html
ctx = await get_template_context()
return _markets_main_panel_html(ctx)

View File

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

View File

@@ -12,7 +12,6 @@ from shared.services.registry import services
from bp import (
register_identity_bp,
register_social_bp,
register_fragments,
)
@@ -94,11 +93,13 @@ 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)
app.register_blueprint(register_fragments())
from shared.sx.pages import auto_mount_pages
auto_mount_pages(app, "federation")
from shared.sx.handlers import auto_mount_fragment_handlers
auto_mount_fragment_handlers(app, "federation")
# --- home page ---
@app.get("/")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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