Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 03f0929fdf | |||
| f551fc7453 | |||
| e30cb0a992 | |||
| 293f7713d6 | |||
| 4ba63bda17 | |||
| 0a81a2af01 | |||
| 0c9dbd6657 | |||
| a4377668be |
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
from .routes import register as register_fragments
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -28,4 +28,4 @@
|
||||
:path "/<slug>/"
|
||||
:auth :login
|
||||
:layout :account
|
||||
:content (fragment-content))
|
||||
:content (fragment-content slug))
|
||||
|
||||
22
blog/app.py
22
blog/app.py
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
from .routes import register as register_fragments
|
||||
@@ -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
|
||||
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
20
cart/app.py
20
cart/app.py
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
from .routes import register as register_fragments
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
from .routes import register as register_fragments
|
||||
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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():
|
||||
|
||||
49
events/sx/handlers/account-page.sx
Normal file
49
events/sx/handlers/account-page.sx
Normal 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))))))))))
|
||||
38
events/sx/handlers/container-cards.sx
Normal file
38
events/sx/handlers/container-cards.sx
Normal 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))))))
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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("/")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
from .routes import register as register_fragments
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
from .routes import register as register_fragments
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"'
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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."""
|
||||
|
||||
@@ -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 "/"
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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
Reference in New Issue
Block a user