Compare commits
10 Commits
44503a7d9b
...
418ac9424f
| Author | SHA1 | Date | |
|---|---|---|---|
| 418ac9424f | |||
| fb8f115acb | |||
| 63b895afd8 | |||
| 50b33ab08e | |||
| bd314a0be7 | |||
| 41cdd6eab8 | |||
| 1a6503782d | |||
| 72997068c6 | |||
| dacb61b0ae | |||
| 400667b15a |
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
import path_setup # noqa: F401 # adds shared/ to sys.path
|
||||
import sx.sx_components as sx_components # noqa: F401 # ensure Hypercorn --reload watches this file
|
||||
from pathlib import Path
|
||||
|
||||
from quart import g, request
|
||||
@@ -72,8 +71,9 @@ def create_app() -> "Quart":
|
||||
app.jinja_loader,
|
||||
])
|
||||
|
||||
# Setup defpage routes
|
||||
import sx.sx_components # noqa: F811 — ensure components loaded
|
||||
# Load .sx component files and setup defpage routes
|
||||
from shared.sx.jinja_bridge import load_service_components
|
||||
load_service_components(str(Path(__file__).resolve().parent), service_name="account")
|
||||
from sxc.pages import setup_account_pages
|
||||
setup_account_pages()
|
||||
|
||||
|
||||
@@ -7,14 +7,13 @@ from __future__ import annotations
|
||||
|
||||
from quart import (
|
||||
Blueprint,
|
||||
request,
|
||||
g,
|
||||
)
|
||||
from sqlalchemy import select
|
||||
|
||||
from shared.models import UserNewsletter
|
||||
from shared.infrastructure.fragments import fetch_fragments
|
||||
from shared.sx.helpers import sx_response
|
||||
from shared.sx.helpers import sx_response, render_to_sx
|
||||
|
||||
|
||||
def register(url_prefix="/"):
|
||||
@@ -55,7 +54,26 @@ def register(url_prefix="/"):
|
||||
|
||||
await g.s.flush()
|
||||
|
||||
from sx.sx_components import render_newsletter_toggle
|
||||
return sx_response(await render_newsletter_toggle(un))
|
||||
# Render toggle directly — no sx_components intermediary
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from shared.infrastructure.urls import account_url
|
||||
|
||||
nid = un.newsletter_id
|
||||
url_fn = getattr(g, "_account_url", None) or account_url
|
||||
toggle_url = url_fn(f"/newsletter/{nid}/toggle/")
|
||||
csrf = generate_csrf_token()
|
||||
bg = "bg-emerald-500" if un.subscribed else "bg-stone-300"
|
||||
translate = "translate-x-6" if un.subscribed else "translate-x-1"
|
||||
checked = "true" if un.subscribed else "false"
|
||||
|
||||
return sx_response(await render_to_sx(
|
||||
"account-newsletter-toggle",
|
||||
id=f"nl-{nid}", url=toggle_url,
|
||||
hdrs=f'{{"X-CSRFToken": "{csrf}"}}',
|
||||
target=f"#nl-{nid}",
|
||||
cls=f"relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 {bg}",
|
||||
checked=checked,
|
||||
knob_cls=f"inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform {translate}",
|
||||
))
|
||||
|
||||
return account_bp
|
||||
|
||||
@@ -44,6 +44,17 @@ from .services import (
|
||||
SESSION_USER_KEY = "uid"
|
||||
ACCOUNT_SESSION_KEY = "account_sid"
|
||||
|
||||
|
||||
async def _render_auth_page(component: str, title: str, **kwargs) -> str:
|
||||
"""Render an auth page with root layout — replaces sx_components helpers."""
|
||||
from shared.sx.helpers import render_to_sx, full_page_sx, root_header_sx
|
||||
from shared.sx.page import get_template_context
|
||||
ctx = await get_template_context()
|
||||
hdr = await root_header_sx(ctx)
|
||||
content = await render_to_sx(component, **{k: v for k, v in kwargs.items() if v})
|
||||
return await full_page_sx(ctx, header_rows=hdr, content=content,
|
||||
meta_html=f"<title>{title}</title>")
|
||||
|
||||
ALLOWED_CLIENTS = {"blog", "market", "cart", "events", "federation", "orders", "test", "sx", "artdag", "artdag_l2"}
|
||||
|
||||
|
||||
@@ -275,10 +286,7 @@ def register(url_prefix="/auth"):
|
||||
redirect_url = pop_login_redirect_target()
|
||||
return redirect(redirect_url)
|
||||
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_login_page
|
||||
ctx = await get_template_context()
|
||||
return await render_login_page(ctx)
|
||||
return await _render_auth_page("account-login-content", "Login \u2014 Rose Ash")
|
||||
|
||||
@rate_limit(
|
||||
key_func=lambda: request.headers.get("X-Forwarded-For", request.remote_addr),
|
||||
@@ -291,20 +299,20 @@ def register(url_prefix="/auth"):
|
||||
|
||||
is_valid, email = validate_email(email_input)
|
||||
if not is_valid:
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_login_page
|
||||
ctx = await get_template_context(error="Please enter a valid email address.", email=email_input)
|
||||
return await render_login_page(ctx), 400
|
||||
return await _render_auth_page(
|
||||
"account-login-content", "Login \u2014 Rose Ash",
|
||||
error="Please enter a valid email address.", email=email_input,
|
||||
), 400
|
||||
|
||||
# Per-email rate limit: 5 magic links per 15 minutes
|
||||
from shared.infrastructure.rate_limit import _check_rate_limit
|
||||
try:
|
||||
allowed, _ = await _check_rate_limit(f"magic_email:{email}", 5, 900)
|
||||
if not allowed:
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_check_email_page
|
||||
ctx = await get_template_context(email=email, email_error=None)
|
||||
return await render_check_email_page(ctx), 200
|
||||
return await _render_auth_page(
|
||||
"account-check-email-content", "Check your email \u2014 Rose Ash",
|
||||
email=email,
|
||||
), 200
|
||||
except Exception:
|
||||
pass # Redis down — allow the request
|
||||
|
||||
@@ -324,10 +332,10 @@ def register(url_prefix="/auth"):
|
||||
"Please try again in a moment."
|
||||
)
|
||||
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_check_email_page
|
||||
ctx = await get_template_context(email=email, email_error=email_error)
|
||||
return await render_check_email_page(ctx)
|
||||
return await _render_auth_page(
|
||||
"account-check-email-content", "Check your email \u2014 Rose Ash",
|
||||
email=email, email_error=email_error,
|
||||
)
|
||||
|
||||
@auth_bp.get("/magic/<token>/")
|
||||
async def magic(token: str):
|
||||
@@ -340,17 +348,17 @@ def register(url_prefix="/auth"):
|
||||
user, error = await validate_magic_link(s, token)
|
||||
|
||||
if error:
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_login_page
|
||||
ctx = await get_template_context(error=error)
|
||||
return await render_login_page(ctx), 400
|
||||
return await _render_auth_page(
|
||||
"account-login-content", "Login \u2014 Rose Ash",
|
||||
error=error,
|
||||
), 400
|
||||
user_id = user.id
|
||||
|
||||
except Exception:
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_login_page
|
||||
ctx = await get_template_context(error="Could not sign you in right now. Please try again.")
|
||||
return await render_login_page(ctx), 502
|
||||
return await _render_auth_page(
|
||||
"account-login-content", "Login \u2014 Rose Ash",
|
||||
error="Could not sign you in right now. Please try again.",
|
||||
), 502
|
||||
|
||||
assert user_id is not None
|
||||
|
||||
@@ -679,11 +687,11 @@ def register(url_prefix="/auth"):
|
||||
@auth_bp.get("/device/")
|
||||
async def device_form():
|
||||
"""Browser form where user enters the code displayed in terminal."""
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_device_page
|
||||
code = request.args.get("code", "")
|
||||
ctx = await get_template_context(code=code)
|
||||
return await render_device_page(ctx)
|
||||
return await _render_auth_page(
|
||||
"account-device-content", "Authorize Device \u2014 Rose Ash",
|
||||
code=code,
|
||||
)
|
||||
|
||||
@auth_bp.post("/device")
|
||||
@auth_bp.post("/device/")
|
||||
@@ -693,20 +701,20 @@ def register(url_prefix="/auth"):
|
||||
user_code = (form.get("code") or "").strip().replace("-", "").upper()
|
||||
|
||||
if not user_code or len(user_code) != 8:
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_device_page
|
||||
ctx = await get_template_context(error="Please enter a valid 8-character code.", code=form.get("code", ""))
|
||||
return await render_device_page(ctx), 400
|
||||
return await _render_auth_page(
|
||||
"account-device-content", "Authorize Device \u2014 Rose Ash",
|
||||
error="Please enter a valid 8-character code.", code=form.get("code", ""),
|
||||
), 400
|
||||
|
||||
from shared.infrastructure.auth_redis import get_auth_redis
|
||||
|
||||
r = await get_auth_redis()
|
||||
device_code = await r.get(f"devflow_uc:{user_code}")
|
||||
if not device_code:
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_device_page
|
||||
ctx = await get_template_context(error="Code not found or expired. Please try again.", code=form.get("code", ""))
|
||||
return await render_device_page(ctx), 400
|
||||
return await _render_auth_page(
|
||||
"account-device-content", "Authorize Device \u2014 Rose Ash",
|
||||
error="Code not found or expired. Please try again.", code=form.get("code", ""),
|
||||
), 400
|
||||
|
||||
if isinstance(device_code, bytes):
|
||||
device_code = device_code.decode()
|
||||
@@ -720,23 +728,19 @@ def register(url_prefix="/auth"):
|
||||
# Logged in — approve immediately
|
||||
ok = await _approve_device(device_code, g.user)
|
||||
if not ok:
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_device_page
|
||||
ctx = await get_template_context(error="Code expired or already used.")
|
||||
return await render_device_page(ctx), 400
|
||||
return await _render_auth_page(
|
||||
"account-device-content", "Authorize Device \u2014 Rose Ash",
|
||||
error="Code expired or already used.",
|
||||
), 400
|
||||
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_device_approved_page
|
||||
ctx = await get_template_context()
|
||||
return await render_device_approved_page(ctx)
|
||||
return await _render_auth_page(
|
||||
"account-device-approved", "Device Authorized \u2014 Rose Ash",
|
||||
)
|
||||
|
||||
@auth_bp.get("/device/complete")
|
||||
@auth_bp.get("/device/complete/")
|
||||
async def device_complete():
|
||||
"""Post-login redirect — completes approval after magic link auth."""
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_device_page, render_device_approved_page
|
||||
|
||||
device_code = request.args.get("code", "")
|
||||
|
||||
if not device_code:
|
||||
@@ -748,12 +752,13 @@ def register(url_prefix="/auth"):
|
||||
|
||||
ok = await _approve_device(device_code, g.user)
|
||||
if not ok:
|
||||
ctx = await get_template_context(
|
||||
return await _render_auth_page(
|
||||
"account-device-content", "Authorize Device \u2014 Rose Ash",
|
||||
error="Code expired or already used. Please start the login process again in your terminal.",
|
||||
)
|
||||
return await render_device_page(ctx), 400
|
||||
), 400
|
||||
|
||||
ctx = await get_template_context()
|
||||
return await render_device_approved_page(ctx)
|
||||
return await _render_auth_page(
|
||||
"account-device-approved", "Device Authorized \u2014 Rose Ash",
|
||||
)
|
||||
|
||||
return auth_bp
|
||||
|
||||
@@ -3,9 +3,7 @@ from __future__ import annotations
|
||||
|
||||
|
||||
def register_domain_services() -> None:
|
||||
"""Register services for the account app.
|
||||
|
||||
Account is a consumer-only dashboard app. It has no own domain.
|
||||
All cross-app data comes via fragments and HTTP data endpoints.
|
||||
"""
|
||||
pass
|
||||
"""Register services for the account app."""
|
||||
from shared.services.registry import services
|
||||
from .account_page import AccountPageService
|
||||
services.register("account_page", AccountPageService())
|
||||
|
||||
40
account/services/account_page.py
Normal file
40
account/services/account_page.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Account page data service — provides serialized dicts for .sx defpages."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class AccountPageService:
|
||||
"""Service for account page data, callable via (service "account-page" ...)."""
|
||||
|
||||
async def newsletters_data(self, session, **kw):
|
||||
"""Return newsletter list with user subscription status."""
|
||||
from quart import g
|
||||
from sqlalchemy import select
|
||||
from shared.models import UserNewsletter
|
||||
from shared.models.ghost_membership_entities import GhostNewsletter
|
||||
|
||||
result = await session.execute(
|
||||
select(GhostNewsletter).order_by(GhostNewsletter.name)
|
||||
)
|
||||
all_newsletters = result.scalars().all()
|
||||
|
||||
sub_result = await session.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": {"id": nl.id, "name": nl.name, "description": nl.description},
|
||||
"un": {"newsletter_id": un.newsletter_id, "subscribed": un.subscribed} if un else None,
|
||||
"subscribed": un.subscribed if un else False,
|
||||
})
|
||||
|
||||
from shared.infrastructure.urls import account_url
|
||||
return {
|
||||
"newsletter_list": newsletter_list,
|
||||
"account_url": account_url(""),
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
"""
|
||||
Account service s-expression page components.
|
||||
|
||||
Renders login, device auth, and check-email pages. Dashboard and newsletters
|
||||
are now fully handled by .sx defcomps called from defpage expressions.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from shared.sx.jinja_bridge import load_service_components
|
||||
from shared.sx.helpers import (
|
||||
render_to_sx,
|
||||
root_header_sx, full_page_sx,
|
||||
)
|
||||
|
||||
# Load account-specific .sx components + handlers at import time
|
||||
load_service_components(os.path.dirname(os.path.dirname(__file__)),
|
||||
service_name="account")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Auth pages (login, device, check_email)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_login_page(ctx: dict) -> str:
|
||||
"""Full page: login form."""
|
||||
error = ctx.get("error", "")
|
||||
email = ctx.get("email", "")
|
||||
hdr = await root_header_sx(ctx)
|
||||
content = await render_to_sx("account-login-content",
|
||||
error=error or None, email=email)
|
||||
return await full_page_sx(ctx, header_rows=hdr,
|
||||
content=content,
|
||||
meta_html='<title>Login \u2014 Rose Ash</title>')
|
||||
|
||||
|
||||
async def render_device_page(ctx: dict) -> str:
|
||||
"""Full page: device authorization form."""
|
||||
error = ctx.get("error", "")
|
||||
code = ctx.get("code", "")
|
||||
hdr = await root_header_sx(ctx)
|
||||
content = await render_to_sx("account-device-content",
|
||||
error=error or None, code=code)
|
||||
return await full_page_sx(ctx, header_rows=hdr,
|
||||
content=content,
|
||||
meta_html='<title>Authorize Device \u2014 Rose Ash</title>')
|
||||
|
||||
|
||||
async def render_device_approved_page(ctx: dict) -> str:
|
||||
"""Full page: device approved."""
|
||||
hdr = await root_header_sx(ctx)
|
||||
content = await render_to_sx("account-device-approved")
|
||||
return await full_page_sx(ctx, header_rows=hdr,
|
||||
content=content,
|
||||
meta_html='<title>Device Authorized \u2014 Rose Ash</title>')
|
||||
|
||||
|
||||
async def render_check_email_page(ctx: dict) -> str:
|
||||
"""Full page: check email after magic link sent."""
|
||||
email = ctx.get("email", "")
|
||||
email_error = ctx.get("email_error")
|
||||
hdr = await root_header_sx(ctx)
|
||||
content = await render_to_sx("account-check-email-content",
|
||||
email=email, email_error=email_error)
|
||||
return await full_page_sx(ctx, header_rows=hdr,
|
||||
content=content,
|
||||
meta_html='<title>Check your email \u2014 Rose Ash</title>')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Fragment renderers for POST handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_newsletter_toggle(un) -> str:
|
||||
"""Render a newsletter toggle switch for POST response."""
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
|
||||
nid = un.newsletter_id
|
||||
from quart import g
|
||||
account_url_fn = getattr(g, "_account_url", None)
|
||||
if account_url_fn is None:
|
||||
from shared.infrastructure.urls import account_url
|
||||
account_url_fn = account_url
|
||||
|
||||
toggle_url = account_url_fn(f"/newsletter/{nid}/toggle/")
|
||||
csrf = generate_csrf_token()
|
||||
|
||||
if un.subscribed:
|
||||
bg = "bg-emerald-500"
|
||||
translate = "translate-x-6"
|
||||
checked = "true"
|
||||
else:
|
||||
bg = "bg-stone-300"
|
||||
translate = "translate-x-1"
|
||||
checked = "false"
|
||||
|
||||
return await render_to_sx(
|
||||
"account-newsletter-toggle",
|
||||
id=f"nl-{nid}", url=toggle_url,
|
||||
hdrs=f'{{"X-CSRFToken": "{csrf}"}}',
|
||||
target=f"#nl-{nid}",
|
||||
cls=f"relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 {bg}",
|
||||
checked=checked,
|
||||
knob_cls=f"inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform {translate}",
|
||||
)
|
||||
@@ -1,13 +1,12 @@
|
||||
"""Account defpage setup — registers layouts, page helpers, and loads .sx pages."""
|
||||
"""Account defpage setup — registers layouts and loads .sx pages."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def setup_account_pages() -> None:
|
||||
"""Register account-specific layouts, page helpers, and load page definitions."""
|
||||
"""Register account-specific layouts and load page definitions."""
|
||||
_register_account_layouts()
|
||||
_register_account_helpers()
|
||||
_load_account_page_files()
|
||||
|
||||
|
||||
@@ -54,6 +53,7 @@ async def _account_oob(ctx: dict, **kw: Any) -> str:
|
||||
async def _account_mobile(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import mobile_menu_sx, mobile_root_nav_sx, render_to_sx
|
||||
from shared.sx.parser import SxExpr
|
||||
|
||||
ctx = _inject_account_nav(ctx)
|
||||
nav_items = await render_to_sx("auth-nav-items",
|
||||
account_url=_call_url(ctx, "account_url", ""),
|
||||
@@ -89,77 +89,3 @@ def _as_sx_nav(ctx: dict) -> Any:
|
||||
return _as_sx(ctx.get("account_nav"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Page helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _register_account_helpers() -> None:
|
||||
from shared.sx.pages import register_page_helpers
|
||||
|
||||
register_page_helpers("account", {
|
||||
"newsletters-content": _h_newsletters_content,
|
||||
"fragment-content": _h_fragment_content,
|
||||
})
|
||||
|
||||
|
||||
async def _h_newsletters_content(**kw):
|
||||
"""Fetch newsletter data, return assembled defcomp call."""
|
||||
from quart import g
|
||||
from sqlalchemy import select
|
||||
from shared.models import UserNewsletter
|
||||
from shared.models.ghost_membership_entities import GhostNewsletter
|
||||
from shared.sx.helpers import render_to_sx
|
||||
|
||||
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": {"id": nl.id, "name": nl.name, "description": nl.description},
|
||||
"un": {"newsletter_id": un.newsletter_id, "subscribed": un.subscribed} if un else None,
|
||||
"subscribed": un.subscribed if un else False,
|
||||
})
|
||||
|
||||
account_url = getattr(g, "_account_url", None)
|
||||
if account_url is None:
|
||||
from shared.infrastructure.urls import account_url as _account_url
|
||||
account_url = _account_url
|
||||
# Call account_url to get the base URL string
|
||||
account_url_str = account_url("") if callable(account_url) else str(account_url or "")
|
||||
|
||||
return await render_to_sx("account-newsletters-content",
|
||||
newsletter_list=newsletter_list,
|
||||
account_url=account_url_str)
|
||||
|
||||
|
||||
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 shared.sx.parser import SxExpr
|
||||
if isinstance(fragment_html, SxExpr):
|
||||
return fragment_html.source
|
||||
s = str(fragment_html) if fragment_html else ""
|
||||
if not s:
|
||||
return ""
|
||||
from shared.sx.helpers import render_to_sx
|
||||
return await render_to_sx("rich-text", html=s)
|
||||
|
||||
@@ -18,7 +18,10 @@
|
||||
:path "/newsletters/"
|
||||
:auth :login
|
||||
:layout :account
|
||||
:content (newsletters-content))
|
||||
:data (service "account-page" "newsletters-data")
|
||||
:content (~account-newsletters-content
|
||||
:newsletter-list newsletter-list
|
||||
:account-url account-url))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Fragment pages (tickets, bookings, etc. from events service)
|
||||
@@ -28,4 +31,10 @@
|
||||
:path "/<slug>/"
|
||||
:auth :login
|
||||
:layout :account
|
||||
:content (fragment-content slug))
|
||||
:content (let* ((user (current-user))
|
||||
(result (frag "events" "account-page"
|
||||
:slug slug
|
||||
:user-id (str (get user "id")))))
|
||||
(if (or (nil? result) (empty? result))
|
||||
(abort 404)
|
||||
result)))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
import path_setup # noqa: F401 # adds shared/ to sys.path
|
||||
import sx.sx_components as sx_components # noqa: F401 # ensure Hypercorn --reload watches this file
|
||||
from shared.sx.jinja_bridge import load_service_components # noqa: F401
|
||||
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
@@ -140,6 +140,8 @@ 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}/"
|
||||
|
||||
load_service_components("cart")
|
||||
|
||||
from shared.sx.handlers import auto_mount_fragment_handlers
|
||||
auto_mount_fragment_handlers(app, "cart")
|
||||
|
||||
|
||||
@@ -151,7 +151,7 @@ def register(url_prefix: str) -> Blueprint:
|
||||
page_config = await resolve_page_config(g.s, cart, calendar_entries, tickets)
|
||||
except ValueError as e:
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_checkout_error_page
|
||||
from sxc.pages import render_checkout_error_page
|
||||
tctx = await get_template_context()
|
||||
html = await render_checkout_error_page(tctx, error=str(e))
|
||||
return await make_response(html, 400)
|
||||
@@ -208,7 +208,7 @@ def register(url_prefix: str) -> Blueprint:
|
||||
|
||||
if not hosted_url:
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_checkout_error_page
|
||||
from sxc.pages import render_checkout_error_page
|
||||
tctx = await get_template_context()
|
||||
html = await render_checkout_error_page(tctx, error="No hosted checkout URL returned from SumUp.")
|
||||
return await make_response(html, 500)
|
||||
|
||||
@@ -73,7 +73,7 @@ def register(url_prefix: str) -> Blueprint:
|
||||
|
||||
if not hosted_url:
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_checkout_error_page
|
||||
from sxc.pages import render_checkout_error_page
|
||||
tctx = await get_template_context()
|
||||
html = await render_checkout_error_page(tctx, error="No hosted checkout URL returned from SumUp.")
|
||||
return await make_response(html, 500)
|
||||
|
||||
@@ -57,7 +57,7 @@ def register() -> Blueprint:
|
||||
if not order:
|
||||
return await make_response("Order not found", 404)
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_order_page, render_order_oob
|
||||
from sxc.pages import render_order_page, render_order_oob
|
||||
|
||||
ctx = await get_template_context()
|
||||
calendar_entries = ctx.get("calendar_entries")
|
||||
@@ -122,7 +122,7 @@ def register() -> Blueprint:
|
||||
|
||||
if not hosted_url:
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_checkout_error_page
|
||||
from sxc.pages import render_checkout_error_page
|
||||
tctx = await get_template_context()
|
||||
html = await render_checkout_error_page(tctx, error="No hosted checkout URL returned from SumUp when trying to reopen payment.", order=order)
|
||||
return await make_response(html, 500)
|
||||
|
||||
@@ -138,7 +138,7 @@ def register(url_prefix: str) -> Blueprint:
|
||||
orders = result.scalars().all()
|
||||
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import (
|
||||
from sxc.pages import (
|
||||
render_orders_page,
|
||||
render_orders_rows,
|
||||
render_orders_oob,
|
||||
|
||||
@@ -47,7 +47,7 @@ def register():
|
||||
g.page_config = SimpleNamespace(**raw_pc) if raw_pc else None
|
||||
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_cart_payments_panel
|
||||
from sxc.pages import render_cart_payments_panel
|
||||
ctx = await get_template_context()
|
||||
html = await render_cart_payments_panel(ctx)
|
||||
return sx_response(html)
|
||||
|
||||
@@ -12,3 +12,6 @@ def register_domain_services() -> None:
|
||||
from shared.services.cart_impl import SqlCartService
|
||||
|
||||
services.cart = SqlCartService()
|
||||
|
||||
from .cart_page import CartPageService
|
||||
services.register("cart_page", CartPageService())
|
||||
|
||||
187
cart/services/cart_page.py
Normal file
187
cart/services/cart_page.py
Normal file
@@ -0,0 +1,187 @@
|
||||
"""Cart page data service — provides serialized dicts for .sx defpages."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _serialize_cart_item(item: Any) -> dict:
|
||||
from quart import url_for
|
||||
from shared.infrastructure.urls import market_product_url
|
||||
|
||||
p = item.product if hasattr(item, "product") else item
|
||||
slug = p.slug if hasattr(p, "slug") else ""
|
||||
unit_price = getattr(p, "special_price", None) or getattr(p, "regular_price", None)
|
||||
currency = getattr(p, "regular_price_currency", "GBP") or "GBP"
|
||||
return {
|
||||
"slug": slug,
|
||||
"title": p.title if hasattr(p, "title") else "",
|
||||
"image": p.image if hasattr(p, "image") else None,
|
||||
"brand": getattr(p, "brand", None),
|
||||
"is_deleted": getattr(item, "is_deleted", False),
|
||||
"unit_price": float(unit_price) if unit_price else None,
|
||||
"special_price": float(p.special_price) if getattr(p, "special_price", None) else None,
|
||||
"regular_price": float(p.regular_price) if getattr(p, "regular_price", None) else None,
|
||||
"currency": currency,
|
||||
"quantity": item.quantity,
|
||||
"product_id": p.id,
|
||||
"product_url": market_product_url(slug),
|
||||
"qty_url": url_for("cart_global.update_quantity", product_id=p.id),
|
||||
}
|
||||
|
||||
|
||||
def _serialize_cal_entry(e: Any) -> dict:
|
||||
name = getattr(e, "name", None) or getattr(e, "calendar_name", "")
|
||||
start = e.start_at if hasattr(e, "start_at") else ""
|
||||
end = getattr(e, "end_at", None)
|
||||
cost = getattr(e, "cost", 0) or 0
|
||||
end_str = f" \u2013 {end}" if end else ""
|
||||
return {
|
||||
"name": name,
|
||||
"date_str": f"{start}{end_str}",
|
||||
"cost": float(cost),
|
||||
}
|
||||
|
||||
|
||||
def _serialize_ticket_group(tg: Any) -> dict:
|
||||
name = tg.entry_name if hasattr(tg, "entry_name") else tg.get("entry_name", "")
|
||||
tt_name = tg.ticket_type_name if hasattr(tg, "ticket_type_name") else tg.get("ticket_type_name", "")
|
||||
price = tg.price if hasattr(tg, "price") else tg.get("price", 0)
|
||||
quantity = tg.quantity if hasattr(tg, "quantity") else tg.get("quantity", 0)
|
||||
line_total = tg.line_total if hasattr(tg, "line_total") else tg.get("line_total", 0)
|
||||
entry_id = tg.entry_id if hasattr(tg, "entry_id") else tg.get("entry_id", "")
|
||||
tt_id = tg.ticket_type_id if hasattr(tg, "ticket_type_id") else tg.get("ticket_type_id", "")
|
||||
start_at = tg.entry_start_at if hasattr(tg, "entry_start_at") else tg.get("entry_start_at")
|
||||
end_at = tg.entry_end_at if hasattr(tg, "entry_end_at") else tg.get("entry_end_at")
|
||||
|
||||
date_str = start_at.strftime("%-d %b %Y, %H:%M") if start_at else ""
|
||||
if end_at:
|
||||
date_str += f" \u2013 {end_at.strftime('%-d %b %Y, %H:%M')}"
|
||||
|
||||
return {
|
||||
"entry_name": name,
|
||||
"ticket_type_name": tt_name or None,
|
||||
"price": float(price or 0),
|
||||
"quantity": quantity,
|
||||
"line_total": float(line_total or 0),
|
||||
"entry_id": entry_id,
|
||||
"ticket_type_id": tt_id or None,
|
||||
"date_str": date_str,
|
||||
}
|
||||
|
||||
|
||||
def _serialize_page_group(grp: Any) -> dict | None:
|
||||
post = grp.get("post") if isinstance(grp, dict) else getattr(grp, "post", None)
|
||||
cart_items = grp.get("cart_items", []) if isinstance(grp, dict) else getattr(grp, "cart_items", [])
|
||||
cal_entries = grp.get("calendar_entries", []) if isinstance(grp, dict) else getattr(grp, "calendar_entries", [])
|
||||
tickets = grp.get("tickets", []) if isinstance(grp, dict) else getattr(grp, "tickets", [])
|
||||
|
||||
if not cart_items and not cal_entries and not tickets:
|
||||
return None
|
||||
|
||||
post_data = None
|
||||
if post:
|
||||
post_data = {
|
||||
"slug": post.slug if hasattr(post, "slug") else post.get("slug", ""),
|
||||
"title": post.title if hasattr(post, "title") else post.get("title", ""),
|
||||
"feature_image": post.feature_image if hasattr(post, "feature_image") else post.get("feature_image"),
|
||||
}
|
||||
market_place = grp.get("market_place") if isinstance(grp, dict) else getattr(grp, "market_place", None)
|
||||
mp_data = None
|
||||
if market_place:
|
||||
mp_data = {"name": market_place.name if hasattr(market_place, "name") else market_place.get("name", "")}
|
||||
|
||||
return {
|
||||
"post": post_data,
|
||||
"product_count": grp.get("product_count", 0) if isinstance(grp, dict) else getattr(grp, "product_count", 0),
|
||||
"calendar_count": grp.get("calendar_count", 0) if isinstance(grp, dict) else getattr(grp, "calendar_count", 0),
|
||||
"ticket_count": grp.get("ticket_count", 0) if isinstance(grp, dict) else getattr(grp, "ticket_count", 0),
|
||||
"total": float(grp.get("total", 0) if isinstance(grp, dict) else getattr(grp, "total", 0)),
|
||||
"market_place": mp_data,
|
||||
}
|
||||
|
||||
|
||||
class CartPageService:
|
||||
"""Service for cart page data, callable via (service "cart-page" ...)."""
|
||||
|
||||
async def overview_data(self, session, **kw):
|
||||
from shared.infrastructure.urls import cart_url
|
||||
from bp.cart.services import get_cart_grouped_by_page
|
||||
|
||||
page_groups = await get_cart_grouped_by_page(session)
|
||||
grp_dicts = [d for d in (_serialize_page_group(grp) for grp in page_groups) if d]
|
||||
return {
|
||||
"page_groups": grp_dicts,
|
||||
"cart_url_base": cart_url(""),
|
||||
}
|
||||
|
||||
async def page_cart_data(self, session, **kw):
|
||||
from quart import g, request, url_for
|
||||
from shared.infrastructure.urls import login_url
|
||||
from shared.utils import route_prefix
|
||||
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(session, post.id)
|
||||
cal_entries = await get_calendar_entries_for_page(session, post.id)
|
||||
page_tickets = await get_tickets_for_page(session, post.id)
|
||||
ticket_groups = group_tickets(page_tickets)
|
||||
|
||||
# Build summary data
|
||||
product_qty = sum(ci.quantity for ci in cart) if cart else 0
|
||||
ticket_qty = len(page_tickets) if page_tickets else 0
|
||||
item_count = product_qty + ticket_qty
|
||||
|
||||
product_total = total(cart) or 0
|
||||
cal_total = calendar_total(cal_entries) or 0
|
||||
tk_total = ticket_total(page_tickets) or 0
|
||||
grand = float(product_total) + float(cal_total) + float(tk_total)
|
||||
|
||||
symbol = "\u00a3"
|
||||
if cart and hasattr(cart[0], "product") and getattr(cart[0].product, "regular_price_currency", None):
|
||||
cur = cart[0].product.regular_price_currency
|
||||
symbol = "\u00a3" if cur == "GBP" else cur
|
||||
|
||||
user = getattr(g, "user", None)
|
||||
page_post = getattr(g, "page_post", None)
|
||||
|
||||
summary = {
|
||||
"item_count": item_count,
|
||||
"grand_total": grand,
|
||||
"symbol": symbol,
|
||||
"is_logged_in": bool(user),
|
||||
}
|
||||
|
||||
if user:
|
||||
if page_post:
|
||||
action = url_for("page_cart.page_checkout")
|
||||
else:
|
||||
action = url_for("cart_global.checkout")
|
||||
summary["checkout_action"] = route_prefix() + action
|
||||
summary["user_email"] = user.email
|
||||
else:
|
||||
summary["login_href"] = login_url(request.url)
|
||||
|
||||
return {
|
||||
"cart_items": [_serialize_cart_item(i) for i in cart],
|
||||
"cal_entries": [_serialize_cal_entry(e) for e in cal_entries],
|
||||
"ticket_groups": [_serialize_ticket_group(tg) for tg in ticket_groups],
|
||||
"summary": summary,
|
||||
}
|
||||
|
||||
async def payments_data(self, session, **kw):
|
||||
from shared.sx.page import get_template_context
|
||||
|
||||
ctx = await get_template_context()
|
||||
page_config = ctx.get("page_config")
|
||||
pc_data = None
|
||||
if page_config:
|
||||
pc_data = {
|
||||
"sumup_api_key": bool(getattr(page_config, "sumup_api_key", None)),
|
||||
"sumup_merchant_code": getattr(page_config, "sumup_merchant_code", None) or "",
|
||||
"sumup_checkout_prefix": getattr(page_config, "sumup_checkout_prefix", None) or "",
|
||||
}
|
||||
return {"page_config": pc_data}
|
||||
@@ -1,408 +0,0 @@
|
||||
"""
|
||||
Cart service s-expression page components.
|
||||
|
||||
Thin Python wrappers for header/layout helpers and route-level render
|
||||
functions. All visual rendering logic lives in .sx defcomps.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
from markupsafe import escape
|
||||
|
||||
from shared.sx.jinja_bridge import load_service_components
|
||||
from shared.sx.helpers import (
|
||||
call_url, root_header_sx, post_admin_header_sx,
|
||||
post_header_sx as _shared_post_header_sx,
|
||||
search_desktop_sx, search_mobile_sx,
|
||||
full_page_sx, oob_page_sx, header_child_sx,
|
||||
render_to_sx,
|
||||
)
|
||||
from shared.sx.parser import SxExpr
|
||||
from shared.infrastructure.urls import cart_url
|
||||
|
||||
# Load cart-specific .sx components + handlers at import time
|
||||
load_service_components(os.path.dirname(os.path.dirname(__file__)),
|
||||
service_name="cart")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Header helpers (used by layouts in sxc/pages/__init__.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _ensure_post_ctx(ctx: dict, page_post: Any) -> dict:
|
||||
"""Ensure ctx has a 'post' dict from page_post DTO (for shared post_header_sx)."""
|
||||
if ctx.get("post") or not page_post:
|
||||
return ctx
|
||||
ctx = {**ctx, "post": {
|
||||
"id": getattr(page_post, "id", None),
|
||||
"slug": getattr(page_post, "slug", ""),
|
||||
"title": getattr(page_post, "title", ""),
|
||||
"feature_image": getattr(page_post, "feature_image", None),
|
||||
}}
|
||||
return ctx
|
||||
|
||||
|
||||
async def _ensure_container_nav(ctx: dict) -> dict:
|
||||
"""Fetch container_nav if not already present (for post header row)."""
|
||||
if ctx.get("container_nav"):
|
||||
return ctx
|
||||
post = ctx.get("post") or {}
|
||||
post_id = post.get("id")
|
||||
slug = post.get("slug", "")
|
||||
if not post_id:
|
||||
return ctx
|
||||
from shared.infrastructure.fragments import fetch_fragments
|
||||
nav_params = {
|
||||
"container_type": "page",
|
||||
"container_id": str(post_id),
|
||||
"post_slug": slug,
|
||||
}
|
||||
events_nav, market_nav = await fetch_fragments([
|
||||
("events", "container-nav", nav_params),
|
||||
("market", "container-nav", nav_params),
|
||||
], required=False)
|
||||
return {**ctx, "container_nav": events_nav + market_nav}
|
||||
|
||||
|
||||
async def _post_header_sx(ctx: dict, page_post: Any, *, oob: bool = False) -> str:
|
||||
"""Build post-level header row from page_post DTO, using shared helper."""
|
||||
ctx = _ensure_post_ctx(ctx, page_post)
|
||||
ctx = await _ensure_container_nav(ctx)
|
||||
return await _shared_post_header_sx(ctx, oob=oob)
|
||||
|
||||
|
||||
async def _cart_header_sx(ctx: dict, *, oob: bool = False) -> str:
|
||||
"""Build the cart section header row."""
|
||||
return await render_to_sx(
|
||||
"menu-row-sx",
|
||||
id="cart-row", level=1, colour="sky",
|
||||
link_href=call_url(ctx, "cart_url", "/"),
|
||||
link_label="cart", icon="fa fa-shopping-cart",
|
||||
child_id="cart-header-child", oob=oob,
|
||||
)
|
||||
|
||||
|
||||
async def _page_cart_header_sx(ctx: dict, page_post: Any, *, oob: bool = False) -> str:
|
||||
"""Build the per-page cart header row."""
|
||||
slug = page_post.slug if page_post else ""
|
||||
title = ((page_post.title if page_post else None) or "")[:160]
|
||||
label_parts = []
|
||||
if page_post and page_post.feature_image:
|
||||
label_parts.append(await render_to_sx("cart-page-label-img", src=page_post.feature_image))
|
||||
label_parts.append(f'(span "{escape(title)}")')
|
||||
label_sx = "(<> " + " ".join(label_parts) + ")"
|
||||
nav_sx = await render_to_sx("cart-all-carts-link", href=call_url(ctx, "cart_url", "/"))
|
||||
return await render_to_sx(
|
||||
"menu-row-sx",
|
||||
id="page-cart-row", level=2, colour="sky",
|
||||
link_href=call_url(ctx, "cart_url", f"/{slug}/"),
|
||||
link_label_content=SxExpr(label_sx),
|
||||
nav=SxExpr(nav_sx), oob=oob,
|
||||
)
|
||||
|
||||
|
||||
async def _auth_header_sx(ctx: dict, *, oob: bool = False) -> str:
|
||||
"""Build the account section header row (for orders)."""
|
||||
return await render_to_sx(
|
||||
"auth-header-row-simple",
|
||||
account_url=call_url(ctx, "account_url", ""),
|
||||
oob=oob,
|
||||
)
|
||||
|
||||
|
||||
async def _orders_header_sx(ctx: dict, list_url: str) -> str:
|
||||
"""Build the orders section header row."""
|
||||
return await render_to_sx("orders-header-row", list_url=list_url)
|
||||
|
||||
|
||||
async def _cart_page_admin_header_sx(ctx: dict, page_post: Any, *, oob: bool = False,
|
||||
selected: str = "") -> str:
|
||||
"""Build the page-level admin header row."""
|
||||
slug = page_post.slug if page_post else ""
|
||||
ctx = _ensure_post_ctx(ctx, page_post)
|
||||
return await post_admin_header_sx(ctx, slug, oob=oob, selected=selected)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Serialization helpers (shared with sxc/pages/__init__.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _serialize_order(order: Any) -> dict:
|
||||
"""Serialize an order for SX defcomps."""
|
||||
from shared.infrastructure.urls import market_product_url
|
||||
created = order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else "\u2014"
|
||||
items = []
|
||||
if order.items:
|
||||
for item in order.items:
|
||||
items.append({
|
||||
"product_image": item.product_image,
|
||||
"product_title": item.product_title or "Unknown product",
|
||||
"product_id": item.product_id,
|
||||
"product_slug": item.product_slug,
|
||||
"product_url": market_product_url(item.product_slug),
|
||||
"quantity": item.quantity,
|
||||
"unit_price_formatted": f"{item.unit_price or 0:.2f}",
|
||||
"currency": item.currency or order.currency or "GBP",
|
||||
})
|
||||
return {
|
||||
"id": order.id,
|
||||
"status": order.status or "pending",
|
||||
"created_at_formatted": created,
|
||||
"description": order.description or "",
|
||||
"total_formatted": f"{order.total_amount or 0:.2f}",
|
||||
"total_amount": float(order.total_amount or 0),
|
||||
"currency": order.currency or "GBP",
|
||||
"items": items,
|
||||
}
|
||||
|
||||
|
||||
def _serialize_calendar_entry(e: Any) -> dict:
|
||||
"""Serialize an order calendar entry for SX defcomps."""
|
||||
st = e.state or ""
|
||||
ds = e.start_at.strftime("%-d %b %Y, %H:%M") if e.start_at else ""
|
||||
if e.end_at:
|
||||
ds += f" \u2013 {e.end_at.strftime('%-d %b %Y, %H:%M')}"
|
||||
return {
|
||||
"name": e.name,
|
||||
"state": st,
|
||||
"date_str": ds,
|
||||
"cost_formatted": f"{e.cost or 0:.2f}",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Orders list (used by cart/bp/orders/routes.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_orders_page(ctx: dict, orders: list, page: int,
|
||||
total_pages: int, search: str | None,
|
||||
search_count: int, url_for_fn: Any,
|
||||
qs_fn: Any) -> str:
|
||||
"""Full page: orders list."""
|
||||
from shared.utils import route_prefix
|
||||
|
||||
ctx["search"] = search
|
||||
ctx["search_count"] = search_count
|
||||
pfx = route_prefix()
|
||||
list_url = pfx + url_for_fn("orders.list_orders")
|
||||
rows_url = list_url
|
||||
detail_url_prefix = pfx + url_for_fn("orders.order.order_detail", order_id=0).rsplit("0/", 1)[0]
|
||||
|
||||
order_dicts = [_serialize_order(o) for o in orders]
|
||||
content = await render_to_sx("orders-list-content",
|
||||
orders=order_dicts,
|
||||
page=page, total_pages=total_pages,
|
||||
rows_url=rows_url, detail_url_prefix=detail_url_prefix)
|
||||
|
||||
hdr = await root_header_sx(ctx)
|
||||
auth = await _auth_header_sx(ctx)
|
||||
orders_hdr = await _orders_header_sx(ctx, list_url)
|
||||
auth_child_inner = await render_to_sx("header-child-sx", id="auth-header-child", inner=SxExpr(orders_hdr))
|
||||
auth_child = await render_to_sx(
|
||||
"header-child-sx",
|
||||
inner=SxExpr("(<> " + auth + " " + auth_child_inner + ")"),
|
||||
)
|
||||
header_rows = "(<> " + hdr + " " + auth_child + ")"
|
||||
|
||||
filt = await render_to_sx("order-list-header", search_mobile=SxExpr(await search_mobile_sx(ctx)))
|
||||
return await full_page_sx(ctx, header_rows=header_rows,
|
||||
filter=filt,
|
||||
aside=await search_desktop_sx(ctx),
|
||||
content=content)
|
||||
|
||||
|
||||
async def render_orders_rows(ctx: dict, orders: list, page: int,
|
||||
total_pages: int, url_for_fn: Any,
|
||||
qs_fn: Any) -> str:
|
||||
"""Pagination: just the table rows."""
|
||||
from shared.utils import route_prefix
|
||||
|
||||
pfx = route_prefix()
|
||||
list_url = pfx + url_for_fn("orders.list_orders")
|
||||
detail_url_prefix = pfx + url_for_fn("orders.order.order_detail", order_id=0).rsplit("0/", 1)[0]
|
||||
|
||||
order_dicts = [_serialize_order(o) for o in orders]
|
||||
parts = []
|
||||
for od in order_dicts:
|
||||
parts.append(await render_to_sx("order-row-pair",
|
||||
order=od,
|
||||
detail_url_prefix=detail_url_prefix))
|
||||
|
||||
if page < total_pages:
|
||||
next_url = list_url + qs_fn(page=page + 1)
|
||||
parts.append(await render_to_sx(
|
||||
"infinite-scroll",
|
||||
url=next_url, page=page, total_pages=total_pages,
|
||||
id_prefix="orders", colspan=5,
|
||||
))
|
||||
else:
|
||||
parts.append(await render_to_sx("order-end-row"))
|
||||
|
||||
return "(<> " + " ".join(parts) + ")"
|
||||
|
||||
|
||||
async def render_orders_oob(ctx: dict, orders: list, page: int,
|
||||
total_pages: int, search: str | None,
|
||||
search_count: int, url_for_fn: Any,
|
||||
qs_fn: Any) -> str:
|
||||
"""OOB response for orders list."""
|
||||
from shared.utils import route_prefix
|
||||
|
||||
ctx["search"] = search
|
||||
ctx["search_count"] = search_count
|
||||
pfx = route_prefix()
|
||||
list_url = pfx + url_for_fn("orders.list_orders")
|
||||
rows_url = list_url
|
||||
detail_url_prefix = pfx + url_for_fn("orders.order.order_detail", order_id=0).rsplit("0/", 1)[0]
|
||||
|
||||
order_dicts = [_serialize_order(o) for o in orders]
|
||||
content = await render_to_sx("orders-list-content",
|
||||
orders=order_dicts,
|
||||
page=page, total_pages=total_pages,
|
||||
rows_url=rows_url, detail_url_prefix=detail_url_prefix)
|
||||
|
||||
auth_oob = await _auth_header_sx(ctx, oob=True)
|
||||
orders_hdr = await _orders_header_sx(ctx, list_url)
|
||||
auth_child_oob = await render_to_sx(
|
||||
"oob-header-sx",
|
||||
parent_id="auth-header-child",
|
||||
row=SxExpr(orders_hdr),
|
||||
)
|
||||
root_oob = await root_header_sx(ctx, oob=True)
|
||||
oobs = "(<> " + auth_oob + " " + auth_child_oob + " " + root_oob + ")"
|
||||
|
||||
filt = await render_to_sx("order-list-header", search_mobile=SxExpr(await search_mobile_sx(ctx)))
|
||||
return await oob_page_sx(oobs=oobs,
|
||||
filter=filt,
|
||||
aside=await search_desktop_sx(ctx),
|
||||
content=content)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Single order detail (used by cart/bp/order/routes.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_order_page(ctx: dict, order: Any,
|
||||
calendar_entries: list | None,
|
||||
url_for_fn: Any) -> str:
|
||||
"""Full page: single order detail."""
|
||||
from shared.utils import route_prefix
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
|
||||
pfx = route_prefix()
|
||||
detail_url = pfx + url_for_fn("orders.order.order_detail", order_id=order.id)
|
||||
list_url = pfx + url_for_fn("orders.list_orders")
|
||||
recheck_url = pfx + url_for_fn("orders.order.order_recheck", order_id=order.id)
|
||||
pay_url = pfx + url_for_fn("orders.order.order_pay", order_id=order.id)
|
||||
|
||||
order_data = _serialize_order(order)
|
||||
cal_data = [_serialize_calendar_entry(e) for e in (calendar_entries or [])]
|
||||
|
||||
main = await render_to_sx("order-detail-content",
|
||||
order=order_data,
|
||||
calendar_entries=cal_data)
|
||||
filt = await render_to_sx("order-detail-filter-content",
|
||||
order=order_data,
|
||||
list_url=list_url, recheck_url=recheck_url,
|
||||
pay_url=pay_url, csrf=generate_csrf_token())
|
||||
|
||||
hdr = await root_header_sx(ctx)
|
||||
order_row = await render_to_sx(
|
||||
"menu-row-sx",
|
||||
id="order-row", level=3, colour="sky",
|
||||
link_href=detail_url, link_label=f"Order {order.id}", icon="fa fa-gbp",
|
||||
)
|
||||
auth = await _auth_header_sx(ctx)
|
||||
orders_hdr = await _orders_header_sx(ctx, list_url)
|
||||
orders_child = await render_to_sx("header-child-sx", id="orders-header-child", inner=SxExpr(order_row))
|
||||
auth_inner = "(<> " + orders_hdr + " " + orders_child + ")"
|
||||
auth_child = await render_to_sx("header-child-sx", id="auth-header-child", inner=SxExpr(auth_inner))
|
||||
order_child = await render_to_sx(
|
||||
"header-child-sx",
|
||||
inner=SxExpr("(<> " + auth + " " + auth_child + ")"),
|
||||
)
|
||||
header_rows = "(<> " + hdr + " " + order_child + ")"
|
||||
|
||||
return await full_page_sx(ctx, header_rows=header_rows, filter=filt, content=main)
|
||||
|
||||
|
||||
async def render_order_oob(ctx: dict, order: Any,
|
||||
calendar_entries: list | None,
|
||||
url_for_fn: Any) -> str:
|
||||
"""OOB response for single order detail."""
|
||||
from shared.utils import route_prefix
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
|
||||
pfx = route_prefix()
|
||||
detail_url = pfx + url_for_fn("orders.order.order_detail", order_id=order.id)
|
||||
list_url = pfx + url_for_fn("orders.list_orders")
|
||||
recheck_url = pfx + url_for_fn("orders.order.order_recheck", order_id=order.id)
|
||||
pay_url = pfx + url_for_fn("orders.order.order_pay", order_id=order.id)
|
||||
|
||||
order_data = _serialize_order(order)
|
||||
cal_data = [_serialize_calendar_entry(e) for e in (calendar_entries or [])]
|
||||
|
||||
main = await render_to_sx("order-detail-content",
|
||||
order=order_data,
|
||||
calendar_entries=cal_data)
|
||||
filt = await render_to_sx("order-detail-filter-content",
|
||||
order=order_data,
|
||||
list_url=list_url, recheck_url=recheck_url,
|
||||
pay_url=pay_url, csrf=generate_csrf_token())
|
||||
|
||||
order_row_oob = await render_to_sx(
|
||||
"menu-row-sx",
|
||||
id="order-row", level=3, colour="sky",
|
||||
link_href=detail_url, link_label=f"Order {order.id}", icon="fa fa-gbp",
|
||||
oob=True,
|
||||
)
|
||||
orders_child_oob = await render_to_sx("oob-header-sx",
|
||||
parent_id="orders-header-child",
|
||||
row=SxExpr(order_row_oob))
|
||||
root_oob = await root_header_sx(ctx, oob=True)
|
||||
oobs = "(<> " + orders_child_oob + " " + root_oob + ")"
|
||||
|
||||
return await oob_page_sx(oobs=oobs, filter=filt, content=main)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Checkout error (used by cart/bp/cart routes + order routes)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_checkout_error_page(ctx: dict, error: str | None = None,
|
||||
order: Any | None = None) -> str:
|
||||
"""Full page: checkout error."""
|
||||
err_msg = error or "Unexpected error while creating the hosted checkout session."
|
||||
order_sx = None
|
||||
if order:
|
||||
order_sx = await render_to_sx("checkout-error-order-id", oid=f"#{order.id}")
|
||||
back_url = cart_url("/")
|
||||
|
||||
hdr = await root_header_sx(ctx)
|
||||
filt = await render_to_sx("checkout-error-header")
|
||||
content = await render_to_sx(
|
||||
"checkout-error-content",
|
||||
msg=err_msg,
|
||||
order=SxExpr(order_sx) if order_sx else None,
|
||||
back_url=back_url,
|
||||
)
|
||||
return await full_page_sx(ctx, header_rows=hdr, filter=filt, content=content)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: POST response renderers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_cart_payments_panel(ctx: dict) -> str:
|
||||
"""Render the payments config panel for PUT response."""
|
||||
page_config = ctx.get("page_config")
|
||||
pc_data = None
|
||||
if page_config:
|
||||
pc_data = {
|
||||
"sumup_api_key": bool(getattr(page_config, "sumup_api_key", None)),
|
||||
"sumup_merchant_code": getattr(page_config, "sumup_merchant_code", None) or "",
|
||||
"sumup_checkout_prefix": getattr(page_config, "sumup_checkout_prefix", None) or "",
|
||||
}
|
||||
return await render_to_sx("cart-payments-content",
|
||||
page_config=pc_data)
|
||||
@@ -1,13 +1,15 @@
|
||||
"""Cart defpage setup — registers layouts, page helpers, and loads .sx pages."""
|
||||
"""Cart defpage setup — registers layouts and loads .sx pages."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from markupsafe import escape
|
||||
from shared.sx.parser import SxExpr
|
||||
|
||||
|
||||
def setup_cart_pages() -> None:
|
||||
"""Register cart-specific layouts, page helpers, and load page definitions."""
|
||||
"""Register cart-specific layouts and load page definitions."""
|
||||
_register_cart_layouts()
|
||||
_register_cart_helpers()
|
||||
_load_cart_page_files()
|
||||
|
||||
|
||||
@@ -17,6 +19,280 @@ def _load_cart_page_files() -> None:
|
||||
load_page_dir(os.path.dirname(__file__), "cart")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Header helpers (moved from sx_components.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _ensure_post_ctx(ctx: dict, page_post: Any) -> dict:
|
||||
"""Ensure ctx has a 'post' dict from page_post DTO."""
|
||||
if ctx.get("post") or not page_post:
|
||||
return ctx
|
||||
return {**ctx, "post": {
|
||||
"id": getattr(page_post, "id", None),
|
||||
"slug": getattr(page_post, "slug", ""),
|
||||
"title": getattr(page_post, "title", ""),
|
||||
"feature_image": getattr(page_post, "feature_image", None),
|
||||
}}
|
||||
|
||||
|
||||
async def _ensure_container_nav(ctx: dict) -> dict:
|
||||
"""Fetch container_nav if not already present."""
|
||||
if ctx.get("container_nav"):
|
||||
return ctx
|
||||
post = ctx.get("post") or {}
|
||||
post_id = post.get("id")
|
||||
if not post_id:
|
||||
return ctx
|
||||
slug = post.get("slug", "")
|
||||
from shared.infrastructure.fragments import fetch_fragments
|
||||
nav_params = {
|
||||
"container_type": "page",
|
||||
"container_id": str(post_id),
|
||||
"post_slug": slug,
|
||||
}
|
||||
events_nav, market_nav = await fetch_fragments([
|
||||
("events", "container-nav", nav_params),
|
||||
("market", "container-nav", nav_params),
|
||||
], required=False)
|
||||
return {**ctx, "container_nav": events_nav + market_nav}
|
||||
|
||||
|
||||
async def _post_header_sx(ctx: dict, page_post: Any, *, oob: bool = False) -> str:
|
||||
from shared.sx.helpers import post_header_sx as _shared_post_header_sx
|
||||
ctx = _ensure_post_ctx(ctx, page_post)
|
||||
ctx = await _ensure_container_nav(ctx)
|
||||
return await _shared_post_header_sx(ctx, oob=oob)
|
||||
|
||||
|
||||
async def _cart_header_sx(ctx: dict, *, oob: bool = False) -> str:
|
||||
from shared.sx.helpers import render_to_sx, call_url
|
||||
return await render_to_sx(
|
||||
"menu-row-sx",
|
||||
id="cart-row", level=1, colour="sky",
|
||||
link_href=call_url(ctx, "cart_url", "/"),
|
||||
link_label="cart", icon="fa fa-shopping-cart",
|
||||
child_id="cart-header-child", oob=oob,
|
||||
)
|
||||
|
||||
|
||||
async def _page_cart_header_sx(ctx: dict, page_post: Any, *, oob: bool = False) -> str:
|
||||
from shared.sx.helpers import render_to_sx, call_url
|
||||
slug = page_post.slug if page_post else ""
|
||||
title = ((page_post.title if page_post else None) or "")[:160]
|
||||
label_parts = []
|
||||
if page_post and page_post.feature_image:
|
||||
label_parts.append(await render_to_sx("cart-page-label-img", src=page_post.feature_image))
|
||||
label_parts.append(f'(span "{escape(title)}")')
|
||||
label_sx = "(<> " + " ".join(label_parts) + ")"
|
||||
nav_sx = await render_to_sx("cart-all-carts-link", href=call_url(ctx, "cart_url", "/"))
|
||||
return await render_to_sx(
|
||||
"menu-row-sx",
|
||||
id="page-cart-row", level=2, colour="sky",
|
||||
link_href=call_url(ctx, "cart_url", f"/{slug}/"),
|
||||
link_label_content=SxExpr(label_sx),
|
||||
nav=SxExpr(nav_sx), oob=oob,
|
||||
)
|
||||
|
||||
|
||||
async def _auth_header_sx(ctx: dict, *, oob: bool = False) -> str:
|
||||
from shared.sx.helpers import render_to_sx, call_url
|
||||
return await render_to_sx(
|
||||
"auth-header-row-simple",
|
||||
account_url=call_url(ctx, "account_url", ""),
|
||||
oob=oob,
|
||||
)
|
||||
|
||||
|
||||
async def _orders_header_sx(ctx: dict, list_url: str) -> str:
|
||||
from shared.sx.helpers import render_to_sx
|
||||
return await render_to_sx("orders-header-row", list_url=list_url)
|
||||
|
||||
|
||||
async def _cart_page_admin_header_sx(ctx: dict, page_post: Any, *, oob: bool = False,
|
||||
selected: str = "") -> str:
|
||||
from shared.sx.helpers import post_admin_header_sx
|
||||
slug = page_post.slug if page_post else ""
|
||||
ctx = _ensure_post_ctx(ctx, page_post)
|
||||
return await post_admin_header_sx(ctx, slug, oob=oob, selected=selected)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Order serialization helpers (used by route render functions below)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _serialize_order(order: Any) -> dict:
|
||||
from shared.infrastructure.urls import market_product_url
|
||||
created = order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else "\u2014"
|
||||
items = []
|
||||
if order.items:
|
||||
for item in order.items:
|
||||
items.append({
|
||||
"product_image": item.product_image,
|
||||
"product_title": item.product_title or "Unknown product",
|
||||
"product_id": item.product_id,
|
||||
"product_slug": item.product_slug,
|
||||
"product_url": market_product_url(item.product_slug),
|
||||
"quantity": item.quantity,
|
||||
"unit_price_formatted": f"{item.unit_price or 0:.2f}",
|
||||
"currency": item.currency or order.currency or "GBP",
|
||||
})
|
||||
return {
|
||||
"id": order.id,
|
||||
"status": order.status or "pending",
|
||||
"created_at_formatted": created,
|
||||
"description": order.description or "",
|
||||
"total_formatted": f"{order.total_amount or 0:.2f}",
|
||||
"total_amount": float(order.total_amount or 0),
|
||||
"currency": order.currency or "GBP",
|
||||
"items": items,
|
||||
}
|
||||
|
||||
|
||||
def _serialize_calendar_entry(e: Any) -> dict:
|
||||
st = e.state or ""
|
||||
ds = e.start_at.strftime("%-d %b %Y, %H:%M") if e.start_at else ""
|
||||
if e.end_at:
|
||||
ds += f" \u2013 {e.end_at.strftime('%-d %b %Y, %H:%M')}"
|
||||
return {"name": e.name, "state": st, "date_str": ds, "cost_formatted": f"{e.cost or 0:.2f}"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Render functions (called by routes)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_orders_page(ctx, orders, page, total_pages, search, search_count, url_for_fn, qs_fn):
|
||||
from shared.sx.helpers import render_to_sx, root_header_sx, search_desktop_sx, search_mobile_sx, full_page_sx
|
||||
from shared.utils import route_prefix
|
||||
ctx["search"] = search
|
||||
ctx["search_count"] = search_count
|
||||
pfx = route_prefix()
|
||||
list_url = pfx + url_for_fn("orders.list_orders")
|
||||
detail_url_prefix = pfx + url_for_fn("orders.order.order_detail", order_id=0).rsplit("0/", 1)[0]
|
||||
order_dicts = [_serialize_order(o) for o in orders]
|
||||
content = await render_to_sx("orders-list-content", orders=order_dicts,
|
||||
page=page, total_pages=total_pages, rows_url=list_url, detail_url_prefix=detail_url_prefix)
|
||||
hdr = await root_header_sx(ctx)
|
||||
auth = await _auth_header_sx(ctx)
|
||||
orders_hdr = await _orders_header_sx(ctx, list_url)
|
||||
auth_child_inner = await render_to_sx("header-child-sx", id="auth-header-child", inner=SxExpr(orders_hdr))
|
||||
auth_child = await render_to_sx("header-child-sx", inner=SxExpr("(<> " + auth + " " + auth_child_inner + ")"))
|
||||
header_rows = "(<> " + hdr + " " + auth_child + ")"
|
||||
filt = await render_to_sx("order-list-header", search_mobile=SxExpr(await search_mobile_sx(ctx)))
|
||||
return await full_page_sx(ctx, header_rows=header_rows, filter=filt,
|
||||
aside=await search_desktop_sx(ctx), content=content)
|
||||
|
||||
|
||||
async def render_orders_rows(ctx, orders, page, total_pages, url_for_fn, qs_fn):
|
||||
from shared.sx.helpers import render_to_sx
|
||||
from shared.utils import route_prefix
|
||||
pfx = route_prefix()
|
||||
list_url = pfx + url_for_fn("orders.list_orders")
|
||||
detail_url_prefix = pfx + url_for_fn("orders.order.order_detail", order_id=0).rsplit("0/", 1)[0]
|
||||
order_dicts = [_serialize_order(o) for o in orders]
|
||||
parts = []
|
||||
for od in order_dicts:
|
||||
parts.append(await render_to_sx("order-row-pair", order=od, detail_url_prefix=detail_url_prefix))
|
||||
if page < total_pages:
|
||||
next_url = list_url + qs_fn(page=page + 1)
|
||||
parts.append(await render_to_sx("infinite-scroll", url=next_url, page=page,
|
||||
total_pages=total_pages, id_prefix="orders", colspan=5))
|
||||
else:
|
||||
parts.append(await render_to_sx("order-end-row"))
|
||||
return "(<> " + " ".join(parts) + ")"
|
||||
|
||||
|
||||
async def render_orders_oob(ctx, orders, page, total_pages, search, search_count, url_for_fn, qs_fn):
|
||||
from shared.sx.helpers import render_to_sx, root_header_sx, search_desktop_sx, search_mobile_sx, oob_page_sx
|
||||
from shared.utils import route_prefix
|
||||
ctx["search"] = search
|
||||
ctx["search_count"] = search_count
|
||||
pfx = route_prefix()
|
||||
list_url = pfx + url_for_fn("orders.list_orders")
|
||||
detail_url_prefix = pfx + url_for_fn("orders.order.order_detail", order_id=0).rsplit("0/", 1)[0]
|
||||
order_dicts = [_serialize_order(o) for o in orders]
|
||||
content = await render_to_sx("orders-list-content", orders=order_dicts,
|
||||
page=page, total_pages=total_pages, rows_url=list_url, detail_url_prefix=detail_url_prefix)
|
||||
auth_oob = await _auth_header_sx(ctx, oob=True)
|
||||
orders_hdr = await _orders_header_sx(ctx, list_url)
|
||||
auth_child_oob = await render_to_sx("oob-header-sx", parent_id="auth-header-child", row=SxExpr(orders_hdr))
|
||||
root_oob = await root_header_sx(ctx, oob=True)
|
||||
oobs = "(<> " + auth_oob + " " + auth_child_oob + " " + root_oob + ")"
|
||||
filt = await render_to_sx("order-list-header", search_mobile=SxExpr(await search_mobile_sx(ctx)))
|
||||
return await oob_page_sx(oobs=oobs, filter=filt, aside=await search_desktop_sx(ctx), content=content)
|
||||
|
||||
|
||||
async def render_order_page(ctx, order, calendar_entries, url_for_fn):
|
||||
from shared.sx.helpers import render_to_sx, root_header_sx, full_page_sx
|
||||
from shared.utils import route_prefix
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
pfx = route_prefix()
|
||||
detail_url = pfx + url_for_fn("orders.order.order_detail", order_id=order.id)
|
||||
list_url = pfx + url_for_fn("orders.list_orders")
|
||||
recheck_url = pfx + url_for_fn("orders.order.order_recheck", order_id=order.id)
|
||||
pay_url = pfx + url_for_fn("orders.order.order_pay", order_id=order.id)
|
||||
order_data = _serialize_order(order)
|
||||
cal_data = [_serialize_calendar_entry(e) for e in (calendar_entries or [])]
|
||||
main = await render_to_sx("order-detail-content", order=order_data, calendar_entries=cal_data)
|
||||
filt = await render_to_sx("order-detail-filter-content", order=order_data,
|
||||
list_url=list_url, recheck_url=recheck_url, pay_url=pay_url, csrf=generate_csrf_token())
|
||||
hdr = await root_header_sx(ctx)
|
||||
order_row = await render_to_sx("menu-row-sx", id="order-row", level=3, colour="sky",
|
||||
link_href=detail_url, link_label=f"Order {order.id}", icon="fa fa-gbp")
|
||||
auth = await _auth_header_sx(ctx)
|
||||
orders_hdr = await _orders_header_sx(ctx, list_url)
|
||||
orders_child = await render_to_sx("header-child-sx", id="orders-header-child", inner=SxExpr(order_row))
|
||||
auth_inner = "(<> " + orders_hdr + " " + orders_child + ")"
|
||||
auth_child = await render_to_sx("header-child-sx", id="auth-header-child", inner=SxExpr(auth_inner))
|
||||
order_child = await render_to_sx("header-child-sx", inner=SxExpr("(<> " + auth + " " + auth_child + ")"))
|
||||
return await full_page_sx(ctx, header_rows="(<> " + hdr + " " + order_child + ")", filter=filt, content=main)
|
||||
|
||||
|
||||
async def render_order_oob(ctx, order, calendar_entries, url_for_fn):
|
||||
from shared.sx.helpers import render_to_sx, root_header_sx, oob_page_sx
|
||||
from shared.utils import route_prefix
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
pfx = route_prefix()
|
||||
detail_url = pfx + url_for_fn("orders.order.order_detail", order_id=order.id)
|
||||
list_url = pfx + url_for_fn("orders.list_orders")
|
||||
recheck_url = pfx + url_for_fn("orders.order.order_recheck", order_id=order.id)
|
||||
pay_url = pfx + url_for_fn("orders.order.order_pay", order_id=order.id)
|
||||
order_data = _serialize_order(order)
|
||||
cal_data = [_serialize_calendar_entry(e) for e in (calendar_entries or [])]
|
||||
main = await render_to_sx("order-detail-content", order=order_data, calendar_entries=cal_data)
|
||||
filt = await render_to_sx("order-detail-filter-content", order=order_data,
|
||||
list_url=list_url, recheck_url=recheck_url, pay_url=pay_url, csrf=generate_csrf_token())
|
||||
order_row_oob = await render_to_sx("menu-row-sx", id="order-row", level=3, colour="sky",
|
||||
link_href=detail_url, link_label=f"Order {order.id}", icon="fa fa-gbp", oob=True)
|
||||
orders_child_oob = await render_to_sx("oob-header-sx", parent_id="orders-header-child", row=SxExpr(order_row_oob))
|
||||
root_oob = await root_header_sx(ctx, oob=True)
|
||||
return await oob_page_sx(oobs="(<> " + orders_child_oob + " " + root_oob + ")", filter=filt, content=main)
|
||||
|
||||
|
||||
async def render_checkout_error_page(ctx, error=None, order=None):
|
||||
from shared.sx.helpers import render_to_sx, root_header_sx, full_page_sx
|
||||
from shared.infrastructure.urls import cart_url
|
||||
err_msg = error or "Unexpected error while creating the hosted checkout session."
|
||||
order_sx = await render_to_sx("checkout-error-order-id", oid=f"#{order.id}") if order else None
|
||||
hdr = await root_header_sx(ctx)
|
||||
filt = await render_to_sx("checkout-error-header")
|
||||
content = await render_to_sx("checkout-error-content", msg=err_msg,
|
||||
order=SxExpr(order_sx) if order_sx else None, back_url=cart_url("/"))
|
||||
return await full_page_sx(ctx, header_rows=hdr, filter=filt, content=content)
|
||||
|
||||
|
||||
async def render_cart_payments_panel(ctx):
|
||||
from shared.sx.helpers import render_to_sx
|
||||
page_config = ctx.get("page_config")
|
||||
pc_data = None
|
||||
if page_config:
|
||||
pc_data = {
|
||||
"sumup_api_key": bool(getattr(page_config, "sumup_api_key", None)),
|
||||
"sumup_merchant_code": getattr(page_config, "sumup_merchant_code", None) or "",
|
||||
"sumup_checkout_prefix": getattr(page_config, "sumup_checkout_prefix", None) or "",
|
||||
}
|
||||
return await render_to_sx("cart-payments-content", page_config=pc_data)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Layouts
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -30,7 +306,6 @@ def _register_cart_layouts() -> None:
|
||||
async def _cart_page_full(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import root_header_sx, render_to_sx
|
||||
from shared.sx.parser import SxExpr
|
||||
from sx.sx_components import _cart_header_sx, _page_cart_header_sx
|
||||
|
||||
page_post = ctx.get("page_post")
|
||||
root_hdr = await root_header_sx(ctx)
|
||||
@@ -47,7 +322,6 @@ async def _cart_page_full(ctx: dict, **kw: Any) -> str:
|
||||
async def _cart_page_oob(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import root_header_sx, render_to_sx
|
||||
from shared.sx.parser import SxExpr
|
||||
from sx.sx_components import _cart_header_sx, _page_cart_header_sx
|
||||
|
||||
page_post = ctx.get("page_post")
|
||||
page_hdr = await _page_cart_header_sx(ctx, page_post)
|
||||
@@ -61,7 +335,6 @@ async def _cart_page_oob(ctx: dict, **kw: Any) -> str:
|
||||
|
||||
async def _cart_admin_full(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import root_header_sx
|
||||
from sx.sx_components import _post_header_sx, _cart_page_admin_header_sx
|
||||
|
||||
page_post = ctx.get("page_post")
|
||||
selected = kw.get("selected", "")
|
||||
@@ -72,246 +345,9 @@ async def _cart_admin_full(ctx: dict, **kw: Any) -> str:
|
||||
|
||||
|
||||
async def _cart_admin_oob(ctx: dict, **kw: Any) -> str:
|
||||
from sx.sx_components import _cart_page_admin_header_sx
|
||||
|
||||
page_post = ctx.get("page_post")
|
||||
selected = kw.get("selected", "")
|
||||
return await _cart_page_admin_header_sx(ctx, page_post, oob=True, selected=selected)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Page helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _register_cart_helpers() -> None:
|
||||
from shared.sx.pages import register_page_helpers
|
||||
|
||||
register_page_helpers("cart", {
|
||||
"overview-content": _h_overview_content,
|
||||
"page-cart-content": _h_page_cart_content,
|
||||
"cart-admin-content": _h_cart_admin_content,
|
||||
"cart-payments-content": _h_cart_payments_content,
|
||||
})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Serialization helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _serialize_cart_item(item: Any) -> dict:
|
||||
"""Serialize a cart item + product for SX defcomps."""
|
||||
from quart import url_for
|
||||
from shared.infrastructure.urls import market_product_url
|
||||
|
||||
p = item.product if hasattr(item, "product") else item
|
||||
slug = p.slug if hasattr(p, "slug") else ""
|
||||
unit_price = getattr(p, "special_price", None) or getattr(p, "regular_price", None)
|
||||
currency = getattr(p, "regular_price_currency", "GBP") or "GBP"
|
||||
return {
|
||||
"slug": slug,
|
||||
"title": p.title if hasattr(p, "title") else "",
|
||||
"image": p.image if hasattr(p, "image") else None,
|
||||
"brand": getattr(p, "brand", None),
|
||||
"is_deleted": getattr(item, "is_deleted", False),
|
||||
"unit_price": float(unit_price) if unit_price else None,
|
||||
"special_price": float(p.special_price) if getattr(p, "special_price", None) else None,
|
||||
"regular_price": float(p.regular_price) if getattr(p, "regular_price", None) else None,
|
||||
"currency": currency,
|
||||
"quantity": item.quantity,
|
||||
"product_id": p.id,
|
||||
"product_url": market_product_url(slug),
|
||||
"qty_url": url_for("cart_global.update_quantity", product_id=p.id),
|
||||
}
|
||||
|
||||
|
||||
def _serialize_cal_entry(e: Any) -> dict:
|
||||
"""Serialize a calendar entry for SX defcomps."""
|
||||
name = getattr(e, "name", None) or getattr(e, "calendar_name", "")
|
||||
start = e.start_at if hasattr(e, "start_at") else ""
|
||||
end = getattr(e, "end_at", None)
|
||||
cost = getattr(e, "cost", 0) or 0
|
||||
end_str = f" \u2013 {end}" if end else ""
|
||||
return {
|
||||
"name": name,
|
||||
"date_str": f"{start}{end_str}",
|
||||
"cost": float(cost),
|
||||
}
|
||||
|
||||
|
||||
def _serialize_ticket_group(tg: Any) -> dict:
|
||||
"""Serialize a ticket group for SX defcomps."""
|
||||
name = tg.entry_name if hasattr(tg, "entry_name") else tg.get("entry_name", "")
|
||||
tt_name = tg.ticket_type_name if hasattr(tg, "ticket_type_name") else tg.get("ticket_type_name", "")
|
||||
price = tg.price if hasattr(tg, "price") else tg.get("price", 0)
|
||||
quantity = tg.quantity if hasattr(tg, "quantity") else tg.get("quantity", 0)
|
||||
line_total = tg.line_total if hasattr(tg, "line_total") else tg.get("line_total", 0)
|
||||
entry_id = tg.entry_id if hasattr(tg, "entry_id") else tg.get("entry_id", "")
|
||||
tt_id = tg.ticket_type_id if hasattr(tg, "ticket_type_id") else tg.get("ticket_type_id", "")
|
||||
start_at = tg.entry_start_at if hasattr(tg, "entry_start_at") else tg.get("entry_start_at")
|
||||
end_at = tg.entry_end_at if hasattr(tg, "entry_end_at") else tg.get("entry_end_at")
|
||||
|
||||
date_str = start_at.strftime("%-d %b %Y, %H:%M") if start_at else ""
|
||||
if end_at:
|
||||
date_str += f" \u2013 {end_at.strftime('%-d %b %Y, %H:%M')}"
|
||||
|
||||
return {
|
||||
"entry_name": name,
|
||||
"ticket_type_name": tt_name or None,
|
||||
"price": float(price or 0),
|
||||
"quantity": quantity,
|
||||
"line_total": float(line_total or 0),
|
||||
"entry_id": entry_id,
|
||||
"ticket_type_id": tt_id or None,
|
||||
"date_str": date_str,
|
||||
}
|
||||
|
||||
|
||||
def _serialize_page_group(grp: Any) -> dict:
|
||||
"""Serialize a page group for SX defcomps."""
|
||||
post = grp.get("post") if isinstance(grp, dict) else getattr(grp, "post", None)
|
||||
cart_items = grp.get("cart_items", []) if isinstance(grp, dict) else getattr(grp, "cart_items", [])
|
||||
cal_entries = grp.get("calendar_entries", []) if isinstance(grp, dict) else getattr(grp, "calendar_entries", [])
|
||||
tickets = grp.get("tickets", []) if isinstance(grp, dict) else getattr(grp, "tickets", [])
|
||||
|
||||
if not cart_items and not cal_entries and not tickets:
|
||||
return None
|
||||
|
||||
post_data = None
|
||||
if post:
|
||||
post_data = {
|
||||
"slug": post.slug if hasattr(post, "slug") else post.get("slug", ""),
|
||||
"title": post.title if hasattr(post, "title") else post.get("title", ""),
|
||||
"feature_image": post.feature_image if hasattr(post, "feature_image") else post.get("feature_image"),
|
||||
}
|
||||
market_place = grp.get("market_place") if isinstance(grp, dict) else getattr(grp, "market_place", None)
|
||||
mp_data = None
|
||||
if market_place:
|
||||
mp_data = {"name": market_place.name if hasattr(market_place, "name") else market_place.get("name", "")}
|
||||
|
||||
return {
|
||||
"post": post_data,
|
||||
"product_count": grp.get("product_count", 0) if isinstance(grp, dict) else getattr(grp, "product_count", 0),
|
||||
"calendar_count": grp.get("calendar_count", 0) if isinstance(grp, dict) else getattr(grp, "calendar_count", 0),
|
||||
"ticket_count": grp.get("ticket_count", 0) if isinstance(grp, dict) else getattr(grp, "ticket_count", 0),
|
||||
"total": float(grp.get("total", 0) if isinstance(grp, dict) else getattr(grp, "total", 0)),
|
||||
"market_place": mp_data,
|
||||
}
|
||||
|
||||
|
||||
def _build_summary_data(ctx: dict, cart: list, cal_entries: list, tickets: list,
|
||||
total_fn, cal_total_fn, ticket_total_fn) -> dict:
|
||||
"""Build cart summary data dict for SX defcomps."""
|
||||
from quart import g, request, url_for
|
||||
from shared.infrastructure.urls import login_url
|
||||
from shared.utils import route_prefix
|
||||
|
||||
product_qty = sum(ci.quantity for ci in cart) if cart else 0
|
||||
ticket_qty = len(tickets) if tickets else 0
|
||||
item_count = product_qty + ticket_qty
|
||||
|
||||
product_total = total_fn(cart) or 0
|
||||
cal_total = cal_total_fn(cal_entries) or 0
|
||||
tk_total = ticket_total_fn(tickets) or 0
|
||||
grand = float(product_total) + float(cal_total) + float(tk_total)
|
||||
|
||||
symbol = "\u00a3"
|
||||
if cart and hasattr(cart[0], "product") and getattr(cart[0].product, "regular_price_currency", None):
|
||||
cur = cart[0].product.regular_price_currency
|
||||
symbol = "\u00a3" if cur == "GBP" else cur
|
||||
|
||||
user = getattr(g, "user", None)
|
||||
page_post = ctx.get("page_post")
|
||||
|
||||
result = {
|
||||
"item_count": item_count,
|
||||
"grand_total": grand,
|
||||
"symbol": symbol,
|
||||
"is_logged_in": bool(user),
|
||||
}
|
||||
|
||||
if user:
|
||||
if page_post:
|
||||
action = url_for("page_cart.page_checkout")
|
||||
else:
|
||||
action = url_for("cart_global.checkout")
|
||||
result["checkout_action"] = route_prefix() + action
|
||||
result["user_email"] = user.email
|
||||
else:
|
||||
result["login_href"] = login_url(request.url)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Page helper implementations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _h_overview_content(**kw):
|
||||
from quart import g
|
||||
from shared.sx.helpers import render_to_sx
|
||||
from shared.infrastructure.urls import cart_url
|
||||
from bp.cart.services import get_cart_grouped_by_page
|
||||
|
||||
page_groups = await get_cart_grouped_by_page(g.s)
|
||||
grp_dicts = [d for d in (_serialize_page_group(grp) for grp in page_groups) if d]
|
||||
return await render_to_sx("cart-overview-content",
|
||||
page_groups=grp_dicts,
|
||||
cart_url_base=cart_url(""))
|
||||
|
||||
|
||||
async def _h_page_cart_content(page_slug=None, **kw):
|
||||
from quart import g
|
||||
from shared.sx.helpers import render_to_sx
|
||||
from shared.sx.parser import SxExpr
|
||||
from shared.sx.page import get_template_context
|
||||
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()
|
||||
sd = _build_summary_data(ctx, cart, cal_entries, page_tickets,
|
||||
total, calendar_total, ticket_total)
|
||||
|
||||
summary_sx = await render_to_sx("cart-summary-from-data",
|
||||
item_count=sd["item_count"],
|
||||
grand_total=sd["grand_total"],
|
||||
symbol=sd["symbol"],
|
||||
is_logged_in=sd["is_logged_in"],
|
||||
checkout_action=sd.get("checkout_action"),
|
||||
login_href=sd.get("login_href"),
|
||||
user_email=sd.get("user_email"))
|
||||
|
||||
return await render_to_sx("cart-page-cart-content",
|
||||
cart_items=[_serialize_cart_item(i) for i in cart],
|
||||
cal_entries=[_serialize_cal_entry(e) for e in cal_entries],
|
||||
ticket_groups=[_serialize_ticket_group(tg) for tg in ticket_groups],
|
||||
summary=SxExpr(summary_sx))
|
||||
|
||||
|
||||
async def _h_cart_admin_content(page_slug=None, **kw):
|
||||
return '(~cart-admin-content)'
|
||||
|
||||
|
||||
async def _h_cart_payments_content(page_slug=None, **kw):
|
||||
from shared.sx.page import get_template_context
|
||||
from shared.sx.helpers import render_to_sx
|
||||
|
||||
ctx = await get_template_context()
|
||||
page_config = ctx.get("page_config")
|
||||
pc_data = None
|
||||
if page_config:
|
||||
pc_data = {
|
||||
"sumup_api_key": bool(getattr(page_config, "sumup_api_key", None)),
|
||||
"sumup_merchant_code": getattr(page_config, "sumup_merchant_code", None) or "",
|
||||
"sumup_checkout_prefix": getattr(page_config, "sumup_checkout_prefix", None) or "",
|
||||
}
|
||||
return await render_to_sx("cart-payments-content",
|
||||
page_config=pc_data)
|
||||
|
||||
@@ -1,25 +1,43 @@
|
||||
;; Cart app defpage declarations.
|
||||
;; All data fetching via (service ...) IO primitives, no Python helpers.
|
||||
|
||||
(defpage cart-overview
|
||||
:path "/"
|
||||
:auth :public
|
||||
:layout :root
|
||||
:content (overview-content))
|
||||
:data (service "cart-page" "overview-data")
|
||||
:content (~cart-overview-content
|
||||
:page-groups page-groups
|
||||
:cart-url-base cart-url-base))
|
||||
|
||||
(defpage page-cart-view
|
||||
:path "/<page_slug>/"
|
||||
:auth :public
|
||||
:layout :cart-page
|
||||
:content (page-cart-content))
|
||||
:data (service "cart-page" "page-cart-data")
|
||||
:content (~cart-page-cart-content
|
||||
:cart-items cart-items
|
||||
:cal-entries cal-entries
|
||||
:ticket-groups ticket-groups
|
||||
:summary (~cart-summary-from-data
|
||||
:item-count (get summary "item_count")
|
||||
:grand-total (get summary "grand_total")
|
||||
:symbol (get summary "symbol")
|
||||
:is-logged-in (get summary "is_logged_in")
|
||||
:checkout-action (get summary "checkout_action")
|
||||
:login-href (get summary "login_href")
|
||||
:user-email (get summary "user_email"))))
|
||||
|
||||
(defpage cart-admin
|
||||
:path "/<page_slug>/admin/"
|
||||
:auth :admin
|
||||
:layout :cart-admin
|
||||
:content (cart-admin-content))
|
||||
:content (~cart-admin-content))
|
||||
|
||||
(defpage cart-payments
|
||||
:path "/<page_slug>/admin/payments/"
|
||||
:auth :admin
|
||||
:layout (:cart-admin :selected "payments")
|
||||
:content (cart-payments-content))
|
||||
:data (service "cart-page" "payments-data")
|
||||
:content (~cart-payments-content
|
||||
:page-config page-config))
|
||||
|
||||
@@ -46,6 +46,7 @@ services:
|
||||
- ./blog/alembic:/app/blog/alembic:ro
|
||||
- ./blog/app.py:/app/app.py
|
||||
- ./blog/sx:/app/sx
|
||||
- ./blog/sxc:/app/sxc
|
||||
- ./blog/bp:/app/bp
|
||||
- ./blog/services:/app/services
|
||||
- ./blog/templates:/app/templates
|
||||
@@ -84,6 +85,7 @@ services:
|
||||
- ./market/alembic:/app/market/alembic:ro
|
||||
- ./market/app.py:/app/app.py
|
||||
- ./market/sx:/app/sx
|
||||
- ./market/sxc:/app/sxc
|
||||
- ./market/bp:/app/bp
|
||||
- ./market/services:/app/services
|
||||
- ./market/templates:/app/templates
|
||||
@@ -121,6 +123,7 @@ services:
|
||||
- ./cart/alembic:/app/cart/alembic:ro
|
||||
- ./cart/app.py:/app/app.py
|
||||
- ./cart/sx:/app/sx
|
||||
- ./cart/sxc:/app/sxc
|
||||
- ./cart/bp:/app/bp
|
||||
- ./cart/services:/app/services
|
||||
- ./cart/templates:/app/templates
|
||||
@@ -158,6 +161,7 @@ services:
|
||||
- ./events/alembic:/app/events/alembic:ro
|
||||
- ./events/app.py:/app/app.py
|
||||
- ./events/sx:/app/sx
|
||||
- ./events/sxc:/app/sxc
|
||||
- ./events/bp:/app/bp
|
||||
- ./events/services:/app/services
|
||||
- ./events/templates:/app/templates
|
||||
@@ -195,6 +199,7 @@ services:
|
||||
- ./federation/alembic:/app/federation/alembic:ro
|
||||
- ./federation/app.py:/app/app.py
|
||||
- ./federation/sx:/app/sx
|
||||
- ./federation/sxc:/app/sxc
|
||||
- ./federation/bp:/app/bp
|
||||
- ./federation/services:/app/services
|
||||
- ./federation/templates:/app/templates
|
||||
@@ -232,6 +237,7 @@ services:
|
||||
- ./account/alembic:/app/account/alembic:ro
|
||||
- ./account/app.py:/app/app.py
|
||||
- ./account/sx:/app/sx
|
||||
- ./account/sxc:/app/sxc
|
||||
- ./account/bp:/app/bp
|
||||
- ./account/services:/app/services
|
||||
- ./account/templates:/app/templates
|
||||
@@ -331,6 +337,7 @@ services:
|
||||
- ./orders/alembic:/app/orders/alembic:ro
|
||||
- ./orders/app.py:/app/app.py
|
||||
- ./orders/sx:/app/sx
|
||||
- ./orders/sxc:/app/sxc
|
||||
- ./orders/bp:/app/bp
|
||||
- ./orders/services:/app/services
|
||||
- ./orders/templates:/app/templates
|
||||
|
||||
@@ -198,7 +198,7 @@ async def _calendar_nav_sx(ctx: dict) -> str:
|
||||
admin_href = url_for("defpage_calendar_admin", calendar_slug=cal_slug)
|
||||
parts.append(await render_to_sx("nav-link", href=admin_href, icon="fa fa-cog",
|
||||
select_colours=select_colours))
|
||||
return "".join(parts)
|
||||
return "(<> " + " ".join(parts) + ")" if parts else ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
import path_setup # noqa: F401 # adds shared/ to sys.path
|
||||
import sx.sx_components as sx_components # noqa: F401 # ensure Hypercorn --reload watches this file
|
||||
from pathlib import Path
|
||||
|
||||
from quart import g, request
|
||||
@@ -83,7 +82,9 @@ def create_app() -> "Quart":
|
||||
app.jinja_loader,
|
||||
])
|
||||
|
||||
# --- defpage setup ---
|
||||
# Load .sx component files and setup defpage routes
|
||||
from shared.sx.jinja_bridge import load_service_components
|
||||
load_service_components(str(Path(__file__).resolve().parent), service_name="federation")
|
||||
from sxc.pages import setup_federation_pages
|
||||
setup_federation_pages()
|
||||
|
||||
@@ -106,10 +107,11 @@ def create_app() -> "Quart":
|
||||
async def home():
|
||||
from quart import make_response
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_federation_home
|
||||
from shared.sx.helpers import root_header_sx, full_page_sx
|
||||
|
||||
ctx = await get_template_context()
|
||||
html = await render_federation_home(ctx)
|
||||
hdr = await root_header_sx(ctx)
|
||||
html = await full_page_sx(ctx, header_rows=hdr)
|
||||
return await make_response(html)
|
||||
|
||||
return app
|
||||
|
||||
@@ -42,6 +42,16 @@ SESSION_USER_KEY = "uid"
|
||||
ALLOWED_CLIENTS = {"blog", "market", "cart", "events", "account"}
|
||||
|
||||
|
||||
async def _render_social_auth_page(component: str, title: str, **kwargs) -> str:
|
||||
"""Render an auth page with social layout — replaces sx_components helpers."""
|
||||
from shared.sx.helpers import render_to_sx
|
||||
from shared.sx.page import get_template_context
|
||||
from sxc.pages import _social_page
|
||||
ctx = await get_template_context()
|
||||
content = await render_to_sx(component, **{k: v for k, v in kwargs.items() if v})
|
||||
return await _social_page(ctx, None, content=content, title=title)
|
||||
|
||||
|
||||
def register(url_prefix="/auth"):
|
||||
auth_bp = Blueprint("auth", __name__, url_prefix=url_prefix)
|
||||
|
||||
@@ -99,10 +109,7 @@ def register(url_prefix="/auth"):
|
||||
# If there's a pending redirect (e.g. OAuth authorize), follow it
|
||||
redirect_url = pop_login_redirect_target()
|
||||
return redirect(redirect_url)
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_login_page
|
||||
ctx = await get_template_context()
|
||||
return await render_login_page(ctx)
|
||||
return await _render_social_auth_page("account-login-content", "Login \u2014 Rose Ash")
|
||||
|
||||
@auth_bp.post("/start/")
|
||||
async def start_login():
|
||||
@@ -111,10 +118,10 @@ def register(url_prefix="/auth"):
|
||||
|
||||
is_valid, email = validate_email(email_input)
|
||||
if not is_valid:
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_login_page
|
||||
ctx = await get_template_context(error="Please enter a valid email address.", email=email_input)
|
||||
return await render_login_page(ctx), 400
|
||||
return await _render_social_auth_page(
|
||||
"account-login-content", "Login \u2014 Rose Ash",
|
||||
error="Please enter a valid email address.", email=email_input,
|
||||
), 400
|
||||
|
||||
user = await find_or_create_user(g.s, email)
|
||||
token, expires = await create_magic_link(g.s, user.id)
|
||||
@@ -132,10 +139,10 @@ def register(url_prefix="/auth"):
|
||||
"Please try again in a moment."
|
||||
)
|
||||
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_check_email_page
|
||||
ctx = await get_template_context(email=email, email_error=email_error)
|
||||
return await render_check_email_page(ctx)
|
||||
return await _render_social_auth_page(
|
||||
"account-check-email-content", "Check your email \u2014 Rose Ash",
|
||||
email=email, email_error=email_error,
|
||||
)
|
||||
|
||||
@auth_bp.get("/magic/<token>/")
|
||||
async def magic(token: str):
|
||||
@@ -148,17 +155,17 @@ def register(url_prefix="/auth"):
|
||||
user, error = await validate_magic_link(s, token)
|
||||
|
||||
if error:
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_login_page
|
||||
ctx = await get_template_context(error=error)
|
||||
return await render_login_page(ctx), 400
|
||||
return await _render_social_auth_page(
|
||||
"account-login-content", "Login \u2014 Rose Ash",
|
||||
error=error,
|
||||
), 400
|
||||
user_id = user.id
|
||||
|
||||
except Exception:
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_login_page
|
||||
ctx = await get_template_context(error="Could not sign you in right now. Please try again.")
|
||||
return await render_login_page(ctx), 502
|
||||
return await _render_social_auth_page(
|
||||
"account-login-content", "Login \u2014 Rose Ash",
|
||||
error="Could not sign you in right now. Please try again.",
|
||||
), 502
|
||||
|
||||
assert user_id is not None
|
||||
|
||||
|
||||
@@ -26,6 +26,33 @@ RESERVED = frozenset({
|
||||
})
|
||||
|
||||
|
||||
async def _render_choose_username(*, actor=None, error="", username=""):
|
||||
"""Render choose-username page — replaces sx_components helper."""
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from shared.config import config
|
||||
from shared.sx.helpers import render_to_sx
|
||||
from shared.sx.parser import SxExpr
|
||||
from shared.sx.page import get_template_context
|
||||
from sxc.pages import _social_page
|
||||
from markupsafe import escape
|
||||
|
||||
ctx = await get_template_context()
|
||||
csrf = generate_csrf_token()
|
||||
ap_domain = config().get("ap_domain", "rose-ash.com")
|
||||
check_url = url_for("identity.check_username")
|
||||
|
||||
error_sx = await render_to_sx("auth-error-banner", error=error) if error else ""
|
||||
content = await render_to_sx(
|
||||
"federation-choose-username",
|
||||
domain=str(escape(ap_domain)),
|
||||
error=SxExpr(error_sx) if error_sx else None,
|
||||
csrf=csrf, username=str(escape(username)),
|
||||
check_url=check_url,
|
||||
)
|
||||
return await _social_page(ctx, actor, content=content,
|
||||
title="Choose Username \u2014 Rose Ash")
|
||||
|
||||
|
||||
def register(url_prefix="/identity"):
|
||||
bp = Blueprint("identity", __name__, url_prefix=url_prefix)
|
||||
|
||||
@@ -39,11 +66,7 @@ def register(url_prefix="/identity"):
|
||||
if actor:
|
||||
return redirect(url_for("activitypub.actor_profile", username=actor.preferred_username))
|
||||
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_choose_username_page
|
||||
ctx = await get_template_context()
|
||||
ctx["actor"] = actor
|
||||
return await render_choose_username_page(ctx)
|
||||
return await _render_choose_username(actor=actor)
|
||||
|
||||
@bp.post("/choose-username")
|
||||
async def choose_username():
|
||||
@@ -71,11 +94,7 @@ def register(url_prefix="/identity"):
|
||||
error = "This username is already taken."
|
||||
|
||||
if error:
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_choose_username_page
|
||||
ctx = await get_template_context(error=error, username=username)
|
||||
ctx["actor"] = None
|
||||
return await render_choose_username_page(ctx), 400
|
||||
return await _render_choose_username(error=error, username=username), 400
|
||||
|
||||
# Create ActorProfile with RSA keys
|
||||
display_name = g.user.name or username
|
||||
|
||||
@@ -7,7 +7,7 @@ from datetime import datetime
|
||||
from quart import Blueprint, request, g, redirect, url_for, abort, Response
|
||||
|
||||
from shared.services.registry import services
|
||||
from shared.sx.helpers import sx_response
|
||||
from shared.sx.helpers import sx_response, render_to_sx
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -47,8 +47,7 @@ def register(url_prefix="/social"):
|
||||
items = await services.federation.get_home_timeline(
|
||||
g.s, actor.id, before=before,
|
||||
)
|
||||
from sx.sx_components import render_timeline_items
|
||||
sx_src = await render_timeline_items(items, "home", actor)
|
||||
sx_src = await _render_timeline_items(items, "home", actor)
|
||||
return sx_response(sx_src)
|
||||
|
||||
@bp.get("/public/timeline")
|
||||
@@ -62,8 +61,7 @@ def register(url_prefix="/social"):
|
||||
pass
|
||||
items = await services.federation.get_public_timeline(g.s, before=before)
|
||||
actor = getattr(g, "_social_actor", None)
|
||||
from sx.sx_components import render_timeline_items
|
||||
sx_src = await render_timeline_items(items, "public", actor)
|
||||
sx_src = await _render_timeline_items(items, "public", actor)
|
||||
return sx_response(sx_src)
|
||||
|
||||
# -- Compose ---------------------------------------------------------------
|
||||
@@ -97,6 +95,8 @@ def register(url_prefix="/social"):
|
||||
|
||||
@bp.get("/search/page")
|
||||
async def search_page():
|
||||
from sxc.pages import _serialize_remote_actor, _serialize_actor
|
||||
|
||||
actor = getattr(g, "_social_actor", None)
|
||||
query = request.args.get("q", "").strip()
|
||||
page = request.args.get("page", 1, type=int)
|
||||
@@ -112,8 +112,18 @@ def register(url_prefix="/social"):
|
||||
g.s, actor.preferred_username, page=1, per_page=1000,
|
||||
)
|
||||
followed_urls = {a.actor_url for a in following}
|
||||
from sx.sx_components import render_search_results
|
||||
sx_src = await render_search_results(actors_list, query, page, followed_urls, actor)
|
||||
|
||||
actor_dicts = [_serialize_remote_actor(a) for a in actors_list]
|
||||
actor_data = _serialize_actor(actor)
|
||||
parts = []
|
||||
for ad in actor_dicts:
|
||||
parts.append(await render_to_sx("federation-actor-card-from-data",
|
||||
a=ad, actor=actor_data,
|
||||
followed_urls=list(followed_urls), list_type="search"))
|
||||
if len(actors_list) >= 20:
|
||||
next_url = url_for("social.search_page", q=query, page=page + 1)
|
||||
parts.append(await render_to_sx("federation-scroll-sentinel", url=next_url))
|
||||
sx_src = "(<> " + " ".join(parts) + ")" if parts else ""
|
||||
return sx_response(sx_src)
|
||||
|
||||
@bp.post("/follow")
|
||||
@@ -144,6 +154,8 @@ def register(url_prefix="/social"):
|
||||
|
||||
async def _actor_card_response(actor, remote_actor_url, is_followed):
|
||||
"""Re-render a single actor card after follow/unfollow via HTMX."""
|
||||
from sxc.pages import _serialize_remote_actor, _serialize_actor
|
||||
|
||||
remote_dto = await services.federation.get_or_fetch_remote_actor(
|
||||
g.s, remote_actor_url,
|
||||
)
|
||||
@@ -151,12 +163,12 @@ def register(url_prefix="/social"):
|
||||
return Response("", status=200)
|
||||
followed_urls = {remote_actor_url} if is_followed else set()
|
||||
referer = request.referrer or ""
|
||||
if "/followers" in referer:
|
||||
list_type = "followers"
|
||||
else:
|
||||
list_type = "following"
|
||||
from sx.sx_components import render_actor_card
|
||||
return sx_response(await render_actor_card(remote_dto, actor, followed_urls, list_type=list_type))
|
||||
list_type = "followers" if "/followers" in referer else "following"
|
||||
actor_data = _serialize_actor(actor)
|
||||
ad = _serialize_remote_actor(remote_dto)
|
||||
return sx_response(await render_to_sx("federation-actor-card-from-data",
|
||||
a=ad, actor=actor_data,
|
||||
followed_urls=list(followed_urls), list_type=list_type))
|
||||
|
||||
# -- Interactions ----------------------------------------------------------
|
||||
|
||||
@@ -198,7 +210,9 @@ def register(url_prefix="/social"):
|
||||
|
||||
async def _interaction_buttons_response(actor, object_id, author_inbox):
|
||||
"""Re-render interaction buttons after a like/boost action."""
|
||||
from shared.models.federation import APInteraction, APRemotePost, APActivity
|
||||
from shared.models.federation import APInteraction
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from shared.sx.parser import SxExpr
|
||||
from sqlalchemy import select
|
||||
|
||||
svc = services.federation
|
||||
@@ -242,32 +256,72 @@ def register(url_prefix="/social"):
|
||||
).limit(1)
|
||||
)).scalar())
|
||||
|
||||
from sx.sx_components import render_interaction_buttons
|
||||
return sx_response(await render_interaction_buttons(
|
||||
object_id=object_id,
|
||||
author_inbox=author_inbox,
|
||||
like_count=like_count,
|
||||
boost_count=boost_count,
|
||||
liked_by_me=liked_by_me,
|
||||
boosted_by_me=boosted_by_me,
|
||||
actor=actor,
|
||||
))
|
||||
csrf = generate_csrf_token()
|
||||
safe_id = object_id.replace("/", "_").replace(":", "_")
|
||||
target = f"#interactions-{safe_id}"
|
||||
|
||||
if liked_by_me:
|
||||
like_action = url_for("social.unlike")
|
||||
like_cls = "text-red-500 hover:text-red-600"
|
||||
like_icon = "\u2665"
|
||||
else:
|
||||
like_action = url_for("social.like")
|
||||
like_cls = "hover:text-red-500"
|
||||
like_icon = "\u2661"
|
||||
|
||||
if boosted_by_me:
|
||||
boost_action = url_for("social.unboost")
|
||||
boost_cls = "text-green-600 hover:text-green-700"
|
||||
else:
|
||||
boost_action = url_for("social.boost")
|
||||
boost_cls = "hover:text-green-600"
|
||||
|
||||
reply_url = url_for("social.defpage_compose_form", reply_to=object_id) if object_id else ""
|
||||
reply_sx = await render_to_sx("federation-reply-link", url=reply_url) if reply_url else ""
|
||||
|
||||
like_form = await render_to_sx("federation-like-form",
|
||||
action=like_action, target=target, oid=object_id, ainbox=author_inbox,
|
||||
csrf=csrf, cls=f"flex items-center gap-1 {like_cls}",
|
||||
icon=like_icon, count=str(like_count))
|
||||
|
||||
boost_form = await render_to_sx("federation-boost-form",
|
||||
action=boost_action, target=target, oid=object_id, ainbox=author_inbox,
|
||||
csrf=csrf, cls=f"flex items-center gap-1 {boost_cls}",
|
||||
count=str(boost_count))
|
||||
|
||||
return sx_response(await render_to_sx("federation-interaction-buttons",
|
||||
like=SxExpr(like_form),
|
||||
boost=SxExpr(boost_form),
|
||||
reply=SxExpr(reply_sx) if reply_sx else None))
|
||||
|
||||
# -- Following / Followers pagination --------------------------------------
|
||||
|
||||
@bp.get("/following/page")
|
||||
async def following_list_page():
|
||||
from sxc.pages import _serialize_remote_actor, _serialize_actor
|
||||
|
||||
actor = _require_actor()
|
||||
page = request.args.get("page", 1, type=int)
|
||||
actors_list, total = await services.federation.get_following(
|
||||
g.s, actor.preferred_username, page=page,
|
||||
)
|
||||
from sx.sx_components import render_following_items
|
||||
sx_src = await render_following_items(actors_list, page, actor)
|
||||
actor_dicts = [_serialize_remote_actor(a) for a in actors_list]
|
||||
actor_data = _serialize_actor(actor)
|
||||
parts = []
|
||||
for ad in actor_dicts:
|
||||
parts.append(await render_to_sx("federation-actor-card-from-data",
|
||||
a=ad, actor=actor_data,
|
||||
followed_urls=[], list_type="following"))
|
||||
if len(actors_list) >= 20:
|
||||
next_url = url_for("social.following_list_page", page=page + 1)
|
||||
parts.append(await render_to_sx("federation-scroll-sentinel", url=next_url))
|
||||
sx_src = "(<> " + " ".join(parts) + ")" if parts else ""
|
||||
return sx_response(sx_src)
|
||||
|
||||
@bp.get("/followers/page")
|
||||
async def followers_list_page():
|
||||
from sxc.pages import _serialize_remote_actor, _serialize_actor
|
||||
|
||||
actor = _require_actor()
|
||||
page = request.args.get("page", 1, type=int)
|
||||
actors_list, total = await services.federation.get_followers_paginated(
|
||||
@@ -277,8 +331,17 @@ def register(url_prefix="/social"):
|
||||
g.s, actor.preferred_username, page=1, per_page=1000,
|
||||
)
|
||||
followed_urls = {a.actor_url for a in following}
|
||||
from sx.sx_components import render_followers_items
|
||||
sx_src = await render_followers_items(actors_list, page, followed_urls, actor)
|
||||
actor_dicts = [_serialize_remote_actor(a) for a in actors_list]
|
||||
actor_data = _serialize_actor(actor)
|
||||
parts = []
|
||||
for ad in actor_dicts:
|
||||
parts.append(await render_to_sx("federation-actor-card-from-data",
|
||||
a=ad, actor=actor_data,
|
||||
followed_urls=list(followed_urls), list_type="followers"))
|
||||
if len(actors_list) >= 20:
|
||||
next_url = url_for("social.followers_list_page", page=page + 1)
|
||||
parts.append(await render_to_sx("federation-scroll-sentinel", url=next_url))
|
||||
sx_src = "(<> " + " ".join(parts) + ")" if parts else ""
|
||||
return sx_response(sx_src)
|
||||
|
||||
@bp.get("/actor/<int:id>/timeline")
|
||||
@@ -294,8 +357,7 @@ def register(url_prefix="/social"):
|
||||
items = await services.federation.get_actor_timeline(
|
||||
g.s, id, before=before,
|
||||
)
|
||||
from sx.sx_components import render_actor_timeline_items
|
||||
sx_src = await render_actor_timeline_items(items, id, actor)
|
||||
sx_src = await _render_timeline_items(items, "actor", actor, id)
|
||||
return sx_response(sx_src)
|
||||
|
||||
# -- Notifications ---------------------------------------------------------
|
||||
@@ -321,3 +383,26 @@ def register(url_prefix="/social"):
|
||||
return redirect(url_for("defpage_notifications"))
|
||||
|
||||
return bp
|
||||
|
||||
|
||||
async def _render_timeline_items(items, timeline_type, actor, actor_id=None):
|
||||
"""Render timeline pagination items as SX fragment."""
|
||||
from sxc.pages import _serialize_timeline_item, _serialize_actor
|
||||
|
||||
item_dicts = [_serialize_timeline_item(i) for i in items]
|
||||
actor_data = _serialize_actor(actor)
|
||||
|
||||
next_url = None
|
||||
if items:
|
||||
last = items[-1]
|
||||
before = last.published.isoformat() if last.published else ""
|
||||
if timeline_type == "actor" and actor_id is not None:
|
||||
next_url = url_for("social.actor_timeline_page", id=actor_id, before=before)
|
||||
else:
|
||||
next_url = url_for(f"social.{timeline_type}_timeline_page", before=before)
|
||||
|
||||
return await render_to_sx("federation-timeline-items",
|
||||
items=item_dicts,
|
||||
timeline_type=timeline_type,
|
||||
actor=actor_data,
|
||||
next_url=next_url)
|
||||
|
||||
@@ -13,3 +13,6 @@ def register_domain_services() -> None:
|
||||
from shared.services.federation_impl import SqlFederationService
|
||||
|
||||
services.federation = SqlFederationService()
|
||||
|
||||
from .federation_page import FederationPageService
|
||||
services.register("federation_page", FederationPageService())
|
||||
|
||||
205
federation/services/federation_page.py
Normal file
205
federation/services/federation_page.py
Normal file
@@ -0,0 +1,205 @@
|
||||
"""Federation page data service — provides serialized dicts for .sx defpages."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def _serialize_actor(actor) -> dict | None:
|
||||
if not actor:
|
||||
return None
|
||||
return {
|
||||
"id": actor.id,
|
||||
"preferred_username": actor.preferred_username,
|
||||
"display_name": getattr(actor, "display_name", None),
|
||||
"icon_url": getattr(actor, "icon_url", None),
|
||||
"summary": getattr(actor, "summary", None),
|
||||
"actor_url": getattr(actor, "actor_url", ""),
|
||||
"domain": getattr(actor, "domain", ""),
|
||||
}
|
||||
|
||||
|
||||
def _serialize_timeline_item(item) -> dict:
|
||||
published = getattr(item, "published", None)
|
||||
return {
|
||||
"object_id": getattr(item, "object_id", "") or "",
|
||||
"author_inbox": getattr(item, "author_inbox", "") or "",
|
||||
"actor_icon": getattr(item, "actor_icon", None),
|
||||
"actor_name": getattr(item, "actor_name", "?"),
|
||||
"actor_username": getattr(item, "actor_username", ""),
|
||||
"actor_domain": getattr(item, "actor_domain", ""),
|
||||
"content": getattr(item, "content", ""),
|
||||
"summary": getattr(item, "summary", None),
|
||||
"published": published.strftime("%b %d, %H:%M") if published else "",
|
||||
"before_cursor": published.isoformat() if published else "",
|
||||
"url": getattr(item, "url", None),
|
||||
"post_type": getattr(item, "post_type", ""),
|
||||
"boosted_by": getattr(item, "boosted_by", None),
|
||||
"like_count": getattr(item, "like_count", 0) or 0,
|
||||
"boost_count": getattr(item, "boost_count", 0) or 0,
|
||||
"liked_by_me": getattr(item, "liked_by_me", False),
|
||||
"boosted_by_me": getattr(item, "boosted_by_me", False),
|
||||
}
|
||||
|
||||
|
||||
def _serialize_remote_actor(a) -> dict:
|
||||
return {
|
||||
"id": getattr(a, "id", None),
|
||||
"display_name": getattr(a, "display_name", None) or getattr(a, "preferred_username", ""),
|
||||
"preferred_username": getattr(a, "preferred_username", ""),
|
||||
"domain": getattr(a, "domain", ""),
|
||||
"icon_url": getattr(a, "icon_url", None),
|
||||
"actor_url": getattr(a, "actor_url", ""),
|
||||
"summary": getattr(a, "summary", None),
|
||||
}
|
||||
|
||||
|
||||
def _get_actor():
|
||||
from quart import g
|
||||
return getattr(g, "_social_actor", None)
|
||||
|
||||
|
||||
def _require_actor():
|
||||
from quart import abort
|
||||
actor = _get_actor()
|
||||
if not actor:
|
||||
abort(403, "You need to choose a federation username first")
|
||||
return actor
|
||||
|
||||
|
||||
class FederationPageService:
|
||||
"""Service for federation page data, callable via (service "federation-page" ...)."""
|
||||
|
||||
async def home_timeline_data(self, session, **kw):
|
||||
actor = _require_actor()
|
||||
from shared.services.registry import services
|
||||
items = await services.federation.get_home_timeline(session, actor.id)
|
||||
return {
|
||||
"items": [_serialize_timeline_item(i) for i in items],
|
||||
"timeline_type": "home",
|
||||
"actor": _serialize_actor(actor),
|
||||
}
|
||||
|
||||
async def public_timeline_data(self, session, **kw):
|
||||
actor = _get_actor()
|
||||
from shared.services.registry import services
|
||||
items = await services.federation.get_public_timeline(session)
|
||||
return {
|
||||
"items": [_serialize_timeline_item(i) for i in items],
|
||||
"timeline_type": "public",
|
||||
"actor": _serialize_actor(actor),
|
||||
}
|
||||
|
||||
async def compose_data(self, session, **kw):
|
||||
from quart import request
|
||||
_require_actor()
|
||||
reply_to = request.args.get("reply_to")
|
||||
return {"reply_to": reply_to or None}
|
||||
|
||||
async def search_data(self, session, **kw):
|
||||
from quart import request
|
||||
actor = _get_actor()
|
||||
from shared.services.registry import services
|
||||
query = request.args.get("q", "").strip()
|
||||
actors_list = []
|
||||
total = 0
|
||||
followed_urls: list[str] = []
|
||||
if query:
|
||||
actors_list, total = await services.federation.search_actors(session, query)
|
||||
if actor:
|
||||
following, _ = await services.federation.get_following(
|
||||
session, actor.preferred_username, page=1, per_page=1000,
|
||||
)
|
||||
followed_urls = [a.actor_url for a in following]
|
||||
return {
|
||||
"query": query,
|
||||
"actors": [_serialize_remote_actor(a) for a in actors_list],
|
||||
"total": total,
|
||||
"followed_urls": followed_urls,
|
||||
"actor": _serialize_actor(actor),
|
||||
}
|
||||
|
||||
async def following_data(self, session, **kw):
|
||||
actor = _require_actor()
|
||||
from shared.services.registry import services
|
||||
actors_list, total = await services.federation.get_following(
|
||||
session, actor.preferred_username,
|
||||
)
|
||||
return {
|
||||
"actors": [_serialize_remote_actor(a) for a in actors_list],
|
||||
"total": total,
|
||||
"actor": _serialize_actor(actor),
|
||||
}
|
||||
|
||||
async def followers_data(self, session, **kw):
|
||||
actor = _require_actor()
|
||||
from shared.services.registry import services
|
||||
actors_list, total = await services.federation.get_followers_paginated(
|
||||
session, actor.preferred_username,
|
||||
)
|
||||
following, _ = await services.federation.get_following(
|
||||
session, actor.preferred_username, page=1, per_page=1000,
|
||||
)
|
||||
followed_urls = [a.actor_url for a in following]
|
||||
return {
|
||||
"actors": [_serialize_remote_actor(a) for a in actors_list],
|
||||
"total": total,
|
||||
"followed_urls": followed_urls,
|
||||
"actor": _serialize_actor(actor),
|
||||
}
|
||||
|
||||
async def actor_timeline_data(self, session, *, id=None, **kw):
|
||||
from quart import abort
|
||||
from sqlalchemy import select as sa_select
|
||||
from shared.models.federation import RemoteActor
|
||||
from shared.services.registry import services
|
||||
from shared.services.federation_impl import _remote_actor_to_dto
|
||||
|
||||
actor = _get_actor()
|
||||
actor_id = id
|
||||
remote = (
|
||||
await session.execute(
|
||||
sa_select(RemoteActor).where(RemoteActor.id == actor_id)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not remote:
|
||||
abort(404)
|
||||
remote_dto = _remote_actor_to_dto(remote)
|
||||
items = await services.federation.get_actor_timeline(session, actor_id)
|
||||
is_following = False
|
||||
if actor:
|
||||
from shared.models.federation import APFollowing
|
||||
existing = (
|
||||
await session.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
|
||||
return {
|
||||
"remote_actor": _serialize_remote_actor(remote_dto),
|
||||
"items": [_serialize_timeline_item(i) for i in items],
|
||||
"is_following": is_following,
|
||||
"actor": _serialize_actor(actor),
|
||||
}
|
||||
|
||||
async def notifications_data(self, session, **kw):
|
||||
actor = _require_actor()
|
||||
from shared.services.registry import services
|
||||
items = await services.federation.get_notifications(session, actor.id)
|
||||
await services.federation.mark_notifications_read(session, actor.id)
|
||||
|
||||
notif_dicts = []
|
||||
for n in items:
|
||||
created = getattr(n, "created_at", None)
|
||||
notif_dicts.append({
|
||||
"from_actor_name": getattr(n, "from_actor_name", "?"),
|
||||
"from_actor_username": getattr(n, "from_actor_username", ""),
|
||||
"from_actor_domain": getattr(n, "from_actor_domain", ""),
|
||||
"from_actor_icon": getattr(n, "from_actor_icon", None),
|
||||
"notification_type": getattr(n, "notification_type", ""),
|
||||
"target_content_preview": getattr(n, "target_content_preview", None),
|
||||
"created_at_formatted": created.strftime("%b %d, %H:%M") if created else "",
|
||||
"read": getattr(n, "read", True),
|
||||
"app_domain": getattr(n, "app_domain", ""),
|
||||
})
|
||||
return {"notifications": notif_dicts}
|
||||
@@ -1,293 +0,0 @@
|
||||
"""
|
||||
Federation service s-expression page components.
|
||||
|
||||
Page helpers now call assembled defcomps in .sx files. This file contains
|
||||
only functions still called directly from route handlers: full-page renders
|
||||
(login, choose-username, profile) and POST fragment renderers (interaction
|
||||
buttons, actor cards, pagination items).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
from markupsafe import escape
|
||||
|
||||
from shared.sx.jinja_bridge import load_service_components
|
||||
from shared.sx.helpers import (
|
||||
render_to_sx,
|
||||
root_header_sx, full_page_sx, header_child_sx,
|
||||
)
|
||||
|
||||
# Load federation-specific .sx components + handlers at import time
|
||||
load_service_components(os.path.dirname(os.path.dirname(__file__)),
|
||||
service_name="federation")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Serialization helpers (shared with pages/__init__.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _serialize_actor(actor) -> dict | None:
|
||||
if not actor:
|
||||
return None
|
||||
return {
|
||||
"id": actor.id,
|
||||
"preferred_username": actor.preferred_username,
|
||||
"display_name": getattr(actor, "display_name", None),
|
||||
"icon_url": getattr(actor, "icon_url", None),
|
||||
"summary": getattr(actor, "summary", None),
|
||||
"actor_url": getattr(actor, "actor_url", ""),
|
||||
"domain": getattr(actor, "domain", ""),
|
||||
}
|
||||
|
||||
|
||||
def _serialize_timeline_item(item) -> dict:
|
||||
published = getattr(item, "published", None)
|
||||
return {
|
||||
"object_id": getattr(item, "object_id", "") or "",
|
||||
"author_inbox": getattr(item, "author_inbox", "") or "",
|
||||
"actor_icon": getattr(item, "actor_icon", None),
|
||||
"actor_name": getattr(item, "actor_name", "?"),
|
||||
"actor_username": getattr(item, "actor_username", ""),
|
||||
"actor_domain": getattr(item, "actor_domain", ""),
|
||||
"content": getattr(item, "content", ""),
|
||||
"summary": getattr(item, "summary", None),
|
||||
"published": published.strftime("%b %d, %H:%M") if published else "",
|
||||
"before_cursor": published.isoformat() if published else "",
|
||||
"url": getattr(item, "url", None),
|
||||
"post_type": getattr(item, "post_type", ""),
|
||||
"boosted_by": getattr(item, "boosted_by", None),
|
||||
"like_count": getattr(item, "like_count", 0) or 0,
|
||||
"boost_count": getattr(item, "boost_count", 0) or 0,
|
||||
"liked_by_me": getattr(item, "liked_by_me", False),
|
||||
"boosted_by_me": getattr(item, "boosted_by_me", False),
|
||||
}
|
||||
|
||||
|
||||
def _serialize_remote_actor(a) -> dict:
|
||||
return {
|
||||
"id": getattr(a, "id", None),
|
||||
"display_name": getattr(a, "display_name", None) or getattr(a, "preferred_username", ""),
|
||||
"preferred_username": getattr(a, "preferred_username", ""),
|
||||
"domain": getattr(a, "domain", ""),
|
||||
"icon_url": getattr(a, "icon_url", None),
|
||||
"actor_url": getattr(a, "actor_url", ""),
|
||||
"summary": getattr(a, "summary", None),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Social page shell
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _social_page(ctx: dict, actor: Any, *, content: str,
|
||||
title: str = "Rose Ash", meta_html: str = "") -> str:
|
||||
from shared.sx.parser import SxExpr
|
||||
actor_data = _serialize_actor(actor)
|
||||
nav = await render_to_sx("federation-social-nav", actor=actor_data)
|
||||
social_hdr = await render_to_sx("federation-social-header", nav=SxExpr(nav))
|
||||
hdr = await root_header_sx(ctx)
|
||||
child = await header_child_sx(social_hdr)
|
||||
header_rows = "(<> " + hdr + " " + child + ")"
|
||||
return await full_page_sx(ctx, header_rows=header_rows, content=content,
|
||||
meta_html=meta_html or f'<title>{escape(title)}</title>')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Full page renders
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_federation_home(ctx: dict) -> str:
|
||||
hdr = await root_header_sx(ctx)
|
||||
return await full_page_sx(ctx, header_rows=hdr)
|
||||
|
||||
|
||||
async def render_login_page(ctx: dict) -> str:
|
||||
error = ctx.get("error", "")
|
||||
email = ctx.get("email", "")
|
||||
content = await render_to_sx("account-login-content",
|
||||
error=error or None, email=str(escape(email)))
|
||||
return await _social_page(ctx, None, content=content, title="Login \u2014 Rose Ash")
|
||||
|
||||
|
||||
async def render_check_email_page(ctx: dict) -> str:
|
||||
email = ctx.get("email", "")
|
||||
email_error = ctx.get("email_error")
|
||||
content = await render_to_sx("account-check-email-content",
|
||||
email=str(escape(email)), email_error=email_error)
|
||||
return await _social_page(ctx, None, content=content,
|
||||
title="Check your email \u2014 Rose Ash")
|
||||
|
||||
|
||||
async def render_choose_username_page(ctx: dict) -> str:
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from quart import url_for
|
||||
from shared.config import config
|
||||
from shared.sx.parser import SxExpr
|
||||
|
||||
csrf = generate_csrf_token()
|
||||
error = ctx.get("error", "")
|
||||
username = ctx.get("username", "")
|
||||
ap_domain = config().get("ap_domain", "rose-ash.com")
|
||||
check_url = url_for("identity.check_username")
|
||||
actor = ctx.get("actor")
|
||||
|
||||
error_sx = await render_to_sx("auth-error-banner", error=error) if error else ""
|
||||
content = await render_to_sx(
|
||||
"federation-choose-username",
|
||||
domain=str(escape(ap_domain)),
|
||||
error=SxExpr(error_sx) if error_sx else None,
|
||||
csrf=csrf, username=str(escape(username)),
|
||||
check_url=check_url,
|
||||
)
|
||||
return await _social_page(ctx, actor, content=content,
|
||||
title="Choose Username \u2014 Rose Ash")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Pagination fragment renderers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_timeline_items(items: list, timeline_type: str,
|
||||
actor: Any, actor_id: int | None = None) -> str:
|
||||
from quart import url_for
|
||||
item_dicts = [_serialize_timeline_item(i) for i in items]
|
||||
actor_data = _serialize_actor(actor)
|
||||
|
||||
# Build next URL
|
||||
next_url = None
|
||||
if items:
|
||||
last = items[-1]
|
||||
before = last.published.isoformat() if last.published else ""
|
||||
if timeline_type == "actor" and actor_id is not None:
|
||||
next_url = url_for("social.actor_timeline_page", id=actor_id, before=before)
|
||||
else:
|
||||
next_url = url_for(f"social.{timeline_type}_timeline_page", before=before)
|
||||
|
||||
return await render_to_sx("federation-timeline-items",
|
||||
items=item_dicts,
|
||||
timeline_type=timeline_type,
|
||||
actor=actor_data,
|
||||
next_url=next_url)
|
||||
|
||||
|
||||
async def render_search_results(actors: list, query: str, page: int,
|
||||
followed_urls: set, actor: Any) -> str:
|
||||
from quart import url_for
|
||||
actor_dicts = [_serialize_remote_actor(a) for a in actors]
|
||||
actor_data = _serialize_actor(actor)
|
||||
parts = []
|
||||
for ad in actor_dicts:
|
||||
parts.append(await render_to_sx("federation-actor-card-from-data",
|
||||
a=ad,
|
||||
actor=actor_data,
|
||||
followed_urls=list(followed_urls),
|
||||
list_type="search"))
|
||||
if len(actors) >= 20:
|
||||
next_url = url_for("social.search_page", q=query, page=page + 1)
|
||||
parts.append(await render_to_sx("federation-scroll-sentinel", url=next_url))
|
||||
return "(<> " + " ".join(parts) + ")" if parts else ""
|
||||
|
||||
|
||||
async def render_following_items(actors: list, page: int, actor: Any) -> str:
|
||||
from quart import url_for
|
||||
actor_dicts = [_serialize_remote_actor(a) for a in actors]
|
||||
actor_data = _serialize_actor(actor)
|
||||
parts = []
|
||||
for ad in actor_dicts:
|
||||
parts.append(await render_to_sx("federation-actor-card-from-data",
|
||||
a=ad,
|
||||
actor=actor_data,
|
||||
followed_urls=[],
|
||||
list_type="following"))
|
||||
if len(actors) >= 20:
|
||||
next_url = url_for("social.following_list_page", page=page + 1)
|
||||
parts.append(await render_to_sx("federation-scroll-sentinel", url=next_url))
|
||||
return "(<> " + " ".join(parts) + ")" if parts else ""
|
||||
|
||||
|
||||
async def render_followers_items(actors: list, page: int,
|
||||
followed_urls: set, actor: Any) -> str:
|
||||
from quart import url_for
|
||||
actor_dicts = [_serialize_remote_actor(a) for a in actors]
|
||||
actor_data = _serialize_actor(actor)
|
||||
parts = []
|
||||
for ad in actor_dicts:
|
||||
parts.append(await render_to_sx("federation-actor-card-from-data",
|
||||
a=ad,
|
||||
actor=actor_data,
|
||||
followed_urls=list(followed_urls),
|
||||
list_type="followers"))
|
||||
if len(actors) >= 20:
|
||||
next_url = url_for("social.followers_list_page", page=page + 1)
|
||||
parts.append(await render_to_sx("federation-scroll-sentinel", url=next_url))
|
||||
return "(<> " + " ".join(parts) + ")" if parts else ""
|
||||
|
||||
|
||||
async def render_actor_timeline_items(items: list, actor_id: int,
|
||||
actor: Any) -> str:
|
||||
return await render_timeline_items(items, "actor", actor, actor_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: POST handler fragment renderers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_interaction_buttons(object_id: str, author_inbox: str,
|
||||
like_count: int, boost_count: int,
|
||||
liked_by_me: bool, boosted_by_me: bool,
|
||||
actor: Any) -> str:
|
||||
"""Render interaction buttons fragment for POST response."""
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from quart import url_for
|
||||
from shared.sx.parser import SxExpr
|
||||
|
||||
csrf = generate_csrf_token()
|
||||
safe_id = object_id.replace("/", "_").replace(":", "_")
|
||||
target = f"#interactions-{safe_id}"
|
||||
|
||||
if liked_by_me:
|
||||
like_action = url_for("social.unlike")
|
||||
like_cls = "text-red-500 hover:text-red-600"
|
||||
like_icon = "\u2665"
|
||||
else:
|
||||
like_action = url_for("social.like")
|
||||
like_cls = "hover:text-red-500"
|
||||
like_icon = "\u2661"
|
||||
|
||||
if boosted_by_me:
|
||||
boost_action = url_for("social.unboost")
|
||||
boost_cls = "text-green-600 hover:text-green-700"
|
||||
else:
|
||||
boost_action = url_for("social.boost")
|
||||
boost_cls = "hover:text-green-600"
|
||||
|
||||
reply_url = url_for("social.defpage_compose_form", reply_to=object_id) if object_id else ""
|
||||
reply_sx = await render_to_sx("federation-reply-link", url=reply_url) if reply_url else ""
|
||||
|
||||
like_form = await render_to_sx("federation-like-form",
|
||||
action=like_action, target=target, oid=object_id, ainbox=author_inbox,
|
||||
csrf=csrf, cls=f"flex items-center gap-1 {like_cls}",
|
||||
icon=like_icon, count=str(like_count))
|
||||
|
||||
boost_form = await render_to_sx("federation-boost-form",
|
||||
action=boost_action, target=target, oid=object_id, ainbox=author_inbox,
|
||||
csrf=csrf, cls=f"flex items-center gap-1 {boost_cls}",
|
||||
count=str(boost_count))
|
||||
|
||||
return await render_to_sx("federation-interaction-buttons",
|
||||
like=SxExpr(like_form),
|
||||
boost=SxExpr(boost_form),
|
||||
reply=SxExpr(reply_sx) if reply_sx else None)
|
||||
|
||||
|
||||
async def render_actor_card(actor_dto: Any, actor: Any, followed_urls: set,
|
||||
*, list_type: str = "following") -> str:
|
||||
"""Render a single actor card fragment for POST response."""
|
||||
actor_data = _serialize_actor(actor)
|
||||
ad = _serialize_remote_actor(actor_dto)
|
||||
return await render_to_sx("federation-actor-card-from-data",
|
||||
a=ad,
|
||||
actor=actor_data,
|
||||
followed_urls=list(followed_urls),
|
||||
list_type=list_type)
|
||||
@@ -1,13 +1,12 @@
|
||||
"""Federation defpage setup — registers layouts, page helpers, and loads .sx pages."""
|
||||
"""Federation defpage setup — registers layouts and loads .sx pages."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def setup_federation_pages() -> None:
|
||||
"""Register federation-specific layouts, page helpers, and load page definitions."""
|
||||
"""Register federation-specific layouts and load page definitions."""
|
||||
_register_federation_layouts()
|
||||
_register_federation_helpers()
|
||||
_load_federation_page_files()
|
||||
|
||||
|
||||
@@ -55,74 +54,42 @@ async def _social_oob(ctx: dict, **kw: Any) -> str:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Page helpers
|
||||
# Serializers and helpers — still used by layouts and route handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _register_federation_helpers() -> None:
|
||||
from shared.sx.pages import register_page_helpers
|
||||
|
||||
register_page_helpers("federation", {
|
||||
"home-timeline-content": _h_home_timeline_content,
|
||||
"public-timeline-content": _h_public_timeline_content,
|
||||
"compose-content": _h_compose_content,
|
||||
"search-content": _h_search_content,
|
||||
"following-content": _h_following_content,
|
||||
"followers-content": _h_followers_content,
|
||||
"actor-timeline-content": _h_actor_timeline_content,
|
||||
"notifications-content": _h_notifications_content,
|
||||
})
|
||||
|
||||
|
||||
def _serialize_actor(actor) -> dict | None:
|
||||
"""Serialize an actor profile to a dict for sx defcomps."""
|
||||
if not actor:
|
||||
return None
|
||||
return {
|
||||
"id": actor.id,
|
||||
"preferred_username": actor.preferred_username,
|
||||
"display_name": getattr(actor, "display_name", None),
|
||||
"icon_url": getattr(actor, "icon_url", None),
|
||||
"summary": getattr(actor, "summary", None),
|
||||
"actor_url": getattr(actor, "actor_url", ""),
|
||||
"domain": getattr(actor, "domain", ""),
|
||||
}
|
||||
from services.federation_page import _serialize_actor as _impl
|
||||
return _impl(actor)
|
||||
|
||||
|
||||
def _serialize_timeline_item(item) -> dict:
|
||||
"""Serialize a timeline item DTO to a dict for sx defcomps."""
|
||||
published = getattr(item, "published", None)
|
||||
return {
|
||||
"object_id": getattr(item, "object_id", "") or "",
|
||||
"author_inbox": getattr(item, "author_inbox", "") or "",
|
||||
"actor_icon": getattr(item, "actor_icon", None),
|
||||
"actor_name": getattr(item, "actor_name", "?"),
|
||||
"actor_username": getattr(item, "actor_username", ""),
|
||||
"actor_domain": getattr(item, "actor_domain", ""),
|
||||
"content": getattr(item, "content", ""),
|
||||
"summary": getattr(item, "summary", None),
|
||||
"published": published.strftime("%b %d, %H:%M") if published else "",
|
||||
"before_cursor": published.isoformat() if published else "",
|
||||
"url": getattr(item, "url", None),
|
||||
"post_type": getattr(item, "post_type", ""),
|
||||
"boosted_by": getattr(item, "boosted_by", None),
|
||||
"like_count": getattr(item, "like_count", 0) or 0,
|
||||
"boost_count": getattr(item, "boost_count", 0) or 0,
|
||||
"liked_by_me": getattr(item, "liked_by_me", False),
|
||||
"boosted_by_me": getattr(item, "boosted_by_me", False),
|
||||
}
|
||||
from services.federation_page import _serialize_timeline_item as _impl
|
||||
return _impl(item)
|
||||
|
||||
|
||||
def _serialize_remote_actor(a) -> dict:
|
||||
"""Serialize a remote actor DTO to a dict for sx defcomps."""
|
||||
return {
|
||||
"id": getattr(a, "id", None),
|
||||
"display_name": getattr(a, "display_name", None) or getattr(a, "preferred_username", ""),
|
||||
"preferred_username": getattr(a, "preferred_username", ""),
|
||||
"domain": getattr(a, "domain", ""),
|
||||
"icon_url": getattr(a, "icon_url", None),
|
||||
"actor_url": getattr(a, "actor_url", ""),
|
||||
"summary": getattr(a, "summary", None),
|
||||
}
|
||||
from services.federation_page import _serialize_remote_actor as _impl
|
||||
return _impl(a)
|
||||
|
||||
|
||||
async def _social_page(ctx: dict, actor, *, content: str,
|
||||
title: str = "Rose Ash", meta_html: str = "") -> str:
|
||||
"""Build a full social page with social header."""
|
||||
from shared.sx.helpers import render_to_sx, root_header_sx, header_child_sx, full_page_sx
|
||||
from shared.sx.parser import SxExpr
|
||||
from markupsafe import escape
|
||||
|
||||
actor_data = _serialize_actor(actor)
|
||||
nav = await render_to_sx("federation-social-nav", actor=actor_data)
|
||||
social_hdr = await render_to_sx("federation-social-header", nav=SxExpr(nav))
|
||||
hdr = await root_header_sx(ctx)
|
||||
child = await header_child_sx(social_hdr)
|
||||
header_rows = "(<> " + hdr + " " + child + ")"
|
||||
return await full_page_sx(ctx, header_rows=header_rows, content=content,
|
||||
meta_html=meta_html or f'<title>{escape(title)}</title>')
|
||||
|
||||
|
||||
def _get_actor():
|
||||
@@ -138,156 +105,3 @@ def _require_actor():
|
||||
if not actor:
|
||||
abort(403, "You need to choose a federation username first")
|
||||
return actor
|
||||
|
||||
|
||||
async def _h_home_timeline_content(**kw):
|
||||
from quart import g
|
||||
from shared.services.registry import services
|
||||
from shared.sx.helpers import render_to_sx
|
||||
actor = _require_actor()
|
||||
items = await services.federation.get_home_timeline(g.s, actor.id)
|
||||
return await render_to_sx("federation-timeline-content",
|
||||
items=[_serialize_timeline_item(i) for i in items],
|
||||
timeline_type="home",
|
||||
actor=_serialize_actor(actor))
|
||||
|
||||
|
||||
async def _h_public_timeline_content(**kw):
|
||||
from quart import g
|
||||
from shared.services.registry import services
|
||||
from shared.sx.helpers import render_to_sx
|
||||
actor = _get_actor()
|
||||
items = await services.federation.get_public_timeline(g.s)
|
||||
return await render_to_sx("federation-timeline-content",
|
||||
items=[_serialize_timeline_item(i) for i in items],
|
||||
timeline_type="public",
|
||||
actor=_serialize_actor(actor))
|
||||
|
||||
|
||||
async def _h_compose_content(**kw):
|
||||
from quart import request
|
||||
from shared.sx.helpers import render_to_sx
|
||||
_require_actor()
|
||||
reply_to = request.args.get("reply_to")
|
||||
return await render_to_sx("federation-compose-content",
|
||||
reply_to=reply_to or None)
|
||||
|
||||
|
||||
async def _h_search_content(**kw):
|
||||
from quart import g, request
|
||||
from shared.services.registry import services
|
||||
from shared.sx.helpers import render_to_sx
|
||||
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}
|
||||
return await render_to_sx("federation-search-content",
|
||||
query=query,
|
||||
actors=[_serialize_remote_actor(a) for a in actors_list],
|
||||
total=total,
|
||||
followed_urls=list(followed_urls),
|
||||
actor=_serialize_actor(actor))
|
||||
|
||||
|
||||
async def _h_following_content(**kw):
|
||||
from quart import g
|
||||
from shared.services.registry import services
|
||||
from shared.sx.helpers import render_to_sx
|
||||
actor = _require_actor()
|
||||
actors_list, total = await services.federation.get_following(
|
||||
g.s, actor.preferred_username,
|
||||
)
|
||||
return await render_to_sx("federation-following-content",
|
||||
actors=[_serialize_remote_actor(a) for a in actors_list],
|
||||
total=total,
|
||||
actor=_serialize_actor(actor))
|
||||
|
||||
|
||||
async def _h_followers_content(**kw):
|
||||
from quart import g
|
||||
from shared.services.registry import services
|
||||
from shared.sx.helpers import render_to_sx
|
||||
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}
|
||||
return await render_to_sx("federation-followers-content",
|
||||
actors=[_serialize_remote_actor(a) for a in actors_list],
|
||||
total=total,
|
||||
followed_urls=list(followed_urls),
|
||||
actor=_serialize_actor(actor))
|
||||
|
||||
|
||||
async def _h_actor_timeline_content(id=None, **kw):
|
||||
from quart import g, abort
|
||||
from shared.services.registry import services
|
||||
from shared.sx.helpers import render_to_sx
|
||||
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
|
||||
return await render_to_sx("federation-actor-timeline-content",
|
||||
remote_actor=_serialize_remote_actor(remote_dto),
|
||||
items=[_serialize_timeline_item(i) for i in items],
|
||||
is_following=is_following,
|
||||
actor=_serialize_actor(actor))
|
||||
|
||||
|
||||
async def _h_notifications_content(**kw):
|
||||
from quart import g
|
||||
from shared.services.registry import services
|
||||
from shared.sx.helpers import render_to_sx
|
||||
actor = _require_actor()
|
||||
items = await services.federation.get_notifications(g.s, actor.id)
|
||||
await services.federation.mark_notifications_read(g.s, actor.id)
|
||||
|
||||
notif_dicts = []
|
||||
for n in items:
|
||||
created = getattr(n, "created_at", None)
|
||||
notif_dicts.append({
|
||||
"from_actor_name": getattr(n, "from_actor_name", "?"),
|
||||
"from_actor_username": getattr(n, "from_actor_username", ""),
|
||||
"from_actor_domain": getattr(n, "from_actor_domain", ""),
|
||||
"from_actor_icon": getattr(n, "from_actor_icon", None),
|
||||
"notification_type": getattr(n, "notification_type", ""),
|
||||
"target_content_preview": getattr(n, "target_content_preview", None),
|
||||
"created_at_formatted": created.strftime("%b %d, %H:%M") if created else "",
|
||||
"read": getattr(n, "read", True),
|
||||
"app_domain": getattr(n, "app_domain", ""),
|
||||
})
|
||||
return await render_to_sx("federation-notifications-content",
|
||||
notifications=notif_dicts)
|
||||
|
||||
@@ -1,49 +1,82 @@
|
||||
;; Federation social pages
|
||||
;; All data fetching via (service ...) IO primitives, no Python helpers.
|
||||
|
||||
(defpage home-timeline
|
||||
:path "/social/"
|
||||
:auth :login
|
||||
:layout :social
|
||||
:content (home-timeline-content))
|
||||
:data (service "federation-page" "home-timeline-data")
|
||||
:content (~federation-timeline-content
|
||||
:items items
|
||||
:timeline-type timeline-type
|
||||
:actor actor))
|
||||
|
||||
(defpage public-timeline
|
||||
:path "/social/public"
|
||||
:auth :public
|
||||
:layout :social
|
||||
:content (public-timeline-content))
|
||||
:data (service "federation-page" "public-timeline-data")
|
||||
:content (~federation-timeline-content
|
||||
:items items
|
||||
:timeline-type timeline-type
|
||||
:actor actor))
|
||||
|
||||
(defpage compose-form
|
||||
:path "/social/compose"
|
||||
:auth :login
|
||||
:layout :social
|
||||
:content (compose-content))
|
||||
:data (service "federation-page" "compose-data")
|
||||
:content (~federation-compose-content
|
||||
:reply-to reply-to))
|
||||
|
||||
(defpage search
|
||||
:path "/social/search"
|
||||
:auth :public
|
||||
:layout :social
|
||||
:content (search-content))
|
||||
:data (service "federation-page" "search-data")
|
||||
:content (~federation-search-content
|
||||
:query query
|
||||
:actors actors
|
||||
:total total
|
||||
:followed-urls followed-urls
|
||||
:actor actor))
|
||||
|
||||
(defpage following-list
|
||||
:path "/social/following"
|
||||
:auth :login
|
||||
:layout :social
|
||||
:content (following-content))
|
||||
:data (service "federation-page" "following-data")
|
||||
:content (~federation-following-content
|
||||
:actors actors
|
||||
:total total
|
||||
:actor actor))
|
||||
|
||||
(defpage followers-list
|
||||
:path "/social/followers"
|
||||
:auth :login
|
||||
:layout :social
|
||||
:content (followers-content))
|
||||
:data (service "federation-page" "followers-data")
|
||||
:content (~federation-followers-content
|
||||
:actors actors
|
||||
:total total
|
||||
:followed-urls followed-urls
|
||||
:actor actor))
|
||||
|
||||
(defpage actor-timeline
|
||||
:path "/social/actor/<int:id>"
|
||||
:auth :public
|
||||
:layout :social
|
||||
:content (actor-timeline-content id))
|
||||
:data (service "federation-page" "actor-timeline-data" :id id)
|
||||
:content (~federation-actor-timeline-content
|
||||
:remote-actor remote-actor
|
||||
:items items
|
||||
:is-following is-following
|
||||
:actor actor))
|
||||
|
||||
(defpage notifications
|
||||
:path "/social/notifications"
|
||||
:auth :login
|
||||
:layout :social
|
||||
:content (notifications-content))
|
||||
:data (service "federation-page" "notifications-data")
|
||||
:content (~federation-notifications-content
|
||||
:notifications notifications))
|
||||
|
||||
@@ -111,7 +111,7 @@ async def _market_header_sx(ctx: dict, *, oob: bool = False) -> str:
|
||||
sub_div=SxExpr(sub_div) if sub_div else None,
|
||||
)
|
||||
|
||||
link_href = url_for("market.browse.defpage_market_home")
|
||||
link_href = url_for("defpage_market_home")
|
||||
|
||||
# Build desktop nav from categories
|
||||
categories = ctx.get("categories", {})
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
from __future__ import annotations
|
||||
import path_setup # noqa: F401 # adds shared/ to sys.path
|
||||
import sx.sx_components as sx_components # noqa: F401 # ensure Hypercorn --reload watches this file
|
||||
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
@@ -69,10 +67,9 @@ def create_app() -> "Quart":
|
||||
app.jinja_loader,
|
||||
])
|
||||
|
||||
# Load orders-specific s-expression components (loaded at import time)
|
||||
import sx.sx_components # noqa: F811
|
||||
|
||||
# Setup defpage routes
|
||||
# Load .sx component files and setup defpage routes
|
||||
from shared.sx.jinja_bridge import load_service_components
|
||||
load_service_components(str(Path(__file__).resolve().parent), service_name="orders")
|
||||
from sxc.pages import setup_orders_pages
|
||||
setup_orders_pages()
|
||||
|
||||
|
||||
@@ -11,6 +11,118 @@ from services.checkout import validate_webhook_secret, get_order_with_details
|
||||
from services.check_sumup_status import check_sumup_status
|
||||
|
||||
|
||||
async def _render_checkout_return(ctx: dict, order=None, status: str = "",
|
||||
calendar_entries=None, order_tickets=None) -> str:
|
||||
"""Render checkout return page — replaces sx_components helper."""
|
||||
from shared.sx.helpers import (
|
||||
render_to_sx, root_header_sx, header_child_sx, full_page_sx, call_url,
|
||||
)
|
||||
from shared.sx.parser import SxExpr
|
||||
from shared.infrastructure.urls import market_product_url
|
||||
|
||||
filt = await render_to_sx("checkout-return-header", status=status)
|
||||
|
||||
if not order:
|
||||
content = await render_to_sx("checkout-return-missing")
|
||||
else:
|
||||
summary = await render_to_sx("order-summary-card",
|
||||
order_id=order.id,
|
||||
created_at=order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else None,
|
||||
description=order.description, status=order.status,
|
||||
currency=order.currency,
|
||||
total_amount=f"{order.total_amount:.2f}" if order.total_amount else None,
|
||||
)
|
||||
|
||||
items = ""
|
||||
if order.items:
|
||||
item_parts = []
|
||||
for item in order.items:
|
||||
product_url = market_product_url(item.product_slug)
|
||||
if item.product_image:
|
||||
img = await render_to_sx("order-item-image",
|
||||
src=item.product_image,
|
||||
alt=item.product_title or "Product image")
|
||||
else:
|
||||
img = await render_to_sx("order-item-no-image")
|
||||
item_parts.append(await render_to_sx("order-item-row",
|
||||
href=product_url, img=SxExpr(img),
|
||||
title=item.product_title or "Unknown product",
|
||||
pid=f"Product ID: {item.product_id}",
|
||||
qty=f"Qty: {item.quantity}",
|
||||
price=f"{item.currency or order.currency or 'GBP'} {item.unit_price or 0:.2f}",
|
||||
))
|
||||
items = await render_to_sx("order-items-panel",
|
||||
items=SxExpr("(<> " + " ".join(item_parts) + ")"))
|
||||
|
||||
calendar = ""
|
||||
if calendar_entries:
|
||||
cal_parts = []
|
||||
for e in calendar_entries:
|
||||
st = e.state or ""
|
||||
pill = (
|
||||
"bg-emerald-100 text-emerald-800" if st == "confirmed"
|
||||
else "bg-amber-100 text-amber-800" if st == "provisional"
|
||||
else "bg-blue-100 text-blue-800" if st == "ordered"
|
||||
else "bg-stone-100 text-stone-700"
|
||||
)
|
||||
ds = e.start_at.strftime("%-d %b %Y, %H:%M") if e.start_at else ""
|
||||
if e.end_at:
|
||||
ds += f" \u2013 {e.end_at.strftime('%-d %b %Y, %H:%M')}"
|
||||
cal_parts.append(await render_to_sx("order-calendar-entry",
|
||||
name=e.name,
|
||||
pill=f"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium {pill}",
|
||||
status=st.capitalize(), date_str=ds,
|
||||
cost=f"\u00a3{e.cost or 0:.2f}",
|
||||
))
|
||||
calendar = await render_to_sx("order-calendar-section",
|
||||
items=SxExpr("(<> " + " ".join(cal_parts) + ")"))
|
||||
|
||||
tickets = ""
|
||||
if order_tickets:
|
||||
tk_parts = []
|
||||
for tk in order_tickets:
|
||||
st = tk.state or ""
|
||||
pill = (
|
||||
"bg-emerald-100 text-emerald-800" if st == "confirmed"
|
||||
else "bg-amber-100 text-amber-800" if st == "reserved"
|
||||
else "bg-blue-100 text-blue-800" if st == "checked_in"
|
||||
else "bg-stone-100 text-stone-700"
|
||||
)
|
||||
pill_cls = f"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium {pill}"
|
||||
ds = tk.entry_start_at.strftime("%-d %b %Y, %H:%M") if tk.entry_start_at else ""
|
||||
if tk.entry_end_at:
|
||||
ds += f" \u2013 {tk.entry_end_at.strftime('%-d %b %Y, %H:%M')}"
|
||||
tk_parts.append(await render_to_sx("checkout-return-ticket",
|
||||
name=tk.entry_name, pill=pill_cls,
|
||||
state=st.replace("_", " ").capitalize(),
|
||||
type_name=tk.ticket_type_name or None,
|
||||
date_str=ds, code=tk.code,
|
||||
price=f"\u00a3{tk.price or 0:.2f}",
|
||||
))
|
||||
tickets = await render_to_sx("checkout-return-tickets",
|
||||
items=SxExpr("(<> " + " ".join(tk_parts) + ")"))
|
||||
|
||||
status_msg = ""
|
||||
if order.status == "failed":
|
||||
status_msg = await render_to_sx("checkout-return-failed", order_id=order.id)
|
||||
elif order.status == "paid":
|
||||
status_msg = await render_to_sx("checkout-return-paid")
|
||||
|
||||
content = await render_to_sx("checkout-return-content",
|
||||
summary=SxExpr(summary),
|
||||
items=SxExpr(items) if items else None,
|
||||
calendar=SxExpr(calendar) if calendar else None,
|
||||
tickets=SxExpr(tickets) if tickets else None,
|
||||
status_message=SxExpr(status_msg) if status_msg else None,
|
||||
)
|
||||
|
||||
account_url = call_url(ctx, "account_url", "")
|
||||
auth_hdr = await render_to_sx("auth-header-row", account_url=account_url)
|
||||
hdr = "(<> " + await root_header_sx(ctx) + " " + await header_child_sx(auth_hdr) + ")"
|
||||
|
||||
return await full_page_sx(ctx, header_rows=hdr, filter=filt, content=content)
|
||||
|
||||
|
||||
def register() -> Blueprint:
|
||||
bp = Blueprint("checkout", __name__, url_prefix="/checkout")
|
||||
|
||||
@@ -47,12 +159,11 @@ def register() -> Blueprint:
|
||||
async def checkout_return(order_id: int):
|
||||
"""Handle the browser returning from SumUp after payment."""
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_checkout_return_page
|
||||
|
||||
order = await get_order_with_details(g.s, order_id)
|
||||
if not order:
|
||||
tctx = await get_template_context()
|
||||
html = await render_checkout_return_page(tctx, order=None, status="missing")
|
||||
html = await _render_checkout_return(tctx, order=None, status="missing")
|
||||
return await make_response(html)
|
||||
|
||||
if order.page_config_id:
|
||||
@@ -90,7 +201,7 @@ def register() -> Blueprint:
|
||||
await g.s.flush()
|
||||
|
||||
tctx = await get_template_context()
|
||||
html = await render_checkout_return_page(
|
||||
html = await _render_checkout_return(
|
||||
tctx, order=order, status=status,
|
||||
calendar_entries=calendar_entries,
|
||||
order_tickets=order_tickets,
|
||||
|
||||
@@ -42,7 +42,7 @@ def register() -> Blueprint:
|
||||
return await make_response("Order not found", 404)
|
||||
|
||||
if order.status == "paid":
|
||||
return redirect(url_for("orders.defpage_order_detail", order_id=order.id))
|
||||
return redirect(url_for("defpage_order_detail", order_id=order.id))
|
||||
|
||||
if order.sumup_hosted_url:
|
||||
return redirect(order.sumup_hosted_url)
|
||||
@@ -70,9 +70,22 @@ def register() -> Blueprint:
|
||||
|
||||
if not hosted_url:
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_checkout_error_page
|
||||
from shared.sx.helpers import render_to_sx, root_header_sx, header_child_sx, full_page_sx, call_url
|
||||
from shared.sx.parser import SxExpr
|
||||
from shared.infrastructure.urls import cart_url
|
||||
tctx = await get_template_context()
|
||||
html = await render_checkout_error_page(tctx, error="No hosted checkout URL returned from SumUp when trying to reopen payment.", order=order)
|
||||
account_url = call_url(tctx, "account_url", "")
|
||||
auth_hdr = await render_to_sx("auth-header-row", account_url=account_url)
|
||||
hdr = "(<> " + await root_header_sx(tctx) + " " + await header_child_sx(auth_hdr) + ")"
|
||||
filt = await render_to_sx("checkout-error-header")
|
||||
order_sx = await render_to_sx("checkout-error-order-id", oid=f"#{order.id}")
|
||||
content = await render_to_sx(
|
||||
"checkout-error-content",
|
||||
msg="No hosted checkout URL returned from SumUp when trying to reopen payment.",
|
||||
order=SxExpr(order_sx),
|
||||
back_url=cart_url("/"),
|
||||
)
|
||||
html = await full_page_sx(tctx, header_rows=hdr, filter=filt, content=content)
|
||||
return await make_response(html, 500)
|
||||
|
||||
return redirect(hosted_url)
|
||||
@@ -89,13 +102,13 @@ def register() -> Blueprint:
|
||||
return await make_response("Order not found", 404)
|
||||
|
||||
if not order.sumup_checkout_id:
|
||||
return redirect(url_for("orders.defpage_order_detail", order_id=order.id))
|
||||
return redirect(url_for("defpage_order_detail", order_id=order.id))
|
||||
|
||||
try:
|
||||
await check_sumup_status(g.s, order)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return redirect(url_for("orders.defpage_order_detail", order_id=order.id))
|
||||
return redirect(url_for("defpage_order_detail", order_id=order.id))
|
||||
|
||||
return bp
|
||||
|
||||
@@ -88,7 +88,7 @@ def register(url_prefix: str) -> Blueprint:
|
||||
"total_formatted": f"{o.total_amount or 0:.2f}",
|
||||
})
|
||||
|
||||
detail_prefix = pfx + url_for("orders.defpage_order_detail", order_id=0).rsplit("0/", 1)[0]
|
||||
detail_prefix = pfx + url_for("defpage_order_detail", order_id=0).rsplit("0/", 1)[0]
|
||||
qs_fn = makeqs_factory()
|
||||
rows_url = pfx + url_for("orders.orders_rows")
|
||||
|
||||
|
||||
@@ -4,3 +4,6 @@ from __future__ import annotations
|
||||
|
||||
def register_domain_services() -> None:
|
||||
"""Register services for the orders app."""
|
||||
from shared.services.registry import services
|
||||
from .orders_page import OrdersPageService
|
||||
services.register("orders_page", OrdersPageService())
|
||||
|
||||
138
orders/services/orders_page.py
Normal file
138
orders/services/orders_page.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""Orders page data service — provides serialized dicts for .sx defpages."""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select, func, or_, cast, String, exists
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from shared.models.order import Order, OrderItem
|
||||
from shared.infrastructure.cart_identity import current_cart_identity
|
||||
from shared.infrastructure.urls import market_product_url
|
||||
|
||||
|
||||
class OrdersPageService:
|
||||
"""Service for orders page data, callable via (service "orders-page" ...)."""
|
||||
|
||||
async def list_page_data(self, session, *, search="", page=1):
|
||||
"""Return orders list + pagination metadata as a dict."""
|
||||
PER_PAGE = 10
|
||||
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:
|
||||
return {"orders": [], "page": 1, "total_pages": 1,
|
||||
"search": "", "search_count": 0}
|
||||
|
||||
page = max(1, int(page))
|
||||
|
||||
where = None
|
||||
if search:
|
||||
term = f"%{search.strip()}%"
|
||||
conds = [
|
||||
Order.status.ilike(term),
|
||||
Order.currency.ilike(term),
|
||||
Order.sumup_checkout_id.ilike(term),
|
||||
Order.sumup_status.ilike(term),
|
||||
Order.description.ilike(term),
|
||||
exists(
|
||||
select(1).select_from(OrderItem)
|
||||
.where(OrderItem.order_id == Order.id,
|
||||
or_(OrderItem.product_title.ilike(term),
|
||||
OrderItem.product_slug.ilike(term)))
|
||||
),
|
||||
]
|
||||
try:
|
||||
conds.append(Order.id == int(search))
|
||||
except (TypeError, ValueError):
|
||||
conds.append(cast(Order.id, String).ilike(term))
|
||||
where = or_(*conds)
|
||||
|
||||
count_q = select(func.count()).select_from(Order).where(owner)
|
||||
if where is not None:
|
||||
count_q = count_q.where(where)
|
||||
total_count = (await session.execute(count_q)).scalar_one() or 0
|
||||
total_pages = max(1, (total_count + PER_PAGE - 1) // PER_PAGE)
|
||||
if page > total_pages:
|
||||
page = total_pages
|
||||
|
||||
stmt = (select(Order).where(owner)
|
||||
.order_by(Order.created_at.desc())
|
||||
.offset((page - 1) * PER_PAGE).limit(PER_PAGE))
|
||||
if where is not None:
|
||||
stmt = stmt.where(where)
|
||||
rows = (await session.execute(stmt)).scalars().all()
|
||||
|
||||
orders = []
|
||||
for o in rows:
|
||||
orders.append({
|
||||
"id": o.id,
|
||||
"status": o.status or "pending",
|
||||
"created_at_formatted": (
|
||||
o.created_at.strftime("%-d %b %Y, %H:%M")
|
||||
if o.created_at else "\u2014"),
|
||||
"description": o.description or "",
|
||||
"currency": o.currency or "GBP",
|
||||
"total_formatted": f"{o.total_amount or 0:.2f}",
|
||||
})
|
||||
|
||||
return {
|
||||
"orders": orders,
|
||||
"page": page,
|
||||
"total_pages": total_pages,
|
||||
"search": search or "",
|
||||
"search_count": total_count,
|
||||
}
|
||||
|
||||
async def detail_page_data(self, session, *, order_id=None):
|
||||
"""Return order detail data as a dict."""
|
||||
from quart import abort
|
||||
|
||||
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 session.execute(
|
||||
select(Order).options(selectinload(Order.items))
|
||||
.where(Order.id == int(order_id), owner)
|
||||
)
|
||||
order = result.scalar_one_or_none()
|
||||
if not order:
|
||||
abort(404)
|
||||
return {}
|
||||
|
||||
items = []
|
||||
for item in (order.items or []):
|
||||
items.append({
|
||||
"product_url": market_product_url(item.product_slug),
|
||||
"product_image": item.product_image,
|
||||
"product_title": item.product_title,
|
||||
"product_id": item.product_id,
|
||||
"quantity": item.quantity,
|
||||
"currency": item.currency,
|
||||
"unit_price_formatted": f"{item.unit_price or 0:.2f}",
|
||||
})
|
||||
|
||||
return {
|
||||
"order": {
|
||||
"id": order.id,
|
||||
"status": order.status or "pending",
|
||||
"created_at_formatted": (
|
||||
order.created_at.strftime("%-d %b %Y, %H:%M")
|
||||
if order.created_at else "\u2014"),
|
||||
"description": order.description or "",
|
||||
"currency": order.currency or "GBP",
|
||||
"total_formatted": (
|
||||
f"{order.total_amount:.2f}"
|
||||
if order.total_amount else "0.00"),
|
||||
"items": items,
|
||||
},
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
"""
|
||||
Orders service s-expression page components.
|
||||
|
||||
Checkout error/return pages are still rendered from Python because they
|
||||
use ``full_page_sx()`` with custom layouts. All other order rendering
|
||||
is now handled by .sx defcomps.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from shared.sx.jinja_bridge import load_service_components
|
||||
from shared.sx.helpers import (
|
||||
call_url, render_to_sx,
|
||||
root_header_sx, full_page_sx, header_child_sx,
|
||||
)
|
||||
from shared.infrastructure.urls import market_product_url, cart_url
|
||||
|
||||
# Load orders-specific .sx components + handlers at import time
|
||||
load_service_components(os.path.dirname(os.path.dirname(__file__)),
|
||||
service_name="orders")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Checkout error
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_checkout_error_page(ctx: dict, error: str | None = None, order: Any | None = None) -> str:
|
||||
"""Full page: checkout error (sx wire format)."""
|
||||
account_url = call_url(ctx, "account_url", "")
|
||||
auth_hdr = await render_to_sx("auth-header-row", account_url=account_url)
|
||||
hdr = await root_header_sx(ctx)
|
||||
hdr = "(<> " + hdr + " " + await header_child_sx(auth_hdr) + ")"
|
||||
filt = await render_to_sx("checkout-error-header")
|
||||
|
||||
err_msg = error or "Unexpected error while creating the hosted checkout session."
|
||||
order_sx = ""
|
||||
if order:
|
||||
order_sx = await render_to_sx("checkout-error-order-id", oid=f"#{order.id}")
|
||||
from shared.sx.parser import SxExpr
|
||||
content = await render_to_sx(
|
||||
"checkout-error-content",
|
||||
msg=err_msg,
|
||||
order=SxExpr(order_sx) if order_sx else None,
|
||||
back_url=cart_url("/"),
|
||||
)
|
||||
|
||||
return await full_page_sx(ctx, header_rows=hdr, filter=filt, content=content)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Checkout return
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_checkout_return_page(ctx: dict, order: Any | None,
|
||||
status: str,
|
||||
calendar_entries: list | None = None,
|
||||
order_tickets: list | None = None) -> str:
|
||||
"""Full page: checkout return after SumUp payment (sx wire format)."""
|
||||
from shared.sx.parser import SxExpr
|
||||
|
||||
filt = await render_to_sx("checkout-return-header", status=status)
|
||||
|
||||
if not order:
|
||||
content = await render_to_sx("checkout-return-missing")
|
||||
else:
|
||||
# Serialize order data for defcomp
|
||||
order_dict = {
|
||||
"id": order.id,
|
||||
"status": order.status or "pending",
|
||||
"created_at_formatted": order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else None,
|
||||
"description": order.description,
|
||||
"currency": order.currency,
|
||||
"total_formatted": f"{order.total_amount:.2f}" if order.total_amount else None,
|
||||
"items": [
|
||||
{
|
||||
"product_url": market_product_url(item.product_slug),
|
||||
"product_image": item.product_image,
|
||||
"product_title": item.product_title,
|
||||
"product_id": item.product_id,
|
||||
"quantity": item.quantity,
|
||||
"currency": item.currency,
|
||||
"unit_price_formatted": f"{item.unit_price or 0:.2f}",
|
||||
}
|
||||
for item in (order.items or [])
|
||||
],
|
||||
}
|
||||
|
||||
summary = await render_to_sx("order-summary-card",
|
||||
order_id=order.id,
|
||||
created_at=order_dict["created_at_formatted"],
|
||||
description=order.description, status=order.status,
|
||||
currency=order.currency,
|
||||
total_amount=order_dict["total_formatted"],
|
||||
)
|
||||
|
||||
# Items
|
||||
items = ""
|
||||
if order.items:
|
||||
item_parts = []
|
||||
for item_d in order_dict["items"]:
|
||||
if item_d["product_image"]:
|
||||
img = await render_to_sx("order-item-image",
|
||||
src=item_d["product_image"],
|
||||
alt=item_d["product_title"] or "Product image")
|
||||
else:
|
||||
img = await render_to_sx("order-item-no-image")
|
||||
item_parts.append(await render_to_sx("order-item-row",
|
||||
href=item_d["product_url"], img=SxExpr(img),
|
||||
title=item_d["product_title"] or "Unknown product",
|
||||
pid=f"Product ID: {item_d['product_id']}",
|
||||
qty=f"Qty: {item_d['quantity']}",
|
||||
price=f"{item_d['currency'] or order.currency or 'GBP'} {item_d['unit_price_formatted']}",
|
||||
))
|
||||
items = await render_to_sx("order-items-panel",
|
||||
items=SxExpr("(<> " + " ".join(item_parts) + ")"))
|
||||
|
||||
# Calendar entries
|
||||
calendar = ""
|
||||
if calendar_entries:
|
||||
cal_parts = []
|
||||
for e in calendar_entries:
|
||||
st = e.state or ""
|
||||
pill = (
|
||||
"bg-emerald-100 text-emerald-800" if st == "confirmed"
|
||||
else "bg-amber-100 text-amber-800" if st == "provisional"
|
||||
else "bg-blue-100 text-blue-800" if st == "ordered"
|
||||
else "bg-stone-100 text-stone-700"
|
||||
)
|
||||
ds = e.start_at.strftime("%-d %b %Y, %H:%M") if e.start_at else ""
|
||||
if e.end_at:
|
||||
ds += f" \u2013 {e.end_at.strftime('%-d %b %Y, %H:%M')}"
|
||||
cal_parts.append(await render_to_sx("order-calendar-entry",
|
||||
name=e.name,
|
||||
pill=f"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium {pill}",
|
||||
status=st.capitalize(), date_str=ds,
|
||||
cost=f"\u00a3{e.cost or 0:.2f}",
|
||||
))
|
||||
calendar = await render_to_sx("order-calendar-section",
|
||||
items=SxExpr("(<> " + " ".join(cal_parts) + ")"))
|
||||
|
||||
# Tickets
|
||||
tickets = ""
|
||||
if order_tickets:
|
||||
tk_parts = []
|
||||
for tk in order_tickets:
|
||||
st = tk.state or ""
|
||||
pill = (
|
||||
"bg-emerald-100 text-emerald-800" if st == "confirmed"
|
||||
else "bg-amber-100 text-amber-800" if st == "reserved"
|
||||
else "bg-blue-100 text-blue-800" if st == "checked_in"
|
||||
else "bg-stone-100 text-stone-700"
|
||||
)
|
||||
pill_cls = f"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium {pill}"
|
||||
ds = tk.entry_start_at.strftime("%-d %b %Y, %H:%M") if tk.entry_start_at else ""
|
||||
if tk.entry_end_at:
|
||||
ds += f" \u2013 {tk.entry_end_at.strftime('%-d %b %Y, %H:%M')}"
|
||||
tk_parts.append(await render_to_sx("checkout-return-ticket",
|
||||
name=tk.entry_name, pill=pill_cls,
|
||||
state=st.replace("_", " ").capitalize(),
|
||||
type_name=tk.ticket_type_name or None,
|
||||
date_str=ds, code=tk.code,
|
||||
price=f"\u00a3{tk.price or 0:.2f}",
|
||||
))
|
||||
tickets = await render_to_sx("checkout-return-tickets",
|
||||
items=SxExpr("(<> " + " ".join(tk_parts) + ")"))
|
||||
|
||||
# Status message
|
||||
status_msg = ""
|
||||
if order.status == "failed":
|
||||
status_msg = await render_to_sx("checkout-return-failed", order_id=order.id)
|
||||
elif order.status == "paid":
|
||||
status_msg = await render_to_sx("checkout-return-paid")
|
||||
|
||||
content = await render_to_sx("checkout-return-content",
|
||||
summary=SxExpr(summary),
|
||||
items=SxExpr(items) if items else None,
|
||||
calendar=SxExpr(calendar) if calendar else None,
|
||||
tickets=SxExpr(tickets) if tickets else None,
|
||||
status_message=SxExpr(status_msg) if status_msg else None,
|
||||
)
|
||||
|
||||
account_url = call_url(ctx, "account_url", "")
|
||||
auth_hdr = await render_to_sx("auth-header-row", account_url=account_url)
|
||||
hdr = await root_header_sx(ctx)
|
||||
hdr = "(<> " + hdr + " " + await header_child_sx(auth_hdr) + ")"
|
||||
|
||||
return await full_page_sx(ctx, header_rows=hdr, filter=filt, content=content)
|
||||
@@ -1,13 +1,12 @@
|
||||
"""Orders defpage setup — registers layouts, page helpers, and loads .sx pages."""
|
||||
"""Orders defpage setup — registers layouts and loads .sx pages."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def setup_orders_pages() -> None:
|
||||
"""Register orders-specific layouts, page helpers, and load page definitions."""
|
||||
"""Register orders-specific layouts and load page definitions."""
|
||||
_register_orders_layouts()
|
||||
_register_orders_helpers()
|
||||
_load_orders_page_files()
|
||||
|
||||
|
||||
@@ -125,325 +124,3 @@ def _as_sx_nav(ctx: dict) -> Any:
|
||||
"""Convert account_nav fragment to SxExpr for use in component calls."""
|
||||
from shared.sx.helpers import _as_sx
|
||||
return _as_sx(ctx.get("account_nav"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Page helpers — Python functions callable from defpage expressions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _register_orders_helpers() -> None:
|
||||
from shared.sx.pages import register_page_helpers
|
||||
|
||||
register_page_helpers("orders", {
|
||||
# Orders list
|
||||
"orders-list-content": _h_orders_list_content,
|
||||
"orders-list-filter": _h_orders_list_filter,
|
||||
"orders-list-aside": _h_orders_list_aside,
|
||||
"orders-list-url": _h_orders_list_url,
|
||||
# Order detail
|
||||
"order-detail-content": _h_order_detail_content,
|
||||
"order-detail-filter": _h_order_detail_filter,
|
||||
"order-detail-url": _h_order_detail_url,
|
||||
"order-list-url-from-detail": _h_order_list_url_from_detail,
|
||||
})
|
||||
|
||||
|
||||
async def _ensure_orders_list():
|
||||
"""Fetch orders list data and store in g.orders_page_data."""
|
||||
from quart import g, url_for
|
||||
if hasattr(g, "orders_page_data"):
|
||||
return
|
||||
from sqlalchemy import select, func, or_, cast, String, exists
|
||||
from shared.models.order import Order, OrderItem
|
||||
from shared.infrastructure.cart_identity import current_cart_identity
|
||||
from shared.utils import route_prefix
|
||||
|
||||
ORDERS_PER_PAGE = 10
|
||||
ident = current_cart_identity()
|
||||
if ident["user_id"]:
|
||||
owner_clause = Order.user_id == ident["user_id"]
|
||||
elif ident["session_id"]:
|
||||
owner_clause = Order.session_id == ident["session_id"]
|
||||
else:
|
||||
g.orders_page_data = None
|
||||
return
|
||||
|
||||
from bp.orders.filters.qs import makeqs_factory, decode
|
||||
q = decode()
|
||||
page, search = q.page, q.search
|
||||
if page < 1:
|
||||
page = 1
|
||||
|
||||
where_clause = None
|
||||
if search:
|
||||
term = f"%{search.strip()}%"
|
||||
conditions = [
|
||||
Order.status.ilike(term),
|
||||
Order.currency.ilike(term),
|
||||
Order.sumup_checkout_id.ilike(term),
|
||||
Order.sumup_status.ilike(term),
|
||||
Order.description.ilike(term),
|
||||
]
|
||||
conditions.append(
|
||||
exists(
|
||||
select(1).select_from(OrderItem)
|
||||
.where(OrderItem.order_id == Order.id,
|
||||
or_(OrderItem.product_title.ilike(term),
|
||||
OrderItem.product_slug.ilike(term)))
|
||||
)
|
||||
)
|
||||
try:
|
||||
search_id = int(search)
|
||||
except (TypeError, ValueError):
|
||||
search_id = None
|
||||
if search_id is not None:
|
||||
conditions.append(Order.id == search_id)
|
||||
else:
|
||||
conditions.append(cast(Order.id, String).ilike(term))
|
||||
where_clause = or_(*conditions)
|
||||
|
||||
count_stmt = select(func.count()).select_from(Order).where(owner_clause)
|
||||
if where_clause is not None:
|
||||
count_stmt = count_stmt.where(where_clause)
|
||||
|
||||
total_count_result = await g.s.execute(count_stmt)
|
||||
total_count = total_count_result.scalar_one() or 0
|
||||
total_pages = max(1, (total_count + ORDERS_PER_PAGE - 1) // ORDERS_PER_PAGE)
|
||||
if page > total_pages:
|
||||
page = total_pages
|
||||
|
||||
offset = (page - 1) * ORDERS_PER_PAGE
|
||||
stmt = (
|
||||
select(Order).where(owner_clause)
|
||||
.order_by(Order.created_at.desc())
|
||||
.offset(offset).limit(ORDERS_PER_PAGE)
|
||||
)
|
||||
if where_clause is not None:
|
||||
stmt = stmt.where(where_clause)
|
||||
|
||||
result = await g.s.execute(stmt)
|
||||
orders = result.scalars().all()
|
||||
pfx = route_prefix()
|
||||
qs_fn = makeqs_factory()
|
||||
|
||||
g.orders_page_data = {
|
||||
"orders": orders,
|
||||
"page": page,
|
||||
"total_pages": total_pages,
|
||||
"search": search,
|
||||
"search_count": total_count,
|
||||
"url_for_fn": url_for,
|
||||
"qs_fn": qs_fn,
|
||||
"list_url": pfx + url_for("defpage_orders_list"),
|
||||
}
|
||||
|
||||
|
||||
async def _ensure_order_detail(order_id):
|
||||
"""Fetch order detail data and store in g.order_detail_data."""
|
||||
from quart import g, url_for, abort
|
||||
if hasattr(g, "order_detail_data"):
|
||||
return
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
from shared.models.order import Order
|
||||
from shared.infrastructure.cart_identity import current_cart_identity
|
||||
from shared.utils import route_prefix
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
|
||||
if order_id is None:
|
||||
abort(404)
|
||||
|
||||
ident = current_cart_identity()
|
||||
if ident["user_id"]:
|
||||
owner = Order.user_id == ident["user_id"]
|
||||
elif ident["session_id"]:
|
||||
owner = Order.session_id == ident["session_id"]
|
||||
else:
|
||||
abort(404)
|
||||
return
|
||||
|
||||
result = await g.s.execute(
|
||||
select(Order).options(selectinload(Order.items))
|
||||
.where(Order.id == order_id, owner)
|
||||
)
|
||||
order = result.scalar_one_or_none()
|
||||
if not order:
|
||||
abort(404)
|
||||
return
|
||||
|
||||
pfx = route_prefix()
|
||||
g.order_detail_data = {
|
||||
"order": order,
|
||||
"calendar_entries": None,
|
||||
"detail_url": pfx + url_for("defpage_order_detail", order_id=order.id),
|
||||
"list_url": pfx + url_for("defpage_orders_list"),
|
||||
"recheck_url": pfx + url_for("orders.order.order_recheck", order_id=order.id),
|
||||
"pay_url": pfx + url_for("orders.order.order_pay", order_id=order.id),
|
||||
"csrf_token": generate_csrf_token(),
|
||||
}
|
||||
|
||||
|
||||
async def _h_orders_list_content(**kw):
|
||||
await _ensure_orders_list()
|
||||
from quart import g
|
||||
from shared.sx.helpers import render_to_sx
|
||||
d = getattr(g, "orders_page_data", None)
|
||||
if not d:
|
||||
return await render_to_sx("order-empty-state")
|
||||
|
||||
orders = d["orders"]
|
||||
url_for_fn = d["url_for_fn"]
|
||||
pfx = d.get("list_url", "/").rsplit("/", 1)[0] if d.get("list_url") else ""
|
||||
|
||||
order_dicts = []
|
||||
for o in orders:
|
||||
order_dicts.append({
|
||||
"id": o.id,
|
||||
"status": o.status or "pending",
|
||||
"created_at_formatted": o.created_at.strftime("%-d %b %Y, %H:%M") if o.created_at else "\u2014",
|
||||
"description": o.description or "",
|
||||
"currency": o.currency or "GBP",
|
||||
"total_formatted": f"{o.total_amount or 0:.2f}",
|
||||
})
|
||||
|
||||
from shared.utils import route_prefix
|
||||
rpfx = route_prefix()
|
||||
detail_prefix = rpfx + url_for_fn("orders.defpage_order_detail", order_id=0).rsplit("0/", 1)[0]
|
||||
rows_url = rpfx + url_for_fn("orders.orders_rows")
|
||||
|
||||
return await render_to_sx("orders-list-content",
|
||||
orders=order_dicts,
|
||||
page=d["page"],
|
||||
total_pages=d["total_pages"],
|
||||
rows_url=rows_url,
|
||||
detail_url_prefix=detail_prefix)
|
||||
|
||||
|
||||
async def _h_orders_list_filter(**kw):
|
||||
await _ensure_orders_list()
|
||||
from quart import g
|
||||
from shared.sx.helpers import render_to_sx
|
||||
from shared.sx.page import SEARCH_HEADERS_MOBILE
|
||||
from shared.sx.parser import SxExpr
|
||||
d = getattr(g, "orders_page_data", None)
|
||||
search = d.get("search", "") if d else ""
|
||||
search_count = d.get("search_count", "") if d else ""
|
||||
search_mobile = await render_to_sx("search-mobile",
|
||||
current_local_href="/",
|
||||
search=search or "",
|
||||
search_count=search_count or "",
|
||||
hx_select="#main-panel",
|
||||
search_headers_mobile=SEARCH_HEADERS_MOBILE,
|
||||
)
|
||||
return await render_to_sx("order-list-header", search_mobile=SxExpr(search_mobile))
|
||||
|
||||
|
||||
async def _h_orders_list_aside(**kw):
|
||||
await _ensure_orders_list()
|
||||
from quart import g
|
||||
from shared.sx.helpers import render_to_sx
|
||||
from shared.sx.page import SEARCH_HEADERS_DESKTOP
|
||||
d = getattr(g, "orders_page_data", None)
|
||||
search = d.get("search", "") if d else ""
|
||||
search_count = d.get("search_count", "") if d else ""
|
||||
return await render_to_sx("search-desktop",
|
||||
current_local_href="/",
|
||||
search=search or "",
|
||||
search_count=search_count or "",
|
||||
hx_select="#main-panel",
|
||||
search_headers_desktop=SEARCH_HEADERS_DESKTOP,
|
||||
)
|
||||
|
||||
|
||||
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 "/"
|
||||
|
||||
|
||||
async def _h_order_detail_content(order_id=None, **kw):
|
||||
await _ensure_order_detail(order_id)
|
||||
from quart import g
|
||||
from shared.sx.helpers import render_to_sx
|
||||
from shared.infrastructure.urls import market_product_url
|
||||
d = getattr(g, "order_detail_data", None)
|
||||
if not d:
|
||||
return ""
|
||||
|
||||
order = d["order"]
|
||||
order_dict = {
|
||||
"id": order.id,
|
||||
"status": order.status or "pending",
|
||||
"created_at_formatted": order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else None,
|
||||
"description": order.description,
|
||||
"currency": order.currency,
|
||||
"total_formatted": f"{order.total_amount:.2f}" if order.total_amount else None,
|
||||
"items": [
|
||||
{
|
||||
"product_url": market_product_url(item.product_slug),
|
||||
"product_image": item.product_image,
|
||||
"product_title": item.product_title,
|
||||
"product_id": item.product_id,
|
||||
"quantity": item.quantity,
|
||||
"currency": item.currency,
|
||||
"unit_price_formatted": f"{item.unit_price or 0:.2f}",
|
||||
}
|
||||
for item in (order.items or [])
|
||||
],
|
||||
}
|
||||
|
||||
cal_entries = d["calendar_entries"]
|
||||
cal_dicts = None
|
||||
if cal_entries:
|
||||
cal_dicts = []
|
||||
for e in cal_entries:
|
||||
ds = e.start_at.strftime("%-d %b %Y, %H:%M") if e.start_at else ""
|
||||
if e.end_at:
|
||||
ds += f" \u2013 {e.end_at.strftime('%-d %b %Y, %H:%M')}"
|
||||
cal_dicts.append({
|
||||
"name": e.name,
|
||||
"state": e.state or "",
|
||||
"date_str": ds,
|
||||
"cost_formatted": f"{e.cost or 0:.2f}",
|
||||
})
|
||||
|
||||
return await render_to_sx("order-detail-content",
|
||||
order=order_dict,
|
||||
calendar_entries=cal_dicts)
|
||||
|
||||
|
||||
async def _h_order_detail_filter(order_id=None, **kw):
|
||||
await _ensure_order_detail(order_id)
|
||||
from quart import g
|
||||
from shared.sx.helpers import render_to_sx
|
||||
d = getattr(g, "order_detail_data", None)
|
||||
if not d:
|
||||
return ""
|
||||
|
||||
order = d["order"]
|
||||
order_dict = {
|
||||
"status": order.status or "pending",
|
||||
"created_at_formatted": order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else "\u2014",
|
||||
}
|
||||
|
||||
return await render_to_sx("order-detail-filter-content",
|
||||
order=order_dict,
|
||||
list_url=d["list_url"],
|
||||
recheck_url=d["recheck_url"],
|
||||
pay_url=d["pay_url"],
|
||||
csrf=d["csrf_token"])
|
||||
|
||||
|
||||
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 "/"
|
||||
|
||||
|
||||
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 "/"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
;; Orders app — declarative page definitions
|
||||
;; All data fetching via (service ...) IO primitives, no Python helpers.
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Orders list
|
||||
@@ -7,11 +8,34 @@
|
||||
(defpage orders-list
|
||||
:path "/"
|
||||
:auth :public
|
||||
:data (service "orders-page" "list-page-data"
|
||||
:search (or (request-arg "search") "")
|
||||
:page (or (request-arg "page" "1") "1"))
|
||||
:layout (:orders
|
||||
:list-url (orders-list-url))
|
||||
:filter (orders-list-filter)
|
||||
:aside (orders-list-aside)
|
||||
:content (orders-list-content))
|
||||
:list-url (str (route-prefix) (url-for "defpage_orders_list")))
|
||||
:filter (~order-list-header
|
||||
:search-mobile (~search-mobile
|
||||
:current-local-href "/"
|
||||
:search (or search "")
|
||||
:search-count (or search-count "")
|
||||
:hx-select "#main-panel"
|
||||
:search-headers-mobile "{\"X-Origin\":\"search-mobile\",\"X-Search\":\"true\"}"))
|
||||
:aside (~search-desktop
|
||||
:current-local-href "/"
|
||||
:search (or search "")
|
||||
:search-count (or search-count "")
|
||||
:hx-select "#main-panel"
|
||||
:search-headers-desktop "{\"X-Origin\":\"search-desktop\",\"X-Search\":\"true\"}")
|
||||
:content (let* ((pfx (route-prefix))
|
||||
(detail-url-raw (str pfx (url-for "defpage_order_detail" :order-id 0)))
|
||||
(detail-prefix (slice detail-url-raw 0 (- (len detail-url-raw) 2)))
|
||||
(rows-url (str pfx (url-for "orders.orders_rows"))))
|
||||
(~orders-list-content
|
||||
:orders orders
|
||||
:page page
|
||||
:total-pages total-pages
|
||||
:rows-url rows-url
|
||||
:detail-url-prefix detail-prefix)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Order detail
|
||||
@@ -20,8 +44,17 @@
|
||||
(defpage order-detail
|
||||
:path "/<int:order_id>/"
|
||||
:auth :public
|
||||
:data (service "orders-page" "detail-page-data" :order-id order-id)
|
||||
:layout (:order-detail
|
||||
: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))
|
||||
:list-url (str (route-prefix) (url-for "defpage_orders_list"))
|
||||
:detail-url (str (route-prefix) (url-for "defpage_order_detail" :order-id order-id)))
|
||||
:filter (let* ((pfx (route-prefix)))
|
||||
(~order-detail-filter-content
|
||||
:order order
|
||||
:list-url (str pfx (url-for "defpage_orders_list"))
|
||||
:recheck-url (str pfx (url-for "orders.order.order_recheck" :order-id order-id))
|
||||
:pay-url (str pfx (url-for "orders.order.order_pay" :order-id order-id))
|
||||
:csrf (csrf-token)))
|
||||
:content (~order-detail-content
|
||||
:order order
|
||||
:calendar-entries calendar-entries))
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
"""Unit tests for orders sx component helpers."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from orders.sx.sx_components import _status_pill_cls
|
||||
|
||||
|
||||
class TestStatusPillCls:
|
||||
def test_paid(self):
|
||||
result = _status_pill_cls("paid")
|
||||
assert "emerald" in result
|
||||
|
||||
def test_Paid_uppercase(self):
|
||||
result = _status_pill_cls("Paid")
|
||||
assert "emerald" in result
|
||||
|
||||
def test_failed(self):
|
||||
result = _status_pill_cls("failed")
|
||||
assert "rose" in result
|
||||
|
||||
def test_cancelled(self):
|
||||
result = _status_pill_cls("cancelled")
|
||||
assert "rose" in result
|
||||
|
||||
def test_pending(self):
|
||||
result = _status_pill_cls("pending")
|
||||
assert "stone" in result
|
||||
|
||||
def test_unknown(self):
|
||||
result = _status_pill_cls("refunded")
|
||||
assert "stone" in result
|
||||
@@ -15,6 +15,8 @@ Usage::
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from shared.contracts.protocols import (
|
||||
CalendarService,
|
||||
MarketService,
|
||||
@@ -36,6 +38,7 @@ class _ServiceRegistry:
|
||||
self._market: MarketService | None = None
|
||||
self._cart: CartService | None = None
|
||||
self._federation: FederationService | None = None
|
||||
self._extra: dict[str, Any] = {}
|
||||
|
||||
# -- calendar -------------------------------------------------------------
|
||||
@property
|
||||
@@ -81,10 +84,27 @@ class _ServiceRegistry:
|
||||
def federation(self, impl: FederationService) -> None:
|
||||
self._federation = impl
|
||||
|
||||
# -- generic registration --------------------------------------------------
|
||||
def register(self, name: str, impl: Any) -> None:
|
||||
"""Register a service by name (for services without typed properties)."""
|
||||
self._extra[name] = impl
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
# Fallback to _extra dict for dynamically registered services
|
||||
try:
|
||||
extra = object.__getattribute__(self, "_extra")
|
||||
if name in extra:
|
||||
return extra[name]
|
||||
except AttributeError:
|
||||
pass
|
||||
raise AttributeError(f"No service registered as: {name}")
|
||||
|
||||
# -- introspection --------------------------------------------------------
|
||||
def has(self, name: str) -> bool:
|
||||
"""Check whether a domain service is registered."""
|
||||
return getattr(self, f"_{name}", None) is not None
|
||||
if getattr(self, f"_{name}", None) is not None:
|
||||
return True
|
||||
return name in self._extra
|
||||
|
||||
|
||||
# Module-level singleton — import this everywhere.
|
||||
|
||||
@@ -910,6 +910,46 @@ async def async_eval_to_sx(
|
||||
return serialize(result)
|
||||
|
||||
|
||||
async def async_eval_slot_to_sx(
|
||||
expr: Any,
|
||||
env: dict[str, Any],
|
||||
ctx: RequestContext | None = None,
|
||||
) -> str:
|
||||
"""Like async_eval_to_sx but expands component calls.
|
||||
|
||||
Used by defpage slot evaluation where the content expression is
|
||||
typically a component call like ``(~dashboard-content)``. Normal
|
||||
``async_eval_to_sx`` serializes component calls without expanding;
|
||||
this variant expands one level so IO primitives in the body execute,
|
||||
then serializes the result as SX wire format.
|
||||
"""
|
||||
if ctx is None:
|
||||
ctx = RequestContext()
|
||||
# If expr is a component call, expand it through _aser
|
||||
if isinstance(expr, list) and expr:
|
||||
head = expr[0]
|
||||
if isinstance(head, Symbol) and head.name.startswith("~"):
|
||||
comp = env.get(head.name)
|
||||
if isinstance(comp, Component):
|
||||
result = await _aser_component(comp, expr[1:], env, ctx)
|
||||
if isinstance(result, SxExpr):
|
||||
return result.source
|
||||
if result is None or result is NIL:
|
||||
return ""
|
||||
return serialize(result)
|
||||
# Fall back to normal async_eval_to_sx
|
||||
result = await _aser(expr, env, ctx)
|
||||
if isinstance(result, SxExpr):
|
||||
return result.source
|
||||
if result is None or result is NIL:
|
||||
return ""
|
||||
# Page helpers return SX source strings from render_to_sx() —
|
||||
# pass through directly instead of quoting via serialize().
|
||||
if isinstance(result, str):
|
||||
return result
|
||||
return serialize(result)
|
||||
|
||||
|
||||
async def _aser(expr: Any, env: dict[str, Any], ctx: RequestContext) -> Any:
|
||||
"""Evaluate *expr*, producing SxExpr for rendering forms, raw values
|
||||
for everything else."""
|
||||
@@ -1022,6 +1062,33 @@ async def _aser_fragment(children: list, env: dict, ctx: RequestContext) -> SxEx
|
||||
return SxExpr("(<> " + " ".join(parts) + ")")
|
||||
|
||||
|
||||
async def _aser_component(
|
||||
comp: Component, args: list, env: dict, ctx: RequestContext,
|
||||
) -> Any:
|
||||
"""Expand a component body through _aser — produces SX, not HTML."""
|
||||
kwargs: dict[str, Any] = {}
|
||||
children: list[Any] = []
|
||||
i = 0
|
||||
while i < len(args):
|
||||
arg = args[i]
|
||||
if isinstance(arg, Keyword) and i + 1 < len(args):
|
||||
kwargs[arg.name] = await async_eval(args[i + 1], env, ctx)
|
||||
i += 2
|
||||
else:
|
||||
children.append(arg)
|
||||
i += 1
|
||||
local = dict(comp.closure)
|
||||
local.update(env)
|
||||
for p in comp.params:
|
||||
local[p] = kwargs.get(p, NIL)
|
||||
if comp.has_children:
|
||||
child_parts = []
|
||||
for c in children:
|
||||
child_parts.append(serialize(await _aser(c, env, ctx)))
|
||||
local["children"] = SxExpr("(<> " + " ".join(child_parts) + ")")
|
||||
return await _aser(comp.body, local, ctx)
|
||||
|
||||
|
||||
async def _aser_call(
|
||||
name: str, args: list, env: dict, ctx: RequestContext,
|
||||
) -> SxExpr:
|
||||
|
||||
@@ -77,7 +77,7 @@ def _as_sx(val: Any) -> SxExpr | None:
|
||||
if not val:
|
||||
return None
|
||||
if isinstance(val, SxExpr):
|
||||
return val
|
||||
return val if val.source else None
|
||||
html = str(val)
|
||||
escaped = html.replace("\\", "\\\\").replace('"', '\\"')
|
||||
return SxExpr(f'(~rich-text :html "{escaped}")')
|
||||
@@ -376,6 +376,9 @@ def _build_component_ast(__name: str, **kwargs: Any) -> list:
|
||||
elif isinstance(val, SxExpr):
|
||||
# SxExpr values need to be parsed into AST
|
||||
from .parser import parse
|
||||
if not val.source:
|
||||
ast.append(NIL)
|
||||
else:
|
||||
ast.append(parse(val.source))
|
||||
else:
|
||||
ast.append(val)
|
||||
|
||||
@@ -132,31 +132,14 @@ def load_page_dir(directory: str, service_name: str) -> list[PageDef]:
|
||||
# Page execution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _eval_slot(expr: Any, env: dict, ctx: Any,
|
||||
async_eval_fn: Any, async_eval_to_sx_fn: Any) -> str:
|
||||
async def _eval_slot(expr: Any, env: dict, ctx: Any) -> str:
|
||||
"""Evaluate a page slot expression and return an sx source string.
|
||||
|
||||
If the expression evaluates to a plain string (e.g. from a Python content
|
||||
builder), use it directly as sx source. If it evaluates to an AST/list,
|
||||
serialize it to sx wire format via async_eval_to_sx.
|
||||
Expands component calls (so IO in the body executes) but serializes
|
||||
the result as SX wire format, not HTML.
|
||||
"""
|
||||
from .html import _RawHTML
|
||||
from .parser import SxExpr
|
||||
# First try async_eval to get the raw value
|
||||
result = await async_eval_fn(expr, env, ctx)
|
||||
# If it's already an sx source string, use as-is
|
||||
if isinstance(result, str):
|
||||
return result
|
||||
if isinstance(result, _RawHTML):
|
||||
return result.html
|
||||
if isinstance(result, SxExpr):
|
||||
return result.source
|
||||
if result is None:
|
||||
return ""
|
||||
# For other types (lists, components rendered to HTML via _RawHTML, etc.),
|
||||
# serialize to sx wire format
|
||||
from .parser import serialize
|
||||
return serialize(result)
|
||||
from .async_eval import async_eval_slot_to_sx
|
||||
return await async_eval_slot_to_sx(expr, env, ctx)
|
||||
|
||||
|
||||
async def execute_page(
|
||||
@@ -174,7 +157,7 @@ async def execute_page(
|
||||
6. Branch: full_page_sx() vs oob_page_sx() based on is_htmx_request()
|
||||
"""
|
||||
from .jinja_bridge import get_component_env, _get_request_context
|
||||
from .async_eval import async_eval, async_eval_to_sx
|
||||
from .async_eval import async_eval
|
||||
from .page import get_template_context
|
||||
from .helpers import full_page_sx, oob_page_sx, sx_response
|
||||
from .layouts import get_layout
|
||||
@@ -201,23 +184,25 @@ async def execute_page(
|
||||
if page_def.data_expr is not None:
|
||||
data_result = await async_eval(page_def.data_expr, env, ctx)
|
||||
if isinstance(data_result, dict):
|
||||
env.update(data_result)
|
||||
# Merge with kebab-case keys so SX symbols can reference them
|
||||
for k, v in data_result.items():
|
||||
env[k.replace("_", "-")] = v
|
||||
|
||||
# Render content slot (required)
|
||||
content_sx = await _eval_slot(page_def.content_expr, env, ctx, async_eval, async_eval_to_sx)
|
||||
content_sx = await _eval_slot(page_def.content_expr, env, ctx)
|
||||
|
||||
# Render optional slots
|
||||
filter_sx = ""
|
||||
if page_def.filter_expr is not None:
|
||||
filter_sx = await _eval_slot(page_def.filter_expr, env, ctx, async_eval, async_eval_to_sx)
|
||||
filter_sx = await _eval_slot(page_def.filter_expr, env, ctx)
|
||||
|
||||
aside_sx = ""
|
||||
if page_def.aside_expr is not None:
|
||||
aside_sx = await _eval_slot(page_def.aside_expr, env, ctx, async_eval, async_eval_to_sx)
|
||||
aside_sx = await _eval_slot(page_def.aside_expr, env, ctx)
|
||||
|
||||
menu_sx = ""
|
||||
if page_def.menu_expr is not None:
|
||||
menu_sx = await _eval_slot(page_def.menu_expr, env, ctx, async_eval, async_eval_to_sx)
|
||||
menu_sx = await _eval_slot(page_def.menu_expr, env, ctx)
|
||||
|
||||
# Resolve layout → header rows + mobile menu fallback
|
||||
tctx = await get_template_context()
|
||||
|
||||
@@ -336,6 +336,17 @@ def serialize(expr: Any, indent: int = 0, pretty: bool = False) -> str:
|
||||
items.append(serialize(v, indent, pretty))
|
||||
return "{" + " ".join(items) + "}"
|
||||
|
||||
# _RawHTML — pre-rendered HTML; wrap as (raw! "...") for SX wire format
|
||||
from .html import _RawHTML
|
||||
if isinstance(expr, _RawHTML):
|
||||
escaped = (
|
||||
expr.html.replace("\\", "\\\\")
|
||||
.replace('"', '\\"')
|
||||
.replace("\n", "\\n")
|
||||
.replace("</script", "<\\/script")
|
||||
)
|
||||
return f'(raw! "{escaped}")'
|
||||
|
||||
# Catch callables (Python functions leaked into sx data)
|
||||
if callable(expr):
|
||||
import logging
|
||||
|
||||
@@ -43,6 +43,8 @@ IO_PRIMITIVES: frozenset[str] = frozenset({
|
||||
"g",
|
||||
"csrf-token",
|
||||
"abort",
|
||||
"url-for",
|
||||
"route-prefix",
|
||||
})
|
||||
|
||||
|
||||
@@ -345,6 +347,34 @@ async def _io_abort(
|
||||
abort(status, message)
|
||||
|
||||
|
||||
async def _io_url_for(
|
||||
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
|
||||
) -> str:
|
||||
"""``(url-for "endpoint" :key val ...)`` → url_for(endpoint, **kwargs).
|
||||
|
||||
Generates a URL for the given endpoint. Keyword args become URL
|
||||
parameters (kebab-case converted to snake_case).
|
||||
"""
|
||||
if not args:
|
||||
raise ValueError("url-for requires an endpoint name")
|
||||
from quart import url_for
|
||||
endpoint = str(args[0])
|
||||
clean = {k.replace("-", "_"): v for k, v in _clean_kwargs(kwargs).items()}
|
||||
# Convert numeric values for int URL params
|
||||
for k, v in clean.items():
|
||||
if isinstance(v, str) and v.isdigit():
|
||||
clean[k] = int(v)
|
||||
return url_for(endpoint, **clean)
|
||||
|
||||
|
||||
async def _io_route_prefix(
|
||||
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
|
||||
) -> str:
|
||||
"""``(route-prefix)`` → current route prefix string."""
|
||||
from shared.utils import route_prefix
|
||||
return route_prefix()
|
||||
|
||||
|
||||
_IO_HANDLERS: dict[str, Any] = {
|
||||
"frag": _io_frag,
|
||||
"query": _io_query,
|
||||
@@ -359,4 +389,6 @@ _IO_HANDLERS: dict[str, Any] = {
|
||||
"g": _io_g,
|
||||
"csrf-token": _io_csrf_token,
|
||||
"abort": _io_abort,
|
||||
"url-for": _io_url_for,
|
||||
"route-prefix": _io_route_prefix,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user