Compare commits
24 Commits
sx
...
984e2ebed0
| Author | SHA1 | Date | |
|---|---|---|---|
| 984e2ebed0 | |||
| d80894dbf5 | |||
| 8e16cc459a | |||
| 336a4ad9a1 | |||
| d6f3250a77 | |||
| 486ab834de | |||
| 41e803335a | |||
| 1f36987f77 | |||
| e53e8cc1f7 | |||
| 418ac9424f | |||
| fb8f115acb | |||
| 63b895afd8 | |||
| 50b33ab08e | |||
| bd314a0be7 | |||
| 41cdd6eab8 | |||
| 1a6503782d | |||
| 72997068c6 | |||
| dacb61b0ae | |||
| 400667b15a | |||
| 44503a7d9b | |||
| e085fe43b4 | |||
| 0554f8a113 | |||
| 4e5f9ff16c | |||
| 193578ef88 |
4
account/actions.sx
Normal file
4
account/actions.sx
Normal file
@@ -0,0 +1,4 @@
|
||||
;; Account service — inter-service action endpoints
|
||||
;;
|
||||
;; ghost-sync-member and ghost-push-member use local service imports —
|
||||
;; remain as Python fallbacks.
|
||||
@@ -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(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
|
||||
|
||||
@@ -1,63 +1,33 @@
|
||||
"""Account app action endpoints.
|
||||
|
||||
Exposes write operations at ``/internal/actions/<action_name>`` for
|
||||
cross-app callers (blog webhooks) via the internal action client.
|
||||
All actions remain as Python fallbacks (local service imports).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, g, jsonify, request
|
||||
from quart import Blueprint, g, request
|
||||
|
||||
from shared.infrastructure.actions import ACTION_HEADER
|
||||
from shared.infrastructure.query_blueprint import create_action_blueprint
|
||||
|
||||
|
||||
def register() -> Blueprint:
|
||||
bp = Blueprint("actions", __name__, url_prefix="/internal/actions")
|
||||
bp, _handlers = create_action_blueprint("account")
|
||||
|
||||
@bp.before_request
|
||||
async def _require_action_header():
|
||||
if not request.headers.get(ACTION_HEADER):
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
from shared.infrastructure.internal_auth import validate_internal_request
|
||||
if not validate_internal_request():
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
|
||||
_handlers: dict[str, object] = {}
|
||||
|
||||
@bp.post("/<action_name>")
|
||||
async def handle_action(action_name: str):
|
||||
handler = _handlers.get(action_name)
|
||||
if handler is None:
|
||||
return jsonify({"error": "unknown action"}), 404
|
||||
try:
|
||||
result = await handler()
|
||||
return jsonify(result)
|
||||
except Exception as exc:
|
||||
import logging
|
||||
logging.getLogger(__name__).exception("Action %s failed", action_name)
|
||||
return jsonify({"error": str(exc)}), 500
|
||||
|
||||
# --- ghost-sync-member ---
|
||||
async def _ghost_sync_member():
|
||||
"""Sync a single Ghost member into db_account."""
|
||||
data = await request.get_json()
|
||||
ghost_id = data.get("ghost_id")
|
||||
if not ghost_id:
|
||||
return {"error": "ghost_id required"}, 400
|
||||
|
||||
from services.ghost_membership import sync_single_member
|
||||
await sync_single_member(g.s, ghost_id)
|
||||
return {"ok": True}
|
||||
|
||||
_handlers["ghost-sync-member"] = _ghost_sync_member
|
||||
|
||||
# --- ghost-push-member ---
|
||||
async def _ghost_push_member():
|
||||
"""Push a local user's membership data to Ghost."""
|
||||
data = await request.get_json()
|
||||
user_id = data.get("user_id")
|
||||
if not user_id:
|
||||
return {"error": "user_id required"}, 400
|
||||
|
||||
from services.ghost_membership import sync_member_to_ghost
|
||||
result_id = await sync_member_to_ghost(g.s, int(user_id))
|
||||
return {"ok": True, "ghost_id": result_id}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,67 +1,14 @@
|
||||
"""Account app data endpoints.
|
||||
|
||||
Exposes read-only JSON queries at ``/internal/data/<query_name>`` for
|
||||
cross-app callers via the internal data client.
|
||||
All queries are defined in ``account/queries.sx``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, g, jsonify, request
|
||||
from quart import Blueprint
|
||||
|
||||
from shared.infrastructure.data_client import DATA_HEADER
|
||||
from sqlalchemy import select
|
||||
from shared.models import User
|
||||
from shared.infrastructure.query_blueprint import create_data_blueprint
|
||||
|
||||
|
||||
def register() -> Blueprint:
|
||||
bp = Blueprint("data", __name__, url_prefix="/internal/data")
|
||||
|
||||
@bp.before_request
|
||||
async def _require_data_header():
|
||||
if not request.headers.get(DATA_HEADER):
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
from shared.infrastructure.internal_auth import validate_internal_request
|
||||
if not validate_internal_request():
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
|
||||
_handlers: dict[str, object] = {}
|
||||
|
||||
@bp.get("/<query_name>")
|
||||
async def handle_query(query_name: str):
|
||||
handler = _handlers.get(query_name)
|
||||
if handler is None:
|
||||
return jsonify({"error": "unknown query"}), 404
|
||||
result = await handler()
|
||||
return jsonify(result)
|
||||
|
||||
# --- user-by-email ---
|
||||
async def _user_by_email():
|
||||
"""Return user_id for a given email address."""
|
||||
email = request.args.get("email", "").strip().lower()
|
||||
if not email:
|
||||
return None
|
||||
result = await g.s.execute(
|
||||
select(User.id).where(User.email.ilike(email))
|
||||
)
|
||||
row = result.first()
|
||||
if not row:
|
||||
return None
|
||||
return {"user_id": row[0]}
|
||||
|
||||
_handlers["user-by-email"] = _user_by_email
|
||||
|
||||
# --- newsletters ---
|
||||
async def _newsletters():
|
||||
"""Return all Ghost newsletters (for blog post editor)."""
|
||||
from shared.models.ghost_membership_entities import GhostNewsletter
|
||||
result = await g.s.execute(
|
||||
select(GhostNewsletter.id, GhostNewsletter.ghost_id, GhostNewsletter.name, GhostNewsletter.slug)
|
||||
.order_by(GhostNewsletter.name)
|
||||
)
|
||||
return [
|
||||
{"id": row[0], "ghost_id": row[1], "name": row[2], "slug": row[3]}
|
||||
for row in result.all()
|
||||
]
|
||||
|
||||
_handlers["newsletters"] = _newsletters
|
||||
|
||||
bp, _handlers = create_data_blueprint("account")
|
||||
return bp
|
||||
|
||||
9
account/queries.sx
Normal file
9
account/queries.sx
Normal file
@@ -0,0 +1,9 @@
|
||||
;; Account service — inter-service data queries
|
||||
|
||||
(defquery user-by-email (&key email)
|
||||
"Return user_id for a given email address."
|
||||
(service "account" "user-by-email" :email email))
|
||||
|
||||
(defquery newsletters ()
|
||||
"Return all Ghost newsletters."
|
||||
(service "account" "newsletters"))
|
||||
@@ -3,9 +3,10 @@ from __future__ import annotations
|
||||
|
||||
|
||||
def register_domain_services() -> None:
|
||||
"""Register services for the account app.
|
||||
"""Register services for the account app."""
|
||||
from shared.services.registry import services
|
||||
from .account_page import AccountPageService
|
||||
services.register("account_page", AccountPageService())
|
||||
|
||||
Account is a consumer-only dashboard app. It has no own domain.
|
||||
All cross-app data comes via fragments and HTTP data endpoints.
|
||||
"""
|
||||
pass
|
||||
from shared.services.account_impl import SqlAccountDataService
|
||||
services.register("account", SqlAccountDataService())
|
||||
|
||||
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(""),
|
||||
}
|
||||
@@ -27,3 +27,25 @@
|
||||
(h1 :class "text-2xl font-bold mb-4" "Device authorized")
|
||||
(p :class "text-stone-600" "You can close this window and return to your terminal.")))
|
||||
|
||||
;; Assembled auth page content — replaces Python _login_page_content etc.
|
||||
|
||||
(defcomp ~account-login-content (&key error email)
|
||||
(~auth-login-form
|
||||
:error (when error (~auth-error-banner :error error))
|
||||
:action (url-for "auth.start_login")
|
||||
:csrf-token (csrf-token)
|
||||
:email (or email "")))
|
||||
|
||||
(defcomp ~account-device-content (&key error code)
|
||||
(~account-device-form
|
||||
:error (when error (~account-device-error :error error))
|
||||
:action (url-for "auth.device_submit")
|
||||
:csrf-token (csrf-token)
|
||||
:code (or code "")))
|
||||
|
||||
(defcomp ~account-check-email-content (&key email email-error)
|
||||
(~auth-check-email
|
||||
:email (escape (or email ""))
|
||||
:error (when email-error
|
||||
(~auth-check-email-error :error (escape email-error)))))
|
||||
|
||||
|
||||
@@ -41,3 +41,20 @@
|
||||
name)
|
||||
logout)
|
||||
labels)))
|
||||
|
||||
;; Assembled dashboard content — replaces Python _account_main_panel_sx
|
||||
(defcomp ~account-dashboard-content (&key error)
|
||||
(let* ((user (current-user))
|
||||
(csrf (csrf-token)))
|
||||
(~account-main-panel
|
||||
:error (when error (~account-error-banner :error error))
|
||||
:email (when (get user "email")
|
||||
(~account-user-email :email (get user "email")))
|
||||
:name (when (get user "name")
|
||||
(~account-user-name :name (get user "name")))
|
||||
:logout (~account-logout-form :csrf-token csrf)
|
||||
:labels (when (not (empty? (or (get user "labels") (list))))
|
||||
(~account-labels-section
|
||||
:items (map (lambda (label)
|
||||
(~account-label-item :name (get label "name")))
|
||||
(get user "labels")))))))
|
||||
|
||||
@@ -29,3 +29,34 @@
|
||||
(div :class "bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-6"
|
||||
(h1 :class "text-xl font-semibold tracking-tight" "Newsletters")
|
||||
list)))
|
||||
|
||||
;; Assembled newsletters content — replaces Python _newsletters_panel_sx
|
||||
;; Takes pre-fetched newsletter-list from page helper
|
||||
(defcomp ~account-newsletters-content (&key newsletter-list account-url)
|
||||
(let* ((csrf (csrf-token)))
|
||||
(if (empty? newsletter-list)
|
||||
(~account-newsletter-empty)
|
||||
(~account-newsletters-panel
|
||||
:list (~account-newsletter-list
|
||||
:items (map (lambda (item)
|
||||
(let* ((nl (get item "newsletter"))
|
||||
(un (get item "un"))
|
||||
(nid (get nl "id"))
|
||||
(subscribed (get item "subscribed"))
|
||||
(toggle-url (str (or account-url "") "/newsletter/" nid "/toggle/"))
|
||||
(bg (if subscribed "bg-emerald-500" "bg-stone-300"))
|
||||
(translate (if subscribed "translate-x-6" "translate-x-1"))
|
||||
(checked (if subscribed "true" "false")))
|
||||
(~account-newsletter-item
|
||||
:name (get nl "name")
|
||||
:desc (when (get nl "description")
|
||||
(~account-newsletter-desc :description (get nl "description")))
|
||||
:toggle (~account-newsletter-toggle
|
||||
:id (str "nl-" nid)
|
||||
:url toggle-url
|
||||
:hdrs (str "{\"X-CSRFToken\": \"" csrf "\"}")
|
||||
:target (str "#nl-" nid)
|
||||
:cls (str "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 (str "inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform " translate)))))
|
||||
newsletter-list))))))
|
||||
|
||||
@@ -1,339 +0,0 @@
|
||||
"""
|
||||
Account service s-expression page components.
|
||||
|
||||
Renders account dashboard, newsletters, fragment pages, login, and device
|
||||
auth pages. Called from route handlers in place of ``render_template()``.
|
||||
"""
|
||||
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, sx_call, SxExpr,
|
||||
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")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Header helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _auth_nav_sx(ctx: dict) -> str:
|
||||
"""Auth section desktop nav items."""
|
||||
parts = [
|
||||
sx_call("nav-link",
|
||||
href=call_url(ctx, "account_url", "/newsletters/"),
|
||||
label="newsletters",
|
||||
select_colours=ctx.get("select_colours", ""),
|
||||
)
|
||||
]
|
||||
account_nav = ctx.get("account_nav")
|
||||
if account_nav:
|
||||
parts.append(account_nav)
|
||||
return "(<> " + " ".join(parts) + ")"
|
||||
|
||||
|
||||
def _auth_header_sx(ctx: dict, *, oob: bool = False) -> str:
|
||||
"""Build the account section header row."""
|
||||
return sx_call(
|
||||
"menu-row-sx",
|
||||
id="auth-row", level=1, colour="sky",
|
||||
link_href=call_url(ctx, "account_url", "/"),
|
||||
link_label="account", icon="fa-solid fa-user",
|
||||
nav=SxExpr(_auth_nav_sx(ctx)),
|
||||
child_id="auth-header-child", oob=oob,
|
||||
)
|
||||
|
||||
|
||||
def _auth_nav_mobile_sx(ctx: dict) -> str:
|
||||
"""Mobile nav menu for auth section."""
|
||||
parts = [
|
||||
sx_call("nav-link",
|
||||
href=call_url(ctx, "account_url", "/newsletters/"),
|
||||
label="newsletters",
|
||||
select_colours=ctx.get("select_colours", ""),
|
||||
)
|
||||
]
|
||||
account_nav = ctx.get("account_nav")
|
||||
if account_nav:
|
||||
parts.append(account_nav)
|
||||
return "(<> " + " ".join(parts) + ")"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Account dashboard (GET /)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _account_main_panel_sx(ctx: dict) -> str:
|
||||
"""Account info panel with user details and logout."""
|
||||
from quart import g
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
|
||||
user = getattr(g, "user", None)
|
||||
error = ctx.get("error", "")
|
||||
|
||||
error_sx = sx_call("account-error-banner", error=error) if error else ""
|
||||
|
||||
user_email_sx = ""
|
||||
user_name_sx = ""
|
||||
if user:
|
||||
user_email_sx = sx_call("account-user-email", email=user.email)
|
||||
if user.name:
|
||||
user_name_sx = sx_call("account-user-name", name=user.name)
|
||||
|
||||
logout_sx = sx_call("account-logout-form", csrf_token=generate_csrf_token())
|
||||
|
||||
labels_sx = ""
|
||||
if user and hasattr(user, "labels") and user.labels:
|
||||
label_items = " ".join(
|
||||
sx_call("account-label-item", name=label.name)
|
||||
for label in user.labels
|
||||
)
|
||||
labels_sx = sx_call("account-labels-section",
|
||||
items=SxExpr("(<> " + label_items + ")"))
|
||||
|
||||
return sx_call(
|
||||
"account-main-panel",
|
||||
error=SxExpr(error_sx) if error_sx else None,
|
||||
email=SxExpr(user_email_sx) if user_email_sx else None,
|
||||
name=SxExpr(user_name_sx) if user_name_sx else None,
|
||||
logout=SxExpr(logout_sx),
|
||||
labels=SxExpr(labels_sx) if labels_sx else None,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Newsletters (GET /newsletters/)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _newsletter_toggle_sx(un: Any, account_url_fn: Any, csrf_token: str) -> str:
|
||||
"""Render a single newsletter toggle switch."""
|
||||
nid = un.newsletter_id
|
||||
toggle_url = account_url_fn(f"/newsletter/{nid}/toggle/")
|
||||
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 sx_call(
|
||||
"account-newsletter-toggle",
|
||||
id=f"nl-{nid}", url=toggle_url,
|
||||
hdrs=f'{{"X-CSRFToken": "{csrf_token}"}}',
|
||||
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}",
|
||||
)
|
||||
|
||||
|
||||
def _newsletter_toggle_off_sx(nid: int, toggle_url: str, csrf_token: str) -> str:
|
||||
"""Render an unsubscribed newsletter toggle (no subscription record yet)."""
|
||||
return sx_call(
|
||||
"account-newsletter-toggle",
|
||||
id=f"nl-{nid}", url=toggle_url,
|
||||
hdrs=f'{{"X-CSRFToken": "{csrf_token}"}}',
|
||||
target=f"#nl-{nid}",
|
||||
cls="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-stone-300",
|
||||
checked="false",
|
||||
knob_cls="inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform translate-x-1",
|
||||
)
|
||||
|
||||
|
||||
def _newsletters_panel_sx(ctx: dict, newsletter_list: list) -> str:
|
||||
"""Newsletters management panel."""
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
|
||||
account_url_fn = ctx.get("account_url") or (lambda p: p)
|
||||
csrf = generate_csrf_token()
|
||||
|
||||
if newsletter_list:
|
||||
items = []
|
||||
for item in newsletter_list:
|
||||
nl = item["newsletter"]
|
||||
un = item.get("un")
|
||||
|
||||
desc_sx = sx_call(
|
||||
"account-newsletter-desc", description=nl.description
|
||||
) if nl.description else ""
|
||||
|
||||
if un:
|
||||
toggle = _newsletter_toggle_sx(un, account_url_fn, csrf)
|
||||
else:
|
||||
toggle_url = account_url_fn(f"/newsletter/{nl.id}/toggle/")
|
||||
toggle = _newsletter_toggle_off_sx(nl.id, toggle_url, csrf)
|
||||
|
||||
items.append(sx_call(
|
||||
"account-newsletter-item",
|
||||
name=nl.name,
|
||||
desc=SxExpr(desc_sx) if desc_sx else None,
|
||||
toggle=SxExpr(toggle),
|
||||
))
|
||||
list_sx = sx_call(
|
||||
"account-newsletter-list",
|
||||
items=SxExpr("(<> " + " ".join(items) + ")"),
|
||||
)
|
||||
else:
|
||||
list_sx = sx_call("account-newsletter-empty")
|
||||
|
||||
return sx_call("account-newsletters-panel", list=SxExpr(list_sx))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth pages (login, device, check_email)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _login_page_content(ctx: dict) -> str:
|
||||
"""Login form content."""
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from quart import url_for
|
||||
|
||||
error = ctx.get("error", "")
|
||||
email = ctx.get("email", "")
|
||||
action = url_for("auth.start_login")
|
||||
|
||||
error_sx = sx_call("auth-error-banner", error=error) if error else ""
|
||||
|
||||
return sx_call(
|
||||
"auth-login-form",
|
||||
error=SxExpr(error_sx) if error_sx else None,
|
||||
action=action,
|
||||
csrf_token=generate_csrf_token(), email=email,
|
||||
)
|
||||
|
||||
|
||||
def _device_page_content(ctx: dict) -> str:
|
||||
"""Device authorization form content."""
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from quart import url_for
|
||||
|
||||
error = ctx.get("error", "")
|
||||
code = ctx.get("code", "")
|
||||
action = url_for("auth.device_submit")
|
||||
|
||||
error_sx = sx_call("account-device-error", error=error) if error else ""
|
||||
|
||||
return sx_call(
|
||||
"account-device-form",
|
||||
error=SxExpr(error_sx) if error_sx else None,
|
||||
action=action,
|
||||
csrf_token=generate_csrf_token(), code=code,
|
||||
)
|
||||
|
||||
|
||||
def _device_approved_content() -> str:
|
||||
"""Device approved success content."""
|
||||
return sx_call("account-device-approved")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Account dashboard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _fragment_content(frag: object) -> str:
|
||||
"""Convert a fragment response to sx content string.
|
||||
|
||||
SxExpr (from text/sx responses) is embedded as-is; plain strings
|
||||
(from text/html) are wrapped in ``~rich-text``.
|
||||
"""
|
||||
from shared.sx.parser import SxExpr
|
||||
if isinstance(frag, SxExpr):
|
||||
return frag.source
|
||||
s = str(frag) if frag else ""
|
||||
if not s:
|
||||
return ""
|
||||
return f'(~rich-text :html "{_sx_escape(s)}")'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Auth pages (login, device)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_login_page(ctx: dict) -> str:
|
||||
"""Full page: login form."""
|
||||
hdr = root_header_sx(ctx)
|
||||
return full_page_sx(ctx, header_rows=hdr,
|
||||
content=_login_page_content(ctx),
|
||||
meta_html='<title>Login \u2014 Rose Ash</title>')
|
||||
|
||||
|
||||
async def render_device_page(ctx: dict) -> str:
|
||||
"""Full page: device authorization form."""
|
||||
hdr = root_header_sx(ctx)
|
||||
return full_page_sx(ctx, header_rows=hdr,
|
||||
content=_device_page_content(ctx),
|
||||
meta_html='<title>Authorize Device \u2014 Rose Ash</title>')
|
||||
|
||||
|
||||
async def render_device_approved_page(ctx: dict) -> str:
|
||||
"""Full page: device approved."""
|
||||
hdr = root_header_sx(ctx)
|
||||
return full_page_sx(ctx, header_rows=hdr,
|
||||
content=_device_approved_content(),
|
||||
meta_html='<title>Device Authorized \u2014 Rose Ash</title>')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Check email page (POST /start/ success)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _check_email_content(email: str, email_error: str | None = None) -> str:
|
||||
"""Check email confirmation content."""
|
||||
from markupsafe import escape
|
||||
|
||||
error_sx = sx_call(
|
||||
"auth-check-email-error", error=str(escape(email_error))
|
||||
) if email_error else ""
|
||||
|
||||
return sx_call(
|
||||
"auth-check-email",
|
||||
email=str(escape(email)),
|
||||
error=SxExpr(error_sx) if error_sx else None,
|
||||
)
|
||||
|
||||
|
||||
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 = root_header_sx(ctx)
|
||||
return full_page_sx(ctx, header_rows=hdr,
|
||||
content=_check_email_content(email, email_error),
|
||||
meta_html='<title>Check your email \u2014 Rose Ash</title>')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Fragment renderers for POST handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def render_newsletter_toggle(un) -> str:
|
||||
"""Render a newsletter toggle switch for POST response (uses account_url)."""
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
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
|
||||
return _newsletter_toggle_sx(un, account_url_fn, generate_csrf_token())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _sx_escape(s: str) -> str:
|
||||
"""Escape a string for embedding in sx string literals."""
|
||||
return s.replace("\\", "\\\\").replace('"', '\\"')
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -26,30 +25,52 @@ def _register_account_layouts() -> None:
|
||||
register_custom_layout("account", _account_full, _account_oob, _account_mobile)
|
||||
|
||||
|
||||
def _account_full(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import root_header_sx, header_child_sx
|
||||
from sx.sx_components import _auth_header_sx
|
||||
async def _account_full(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import root_header_sx, header_child_sx, render_to_sx
|
||||
|
||||
root_hdr = root_header_sx(ctx)
|
||||
hdr_child = header_child_sx(_auth_header_sx(ctx))
|
||||
root_hdr = await root_header_sx(ctx)
|
||||
auth_hdr = await render_to_sx("auth-header-row",
|
||||
account_url=_call_url(ctx, "account_url", ""),
|
||||
select_colours=ctx.get("select_colours", ""),
|
||||
account_nav=_as_sx_nav(ctx),
|
||||
)
|
||||
hdr_child = await header_child_sx(auth_hdr)
|
||||
return "(<> " + root_hdr + " " + hdr_child + ")"
|
||||
|
||||
|
||||
def _account_oob(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import root_header_sx
|
||||
from sx.sx_components import _auth_header_sx
|
||||
async def _account_oob(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import root_header_sx, render_to_sx
|
||||
|
||||
return "(<> " + _auth_header_sx(ctx, oob=True) + " " + root_header_sx(ctx, oob=True) + ")"
|
||||
auth_hdr = await render_to_sx("auth-header-row",
|
||||
account_url=_call_url(ctx, "account_url", ""),
|
||||
select_colours=ctx.get("select_colours", ""),
|
||||
account_nav=_as_sx_nav(ctx),
|
||||
oob=True,
|
||||
)
|
||||
return "(<> " + auth_hdr + " " + await root_header_sx(ctx, oob=True) + ")"
|
||||
|
||||
|
||||
def _account_mobile(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import mobile_menu_sx, mobile_root_nav_sx, sx_call, SxExpr
|
||||
from sx.sx_components import _auth_nav_mobile_sx
|
||||
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)
|
||||
auth_section = sx_call("mobile-menu-section",
|
||||
nav_items = await render_to_sx("auth-nav-items",
|
||||
account_url=_call_url(ctx, "account_url", ""),
|
||||
select_colours=ctx.get("select_colours", ""),
|
||||
account_nav=_as_sx_nav(ctx),
|
||||
)
|
||||
auth_section = await render_to_sx("mobile-menu-section",
|
||||
label="account", href="/", level=1, colour="sky",
|
||||
items=SxExpr(_auth_nav_mobile_sx(ctx)))
|
||||
return mobile_menu_sx(auth_section, mobile_root_nav_sx(ctx))
|
||||
items=SxExpr(nav_items))
|
||||
return mobile_menu_sx(auth_section, await mobile_root_nav_sx(ctx))
|
||||
|
||||
|
||||
def _call_url(ctx: dict, key: str, path: str = "/") -> str:
|
||||
fn = ctx.get(key)
|
||||
if callable(fn):
|
||||
return fn(path)
|
||||
return str(fn or "") + path
|
||||
|
||||
|
||||
def _inject_account_nav(ctx: dict) -> dict:
|
||||
@@ -61,74 +82,10 @@ def _inject_account_nav(ctx: dict) -> dict:
|
||||
return ctx
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Page helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _register_account_helpers() -> None:
|
||||
from shared.sx.pages import register_page_helpers
|
||||
|
||||
register_page_helpers("account", {
|
||||
"account-content": _h_account_content,
|
||||
"newsletters-content": _h_newsletters_content,
|
||||
"fragment-content": _h_fragment_content,
|
||||
})
|
||||
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
|
||||
ctx = _inject_account_nav(ctx)
|
||||
return _as_sx(ctx.get("account_nav"))
|
||||
|
||||
|
||||
def _h_account_content(**kw):
|
||||
from sx.sx_components import _account_main_panel_sx
|
||||
return _account_main_panel_sx({})
|
||||
|
||||
|
||||
async def _h_newsletters_content(**kw):
|
||||
from quart import g
|
||||
from sqlalchemy import select
|
||||
from shared.models import UserNewsletter
|
||||
from shared.models.ghost_membership_entities import GhostNewsletter
|
||||
|
||||
result = await g.s.execute(
|
||||
select(GhostNewsletter).order_by(GhostNewsletter.name)
|
||||
)
|
||||
all_newsletters = result.scalars().all()
|
||||
|
||||
sub_result = await g.s.execute(
|
||||
select(UserNewsletter).where(
|
||||
UserNewsletter.user_id == g.user.id,
|
||||
)
|
||||
)
|
||||
user_subs = {un.newsletter_id: un for un in sub_result.scalars().all()}
|
||||
|
||||
newsletter_list = []
|
||||
for nl in all_newsletters:
|
||||
un = user_subs.get(nl.id)
|
||||
newsletter_list.append({
|
||||
"newsletter": nl,
|
||||
"un": un,
|
||||
"subscribed": un.subscribed if un else False,
|
||||
})
|
||||
|
||||
if not newsletter_list:
|
||||
from shared.sx.helpers import sx_call
|
||||
return sx_call("account-newsletter-empty")
|
||||
from sx.sx_components import _newsletters_panel_sx
|
||||
ctx = {"account_url": getattr(g, "_account_url", None)}
|
||||
if ctx["account_url"] is None:
|
||||
from shared.infrastructure.urls import account_url
|
||||
ctx["account_url"] = account_url
|
||||
return _newsletters_panel_sx(ctx, newsletter_list)
|
||||
|
||||
|
||||
async def _h_fragment_content(slug=None, **kw):
|
||||
from quart import g, abort
|
||||
from shared.infrastructure.fragments import fetch_fragment
|
||||
|
||||
if not slug or not g.get("user"):
|
||||
return ""
|
||||
fragment_html = await fetch_fragment(
|
||||
"events", "account-page",
|
||||
params={"slug": slug, "user_id": str(g.user.id)},
|
||||
)
|
||||
if not fragment_html:
|
||||
abort(404)
|
||||
from sx.sx_components import _fragment_content
|
||||
return _fragment_content(fragment_html)
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
:path "/"
|
||||
:auth :login
|
||||
:layout :account
|
||||
:content (account-content))
|
||||
:content (~account-dashboard-content))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Newsletters
|
||||
@@ -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)))
|
||||
|
||||
12
blog/actions.sx
Normal file
12
blog/actions.sx
Normal file
@@ -0,0 +1,12 @@
|
||||
;; Blog service — inter-service action endpoints
|
||||
|
||||
(defaction update-page-config (&key container-type container-id
|
||||
features sumup-merchant-code
|
||||
sumup-checkout-prefix sumup-api-key)
|
||||
"Create or update a PageConfig with features and SumUp settings."
|
||||
(service "page-config" "update"
|
||||
:container-type container-type :container-id container-id
|
||||
:features features
|
||||
:sumup-merchant-code sumup-merchant-code
|
||||
:sumup-checkout-prefix sumup-checkout-prefix
|
||||
:sumup-api-key sumup-api-key))
|
||||
@@ -1,96 +1,14 @@
|
||||
"""Blog app action endpoints.
|
||||
|
||||
Exposes write operations at ``/internal/actions/<action_name>`` for
|
||||
cross-app callers via the internal action client.
|
||||
All actions are defined in ``blog/actions.sx``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, g, jsonify, request
|
||||
from quart import Blueprint
|
||||
|
||||
from shared.infrastructure.actions import ACTION_HEADER
|
||||
from shared.infrastructure.query_blueprint import create_action_blueprint
|
||||
|
||||
|
||||
def register() -> Blueprint:
|
||||
bp = Blueprint("actions", __name__, url_prefix="/internal/actions")
|
||||
|
||||
@bp.before_request
|
||||
async def _require_action_header():
|
||||
if not request.headers.get(ACTION_HEADER):
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
from shared.infrastructure.internal_auth import validate_internal_request
|
||||
if not validate_internal_request():
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
|
||||
_handlers: dict[str, object] = {}
|
||||
|
||||
@bp.post("/<action_name>")
|
||||
async def handle_action(action_name: str):
|
||||
handler = _handlers.get(action_name)
|
||||
if handler is None:
|
||||
return jsonify({"error": "unknown action"}), 404
|
||||
result = await handler()
|
||||
return jsonify(result or {"ok": True})
|
||||
|
||||
# --- update-page-config ---
|
||||
async def _update_page_config():
|
||||
"""Create or update a PageConfig (page_configs now lives in db_blog)."""
|
||||
from shared.models.page_config import PageConfig
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
data = await request.get_json(force=True)
|
||||
container_type = data.get("container_type", "page")
|
||||
container_id = data.get("container_id")
|
||||
if container_id is None:
|
||||
return {"error": "container_id required"}, 400
|
||||
|
||||
pc = (await g.s.execute(
|
||||
select(PageConfig).where(
|
||||
PageConfig.container_type == container_type,
|
||||
PageConfig.container_id == container_id,
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
if pc is None:
|
||||
pc = PageConfig(
|
||||
container_type=container_type,
|
||||
container_id=container_id,
|
||||
features=data.get("features", {}),
|
||||
)
|
||||
g.s.add(pc)
|
||||
await g.s.flush()
|
||||
|
||||
if "features" in data:
|
||||
features = dict(pc.features or {})
|
||||
for key, val in data["features"].items():
|
||||
if isinstance(val, bool):
|
||||
features[key] = val
|
||||
elif val in ("true", "1", "on"):
|
||||
features[key] = True
|
||||
elif val in ("false", "0", "off", None):
|
||||
features[key] = False
|
||||
pc.features = features
|
||||
flag_modified(pc, "features")
|
||||
|
||||
if "sumup_merchant_code" in data:
|
||||
pc.sumup_merchant_code = data["sumup_merchant_code"] or None
|
||||
if "sumup_checkout_prefix" in data:
|
||||
pc.sumup_checkout_prefix = data["sumup_checkout_prefix"] or None
|
||||
if "sumup_api_key" in data:
|
||||
pc.sumup_api_key = data["sumup_api_key"] or None
|
||||
|
||||
await g.s.flush()
|
||||
|
||||
return {
|
||||
"id": pc.id,
|
||||
"container_type": pc.container_type,
|
||||
"container_id": pc.container_id,
|
||||
"features": pc.features or {},
|
||||
"sumup_merchant_code": pc.sumup_merchant_code,
|
||||
"sumup_checkout_prefix": pc.sumup_checkout_prefix,
|
||||
"sumup_configured": bool(pc.sumup_api_key),
|
||||
}
|
||||
|
||||
_handlers["update-page-config"] = _update_page_config
|
||||
|
||||
bp, _handlers = create_action_blueprint("blog")
|
||||
return bp
|
||||
|
||||
@@ -235,7 +235,7 @@ def register(url_prefix, title):
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_new_post_page, render_editor_panel
|
||||
tctx = await get_template_context()
|
||||
tctx["editor_html"] = render_editor_panel(save_error="Invalid JSON in editor content.")
|
||||
tctx["editor_html"] = await render_editor_panel(save_error="Invalid JSON in editor content.")
|
||||
html = await render_new_post_page(tctx)
|
||||
return await make_response(html, 400)
|
||||
|
||||
@@ -244,7 +244,7 @@ def register(url_prefix, title):
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_new_post_page, render_editor_panel
|
||||
tctx = await get_template_context()
|
||||
tctx["editor_html"] = render_editor_panel(save_error=reason)
|
||||
tctx["editor_html"] = await render_editor_panel(save_error=reason)
|
||||
html = await render_new_post_page(tctx)
|
||||
return await make_response(html, 400)
|
||||
|
||||
@@ -291,7 +291,7 @@ def register(url_prefix, title):
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_new_post_page, render_editor_panel
|
||||
tctx = await get_template_context()
|
||||
tctx["editor_html"] = render_editor_panel(save_error="Invalid JSON in editor content.", is_page=True)
|
||||
tctx["editor_html"] = await render_editor_panel(save_error="Invalid JSON in editor content.", is_page=True)
|
||||
tctx["is_page"] = True
|
||||
html = await render_new_post_page(tctx)
|
||||
return await make_response(html, 400)
|
||||
@@ -301,7 +301,7 @@ def register(url_prefix, title):
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_new_post_page, render_editor_panel
|
||||
tctx = await get_template_context()
|
||||
tctx["editor_html"] = render_editor_panel(save_error=reason, is_page=True)
|
||||
tctx["editor_html"] = await render_editor_panel(save_error=reason, is_page=True)
|
||||
tctx["is_page"] = True
|
||||
html = await render_new_post_page(tctx)
|
||||
return await make_response(html, 400)
|
||||
|
||||
@@ -1,185 +1,14 @@
|
||||
"""Blog app data endpoints.
|
||||
|
||||
Exposes read-only JSON queries at ``/internal/data/<query_name>`` for
|
||||
cross-app callers via the internal data client.
|
||||
All queries are defined in ``blog/queries.sx``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, g, jsonify, request
|
||||
from quart import Blueprint
|
||||
|
||||
from shared.infrastructure.data_client import DATA_HEADER
|
||||
from shared.contracts.dtos import dto_to_dict
|
||||
from services import blog_service
|
||||
from shared.infrastructure.query_blueprint import create_data_blueprint
|
||||
|
||||
|
||||
def register() -> Blueprint:
|
||||
bp = Blueprint("data", __name__, url_prefix="/internal/data")
|
||||
|
||||
@bp.before_request
|
||||
async def _require_data_header():
|
||||
if not request.headers.get(DATA_HEADER):
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
from shared.infrastructure.internal_auth import validate_internal_request
|
||||
if not validate_internal_request():
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
|
||||
_handlers: dict[str, object] = {}
|
||||
|
||||
@bp.get("/<query_name>")
|
||||
async def handle_query(query_name: str):
|
||||
handler = _handlers.get(query_name)
|
||||
if handler is None:
|
||||
return jsonify({"error": "unknown query"}), 404
|
||||
result = await handler()
|
||||
return jsonify(result)
|
||||
|
||||
# --- post-by-slug ---
|
||||
async def _post_by_slug():
|
||||
slug = request.args.get("slug", "")
|
||||
post = await blog_service.get_post_by_slug(g.s, slug)
|
||||
if not post:
|
||||
return None
|
||||
return dto_to_dict(post)
|
||||
|
||||
_handlers["post-by-slug"] = _post_by_slug
|
||||
|
||||
# --- post-by-id ---
|
||||
async def _post_by_id():
|
||||
post_id = int(request.args.get("id", 0))
|
||||
post = await blog_service.get_post_by_id(g.s, post_id)
|
||||
if not post:
|
||||
return None
|
||||
return dto_to_dict(post)
|
||||
|
||||
_handlers["post-by-id"] = _post_by_id
|
||||
|
||||
# --- posts-by-ids ---
|
||||
async def _posts_by_ids():
|
||||
ids_raw = request.args.get("ids", "")
|
||||
if not ids_raw:
|
||||
return []
|
||||
ids = [int(x.strip()) for x in ids_raw.split(",") if x.strip()]
|
||||
posts = await blog_service.get_posts_by_ids(g.s, ids)
|
||||
return [dto_to_dict(p) for p in posts]
|
||||
|
||||
_handlers["posts-by-ids"] = _posts_by_ids
|
||||
|
||||
# --- search-posts ---
|
||||
async def _search_posts():
|
||||
query = request.args.get("query", "")
|
||||
page = int(request.args.get("page", 1))
|
||||
per_page = int(request.args.get("per_page", 10))
|
||||
posts, total = await blog_service.search_posts(g.s, query, page, per_page)
|
||||
return {"posts": [dto_to_dict(p) for p in posts], "total": total}
|
||||
|
||||
_handlers["search-posts"] = _search_posts
|
||||
|
||||
# --- page-config-ensure ---
|
||||
async def _page_config_ensure():
|
||||
"""Get or create a PageConfig for a container_type + container_id."""
|
||||
from sqlalchemy import select
|
||||
from shared.models.page_config import PageConfig
|
||||
|
||||
container_type = request.args.get("container_type", "page")
|
||||
container_id = request.args.get("container_id", type=int)
|
||||
if container_id is None:
|
||||
return {"error": "container_id required"}, 400
|
||||
|
||||
row = (await g.s.execute(
|
||||
select(PageConfig).where(
|
||||
PageConfig.container_type == container_type,
|
||||
PageConfig.container_id == container_id,
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
if row is None:
|
||||
row = PageConfig(
|
||||
container_type=container_type,
|
||||
container_id=container_id,
|
||||
features={},
|
||||
)
|
||||
g.s.add(row)
|
||||
await g.s.flush()
|
||||
|
||||
return {
|
||||
"id": row.id,
|
||||
"container_type": row.container_type,
|
||||
"container_id": row.container_id,
|
||||
}
|
||||
|
||||
_handlers["page-config-ensure"] = _page_config_ensure
|
||||
|
||||
# --- page-config ---
|
||||
async def _page_config():
|
||||
"""Return a single PageConfig by container_type + container_id."""
|
||||
from sqlalchemy import select
|
||||
from shared.models.page_config import PageConfig
|
||||
|
||||
ct = request.args.get("container_type", "page")
|
||||
cid = request.args.get("container_id", type=int)
|
||||
if cid is None:
|
||||
return None
|
||||
pc = (await g.s.execute(
|
||||
select(PageConfig).where(
|
||||
PageConfig.container_type == ct,
|
||||
PageConfig.container_id == cid,
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
if not pc:
|
||||
return None
|
||||
return _page_config_dict(pc)
|
||||
|
||||
_handlers["page-config"] = _page_config
|
||||
|
||||
# --- page-config-by-id ---
|
||||
async def _page_config_by_id():
|
||||
"""Return a single PageConfig by its primary key."""
|
||||
from shared.models.page_config import PageConfig
|
||||
|
||||
pc_id = request.args.get("id", type=int)
|
||||
if pc_id is None:
|
||||
return None
|
||||
pc = await g.s.get(PageConfig, pc_id)
|
||||
if not pc:
|
||||
return None
|
||||
return _page_config_dict(pc)
|
||||
|
||||
_handlers["page-config-by-id"] = _page_config_by_id
|
||||
|
||||
# --- page-configs-batch ---
|
||||
async def _page_configs_batch():
|
||||
"""Return PageConfigs for multiple container_ids (comma-separated)."""
|
||||
from sqlalchemy import select
|
||||
from shared.models.page_config import PageConfig
|
||||
|
||||
ct = request.args.get("container_type", "page")
|
||||
ids_raw = request.args.get("ids", "")
|
||||
if not ids_raw:
|
||||
return []
|
||||
ids = [int(x.strip()) for x in ids_raw.split(",") if x.strip()]
|
||||
if not ids:
|
||||
return []
|
||||
result = await g.s.execute(
|
||||
select(PageConfig).where(
|
||||
PageConfig.container_type == ct,
|
||||
PageConfig.container_id.in_(ids),
|
||||
)
|
||||
)
|
||||
return [_page_config_dict(pc) for pc in result.scalars().all()]
|
||||
|
||||
_handlers["page-configs-batch"] = _page_configs_batch
|
||||
|
||||
bp, _handlers = create_data_blueprint("blog")
|
||||
return bp
|
||||
|
||||
|
||||
def _page_config_dict(pc) -> dict:
|
||||
"""Serialize PageConfig to a JSON-safe dict."""
|
||||
return {
|
||||
"id": pc.id,
|
||||
"container_type": pc.container_type,
|
||||
"container_id": pc.container_id,
|
||||
"features": pc.features or {},
|
||||
"sumup_merchant_code": pc.sumup_merchant_code,
|
||||
"sumup_api_key": pc.sumup_api_key,
|
||||
"sumup_checkout_prefix": pc.sumup_checkout_prefix,
|
||||
}
|
||||
|
||||
@@ -17,10 +17,10 @@ from shared.sx.helpers import sx_response
|
||||
def register():
|
||||
bp = Blueprint("menu_items", __name__, url_prefix='/settings/menu_items')
|
||||
|
||||
def get_menu_items_nav_oob_sync(menu_items):
|
||||
async def get_menu_items_nav_oob_async(menu_items):
|
||||
"""Helper to generate OOB update for root nav menu items"""
|
||||
from sx.sx_components import render_menu_items_nav_oob
|
||||
return render_menu_items_nav_oob(menu_items)
|
||||
return await render_menu_items_nav_oob(menu_items)
|
||||
|
||||
@bp.get("/new/")
|
||||
@require_admin
|
||||
@@ -51,8 +51,8 @@ def register():
|
||||
# Get updated list and nav OOB
|
||||
menu_items = await get_all_menu_items(g.s)
|
||||
from sx.sx_components import render_menu_items_list
|
||||
html = render_menu_items_list(menu_items)
|
||||
nav_oob = get_menu_items_nav_oob_sync(menu_items)
|
||||
html = await render_menu_items_list(menu_items)
|
||||
nav_oob = await get_menu_items_nav_oob_async(menu_items)
|
||||
return sx_response(html + nav_oob)
|
||||
|
||||
except MenuItemError as e:
|
||||
@@ -91,8 +91,8 @@ def register():
|
||||
# Get updated list and nav OOB
|
||||
menu_items = await get_all_menu_items(g.s)
|
||||
from sx.sx_components import render_menu_items_list
|
||||
html = render_menu_items_list(menu_items)
|
||||
nav_oob = get_menu_items_nav_oob_sync(menu_items)
|
||||
html = await render_menu_items_list(menu_items)
|
||||
nav_oob = await get_menu_items_nav_oob_async(menu_items)
|
||||
return sx_response(html + nav_oob)
|
||||
|
||||
except MenuItemError as e:
|
||||
@@ -112,8 +112,8 @@ def register():
|
||||
# Get updated list and nav OOB
|
||||
menu_items = await get_all_menu_items(g.s)
|
||||
from sx.sx_components import render_menu_items_list
|
||||
html = render_menu_items_list(menu_items)
|
||||
nav_oob = get_menu_items_nav_oob_sync(menu_items)
|
||||
html = await render_menu_items_list(menu_items)
|
||||
nav_oob = await get_menu_items_nav_oob_async(menu_items)
|
||||
return sx_response(html + nav_oob)
|
||||
|
||||
@bp.get("/pages/search/")
|
||||
@@ -128,7 +128,7 @@ def register():
|
||||
has_more = (page * per_page) < total
|
||||
|
||||
from sx.sx_components import render_page_search_results
|
||||
return sx_response(render_page_search_results(pages, query, page, has_more))
|
||||
return sx_response(await render_page_search_results(pages, query, page, has_more))
|
||||
|
||||
@bp.post("/reorder/")
|
||||
@require_admin
|
||||
@@ -153,8 +153,8 @@ def register():
|
||||
# Get updated list and nav OOB
|
||||
menu_items = await get_all_menu_items(g.s)
|
||||
from sx.sx_components import render_menu_items_list
|
||||
html = render_menu_items_list(menu_items)
|
||||
nav_oob = get_menu_items_nav_oob_sync(menu_items)
|
||||
html = await render_menu_items_list(menu_items)
|
||||
nav_oob = await get_menu_items_nav_oob_async(menu_items)
|
||||
return sx_response(html + nav_oob)
|
||||
|
||||
return bp
|
||||
|
||||
@@ -90,7 +90,7 @@ def register():
|
||||
features = result.get("features", {})
|
||||
|
||||
from sx.sx_components import render_features_panel
|
||||
html = render_features_panel(
|
||||
html = await render_features_panel(
|
||||
features, post,
|
||||
sumup_configured=result.get("sumup_configured", False),
|
||||
sumup_merchant_code=result.get("sumup_merchant_code") or "",
|
||||
@@ -129,7 +129,7 @@ def register():
|
||||
|
||||
features = result.get("features", {})
|
||||
from sx.sx_components import render_features_panel
|
||||
html = render_features_panel(
|
||||
html = await render_features_panel(
|
||||
features, post,
|
||||
sumup_configured=result.get("sumup_configured", False),
|
||||
sumup_merchant_code=result.get("sumup_merchant_code") or "",
|
||||
@@ -259,8 +259,8 @@ def register():
|
||||
from sx.sx_components import render_associated_entries, render_nav_entries_oob
|
||||
|
||||
post = g.post_data["post"]
|
||||
admin_list = render_associated_entries(all_calendars, associated_entry_ids, post["slug"])
|
||||
nav_entries_html = render_nav_entries_oob(associated_entries, calendars, post)
|
||||
admin_list = await render_associated_entries(all_calendars, associated_entry_ids, post["slug"])
|
||||
nav_entries_html = await render_nav_entries_oob(associated_entries, calendars, post)
|
||||
|
||||
return sx_response(admin_list + nav_entries_html)
|
||||
|
||||
@@ -436,7 +436,7 @@ def register():
|
||||
page_markets = await _fetch_page_markets(post_id)
|
||||
|
||||
from sx.sx_components import render_markets_panel
|
||||
return sx_response(render_markets_panel(page_markets, post))
|
||||
return sx_response(await render_markets_panel(page_markets, post))
|
||||
|
||||
@bp.post("/markets/new/")
|
||||
@require_admin
|
||||
@@ -462,7 +462,7 @@ def register():
|
||||
page_markets = await _fetch_page_markets(post_id)
|
||||
|
||||
from sx.sx_components import render_markets_panel
|
||||
return sx_response(render_markets_panel(page_markets, post))
|
||||
return sx_response(await render_markets_panel(page_markets, post))
|
||||
|
||||
@bp.delete("/markets/<market_slug>/")
|
||||
@require_admin
|
||||
@@ -482,6 +482,6 @@ def register():
|
||||
page_markets = await _fetch_page_markets(post_id)
|
||||
|
||||
from sx.sx_components import render_markets_panel
|
||||
return sx_response(render_markets_panel(page_markets, post))
|
||||
return sx_response(await render_markets_panel(page_markets, post))
|
||||
|
||||
return bp
|
||||
|
||||
@@ -125,7 +125,7 @@ def register():
|
||||
|
||||
# Get post_id from g.post_data
|
||||
if not g.user:
|
||||
return sx_response(render_like_toggle_button(slug, False, like_url), status=403)
|
||||
return sx_response(await render_like_toggle_button(slug, False, like_url), status=403)
|
||||
|
||||
post_id = g.post_data["post"]["id"]
|
||||
user_id = g.user.id
|
||||
@@ -135,7 +135,7 @@ def register():
|
||||
})
|
||||
liked = result["liked"]
|
||||
|
||||
return sx_response(render_like_toggle_button(slug, liked, like_url))
|
||||
return sx_response(await render_like_toggle_button(slug, liked, like_url))
|
||||
|
||||
@bp.get("/w/<widget_domain>/")
|
||||
async def widget_paginate(slug: str, widget_domain: str):
|
||||
|
||||
@@ -47,7 +47,7 @@ def register():
|
||||
|
||||
snippets = await _visible_snippets(g.s)
|
||||
from sx.sx_components import render_snippets_list
|
||||
return sx_response(render_snippets_list(snippets, is_admin))
|
||||
return sx_response(await render_snippets_list(snippets, is_admin))
|
||||
|
||||
@bp.patch("/<int:snippet_id>/visibility/")
|
||||
@require_login
|
||||
@@ -71,6 +71,6 @@ def register():
|
||||
|
||||
snippets = await _visible_snippets(g.s)
|
||||
from sx.sx_components import render_snippets_list
|
||||
return sx_response(render_snippets_list(snippets, True))
|
||||
return sx_response(await render_snippets_list(snippets, True))
|
||||
|
||||
return bp
|
||||
|
||||
38
blog/queries.sx
Normal file
38
blog/queries.sx
Normal file
@@ -0,0 +1,38 @@
|
||||
;; Blog service — inter-service data queries
|
||||
|
||||
(defquery post-by-slug (&key slug)
|
||||
"Fetch a single blog post by its URL slug."
|
||||
(service "blog" "get-post-by-slug" :slug slug))
|
||||
|
||||
(defquery post-by-id (&key id)
|
||||
"Fetch a single blog post by its primary key."
|
||||
(service "blog" "get-post-by-id" :id id))
|
||||
|
||||
(defquery posts-by-ids (&key ids)
|
||||
"Fetch multiple blog posts by comma-separated IDs."
|
||||
(service "blog" "get-posts-by-ids" :ids (split-ids ids)))
|
||||
|
||||
(defquery search-posts (&key query page per-page)
|
||||
"Search blog posts by text query, paginated."
|
||||
(let ((result (service "blog" "search-posts"
|
||||
:query query :page page :per-page per-page)))
|
||||
{"posts" (nth result 0) "total" (nth result 1)}))
|
||||
|
||||
(defquery page-config-ensure (&key container-type container-id)
|
||||
"Get or create a PageConfig for a container."
|
||||
(service "page-config" "ensure"
|
||||
:container-type container-type :container-id container-id))
|
||||
|
||||
(defquery page-config (&key container-type container-id)
|
||||
"Return a single PageConfig by container type + id."
|
||||
(service "page-config" "get-by-container"
|
||||
:container-type container-type :container-id container-id))
|
||||
|
||||
(defquery page-config-by-id (&key id)
|
||||
"Return a single PageConfig by primary key."
|
||||
(service "page-config" "get-by-id" :id id))
|
||||
|
||||
(defquery page-configs-batch (&key container-type ids)
|
||||
"Return PageConfigs for multiple container IDs (comma-separated)."
|
||||
(service "page-config" "get-batch"
|
||||
:container-type container-type :ids (split-ids ids)))
|
||||
@@ -71,8 +71,16 @@ def register_domain_services() -> None:
|
||||
Blog owns: Post, Tag, Author, PostAuthor, PostTag.
|
||||
Cross-app calls go over HTTP via call_action() / fetch_data().
|
||||
"""
|
||||
# Federation needed for AP shared infrastructure (activitypub blueprint)
|
||||
from shared.services.registry import services
|
||||
services.register("blog", blog_service)
|
||||
|
||||
from shared.services.page_config_impl import SqlPageConfigService
|
||||
services.register("page_config", SqlPageConfigService())
|
||||
|
||||
# Federation needed for AP shared infrastructure (activitypub blueprint)
|
||||
if not services.has("federation"):
|
||||
from shared.services.federation_impl import SqlFederationService
|
||||
services.federation = SqlFederationService()
|
||||
|
||||
from .blog_page import BlogPageService
|
||||
services.register("blog_page", BlogPageService())
|
||||
|
||||
234
blog/services/blog_page.py
Normal file
234
blog/services/blog_page.py
Normal file
@@ -0,0 +1,234 @@
|
||||
"""Blog page data service — provides serialized dicts for .sx defpages."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class BlogPageService:
|
||||
"""Service for blog page data, callable via (service "blog-page" ...)."""
|
||||
|
||||
async def cache_data(self, session, **kw):
|
||||
from quart import url_for as qurl
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
return {
|
||||
"clear_url": qurl("settings.cache_clear"),
|
||||
"csrf": generate_csrf_token(),
|
||||
}
|
||||
|
||||
async def snippets_data(self, session, **kw):
|
||||
from quart import g, url_for as qurl
|
||||
from sqlalchemy import select, or_
|
||||
from models import Snippet
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
|
||||
uid = g.user.id
|
||||
is_admin = g.rights.get("admin")
|
||||
csrf = generate_csrf_token()
|
||||
filters = [Snippet.user_id == uid, Snippet.visibility == "shared"]
|
||||
if is_admin:
|
||||
filters.append(Snippet.visibility == "admin")
|
||||
rows = (await session.execute(
|
||||
select(Snippet).where(or_(*filters)).order_by(Snippet.name)
|
||||
)).scalars().all()
|
||||
|
||||
snippets = []
|
||||
for s in rows:
|
||||
s_id = s.id
|
||||
s_vis = s.visibility or "private"
|
||||
s_uid = s.user_id
|
||||
owner = "You" if s_uid == uid else f"User #{s_uid}"
|
||||
can_delete = s_uid == uid or is_admin
|
||||
d = {
|
||||
"id": s_id,
|
||||
"name": s.name or "",
|
||||
"visibility": s_vis,
|
||||
"owner": owner,
|
||||
"can_delete": can_delete,
|
||||
}
|
||||
if is_admin:
|
||||
d["patch_url"] = qurl("snippets.patch_visibility", snippet_id=s_id)
|
||||
if can_delete:
|
||||
d["delete_url"] = qurl("snippets.delete_snippet", snippet_id=s_id)
|
||||
snippets.append(d)
|
||||
return {
|
||||
"snippets": snippets,
|
||||
"is_admin": bool(is_admin),
|
||||
"csrf": csrf,
|
||||
}
|
||||
|
||||
async def menu_items_data(self, session, **kw):
|
||||
from quart import url_for as qurl
|
||||
from bp.menu_items.services.menu_items import get_all_menu_items
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
|
||||
menu_items = await get_all_menu_items(session)
|
||||
csrf = generate_csrf_token()
|
||||
items = []
|
||||
for mi in menu_items:
|
||||
i_id = mi.id
|
||||
label = mi.label or ""
|
||||
fi = getattr(mi, "feature_image", None)
|
||||
sort = mi.position or 0
|
||||
items.append({
|
||||
"id": i_id,
|
||||
"label": label,
|
||||
"url": mi.url or "",
|
||||
"sort_order": sort,
|
||||
"feature_image": fi,
|
||||
"edit_url": qurl("menu_items.edit_menu_item", item_id=i_id),
|
||||
"delete_url": qurl("menu_items.delete_menu_item_route", item_id=i_id),
|
||||
})
|
||||
return {
|
||||
"menu_items": items,
|
||||
"new_url": qurl("menu_items.new_menu_item"),
|
||||
"csrf": csrf,
|
||||
}
|
||||
|
||||
async def tag_groups_data(self, session, **kw):
|
||||
from quart import url_for as qurl
|
||||
from sqlalchemy import select
|
||||
from models.tag_group import TagGroup
|
||||
from bp.blog.admin.routes import _unassigned_tags
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
|
||||
groups_rows = list(
|
||||
(await session.execute(
|
||||
select(TagGroup).order_by(TagGroup.sort_order, TagGroup.name)
|
||||
)).scalars()
|
||||
)
|
||||
unassigned = await _unassigned_tags(session)
|
||||
|
||||
groups = []
|
||||
for g in groups_rows:
|
||||
groups.append({
|
||||
"id": g.id,
|
||||
"name": g.name or "",
|
||||
"slug": getattr(g, "slug", "") or "",
|
||||
"feature_image": getattr(g, "feature_image", None),
|
||||
"colour": getattr(g, "colour", None),
|
||||
"sort_order": getattr(g, "sort_order", 0) or 0,
|
||||
"edit_href": qurl("blog.tag_groups_admin.defpage_tag_group_edit", id=g.id),
|
||||
})
|
||||
|
||||
unassigned_tags = []
|
||||
for t in unassigned:
|
||||
unassigned_tags.append({
|
||||
"name": getattr(t, "name", "") if hasattr(t, "name") else t.get("name", ""),
|
||||
})
|
||||
|
||||
return {
|
||||
"groups": groups,
|
||||
"unassigned_tags": unassigned_tags,
|
||||
"create_url": qurl("blog.tag_groups_admin.create"),
|
||||
"csrf": generate_csrf_token(),
|
||||
}
|
||||
|
||||
async def tag_group_edit_data(self, session, *, id=None, **kw):
|
||||
from quart import abort, url_for as qurl
|
||||
from sqlalchemy import select
|
||||
from models.tag_group import TagGroup, TagGroupTag
|
||||
from models.ghost_content import Tag
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
|
||||
tg = await session.get(TagGroup, id)
|
||||
if not tg:
|
||||
abort(404)
|
||||
|
||||
assigned_rows = list(
|
||||
(await session.execute(
|
||||
select(TagGroupTag.tag_id).where(TagGroupTag.tag_group_id == id)
|
||||
)).scalars()
|
||||
)
|
||||
assigned_set = set(assigned_rows)
|
||||
|
||||
all_tags_rows = list(
|
||||
(await session.execute(
|
||||
select(Tag).where(
|
||||
Tag.deleted_at.is_(None),
|
||||
(Tag.visibility == "public") | (Tag.visibility.is_(None)),
|
||||
).order_by(Tag.name)
|
||||
)).scalars()
|
||||
)
|
||||
|
||||
all_tags = []
|
||||
for t in all_tags_rows:
|
||||
all_tags.append({
|
||||
"id": t.id,
|
||||
"name": getattr(t, "name", "") or "",
|
||||
"feature_image": getattr(t, "feature_image", None),
|
||||
"checked": t.id in assigned_set,
|
||||
})
|
||||
|
||||
return {
|
||||
"group": {
|
||||
"id": tg.id,
|
||||
"name": tg.name or "",
|
||||
"colour": getattr(tg, "colour", "") or "",
|
||||
"sort_order": getattr(tg, "sort_order", 0) or 0,
|
||||
"feature_image": getattr(tg, "feature_image", "") or "",
|
||||
},
|
||||
"all_tags": all_tags,
|
||||
"save_url": qurl("blog.tag_groups_admin.save", id=tg.id),
|
||||
"delete_url": qurl("blog.tag_groups_admin.delete_group", id=tg.id),
|
||||
"csrf": generate_csrf_token(),
|
||||
}
|
||||
|
||||
async def post_admin_data(self, session, *, slug=None, **kw):
|
||||
"""Post admin panel — just needs post loaded into context."""
|
||||
from quart import g
|
||||
from sqlalchemy import select
|
||||
from shared.models.page_config import PageConfig
|
||||
|
||||
# _ensure_post_data is called by before_request in defpage context
|
||||
post = (g.post_data or {}).get("post", {})
|
||||
features = {}
|
||||
sumup_configured = False
|
||||
if post.get("is_page"):
|
||||
pc = (await session.execute(
|
||||
select(PageConfig).where(
|
||||
PageConfig.container_type == "page",
|
||||
PageConfig.container_id == post["id"],
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
if pc:
|
||||
features = pc.features or {}
|
||||
sumup_configured = bool(pc.sumup_api_key)
|
||||
return {
|
||||
"features": features,
|
||||
"sumup_configured": sumup_configured,
|
||||
}
|
||||
|
||||
async def preview_data(self, session, *, slug=None, **kw):
|
||||
"""Build preview data with prettified/rendered content."""
|
||||
from quart import g
|
||||
from models.ghost_content import Post
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
post_id = g.post_data["post"]["id"]
|
||||
post = (await session.execute(
|
||||
sa_select(Post).where(Post.id == post_id)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
result = {}
|
||||
sx_content = getattr(post, "sx_content", None) or ""
|
||||
if sx_content:
|
||||
from shared.sx.prettify import sx_to_pretty_sx
|
||||
result["sx_pretty"] = sx_to_pretty_sx(sx_content)
|
||||
lexical_raw = getattr(post, "lexical", None) or ""
|
||||
if lexical_raw:
|
||||
from shared.sx.prettify import json_to_pretty_sx
|
||||
result["json_pretty"] = json_to_pretty_sx(lexical_raw)
|
||||
if sx_content:
|
||||
from shared.sx.parser import parse as sx_parse
|
||||
from shared.sx.html import render as sx_html_render
|
||||
from shared.sx.jinja_bridge import _COMPONENT_ENV
|
||||
try:
|
||||
parsed = sx_parse(sx_content)
|
||||
result["sx_rendered"] = sx_html_render(parsed, dict(_COMPONENT_ENV))
|
||||
except Exception:
|
||||
result["sx_rendered"] = "<em>Error rendering sx</em>"
|
||||
if lexical_raw:
|
||||
from bp.blog.ghost.lexical_renderer import render_lexical
|
||||
try:
|
||||
result["lex_rendered"] = render_lexical(lexical_raw)
|
||||
except Exception:
|
||||
result["lex_rendered"] = "<em>Error rendering lexical</em>"
|
||||
return result
|
||||
114
blog/sx/admin.sx
114
blog/sx/admin.sx
@@ -169,3 +169,117 @@
|
||||
(details :class "border rounded bg-white"
|
||||
(summary :class "cursor-pointer px-4 py-3 font-medium text-sm bg-stone-100 hover:bg-stone-200 select-none" title)
|
||||
(div :class "p-4 overflow-x-auto text-xs" content)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Data-driven content defcomps (called from defpages with service data)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
;; Snippets — receives serialized snippet dicts from service
|
||||
(defcomp ~blog-snippets-content (&key snippets is-admin csrf)
|
||||
(~blog-snippets-panel
|
||||
:list (if (empty? (or snippets (list)))
|
||||
(~empty-state :icon "fa fa-puzzle-piece"
|
||||
:message "No snippets yet. Create one from the blog editor.")
|
||||
(~blog-snippets-list
|
||||
:rows (map (lambda (s)
|
||||
(let* ((badge-colours (dict
|
||||
"private" "bg-stone-200 text-stone-700"
|
||||
"shared" "bg-blue-100 text-blue-700"
|
||||
"admin" "bg-amber-100 text-amber-700"))
|
||||
(vis (or (get s "visibility") "private"))
|
||||
(badge-cls (or (get badge-colours vis) "bg-stone-200 text-stone-700"))
|
||||
(name (get s "name"))
|
||||
(owner (get s "owner"))
|
||||
(can-delete (get s "can_delete")))
|
||||
(~blog-snippet-row
|
||||
:name name :owner owner :badge-cls badge-cls :visibility vis
|
||||
:extra (<>
|
||||
(when is-admin
|
||||
(~blog-snippet-visibility-select
|
||||
:patch-url (get s "patch_url")
|
||||
:hx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}")
|
||||
:options (<>
|
||||
(~blog-snippet-option :value "private" :selected (= vis "private") :label "private")
|
||||
(~blog-snippet-option :value "shared" :selected (= vis "shared") :label "shared")
|
||||
(~blog-snippet-option :value "admin" :selected (= vis "admin") :label "admin"))))
|
||||
(when can-delete
|
||||
(~delete-btn
|
||||
:url (get s "delete_url")
|
||||
:trigger-target "#snippets-list"
|
||||
:title "Delete snippet?"
|
||||
:text (str "Delete \u201c" name "\u201d?")
|
||||
:sx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}")
|
||||
:cls "px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800 flex-shrink-0"))))))
|
||||
(or snippets (list)))))))
|
||||
|
||||
;; Menu Items — receives serialized menu item dicts from service
|
||||
(defcomp ~blog-menu-items-content (&key menu-items new-url csrf)
|
||||
(~blog-menu-items-panel
|
||||
:new-url new-url
|
||||
:list (if (empty? (or menu-items (list)))
|
||||
(~empty-state :icon "fa fa-inbox"
|
||||
:message "No menu items yet. Add one to get started!")
|
||||
(~blog-menu-items-list
|
||||
:rows (map (lambda (mi)
|
||||
(~blog-menu-item-row
|
||||
:img (~img-or-placeholder
|
||||
:src (get mi "feature_image") :alt (get mi "label")
|
||||
:size-cls "w-12 h-12 rounded-full object-cover flex-shrink-0")
|
||||
:label (get mi "label")
|
||||
:slug (get mi "url")
|
||||
:sort-order (str (or (get mi "sort_order") 0))
|
||||
:edit-url (get mi "edit_url")
|
||||
:delete-url (get mi "delete_url")
|
||||
:confirm-text (str "Remove " (get mi "label") " from the menu?")
|
||||
:hx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}")))
|
||||
(or menu-items (list)))))))
|
||||
|
||||
;; Tag Groups — receives serialized tag group data from service
|
||||
(defcomp ~blog-tag-groups-content (&key groups unassigned-tags create-url csrf)
|
||||
(~blog-tag-groups-main
|
||||
:form (~blog-tag-groups-create-form :create-url create-url :csrf csrf)
|
||||
:groups (if (empty? (or groups (list)))
|
||||
(~empty-state :icon "fa fa-tags" :message "No tag groups yet.")
|
||||
(~blog-tag-groups-list
|
||||
:items (map (lambda (g)
|
||||
(let* ((fi (get g "feature_image"))
|
||||
(colour (get g "colour"))
|
||||
(name (get g "name"))
|
||||
(initial (slice (or name "?") 0 1))
|
||||
(icon (if fi
|
||||
(~blog-tag-group-icon-image :src fi :name name)
|
||||
(~blog-tag-group-icon-color
|
||||
:style (if colour (str "background:" colour) "background:#e7e5e4")
|
||||
:initial initial))))
|
||||
(~blog-tag-group-li
|
||||
:icon icon
|
||||
:edit-href (get g "edit_href")
|
||||
:name name
|
||||
:slug (or (get g "slug") "")
|
||||
:sort-order (or (get g "sort_order") 0))))
|
||||
(or groups (list)))))
|
||||
:unassigned (when (not (empty? (or unassigned-tags (list))))
|
||||
(~blog-unassigned-tags
|
||||
:heading (str (len (or unassigned-tags (list))) " Unassigned Tags")
|
||||
:spans (map (lambda (t)
|
||||
(~blog-unassigned-tag :name (get t "name")))
|
||||
(or unassigned-tags (list)))))))
|
||||
|
||||
;; Tag Group Edit — receives serialized tag group + tags from service
|
||||
(defcomp ~blog-tag-group-edit-content (&key group all-tags save-url delete-url csrf)
|
||||
(~blog-tag-group-edit-main
|
||||
:edit-form (~blog-tag-group-edit-form
|
||||
:save-url save-url :csrf csrf
|
||||
:name (get group "name")
|
||||
:colour (get group "colour")
|
||||
:sort-order (get group "sort_order")
|
||||
:feature-image (get group "feature_image")
|
||||
:tags (map (lambda (t)
|
||||
(~blog-tag-checkbox
|
||||
:tag-id (get t "id")
|
||||
:checked (get t "checked")
|
||||
:img (when (get t "feature_image")
|
||||
(~blog-tag-checkbox-image :src (get t "feature_image")))
|
||||
:name (get t "name")))
|
||||
(or all-tags (list))))
|
||||
:delete-form (~blog-tag-group-delete-form :delete-url delete-url :csrf csrf)))
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -129,148 +129,148 @@ def _register_blog_layouts() -> None:
|
||||
|
||||
# --- Blog layout (root + blog header) ---
|
||||
|
||||
def _blog_full(ctx: dict, **kw: Any) -> str:
|
||||
async def _blog_full(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import root_header_sx
|
||||
from sx.sx_components import _blog_header_sx
|
||||
root_hdr = root_header_sx(ctx)
|
||||
blog_hdr = _blog_header_sx(ctx)
|
||||
root_hdr = await root_header_sx(ctx)
|
||||
blog_hdr = await _blog_header_sx(ctx)
|
||||
return "(<> " + root_hdr + " " + blog_hdr + ")"
|
||||
|
||||
|
||||
def _blog_oob(ctx: dict, **kw: Any) -> str:
|
||||
async def _blog_oob(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import root_header_sx, oob_header_sx
|
||||
from sx.sx_components import _blog_header_sx
|
||||
root_hdr = root_header_sx(ctx)
|
||||
blog_hdr = _blog_header_sx(ctx)
|
||||
root_hdr = await root_header_sx(ctx)
|
||||
blog_hdr = await _blog_header_sx(ctx)
|
||||
rows = "(<> " + root_hdr + " " + blog_hdr + ")"
|
||||
return oob_header_sx("root-header-child", "blog-header-child", rows)
|
||||
return await oob_header_sx("root-header-child", "blog-header-child", rows)
|
||||
|
||||
|
||||
# --- Settings layout (root + settings header) ---
|
||||
|
||||
def _settings_full(ctx: dict, **kw: Any) -> str:
|
||||
async def _settings_full(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import root_header_sx
|
||||
from sx.sx_components import _settings_header_sx
|
||||
root_hdr = root_header_sx(ctx)
|
||||
settings_hdr = _settings_header_sx(ctx)
|
||||
root_hdr = await root_header_sx(ctx)
|
||||
settings_hdr = await _settings_header_sx(ctx)
|
||||
return "(<> " + root_hdr + " " + settings_hdr + ")"
|
||||
|
||||
|
||||
def _settings_oob(ctx: dict, **kw: Any) -> str:
|
||||
async def _settings_oob(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import root_header_sx, oob_header_sx
|
||||
from sx.sx_components import _settings_header_sx
|
||||
root_hdr = root_header_sx(ctx)
|
||||
settings_hdr = _settings_header_sx(ctx)
|
||||
root_hdr = await root_header_sx(ctx)
|
||||
settings_hdr = await _settings_header_sx(ctx)
|
||||
rows = "(<> " + root_hdr + " " + settings_hdr + ")"
|
||||
return oob_header_sx("root-header-child", "root-settings-header-child", rows)
|
||||
return await oob_header_sx("root-header-child", "root-settings-header-child", rows)
|
||||
|
||||
|
||||
def _settings_mobile(ctx: dict, **kw: Any) -> str:
|
||||
async def _settings_mobile(ctx: dict, **kw: Any) -> str:
|
||||
from sx.sx_components import _settings_nav_sx
|
||||
return _settings_nav_sx(ctx)
|
||||
return await _settings_nav_sx(ctx)
|
||||
|
||||
|
||||
# --- Sub-settings helpers ---
|
||||
|
||||
def _sub_settings_full(ctx: dict, row_id: str, child_id: str,
|
||||
async def _sub_settings_full(ctx: dict, row_id: str, child_id: str,
|
||||
endpoint: str, icon: str, label: str) -> str:
|
||||
from shared.sx.helpers import root_header_sx
|
||||
from sx.sx_components import _settings_header_sx, _sub_settings_header_sx
|
||||
from quart import url_for as qurl
|
||||
root_hdr = root_header_sx(ctx)
|
||||
settings_hdr = _settings_header_sx(ctx)
|
||||
sub_hdr = _sub_settings_header_sx(row_id, child_id,
|
||||
root_hdr = await root_header_sx(ctx)
|
||||
settings_hdr = await _settings_header_sx(ctx)
|
||||
sub_hdr = await _sub_settings_header_sx(row_id, child_id,
|
||||
qurl(endpoint), icon, label, ctx)
|
||||
return "(<> " + root_hdr + " " + settings_hdr + " " + sub_hdr + ")"
|
||||
|
||||
|
||||
def _sub_settings_oob(ctx: dict, row_id: str, child_id: str,
|
||||
async def _sub_settings_oob(ctx: dict, row_id: str, child_id: str,
|
||||
endpoint: str, icon: str, label: str) -> str:
|
||||
from shared.sx.helpers import oob_header_sx
|
||||
from sx.sx_components import _settings_header_sx, _sub_settings_header_sx
|
||||
from quart import url_for as qurl
|
||||
settings_hdr_oob = _settings_header_sx(ctx, oob=True)
|
||||
sub_hdr = _sub_settings_header_sx(row_id, child_id,
|
||||
settings_hdr_oob = await _settings_header_sx(ctx, oob=True)
|
||||
sub_hdr = await _sub_settings_header_sx(row_id, child_id,
|
||||
qurl(endpoint), icon, label, ctx)
|
||||
sub_oob = oob_header_sx("root-settings-header-child", child_id, sub_hdr)
|
||||
sub_oob = await oob_header_sx("root-settings-header-child", child_id, sub_hdr)
|
||||
return "(<> " + settings_hdr_oob + " " + sub_oob + ")"
|
||||
|
||||
|
||||
# --- Cache ---
|
||||
|
||||
def _cache_full(ctx: dict, **kw: Any) -> str:
|
||||
return _sub_settings_full(ctx, "cache-row", "cache-header-child",
|
||||
async def _cache_full(ctx: dict, **kw: Any) -> str:
|
||||
return await _sub_settings_full(ctx, "cache-row", "cache-header-child",
|
||||
"defpage_cache_page", "refresh", "Cache")
|
||||
|
||||
|
||||
def _cache_oob(ctx: dict, **kw: Any) -> str:
|
||||
return _sub_settings_oob(ctx, "cache-row", "cache-header-child",
|
||||
async def _cache_oob(ctx: dict, **kw: Any) -> str:
|
||||
return await _sub_settings_oob(ctx, "cache-row", "cache-header-child",
|
||||
"defpage_cache_page", "refresh", "Cache")
|
||||
|
||||
|
||||
# --- Snippets ---
|
||||
|
||||
def _snippets_full(ctx: dict, **kw: Any) -> str:
|
||||
return _sub_settings_full(ctx, "snippets-row", "snippets-header-child",
|
||||
async def _snippets_full(ctx: dict, **kw: Any) -> str:
|
||||
return await _sub_settings_full(ctx, "snippets-row", "snippets-header-child",
|
||||
"defpage_snippets_page", "puzzle-piece", "Snippets")
|
||||
|
||||
|
||||
def _snippets_oob(ctx: dict, **kw: Any) -> str:
|
||||
return _sub_settings_oob(ctx, "snippets-row", "snippets-header-child",
|
||||
async def _snippets_oob(ctx: dict, **kw: Any) -> str:
|
||||
return await _sub_settings_oob(ctx, "snippets-row", "snippets-header-child",
|
||||
"defpage_snippets_page", "puzzle-piece", "Snippets")
|
||||
|
||||
|
||||
# --- Menu Items ---
|
||||
|
||||
def _menu_items_full(ctx: dict, **kw: Any) -> str:
|
||||
return _sub_settings_full(ctx, "menu_items-row", "menu_items-header-child",
|
||||
async def _menu_items_full(ctx: dict, **kw: Any) -> str:
|
||||
return await _sub_settings_full(ctx, "menu_items-row", "menu_items-header-child",
|
||||
"defpage_menu_items_page", "bars", "Menu Items")
|
||||
|
||||
|
||||
def _menu_items_oob(ctx: dict, **kw: Any) -> str:
|
||||
return _sub_settings_oob(ctx, "menu_items-row", "menu_items-header-child",
|
||||
async def _menu_items_oob(ctx: dict, **kw: Any) -> str:
|
||||
return await _sub_settings_oob(ctx, "menu_items-row", "menu_items-header-child",
|
||||
"defpage_menu_items_page", "bars", "Menu Items")
|
||||
|
||||
|
||||
# --- Tag Groups ---
|
||||
|
||||
def _tag_groups_full(ctx: dict, **kw: Any) -> str:
|
||||
return _sub_settings_full(ctx, "tag-groups-row", "tag-groups-header-child",
|
||||
async def _tag_groups_full(ctx: dict, **kw: Any) -> str:
|
||||
return await _sub_settings_full(ctx, "tag-groups-row", "tag-groups-header-child",
|
||||
"defpage_tag_groups_page", "tags", "Tag Groups")
|
||||
|
||||
|
||||
def _tag_groups_oob(ctx: dict, **kw: Any) -> str:
|
||||
return _sub_settings_oob(ctx, "tag-groups-row", "tag-groups-header-child",
|
||||
async def _tag_groups_oob(ctx: dict, **kw: Any) -> str:
|
||||
return await _sub_settings_oob(ctx, "tag-groups-row", "tag-groups-header-child",
|
||||
"defpage_tag_groups_page", "tags", "Tag Groups")
|
||||
|
||||
|
||||
# --- Tag Group Edit ---
|
||||
|
||||
def _tag_group_edit_full(ctx: dict, **kw: Any) -> str:
|
||||
async def _tag_group_edit_full(ctx: dict, **kw: Any) -> str:
|
||||
from quart import request
|
||||
g_id = (request.view_args or {}).get("id")
|
||||
from quart import url_for as qurl
|
||||
from shared.sx.helpers import root_header_sx
|
||||
from sx.sx_components import _settings_header_sx, _sub_settings_header_sx
|
||||
root_hdr = root_header_sx(ctx)
|
||||
settings_hdr = _settings_header_sx(ctx)
|
||||
sub_hdr = _sub_settings_header_sx("tag-groups-row", "tag-groups-header-child",
|
||||
root_hdr = await root_header_sx(ctx)
|
||||
settings_hdr = await _settings_header_sx(ctx)
|
||||
sub_hdr = await _sub_settings_header_sx("tag-groups-row", "tag-groups-header-child",
|
||||
qurl("defpage_tag_group_edit", id=g_id),
|
||||
"tags", "Tag Groups", ctx)
|
||||
return "(<> " + root_hdr + " " + settings_hdr + " " + sub_hdr + ")"
|
||||
|
||||
|
||||
def _tag_group_edit_oob(ctx: dict, **kw: Any) -> str:
|
||||
async def _tag_group_edit_oob(ctx: dict, **kw: Any) -> str:
|
||||
from quart import request
|
||||
g_id = (request.view_args or {}).get("id")
|
||||
from quart import url_for as qurl
|
||||
from shared.sx.helpers import oob_header_sx
|
||||
from sx.sx_components import _settings_header_sx, _sub_settings_header_sx
|
||||
settings_hdr_oob = _settings_header_sx(ctx, oob=True)
|
||||
sub_hdr = _sub_settings_header_sx("tag-groups-row", "tag-groups-header-child",
|
||||
settings_hdr_oob = await _settings_header_sx(ctx, oob=True)
|
||||
sub_hdr = await _sub_settings_header_sx("tag-groups-row", "tag-groups-header-child",
|
||||
qurl("defpage_tag_group_edit", id=g_id),
|
||||
"tags", "Tag Groups", ctx)
|
||||
sub_oob = oob_header_sx("root-settings-header-child", "tag-groups-header-child", sub_hdr)
|
||||
sub_oob = await oob_header_sx("root-settings-header-child", "tag-groups-header-child", sub_hdr)
|
||||
return "(<> " + settings_hdr_oob + " " + sub_oob + ")"
|
||||
|
||||
|
||||
@@ -289,12 +289,6 @@ def _register_blog_helpers() -> None:
|
||||
"post-entries-content": _h_post_entries_content,
|
||||
"post-settings-content": _h_post_settings_content,
|
||||
"post-edit-content": _h_post_edit_content,
|
||||
"settings-content": _h_settings_content,
|
||||
"cache-content": _h_cache_content,
|
||||
"snippets-content": _h_snippets_content,
|
||||
"menu-items-content": _h_menu_items_content,
|
||||
"tag-groups-content": _h_tag_groups_content,
|
||||
"tag-group-edit-content": _h_tag_group_edit_content,
|
||||
})
|
||||
|
||||
|
||||
@@ -302,12 +296,12 @@ def _register_blog_helpers() -> None:
|
||||
|
||||
async def _h_editor_content(**kw):
|
||||
from sx.sx_components import render_editor_panel
|
||||
return render_editor_panel()
|
||||
return await render_editor_panel()
|
||||
|
||||
|
||||
async def _h_editor_page_content(**kw):
|
||||
from sx.sx_components import render_editor_panel
|
||||
return render_editor_panel(is_page=True)
|
||||
return await render_editor_panel(is_page=True)
|
||||
|
||||
|
||||
# --- Post admin helpers ---
|
||||
@@ -391,7 +385,7 @@ async def _h_post_preview_content(slug=None, **kw):
|
||||
from sx.sx_components import _preview_main_panel_sx
|
||||
tctx = await get_template_context()
|
||||
tctx.update(preview_ctx)
|
||||
return _preview_main_panel_sx(tctx)
|
||||
return await _preview_main_panel_sx(tctx)
|
||||
|
||||
|
||||
async def _h_post_entries_content(slug=None, **kw):
|
||||
@@ -415,7 +409,7 @@ async def _h_post_entries_content(slug=None, **kw):
|
||||
tctx = await get_template_context()
|
||||
tctx["all_calendars"] = all_calendars
|
||||
tctx["associated_entry_ids"] = associated_entry_ids
|
||||
return _post_entries_content_sx(tctx)
|
||||
return await _post_entries_content_sx(tctx)
|
||||
|
||||
|
||||
async def _h_post_settings_content(slug=None, **kw):
|
||||
@@ -468,107 +462,6 @@ async def _h_post_edit_content(slug=None, **kw):
|
||||
tctx["save_success"] = save_success
|
||||
tctx["save_error"] = save_error
|
||||
tctx["newsletters"] = newsletters
|
||||
return _post_edit_content_sx(tctx)
|
||||
return await _post_edit_content_sx(tctx)
|
||||
|
||||
|
||||
# --- Settings helpers ---
|
||||
|
||||
async def _h_settings_content(**kw):
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import _settings_main_panel_sx
|
||||
tctx = await get_template_context()
|
||||
return _settings_main_panel_sx(tctx)
|
||||
|
||||
|
||||
async def _h_cache_content(**kw):
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import _cache_main_panel_sx
|
||||
tctx = await get_template_context()
|
||||
return _cache_main_panel_sx(tctx)
|
||||
|
||||
|
||||
# --- Snippets helper ---
|
||||
|
||||
async def _h_snippets_content(**kw):
|
||||
from quart import g
|
||||
from sqlalchemy import select, or_
|
||||
from models import Snippet
|
||||
uid = g.user.id
|
||||
is_admin = g.rights.get("admin")
|
||||
filters = [Snippet.user_id == uid, Snippet.visibility == "shared"]
|
||||
if is_admin:
|
||||
filters.append(Snippet.visibility == "admin")
|
||||
rows = (await g.s.execute(
|
||||
select(Snippet).where(or_(*filters)).order_by(Snippet.name)
|
||||
)).scalars().all()
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import _snippets_main_panel_sx
|
||||
tctx = await get_template_context()
|
||||
tctx["snippets"] = rows
|
||||
tctx["is_admin"] = is_admin
|
||||
return _snippets_main_panel_sx(tctx)
|
||||
|
||||
|
||||
# --- Menu Items helper ---
|
||||
|
||||
async def _h_menu_items_content(**kw):
|
||||
from quart import g
|
||||
from bp.menu_items.services.menu_items import get_all_menu_items
|
||||
menu_items = await get_all_menu_items(g.s)
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import _menu_items_main_panel_sx
|
||||
tctx = await get_template_context()
|
||||
tctx["menu_items"] = menu_items
|
||||
return _menu_items_main_panel_sx(tctx)
|
||||
|
||||
|
||||
# --- Tag Groups helpers ---
|
||||
|
||||
async def _h_tag_groups_content(**kw):
|
||||
from quart import g
|
||||
from sqlalchemy import select
|
||||
from models.tag_group import TagGroup
|
||||
from bp.blog.admin.routes import _unassigned_tags
|
||||
groups = list(
|
||||
(await g.s.execute(
|
||||
select(TagGroup).order_by(TagGroup.sort_order, TagGroup.name)
|
||||
)).scalars()
|
||||
)
|
||||
unassigned = await _unassigned_tags(g.s)
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import _tag_groups_main_panel_sx
|
||||
tctx = await get_template_context()
|
||||
tctx.update({"groups": groups, "unassigned_tags": unassigned})
|
||||
return _tag_groups_main_panel_sx(tctx)
|
||||
|
||||
|
||||
async def _h_tag_group_edit_content(id=None, **kw):
|
||||
from quart import g, abort
|
||||
from sqlalchemy import select
|
||||
from models.tag_group import TagGroup, TagGroupTag
|
||||
from models.ghost_content import Tag
|
||||
tg = await g.s.get(TagGroup, id)
|
||||
if not tg:
|
||||
abort(404)
|
||||
assigned_rows = list(
|
||||
(await g.s.execute(
|
||||
select(TagGroupTag.tag_id).where(TagGroupTag.tag_group_id == id)
|
||||
)).scalars()
|
||||
)
|
||||
all_tags = list(
|
||||
(await g.s.execute(
|
||||
select(Tag).where(
|
||||
Tag.deleted_at.is_(None),
|
||||
(Tag.visibility == "public") | (Tag.visibility.is_(None)),
|
||||
).order_by(Tag.name)
|
||||
)).scalars()
|
||||
)
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import _tag_groups_edit_main_panel_sx
|
||||
tctx = await get_template_context()
|
||||
tctx.update({
|
||||
"group": tg,
|
||||
"all_tags": all_tags,
|
||||
"assigned_tag_ids": set(assigned_rows),
|
||||
})
|
||||
return _tag_groups_edit_main_panel_sx(tctx)
|
||||
|
||||
@@ -59,13 +59,14 @@
|
||||
:path "/settings/"
|
||||
:auth :admin
|
||||
:layout :blog-settings
|
||||
:content (settings-content))
|
||||
:content (div :class "max-w-2xl mx-auto px-4 py-6"))
|
||||
|
||||
(defpage cache-page
|
||||
:path "/settings/cache/"
|
||||
:auth :admin
|
||||
:layout :blog-cache
|
||||
:content (cache-content))
|
||||
:data (service "blog-page" "cache-data")
|
||||
:content (~blog-cache-panel :clear-url clear-url :csrf csrf))
|
||||
|
||||
; --- Snippets ---
|
||||
|
||||
@@ -73,7 +74,9 @@
|
||||
:path "/settings/snippets/"
|
||||
:auth :login
|
||||
:layout :blog-snippets
|
||||
:content (snippets-content))
|
||||
:data (service "blog-page" "snippets-data")
|
||||
:content (~blog-snippets-content
|
||||
:snippets snippets :is-admin is-admin :csrf csrf))
|
||||
|
||||
; --- Menu Items ---
|
||||
|
||||
@@ -81,7 +84,9 @@
|
||||
:path "/settings/menu_items/"
|
||||
:auth :admin
|
||||
:layout :blog-menu-items
|
||||
:content (menu-items-content))
|
||||
:data (service "blog-page" "menu-items-data")
|
||||
:content (~blog-menu-items-content
|
||||
:menu-items menu-items :new-url new-url :csrf csrf))
|
||||
|
||||
; --- Tag Groups ---
|
||||
|
||||
@@ -89,10 +94,16 @@
|
||||
:path "/settings/tag-groups/"
|
||||
:auth :admin
|
||||
:layout :blog-tag-groups
|
||||
:content (tag-groups-content))
|
||||
:data (service "blog-page" "tag-groups-data")
|
||||
:content (~blog-tag-groups-content
|
||||
:groups groups :unassigned-tags unassigned-tags
|
||||
:create-url create-url :csrf csrf))
|
||||
|
||||
(defpage tag-group-edit
|
||||
:path "/settings/tag-groups/<int:id>/"
|
||||
:auth :admin
|
||||
:layout :blog-tag-group-edit
|
||||
:content (tag-group-edit-content id))
|
||||
:data (service "blog-page" "tag-group-edit-data" :id id)
|
||||
:content (~blog-tag-group-edit-content
|
||||
:group group :all-tags all-tags
|
||||
:save-url save-url :delete-url delete-url :csrf csrf))
|
||||
|
||||
10
cart/actions.sx
Normal file
10
cart/actions.sx
Normal file
@@ -0,0 +1,10 @@
|
||||
;; Cart service — inter-service action endpoints
|
||||
|
||||
(defaction adopt-cart-for-user (&key user-id session-id)
|
||||
"Transfer anonymous cart items to a logged-in user."
|
||||
(do
|
||||
(service "cart" "adopt-cart-for-user"
|
||||
:user-id user-id :session-id session-id)
|
||||
{"ok" true}))
|
||||
|
||||
;; clear-cart-for-order: remains as Python fallback (complex object construction)
|
||||
@@ -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,9 @@ def create_app() -> "Quart":
|
||||
app.jinja_env.globals["cart_quantity_url"] = lambda product_id: f"/quantity/{product_id}/"
|
||||
app.jinja_env.globals["cart_delete_url"] = lambda product_id: f"/delete/{product_id}/"
|
||||
|
||||
import os as _os
|
||||
load_service_components(_os.path.dirname(_os.path.abspath(__file__)), service_name="cart")
|
||||
|
||||
from shared.sx.handlers import auto_mount_fragment_handlers
|
||||
auto_mount_fragment_handlers(app, "cart")
|
||||
|
||||
|
||||
@@ -1,64 +1,26 @@
|
||||
"""Cart app action endpoints.
|
||||
|
||||
Exposes write operations at ``/internal/actions/<action_name>`` for
|
||||
cross-app callers (login handler) via the internal action client.
|
||||
adopt-cart-for-user is defined in ``cart/actions.sx``.
|
||||
clear-cart-for-order remains as a Python fallback (complex object construction).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, g, jsonify, request
|
||||
from quart import Blueprint, g, request
|
||||
|
||||
from shared.infrastructure.actions import ACTION_HEADER
|
||||
from shared.services.registry import services
|
||||
from shared.infrastructure.query_blueprint import create_action_blueprint
|
||||
|
||||
|
||||
def register() -> Blueprint:
|
||||
bp = Blueprint("actions", __name__, url_prefix="/internal/actions")
|
||||
bp, _handlers = create_action_blueprint("cart")
|
||||
|
||||
@bp.before_request
|
||||
async def _require_action_header():
|
||||
if not request.headers.get(ACTION_HEADER):
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
from shared.infrastructure.internal_auth import validate_internal_request
|
||||
if not validate_internal_request():
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
|
||||
_handlers: dict[str, object] = {}
|
||||
|
||||
@bp.post("/<action_name>")
|
||||
async def handle_action(action_name: str):
|
||||
handler = _handlers.get(action_name)
|
||||
if handler is None:
|
||||
return jsonify({"error": "unknown action"}), 404
|
||||
try:
|
||||
result = await handler()
|
||||
return jsonify(result)
|
||||
except Exception as exc:
|
||||
import logging
|
||||
logging.getLogger(__name__).exception("Action %s failed", action_name)
|
||||
return jsonify({"error": str(exc)}), 500
|
||||
|
||||
# --- adopt-cart-for-user ---
|
||||
async def _adopt_cart():
|
||||
data = await request.get_json()
|
||||
await services.cart.adopt_cart_for_user(
|
||||
g.s, data["user_id"], data["session_id"],
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
_handlers["adopt-cart-for-user"] = _adopt_cart
|
||||
|
||||
# --- clear-cart-for-order ---
|
||||
async def _clear_cart_for_order():
|
||||
"""Soft-delete cart items after an order is paid. Called by orders service."""
|
||||
from bp.cart.services.clear_cart_for_order import clear_cart_for_order
|
||||
from shared.models.order import Order
|
||||
|
||||
data = await request.get_json()
|
||||
user_id = data.get("user_id")
|
||||
session_id = data.get("session_id")
|
||||
page_post_id = data.get("page_post_id")
|
||||
|
||||
# Build a minimal order-like object with the fields clear_cart_for_order needs
|
||||
order = type("_Order", (), {
|
||||
"user_id": user_id,
|
||||
"session_id": session_id,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,79 +1,14 @@
|
||||
"""Cart app data endpoints.
|
||||
|
||||
Exposes read-only JSON queries at ``/internal/data/<query_name>`` for
|
||||
cross-app callers via the internal data client.
|
||||
All queries are defined in ``cart/queries.sx``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, g, jsonify, request
|
||||
from quart import Blueprint
|
||||
|
||||
from shared.infrastructure.data_client import DATA_HEADER
|
||||
from shared.contracts.dtos import dto_to_dict
|
||||
from shared.services.registry import services
|
||||
from shared.infrastructure.query_blueprint import create_data_blueprint
|
||||
|
||||
|
||||
def register() -> Blueprint:
|
||||
bp = Blueprint("data", __name__, url_prefix="/internal/data")
|
||||
|
||||
@bp.before_request
|
||||
async def _require_data_header():
|
||||
if not request.headers.get(DATA_HEADER):
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
from shared.infrastructure.internal_auth import validate_internal_request
|
||||
if not validate_internal_request():
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
|
||||
_handlers: dict[str, object] = {}
|
||||
|
||||
@bp.get("/<query_name>")
|
||||
async def handle_query(query_name: str):
|
||||
handler = _handlers.get(query_name)
|
||||
if handler is None:
|
||||
return jsonify({"error": "unknown query"}), 404
|
||||
result = await handler()
|
||||
return jsonify(result)
|
||||
|
||||
# --- cart-summary ---
|
||||
async def _cart_summary():
|
||||
user_id = request.args.get("user_id", type=int)
|
||||
session_id = request.args.get("session_id")
|
||||
page_slug = request.args.get("page_slug")
|
||||
summary = await services.cart.cart_summary(
|
||||
g.s, user_id=user_id, session_id=session_id, page_slug=page_slug,
|
||||
)
|
||||
return dto_to_dict(summary)
|
||||
|
||||
_handlers["cart-summary"] = _cart_summary
|
||||
|
||||
# --- cart-items (product slugs + quantities for template rendering) ---
|
||||
async def _cart_items():
|
||||
from sqlalchemy import select
|
||||
from shared.models.market import CartItem
|
||||
|
||||
user_id = request.args.get("user_id", type=int)
|
||||
session_id = request.args.get("session_id")
|
||||
|
||||
filters = [CartItem.deleted_at.is_(None)]
|
||||
if user_id is not None:
|
||||
filters.append(CartItem.user_id == user_id)
|
||||
elif session_id is not None:
|
||||
filters.append(CartItem.session_id == session_id)
|
||||
else:
|
||||
return []
|
||||
|
||||
result = await g.s.execute(
|
||||
select(CartItem).where(*filters)
|
||||
)
|
||||
items = result.scalars().all()
|
||||
return [
|
||||
{
|
||||
"product_id": item.product_id,
|
||||
"product_slug": item.product_slug,
|
||||
"quantity": item.quantity,
|
||||
}
|
||||
for item in items
|
||||
]
|
||||
|
||||
_handlers["cart-items"] = _cart_items
|
||||
|
||||
bp, _handlers = create_data_blueprint("cart")
|
||||
return bp
|
||||
|
||||
@@ -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,9 +47,9 @@ 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 = render_cart_payments_panel(ctx)
|
||||
html = await render_cart_payments_panel(ctx)
|
||||
return sx_response(html)
|
||||
|
||||
return bp
|
||||
|
||||
11
cart/queries.sx
Normal file
11
cart/queries.sx
Normal file
@@ -0,0 +1,11 @@
|
||||
;; Cart service — inter-service data queries
|
||||
|
||||
(defquery cart-summary (&key user-id session-id page-slug)
|
||||
"Cart summary for a user or session, optionally filtered by page."
|
||||
(service "cart" "cart-summary"
|
||||
:user-id user-id :session-id session-id :page-slug page-slug))
|
||||
|
||||
(defquery cart-items (&key user-id session-id)
|
||||
"Product slugs and quantities in the cart."
|
||||
(service "cart-data" "cart-items"
|
||||
:user-id user-id :session-id session-id))
|
||||
@@ -12,3 +12,9 @@ def register_domain_services() -> None:
|
||||
from shared.services.cart_impl import SqlCartService
|
||||
|
||||
services.cart = SqlCartService()
|
||||
|
||||
from shared.services.cart_items_impl import SqlCartItemsService
|
||||
services.register("cart_data", SqlCartItemsService())
|
||||
|
||||
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}
|
||||
111
cart/sx/items.sx
111
cart/sx/items.sx
@@ -52,3 +52,114 @@
|
||||
(div :id "cart"
|
||||
(div (section :class "space-y-3 sm:space-y-4" items cal tickets)
|
||||
summary))))
|
||||
|
||||
;; Assembled cart item from serialized data — replaces Python _cart_item_sx
|
||||
(defcomp ~cart-item-from-data (&key item)
|
||||
(let* ((slug (or (get item "slug") ""))
|
||||
(title (or (get item "title") ""))
|
||||
(image (get item "image"))
|
||||
(brand (get item "brand"))
|
||||
(is-deleted (get item "is_deleted"))
|
||||
(unit-price (get item "unit_price"))
|
||||
(special-price (get item "special_price"))
|
||||
(regular-price (get item "regular_price"))
|
||||
(currency (or (get item "currency") "GBP"))
|
||||
(symbol (if (= currency "GBP") "\u00a3" currency))
|
||||
(quantity (or (get item "quantity") 1))
|
||||
(product-id (get item "product_id"))
|
||||
(prod-url (or (get item "product_url") ""))
|
||||
(qty-url (or (get item "qty_url") ""))
|
||||
(csrf (csrf-token))
|
||||
(line-total (when unit-price (* unit-price quantity))))
|
||||
(~cart-item
|
||||
:id (str "cart-item-" slug)
|
||||
:img (if image
|
||||
(~cart-item-img :src image :alt title)
|
||||
(~img-or-placeholder :src nil
|
||||
:size-cls "w-24 h-24 sm:w-32 sm:h-28 rounded-xl border border-dashed border-stone-300"
|
||||
:placeholder-text "No image"))
|
||||
:prod-url prod-url
|
||||
:title title
|
||||
:brand (when brand (~cart-item-brand :brand brand))
|
||||
:deleted (when is-deleted (~cart-item-deleted))
|
||||
:price (if unit-price
|
||||
(<>
|
||||
(~cart-item-price :text (str symbol (format-decimal unit-price 2)))
|
||||
(when (and special-price (!= special-price regular-price))
|
||||
(~cart-item-price-was :text (str symbol (format-decimal regular-price 2)))))
|
||||
(~cart-item-no-price))
|
||||
:qty-url qty-url :csrf csrf
|
||||
:minus (str (- quantity 1))
|
||||
:qty (str quantity)
|
||||
:plus (str (+ quantity 1))
|
||||
:line-total (when line-total
|
||||
(~cart-item-line-total :text (str "Line total: " symbol (format-decimal line-total 2)))))))
|
||||
|
||||
;; Assembled calendar entries section — replaces Python _calendar_entries_sx
|
||||
(defcomp ~cart-cal-section-from-data (&key entries)
|
||||
(when (not (empty? entries))
|
||||
(~cart-cal-section
|
||||
:items (map (lambda (e)
|
||||
(let* ((name (or (get e "name") ""))
|
||||
(date-str (or (get e "date_str") "")))
|
||||
(~cart-cal-entry
|
||||
:name name :date-str date-str
|
||||
:cost (str "\u00a3" (format-decimal (or (get e "cost") 0) 2)))))
|
||||
entries))))
|
||||
|
||||
;; Assembled ticket groups section — replaces Python _ticket_groups_sx
|
||||
(defcomp ~cart-tickets-section-from-data (&key ticket-groups)
|
||||
(when (not (empty? ticket-groups))
|
||||
(let* ((csrf (csrf-token))
|
||||
(qty-url (url-for "cart_global.update_ticket_quantity")))
|
||||
(~cart-tickets-section
|
||||
:items (map (lambda (tg)
|
||||
(let* ((name (or (get tg "entry_name") ""))
|
||||
(tt-name (get tg "ticket_type_name"))
|
||||
(price (or (get tg "price") 0))
|
||||
(quantity (or (get tg "quantity") 0))
|
||||
(line-total (or (get tg "line_total") 0))
|
||||
(entry-id (str (or (get tg "entry_id") "")))
|
||||
(tt-id (get tg "ticket_type_id"))
|
||||
(date-str (or (get tg "date_str") "")))
|
||||
(~cart-ticket-article
|
||||
:name name
|
||||
:type-name (when tt-name (~cart-ticket-type-name :name tt-name))
|
||||
:date-str date-str
|
||||
:price (str "\u00a3" (format-decimal price 2))
|
||||
:qty-url qty-url :csrf csrf
|
||||
:entry-id entry-id
|
||||
:type-hidden (when tt-id (~cart-ticket-type-hidden :value (str tt-id)))
|
||||
:minus (str (max (- quantity 1) 0))
|
||||
:qty (str quantity)
|
||||
:plus (str (+ quantity 1))
|
||||
:line-total (str "Line total: \u00a3" (format-decimal line-total 2)))))
|
||||
ticket-groups)))))
|
||||
|
||||
;; Assembled cart summary — replaces Python _cart_summary_sx
|
||||
(defcomp ~cart-summary-from-data (&key item-count grand-total symbol is-logged-in checkout-action login-href user-email)
|
||||
(~cart-summary-panel
|
||||
:item-count (str item-count)
|
||||
:subtotal (str symbol (format-decimal grand-total 2))
|
||||
:checkout (if is-logged-in
|
||||
(~cart-checkout-form
|
||||
:action checkout-action :csrf (csrf-token)
|
||||
:label (str " Checkout as " user-email))
|
||||
(~cart-checkout-signin :href login-href))))
|
||||
|
||||
;; Assembled page cart content — replaces Python _page_cart_main_panel_sx
|
||||
(defcomp ~cart-page-cart-content (&key cart-items cal-entries ticket-groups summary)
|
||||
(if (and (empty? (or cart-items (list)))
|
||||
(empty? (or cal-entries (list)))
|
||||
(empty? (or ticket-groups (list))))
|
||||
(div :class "max-w-full px-3 py-3 space-y-3"
|
||||
(div :id "cart"
|
||||
(div :class "rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center"
|
||||
(~empty-state :icon "fa fa-shopping-cart" :message "Your cart is empty" :cls "text-center"))))
|
||||
(~cart-page-panel
|
||||
:items (map (lambda (item) (~cart-item-from-data :item item)) (or cart-items (list)))
|
||||
:cal (when (not (empty? (or cal-entries (list))))
|
||||
(~cart-cal-section-from-data :entries cal-entries))
|
||||
:tickets (when (not (empty? (or ticket-groups (list))))
|
||||
(~cart-tickets-section-from-data :ticket-groups ticket-groups))
|
||||
:summary summary)))
|
||||
|
||||
@@ -39,3 +39,56 @@
|
||||
(defcomp ~cart-overview-panel (&key cards)
|
||||
(div :class "max-w-full px-3 py-3 space-y-3"
|
||||
(div :class "space-y-4" cards)))
|
||||
|
||||
(defcomp ~cart-empty ()
|
||||
(div :class "max-w-full px-3 py-3 space-y-3"
|
||||
(div :class "rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center"
|
||||
(~empty-state :icon "fa fa-shopping-cart" :message "Your cart is empty" :cls "text-center"))))
|
||||
|
||||
;; Assembled page group card — replaces Python _page_group_card_sx
|
||||
(defcomp ~cart-page-group-card-from-data (&key grp cart-url-base)
|
||||
(let* ((post (get grp "post"))
|
||||
(product-count (or (get grp "product_count") 0))
|
||||
(calendar-count (or (get grp "calendar_count") 0))
|
||||
(ticket-count (or (get grp "ticket_count") 0))
|
||||
(total (or (get grp "total") 0))
|
||||
(market-place (get grp "market_place"))
|
||||
(badges (<>
|
||||
(when (> product-count 0)
|
||||
(~cart-badge :icon "fa fa-box-open"
|
||||
:text (str product-count " item" (pluralize product-count))))
|
||||
(when (> calendar-count 0)
|
||||
(~cart-badge :icon "fa fa-calendar"
|
||||
:text (str calendar-count " booking" (pluralize calendar-count))))
|
||||
(when (> ticket-count 0)
|
||||
(~cart-badge :icon "fa fa-ticket"
|
||||
:text (str ticket-count " ticket" (pluralize ticket-count)))))))
|
||||
(if post
|
||||
(let* ((slug (or (get post "slug") ""))
|
||||
(title (or (get post "title") ""))
|
||||
(feature-image (get post "feature_image"))
|
||||
(mp-name (if market-place (or (get market-place "name") "") ""))
|
||||
(display-title (if (!= mp-name "") mp-name title)))
|
||||
(~cart-group-card
|
||||
:href (str cart-url-base "/" slug "/")
|
||||
:img (if feature-image
|
||||
(~cart-group-card-img :src feature-image :alt title)
|
||||
(~img-or-placeholder :src nil :size-cls "h-16 w-16 rounded-xl"
|
||||
:placeholder-icon "fa fa-store text-xl"))
|
||||
:display-title display-title
|
||||
:subtitle (when (!= mp-name "")
|
||||
(~cart-mp-subtitle :title title))
|
||||
:badges (~cart-badges-wrap :badges badges)
|
||||
:total (str "\u00a3" (format-decimal total 2))))
|
||||
(~cart-orphan-card
|
||||
:badges (~cart-badges-wrap :badges badges)
|
||||
:total (str "\u00a3" (format-decimal total 2))))))
|
||||
|
||||
;; Assembled cart overview content — replaces Python _overview_main_panel_sx
|
||||
(defcomp ~cart-overview-content (&key page-groups cart-url-base)
|
||||
(if (empty? page-groups)
|
||||
(~cart-empty)
|
||||
(~cart-overview-panel
|
||||
:cards (map (lambda (grp)
|
||||
(~cart-page-group-card-from-data :grp grp :cart-url-base cart-url-base))
|
||||
page-groups))))
|
||||
|
||||
@@ -5,3 +5,27 @@
|
||||
(~sumup-settings-form :update-url update-url :csrf csrf :merchant-code merchant-code
|
||||
:placeholder placeholder :input-cls input-cls :sumup-configured sumup-configured
|
||||
:checkout-prefix checkout-prefix :sx-select "#payments-panel")))
|
||||
|
||||
;; Assembled cart admin overview content
|
||||
(defcomp ~cart-admin-content ()
|
||||
(let* ((payments-href (url-for "defpage_cart_payments")))
|
||||
(div :id "main-panel"
|
||||
(div :class "flex items-center justify-between p-3 border-b"
|
||||
(span :class "font-medium" (i :class "fa fa-credit-card text-purple-600 mr-1") " Payments")
|
||||
(a :href payments-href :class "text-sm underline" "configure")))))
|
||||
|
||||
;; Assembled cart payments content
|
||||
(defcomp ~cart-payments-content (&key page-config)
|
||||
(let* ((sumup-configured (and page-config (get page-config "sumup_api_key")))
|
||||
(merchant-code (or (get page-config "sumup_merchant_code") ""))
|
||||
(checkout-prefix (or (get page-config "sumup_checkout_prefix") ""))
|
||||
(placeholder (if sumup-configured "--------" "sup_sk_..."))
|
||||
(input-cls "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500"))
|
||||
(~cart-payments-panel
|
||||
:update-url (url-for "page_admin.update_sumup")
|
||||
:csrf (csrf-token)
|
||||
:merchant-code merchant-code
|
||||
:placeholder placeholder
|
||||
:input-cls input-cls
|
||||
:sumup-configured sumup-configured
|
||||
:checkout-prefix checkout-prefix)))
|
||||
|
||||
@@ -1,807 +0,0 @@
|
||||
"""
|
||||
Cart service s-expression page components.
|
||||
|
||||
Renders cart overview, page cart, orders list, and single order detail.
|
||||
Called from route handlers in place of ``render_template()``.
|
||||
"""
|
||||
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,
|
||||
sx_call, SxExpr,
|
||||
)
|
||||
from shared.infrastructure.urls import market_product_url, 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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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 _shared_post_header_sx(ctx, oob=oob)
|
||||
|
||||
|
||||
def _cart_header_sx(ctx: dict, *, oob: bool = False) -> str:
|
||||
"""Build the cart section header row."""
|
||||
return sx_call(
|
||||
"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,
|
||||
)
|
||||
|
||||
|
||||
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(sx_call("cart-page-label-img", src=page_post.feature_image))
|
||||
label_parts.append(f'(span "{escape(title)}")')
|
||||
label_sx = "(<> " + " ".join(label_parts) + ")"
|
||||
nav_sx = sx_call("cart-all-carts-link", href=call_url(ctx, "cart_url", "/"))
|
||||
return sx_call(
|
||||
"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,
|
||||
)
|
||||
|
||||
|
||||
def _auth_header_sx(ctx: dict, *, oob: bool = False) -> str:
|
||||
"""Build the account section header row (for orders)."""
|
||||
return sx_call(
|
||||
"menu-row-sx",
|
||||
id="auth-row", level=1, colour="sky",
|
||||
link_href=call_url(ctx, "account_url", "/"),
|
||||
link_label="account", icon="fa-solid fa-user",
|
||||
child_id="auth-header-child", oob=oob,
|
||||
)
|
||||
|
||||
|
||||
def _orders_header_sx(ctx: dict, list_url: str) -> str:
|
||||
"""Build the orders section header row."""
|
||||
return sx_call(
|
||||
"menu-row-sx",
|
||||
id="orders-row", level=2, colour="sky",
|
||||
link_href=list_url, link_label="Orders", icon="fa fa-gbp",
|
||||
child_id="orders-header-child",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cart overview
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _badge_sx(icon: str, count: int, label: str) -> str:
|
||||
"""Render a count badge."""
|
||||
s = "s" if count != 1 else ""
|
||||
return sx_call("cart-badge", icon=icon, text=f"{count} {label}{s}")
|
||||
|
||||
|
||||
def _page_group_card_sx(grp: Any, ctx: dict) -> str:
|
||||
"""Render a single page group card for cart overview."""
|
||||
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", [])
|
||||
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 = grp.get("total", 0) if isinstance(grp, dict) else getattr(grp, "total", 0)
|
||||
market_place = grp.get("market_place") if isinstance(grp, dict) else getattr(grp, "market_place", None)
|
||||
|
||||
if not cart_items and not cal_entries and not tickets:
|
||||
return ""
|
||||
|
||||
# Count badges
|
||||
badge_parts = []
|
||||
if product_count > 0:
|
||||
badge_parts.append(_badge_sx("fa fa-box-open", product_count, "item"))
|
||||
if calendar_count > 0:
|
||||
badge_parts.append(_badge_sx("fa fa-calendar", calendar_count, "booking"))
|
||||
if ticket_count > 0:
|
||||
badge_parts.append(_badge_sx("fa fa-ticket", ticket_count, "ticket"))
|
||||
badges_sx = "(<> " + " ".join(badge_parts) + ")" if badge_parts else '""'
|
||||
badges_wrap = sx_call("cart-badges-wrap", badges=SxExpr(badges_sx))
|
||||
|
||||
if post:
|
||||
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")
|
||||
cart_href = call_url(ctx, "cart_url", f"/{slug}/")
|
||||
|
||||
if feature_image:
|
||||
img = sx_call("cart-group-card-img", src=feature_image, alt=title)
|
||||
else:
|
||||
img = sx_call("img-or-placeholder", src=None,
|
||||
size_cls="h-16 w-16 rounded-xl",
|
||||
placeholder_icon="fa fa-store text-xl")
|
||||
|
||||
mp_sub = ""
|
||||
if market_place:
|
||||
mp_name = market_place.name if hasattr(market_place, "name") else market_place.get("name", "")
|
||||
mp_sub = sx_call("cart-mp-subtitle", title=title)
|
||||
else:
|
||||
mp_name = ""
|
||||
display_title = mp_name or title
|
||||
|
||||
return sx_call(
|
||||
"cart-group-card",
|
||||
href=cart_href, img=SxExpr(img), display_title=display_title,
|
||||
subtitle=SxExpr(mp_sub) if mp_sub else None,
|
||||
badges=SxExpr(badges_wrap),
|
||||
total=f"\u00a3{total:.2f}",
|
||||
)
|
||||
else:
|
||||
# Orphan items
|
||||
return sx_call(
|
||||
"cart-orphan-card",
|
||||
badges=SxExpr(badges_wrap),
|
||||
total=f"\u00a3{total:.2f}",
|
||||
)
|
||||
|
||||
|
||||
def _empty_cart_sx() -> str:
|
||||
"""Empty cart state."""
|
||||
empty = sx_call("empty-state", icon="fa fa-shopping-cart",
|
||||
message="Your cart is empty", cls="text-center")
|
||||
return (
|
||||
'(div :class "max-w-full px-3 py-3 space-y-3"'
|
||||
' (div :class "rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center"'
|
||||
f' {empty}))'
|
||||
)
|
||||
|
||||
|
||||
def _overview_main_panel_sx(page_groups: list, ctx: dict) -> str:
|
||||
"""Cart overview main panel."""
|
||||
if not page_groups:
|
||||
return _empty_cart_sx()
|
||||
|
||||
cards = [_page_group_card_sx(grp, ctx) for grp in page_groups]
|
||||
has_items = any(c for c in cards)
|
||||
if not has_items:
|
||||
return _empty_cart_sx()
|
||||
|
||||
cards_sx = "(<> " + " ".join(c for c in cards if c) + ")"
|
||||
return sx_call("cart-overview-panel", cards=SxExpr(cards_sx))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Page cart
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _cart_item_sx(item: Any, ctx: dict) -> str:
|
||||
"""Render a single product cart item."""
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from quart import url_for
|
||||
|
||||
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"
|
||||
symbol = "\u00a3" if currency == "GBP" else currency
|
||||
csrf = generate_csrf_token()
|
||||
qty_url = url_for("cart_global.update_quantity", product_id=p.id)
|
||||
prod_url = market_product_url(slug)
|
||||
|
||||
if p.image:
|
||||
img = sx_call("cart-item-img", src=p.image, alt=p.title)
|
||||
else:
|
||||
img = sx_call("img-or-placeholder", src=None,
|
||||
size_cls="w-24 h-24 sm:w-32 sm:h-28 rounded-xl border border-dashed border-stone-300",
|
||||
placeholder_text="No image")
|
||||
|
||||
price_parts = []
|
||||
if unit_price:
|
||||
price_parts.append(sx_call("cart-item-price", text=f"{symbol}{unit_price:.2f}"))
|
||||
if p.special_price and p.special_price != p.regular_price:
|
||||
price_parts.append(sx_call("cart-item-price-was", text=f"{symbol}{p.regular_price:.2f}"))
|
||||
else:
|
||||
price_parts.append(sx_call("cart-item-no-price"))
|
||||
price_sx = "(<> " + " ".join(price_parts) + ")" if len(price_parts) > 1 else price_parts[0]
|
||||
|
||||
deleted_sx = sx_call("cart-item-deleted") if getattr(item, "is_deleted", False) else None
|
||||
|
||||
brand_sx = sx_call("cart-item-brand", brand=p.brand) if getattr(p, "brand", None) else None
|
||||
|
||||
line_total_sx = None
|
||||
if unit_price:
|
||||
lt = unit_price * item.quantity
|
||||
line_total_sx = sx_call("cart-item-line-total", text=f"Line total: {symbol}{lt:.2f}")
|
||||
|
||||
return sx_call(
|
||||
"cart-item",
|
||||
id=f"cart-item-{slug}", img=SxExpr(img), prod_url=prod_url, title=p.title,
|
||||
brand=SxExpr(brand_sx) if brand_sx else None,
|
||||
deleted=SxExpr(deleted_sx) if deleted_sx else None,
|
||||
price=SxExpr(price_sx),
|
||||
qty_url=qty_url, csrf=csrf, minus=str(item.quantity - 1),
|
||||
qty=str(item.quantity), plus=str(item.quantity + 1),
|
||||
line_total=SxExpr(line_total_sx) if line_total_sx else None,
|
||||
)
|
||||
|
||||
|
||||
def _calendar_entries_sx(entries: list) -> str:
|
||||
"""Render calendar booking entries in cart."""
|
||||
if not entries:
|
||||
return ""
|
||||
parts = []
|
||||
for e in entries:
|
||||
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 ""
|
||||
parts.append(sx_call(
|
||||
"cart-cal-entry",
|
||||
name=name, date_str=f"{start}{end_str}", cost=f"\u00a3{cost:.2f}",
|
||||
))
|
||||
items_sx = "(<> " + " ".join(parts) + ")"
|
||||
return sx_call("cart-cal-section", items=SxExpr(items_sx))
|
||||
|
||||
|
||||
def _ticket_groups_sx(ticket_groups: list, ctx: dict) -> str:
|
||||
"""Render ticket groups in cart."""
|
||||
if not ticket_groups:
|
||||
return ""
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from quart import url_for
|
||||
|
||||
csrf = generate_csrf_token()
|
||||
qty_url = url_for("cart_global.update_ticket_quantity")
|
||||
parts = []
|
||||
|
||||
for tg in ticket_groups:
|
||||
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')}"
|
||||
|
||||
tt_name_sx = sx_call("cart-ticket-type-name", name=tt_name) if tt_name else None
|
||||
tt_hidden_sx = sx_call("cart-ticket-type-hidden", value=str(tt_id)) if tt_id else None
|
||||
|
||||
parts.append(sx_call(
|
||||
"cart-ticket-article",
|
||||
name=name,
|
||||
type_name=SxExpr(tt_name_sx) if tt_name_sx else None,
|
||||
date_str=date_str,
|
||||
price=f"\u00a3{price or 0:.2f}", qty_url=qty_url, csrf=csrf,
|
||||
entry_id=str(entry_id),
|
||||
type_hidden=SxExpr(tt_hidden_sx) if tt_hidden_sx else None,
|
||||
minus=str(max(quantity - 1, 0)), qty=str(quantity),
|
||||
plus=str(quantity + 1), line_total=f"Line total: \u00a3{line_total:.2f}",
|
||||
))
|
||||
|
||||
items_sx = "(<> " + " ".join(parts) + ")"
|
||||
return sx_call("cart-tickets-section", items=SxExpr(items_sx))
|
||||
|
||||
|
||||
def _cart_summary_sx(ctx: dict, cart: list, cal_entries: list, tickets: list,
|
||||
total_fn: Any, cal_total_fn: Any, ticket_total_fn: Any) -> str:
|
||||
"""Render the order summary sidebar."""
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from quart import g, url_for, request
|
||||
from shared.infrastructure.urls import login_url
|
||||
|
||||
csrf = generate_csrf_token()
|
||||
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")
|
||||
|
||||
if user:
|
||||
if page_post:
|
||||
action = url_for("page_cart.page_checkout")
|
||||
else:
|
||||
action = url_for("cart_global.checkout")
|
||||
from shared.utils import route_prefix
|
||||
action = route_prefix() + action
|
||||
checkout_sx = sx_call(
|
||||
"cart-checkout-form",
|
||||
action=action, csrf=csrf, label=f" Checkout as {user.email}",
|
||||
)
|
||||
else:
|
||||
href = login_url(request.url)
|
||||
checkout_sx = sx_call("cart-checkout-signin", href=href)
|
||||
|
||||
return sx_call(
|
||||
"cart-summary-panel",
|
||||
item_count=str(item_count), subtotal=f"{symbol}{grand:.2f}",
|
||||
checkout=SxExpr(checkout_sx),
|
||||
)
|
||||
|
||||
|
||||
def _page_cart_main_panel_sx(ctx: dict, cart: list, cal_entries: list,
|
||||
tickets: list, ticket_groups: list,
|
||||
total_fn: Any, cal_total_fn: Any,
|
||||
ticket_total_fn: Any) -> str:
|
||||
"""Page cart main panel."""
|
||||
if not cart and not cal_entries and not tickets:
|
||||
empty = sx_call("empty-state", icon="fa fa-shopping-cart",
|
||||
message="Your cart is empty", cls="text-center")
|
||||
return (
|
||||
'(div :class "max-w-full px-3 py-3 space-y-3"'
|
||||
' (div :id "cart"'
|
||||
' (div :class "rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center"'
|
||||
f' {empty})))'
|
||||
)
|
||||
|
||||
item_parts = [_cart_item_sx(item, ctx) for item in cart]
|
||||
items_sx = "(<> " + " ".join(item_parts) + ")" if item_parts else '""'
|
||||
cal_sx = _calendar_entries_sx(cal_entries)
|
||||
tickets_sx = _ticket_groups_sx(ticket_groups, ctx)
|
||||
summary_sx = _cart_summary_sx(ctx, cart, cal_entries, tickets, total_fn, cal_total_fn, ticket_total_fn)
|
||||
|
||||
return sx_call(
|
||||
"cart-page-panel",
|
||||
items=SxExpr(items_sx),
|
||||
cal=SxExpr(cal_sx) if cal_sx else None,
|
||||
tickets=SxExpr(tickets_sx) if tickets_sx else None,
|
||||
summary=SxExpr(summary_sx),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Orders list (same pattern as orders service)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _order_row_sx(order: Any, detail_url: str) -> str:
|
||||
"""Render a single order as desktop table row + mobile card."""
|
||||
status = order.status or "pending"
|
||||
sl = status.lower()
|
||||
pill = (
|
||||
"border-emerald-300 bg-emerald-50 text-emerald-700" if sl == "paid"
|
||||
else "border-rose-300 bg-rose-50 text-rose-700" if sl in ("failed", "cancelled")
|
||||
else "border-stone-300 bg-stone-50 text-stone-700"
|
||||
)
|
||||
pill_cls = f"inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] sm:text-xs {pill}"
|
||||
created = order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else "\u2014"
|
||||
total = f"{order.currency or 'GBP'} {order.total_amount or 0:.2f}"
|
||||
|
||||
desktop = sx_call(
|
||||
"order-row-desktop",
|
||||
oid=f"#{order.id}", created=created, desc=order.description or "",
|
||||
total=total, pill=pill_cls, status=status, url=detail_url,
|
||||
)
|
||||
|
||||
mobile_pill = f"inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] {pill}"
|
||||
mobile = sx_call(
|
||||
"order-row-mobile",
|
||||
oid=f"#{order.id}", pill=mobile_pill, status=status,
|
||||
created=created, total=total, url=detail_url,
|
||||
)
|
||||
|
||||
return "(<> " + desktop + " " + mobile + ")"
|
||||
|
||||
|
||||
def _orders_rows_sx(orders: list, page: int, total_pages: int,
|
||||
url_for_fn: Any, qs_fn: Any) -> str:
|
||||
"""Render order rows + infinite scroll sentinel."""
|
||||
from shared.utils import route_prefix
|
||||
pfx = route_prefix()
|
||||
|
||||
parts = [
|
||||
_order_row_sx(o, pfx + url_for_fn("orders.order.order_detail", order_id=o.id))
|
||||
for o in orders
|
||||
]
|
||||
|
||||
if page < total_pages:
|
||||
next_url = pfx + url_for_fn("orders.list_orders") + qs_fn(page=page + 1)
|
||||
parts.append(sx_call(
|
||||
"infinite-scroll",
|
||||
url=next_url, page=page, total_pages=total_pages,
|
||||
id_prefix="orders", colspan=5,
|
||||
))
|
||||
else:
|
||||
parts.append(sx_call("order-end-row"))
|
||||
|
||||
return "(<> " + " ".join(parts) + ")"
|
||||
|
||||
|
||||
def _orders_main_panel_sx(orders: list, rows_sx: str) -> str:
|
||||
"""Main panel for orders list."""
|
||||
if not orders:
|
||||
return sx_call("order-empty-state")
|
||||
return sx_call("order-table", rows=SxExpr(rows_sx))
|
||||
|
||||
|
||||
def _orders_summary_sx(ctx: dict) -> str:
|
||||
"""Filter section for orders list."""
|
||||
return sx_call("order-list-header", search_mobile=SxExpr(search_mobile_sx(ctx)))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Single order detail
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _order_items_sx(order: Any) -> str:
|
||||
"""Render order items list."""
|
||||
if not order or not order.items:
|
||||
return ""
|
||||
parts = []
|
||||
for item in order.items:
|
||||
prod_url = market_product_url(item.product_slug)
|
||||
if item.product_image:
|
||||
img = sx_call(
|
||||
"order-item-image",
|
||||
src=item.product_image, alt=item.product_title or "Product image",
|
||||
)
|
||||
else:
|
||||
img = sx_call("order-item-no-image")
|
||||
parts.append(sx_call(
|
||||
"order-item-row",
|
||||
href=prod_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_sx = "(<> " + " ".join(parts) + ")"
|
||||
return sx_call("order-items-panel", items=SxExpr(items_sx))
|
||||
|
||||
|
||||
def _order_summary_sx(order: Any) -> str:
|
||||
"""Order summary card."""
|
||||
return sx_call(
|
||||
"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,
|
||||
)
|
||||
|
||||
|
||||
def _order_calendar_items_sx(calendar_entries: list | None) -> str:
|
||||
"""Render calendar bookings for an order."""
|
||||
if not calendar_entries:
|
||||
return ""
|
||||
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"
|
||||
)
|
||||
pill_cls = f"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium {pill}"
|
||||
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')}"
|
||||
parts.append(sx_call(
|
||||
"order-calendar-entry",
|
||||
name=e.name, pill=pill_cls, status=st.capitalize(),
|
||||
date_str=ds, cost=f"\u00a3{e.cost or 0:.2f}",
|
||||
))
|
||||
items_sx = "(<> " + " ".join(parts) + ")"
|
||||
return sx_call("order-calendar-section", items=SxExpr(items_sx))
|
||||
|
||||
|
||||
def _order_main_sx(order: Any, calendar_entries: list | None) -> str:
|
||||
"""Main panel for single order detail."""
|
||||
summary = _order_summary_sx(order)
|
||||
items = _order_items_sx(order)
|
||||
cal = _order_calendar_items_sx(calendar_entries)
|
||||
return sx_call(
|
||||
"order-detail-panel",
|
||||
summary=SxExpr(summary),
|
||||
items=SxExpr(items) if items else None,
|
||||
calendar=SxExpr(cal) if cal else None,
|
||||
)
|
||||
|
||||
|
||||
def _order_filter_sx(order: Any, list_url: str, recheck_url: str,
|
||||
pay_url: str, csrf_token: str) -> str:
|
||||
"""Filter section for single order detail."""
|
||||
created = order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else "\u2014"
|
||||
status = order.status or "pending"
|
||||
|
||||
pay_sx = None
|
||||
if status != "paid":
|
||||
pay_sx = sx_call("order-pay-btn", url=pay_url)
|
||||
|
||||
return sx_call(
|
||||
"order-detail-filter",
|
||||
info=f"Placed {created} \u00b7 Status: {status}",
|
||||
list_url=list_url, recheck_url=recheck_url, csrf=csrf_token,
|
||||
pay=SxExpr(pay_sx) if pay_sx else None,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Cart overview
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Orders list
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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
|
||||
list_url = route_prefix() + url_for_fn("orders.list_orders")
|
||||
|
||||
rows = _orders_rows_sx(orders, page, total_pages, url_for_fn, qs_fn)
|
||||
main = _orders_main_panel_sx(orders, rows)
|
||||
|
||||
hdr = root_header_sx(ctx)
|
||||
auth = _auth_header_sx(ctx)
|
||||
orders_hdr = _orders_header_sx(ctx, list_url)
|
||||
auth_child = sx_call(
|
||||
"header-child-sx",
|
||||
inner=SxExpr("(<> " + auth + " " + sx_call("header-child-sx", id="auth-header-child", inner=SxExpr(orders_hdr)) + ")"),
|
||||
)
|
||||
header_rows = "(<> " + hdr + " " + auth_child + ")"
|
||||
|
||||
return full_page_sx(ctx, header_rows=header_rows,
|
||||
filter=_orders_summary_sx(ctx),
|
||||
aside=search_desktop_sx(ctx),
|
||||
content=main)
|
||||
|
||||
|
||||
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."""
|
||||
return _orders_rows_sx(orders, page, total_pages, url_for_fn, qs_fn)
|
||||
|
||||
|
||||
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
|
||||
list_url = route_prefix() + url_for_fn("orders.list_orders")
|
||||
|
||||
rows = _orders_rows_sx(orders, page, total_pages, url_for_fn, qs_fn)
|
||||
main = _orders_main_panel_sx(orders, rows)
|
||||
|
||||
auth_oob = _auth_header_sx(ctx, oob=True)
|
||||
auth_child_oob = sx_call(
|
||||
"oob-header-sx",
|
||||
parent_id="auth-header-child",
|
||||
row=SxExpr(_orders_header_sx(ctx, list_url)),
|
||||
)
|
||||
root_oob = root_header_sx(ctx, oob=True)
|
||||
oobs = "(<> " + auth_oob + " " + auth_child_oob + " " + root_oob + ")"
|
||||
|
||||
return oob_page_sx(oobs=oobs,
|
||||
filter=_orders_summary_sx(ctx),
|
||||
aside=search_desktop_sx(ctx),
|
||||
content=main)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Single order detail
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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)
|
||||
|
||||
main = _order_main_sx(order, calendar_entries)
|
||||
filt = _order_filter_sx(order, list_url, recheck_url, pay_url, generate_csrf_token())
|
||||
|
||||
hdr = root_header_sx(ctx)
|
||||
order_row = sx_call(
|
||||
"menu-row-sx",
|
||||
id="order-row", level=3, colour="sky",
|
||||
link_href=detail_url, link_label=f"Order {order.id}", icon="fa fa-gbp",
|
||||
)
|
||||
order_child = sx_call(
|
||||
"header-child-sx",
|
||||
inner=SxExpr("(<> " + _auth_header_sx(ctx) + " " + sx_call("header-child-sx", id="auth-header-child", inner=SxExpr(
|
||||
"(<> " + _orders_header_sx(ctx, list_url) + " " + sx_call("header-child-sx", id="orders-header-child", inner=SxExpr(order_row)) + ")"
|
||||
)) + ")"),
|
||||
)
|
||||
header_rows = "(<> " + hdr + " " + order_child + ")"
|
||||
|
||||
return 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)
|
||||
|
||||
main = _order_main_sx(order, calendar_entries)
|
||||
filt = _order_filter_sx(order, list_url, recheck_url, pay_url, generate_csrf_token())
|
||||
|
||||
order_row_oob = sx_call(
|
||||
"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 = sx_call("oob-header-sx",
|
||||
parent_id="orders-header-child",
|
||||
row=SxExpr(order_row_oob))
|
||||
root_oob = root_header_sx(ctx, oob=True)
|
||||
oobs = "(<> " + orders_child_oob + " " + root_oob + ")"
|
||||
|
||||
return oob_page_sx(oobs=oobs, filter=filt, content=main)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Checkout error
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _checkout_error_filter_sx() -> str:
|
||||
return sx_call("checkout-error-header")
|
||||
|
||||
|
||||
def _checkout_error_content_sx(error: str | None, order: Any | None) -> str:
|
||||
err_msg = error or "Unexpected error while creating the hosted checkout session."
|
||||
order_sx = None
|
||||
if order:
|
||||
order_sx = sx_call("checkout-error-order-id", oid=f"#{order.id}")
|
||||
back_url = cart_url("/")
|
||||
return sx_call(
|
||||
"checkout-error-content",
|
||||
msg=err_msg,
|
||||
order=SxExpr(order_sx) if order_sx else None,
|
||||
back_url=back_url,
|
||||
)
|
||||
|
||||
|
||||
async def render_checkout_error_page(ctx: dict, error: str | None = None, order: Any | None = None) -> str:
|
||||
"""Full page: checkout error."""
|
||||
hdr = root_header_sx(ctx)
|
||||
filt = _checkout_error_filter_sx()
|
||||
content = _checkout_error_content_sx(error, order)
|
||||
return full_page_sx(ctx, header_rows=hdr, filter=filt, content=content)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Page admin (/<page_slug>/admin/)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _cart_page_admin_header_sx(ctx: dict, page_post: Any, *, oob: bool = False,
|
||||
selected: str = "") -> str:
|
||||
"""Build the page-level admin header row -- delegates to shared helper."""
|
||||
slug = page_post.slug if page_post else ""
|
||||
ctx = _ensure_post_ctx(ctx, page_post)
|
||||
return post_admin_header_sx(ctx, slug, oob=oob, selected=selected)
|
||||
|
||||
|
||||
def _cart_admin_main_panel_sx(ctx: dict) -> str:
|
||||
"""Admin overview panel -- links to sub-admin pages."""
|
||||
from quart import url_for
|
||||
payments_href = url_for("defpage_cart_payments")
|
||||
return (
|
||||
'(div :id "main-panel"'
|
||||
' (div :class "flex items-center justify-between p-3 border-b"'
|
||||
' (span :class "font-medium" (i :class "fa fa-credit-card text-purple-600 mr-1") " Payments")'
|
||||
f' (a :href "{payments_href}" :class "text-sm underline" "configure")))'
|
||||
)
|
||||
|
||||
|
||||
def _cart_payments_main_panel_sx(ctx: dict) -> str:
|
||||
"""Render SumUp payment config form."""
|
||||
from quart import url_for
|
||||
csrf_token = ctx.get("csrf_token")
|
||||
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
|
||||
page_config = ctx.get("page_config")
|
||||
sumup_configured = bool(page_config and getattr(page_config, "sumup_api_key", None))
|
||||
merchant_code = (getattr(page_config, "sumup_merchant_code", None) or "") if page_config else ""
|
||||
checkout_prefix = (getattr(page_config, "sumup_checkout_prefix", None) or "") if page_config else ""
|
||||
update_url = url_for("page_admin.update_sumup")
|
||||
|
||||
placeholder = "--------" if sumup_configured else "sup_sk_..."
|
||||
input_cls = "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500"
|
||||
|
||||
return sx_call("cart-payments-panel",
|
||||
update_url=update_url, csrf=csrf,
|
||||
merchant_code=merchant_code, placeholder=placeholder,
|
||||
input_cls=input_cls, sumup_configured=sumup_configured,
|
||||
checkout_prefix=checkout_prefix)
|
||||
|
||||
|
||||
|
||||
def render_cart_payments_panel(ctx: dict) -> str:
|
||||
"""Render the payments config panel for PUT response."""
|
||||
return _cart_payments_main_panel_sx(ctx)
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -27,111 +303,51 @@ def _register_cart_layouts() -> None:
|
||||
register_custom_layout("cart-admin", _cart_admin_full, _cart_admin_oob)
|
||||
|
||||
|
||||
def _cart_page_full(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import root_header_sx, sx_call, SxExpr
|
||||
from sx.sx_components import _cart_header_sx, _page_cart_header_sx
|
||||
|
||||
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
|
||||
|
||||
page_post = ctx.get("page_post")
|
||||
root_hdr = root_header_sx(ctx)
|
||||
child = _cart_header_sx(ctx)
|
||||
page_hdr = _page_cart_header_sx(ctx, page_post)
|
||||
nested = sx_call(
|
||||
root_hdr = await root_header_sx(ctx)
|
||||
child = await _cart_header_sx(ctx)
|
||||
page_hdr = await _page_cart_header_sx(ctx, page_post)
|
||||
inner_child = await render_to_sx("header-child-sx", id="cart-header-child", inner=SxExpr(page_hdr))
|
||||
nested = await render_to_sx(
|
||||
"header-child-sx",
|
||||
inner=SxExpr("(<> " + child + " " + sx_call("header-child-sx", id="cart-header-child", inner=SxExpr(page_hdr)) + ")"),
|
||||
inner=SxExpr("(<> " + child + " " + inner_child + ")"),
|
||||
)
|
||||
return "(<> " + root_hdr + " " + nested + ")"
|
||||
|
||||
|
||||
def _cart_page_oob(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import root_header_sx, sx_call, SxExpr
|
||||
from sx.sx_components import _cart_header_sx, _page_cart_header_sx
|
||||
|
||||
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
|
||||
|
||||
page_post = ctx.get("page_post")
|
||||
child_oob = sx_call("oob-header-sx",
|
||||
page_hdr = await _page_cart_header_sx(ctx, page_post)
|
||||
child_oob = await render_to_sx("oob-header-sx",
|
||||
parent_id="cart-header-child",
|
||||
row=SxExpr(_page_cart_header_sx(ctx, page_post)))
|
||||
cart_hdr_oob = _cart_header_sx(ctx, oob=True)
|
||||
root_hdr_oob = root_header_sx(ctx, oob=True)
|
||||
row=SxExpr(page_hdr))
|
||||
cart_hdr_oob = await _cart_header_sx(ctx, oob=True)
|
||||
root_hdr_oob = await root_header_sx(ctx, oob=True)
|
||||
return "(<> " + child_oob + " " + cart_hdr_oob + " " + root_hdr_oob + ")"
|
||||
|
||||
|
||||
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", "")
|
||||
root_hdr = root_header_sx(ctx)
|
||||
root_hdr = await root_header_sx(ctx)
|
||||
post_hdr = await _post_header_sx(ctx, page_post)
|
||||
admin_hdr = _cart_page_admin_header_sx(ctx, page_post, selected=selected)
|
||||
admin_hdr = await _cart_page_admin_header_sx(ctx, page_post, selected=selected)
|
||||
return "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")"
|
||||
|
||||
|
||||
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 _cart_page_admin_header_sx(ctx, page_post, oob=True, selected=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,
|
||||
})
|
||||
|
||||
|
||||
async def _h_overview_content(**kw):
|
||||
from quart import g
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import _overview_main_panel_sx
|
||||
from bp.cart.services import get_cart_grouped_by_page
|
||||
page_groups = await get_cart_grouped_by_page(g.s)
|
||||
ctx = await get_template_context()
|
||||
return _overview_main_panel_sx(page_groups, ctx)
|
||||
|
||||
|
||||
async def _h_page_cart_content(page_slug=None, **kw):
|
||||
from quart import g
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import _page_cart_main_panel_sx
|
||||
from bp.cart.services import total, calendar_total, ticket_total
|
||||
from bp.cart.services.page_cart import (
|
||||
get_cart_for_page, get_calendar_entries_for_page, get_tickets_for_page,
|
||||
)
|
||||
from bp.cart.services.ticket_groups import group_tickets
|
||||
|
||||
post = g.page_post
|
||||
cart = await get_cart_for_page(g.s, post.id)
|
||||
cal_entries = await get_calendar_entries_for_page(g.s, post.id)
|
||||
page_tickets = await get_tickets_for_page(g.s, post.id)
|
||||
ticket_groups = group_tickets(page_tickets)
|
||||
|
||||
ctx = await get_template_context()
|
||||
return _page_cart_main_panel_sx(
|
||||
ctx, cart, cal_entries, page_tickets, ticket_groups,
|
||||
total, calendar_total, ticket_total,
|
||||
)
|
||||
|
||||
|
||||
async def _h_cart_admin_content(page_slug=None, **kw):
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import _cart_admin_main_panel_sx
|
||||
ctx = await get_template_context()
|
||||
return _cart_admin_main_panel_sx(ctx)
|
||||
|
||||
|
||||
async def _h_cart_payments_content(page_slug=None, **kw):
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import _cart_payments_main_panel_sx
|
||||
ctx = await get_template_context()
|
||||
return _cart_payments_main_panel_sx(ctx)
|
||||
|
||||
@@ -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
|
||||
|
||||
65
events/actions.sx
Normal file
65
events/actions.sx
Normal file
@@ -0,0 +1,65 @@
|
||||
;; Events service — inter-service action endpoints
|
||||
;;
|
||||
;; Each defaction replaces a Python handler in bp/actions/routes.py.
|
||||
;; The (service ...) primitive calls the registered CalendarService method
|
||||
;; with g.s (async session) + keyword args.
|
||||
|
||||
(defaction adjust-ticket-quantity (&key entry-id count user-id session-id ticket-type-id)
|
||||
"Add or remove tickets for a calendar entry."
|
||||
(do
|
||||
(service "calendar" "adjust-ticket-quantity"
|
||||
:entry-id entry-id :count count
|
||||
:user-id user-id :session-id session-id
|
||||
:ticket-type-id ticket-type-id)
|
||||
{"ok" true}))
|
||||
|
||||
(defaction claim-entries-for-order (&key order-id user-id session-id page-post-id)
|
||||
"Claim pending calendar entries for an order."
|
||||
(do
|
||||
(service "calendar" "claim-entries-for-order"
|
||||
:order-id order-id :user-id user-id
|
||||
:session-id session-id :page-post-id page-post-id)
|
||||
{"ok" true}))
|
||||
|
||||
(defaction claim-tickets-for-order (&key order-id user-id session-id page-post-id)
|
||||
"Claim pending tickets for an order."
|
||||
(do
|
||||
(service "calendar" "claim-tickets-for-order"
|
||||
:order-id order-id :user-id user-id
|
||||
:session-id session-id :page-post-id page-post-id)
|
||||
{"ok" true}))
|
||||
|
||||
(defaction confirm-entries-for-order (&key order-id user-id session-id)
|
||||
"Confirm calendar entries after payment."
|
||||
(do
|
||||
(service "calendar" "confirm-entries-for-order"
|
||||
:order-id order-id :user-id user-id :session-id session-id)
|
||||
{"ok" true}))
|
||||
|
||||
(defaction confirm-tickets-for-order (&key order-id)
|
||||
"Confirm tickets after payment."
|
||||
(do
|
||||
(service "calendar" "confirm-tickets-for-order" :order-id order-id)
|
||||
{"ok" true}))
|
||||
|
||||
(defaction toggle-entry-post (&key entry-id content-type content-id)
|
||||
"Toggle association between a calendar entry and a content item."
|
||||
(let ((is-associated (service "calendar" "toggle-entry-post"
|
||||
:entry-id entry-id
|
||||
:content-type content-type
|
||||
:content-id content-id)))
|
||||
{"is_associated" is-associated}))
|
||||
|
||||
(defaction adopt-entries-for-user (&key user-id session-id)
|
||||
"Transfer anonymous calendar entries to a logged-in user."
|
||||
(do
|
||||
(service "calendar" "adopt-entries-for-user"
|
||||
:user-id user-id :session-id session-id)
|
||||
{"ok" true}))
|
||||
|
||||
(defaction adopt-tickets-for-user (&key user-id session-id)
|
||||
"Transfer anonymous tickets to a logged-in user."
|
||||
(do
|
||||
(service "calendar" "adopt-tickets-for-user"
|
||||
:user-id user-id :session-id session-id)
|
||||
{"ok" true}))
|
||||
@@ -1,139 +1,15 @@
|
||||
"""Events app action endpoints.
|
||||
|
||||
Exposes write operations at ``/internal/actions/<action_name>`` for
|
||||
cross-app callers (cart, blog) via the internal action client.
|
||||
All actions are defined declaratively in ``events/actions.sx`` and
|
||||
dispatched via the sx query registry. No Python fallbacks needed.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, g, jsonify, request
|
||||
from shared.infrastructure.query_blueprint import create_action_blueprint
|
||||
|
||||
from shared.infrastructure.actions import ACTION_HEADER
|
||||
from shared.services.registry import services
|
||||
from quart import Blueprint
|
||||
|
||||
|
||||
def register() -> Blueprint:
|
||||
bp = Blueprint("actions", __name__, url_prefix="/internal/actions")
|
||||
|
||||
@bp.before_request
|
||||
async def _require_action_header():
|
||||
if not request.headers.get(ACTION_HEADER):
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
from shared.infrastructure.internal_auth import validate_internal_request
|
||||
if not validate_internal_request():
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
|
||||
_handlers: dict[str, object] = {}
|
||||
|
||||
@bp.post("/<action_name>")
|
||||
async def handle_action(action_name: str):
|
||||
handler = _handlers.get(action_name)
|
||||
if handler is None:
|
||||
return jsonify({"error": "unknown action"}), 404
|
||||
try:
|
||||
result = await handler()
|
||||
return jsonify(result)
|
||||
except Exception as exc:
|
||||
import logging
|
||||
logging.getLogger(__name__).exception("Action %s failed", action_name)
|
||||
return jsonify({"error": str(exc)}), 500
|
||||
|
||||
# --- adjust-ticket-quantity ---
|
||||
async def _adjust_ticket_quantity():
|
||||
data = await request.get_json()
|
||||
await services.calendar.adjust_ticket_quantity(
|
||||
g.s,
|
||||
data["entry_id"],
|
||||
data["count"],
|
||||
user_id=data.get("user_id"),
|
||||
session_id=data.get("session_id"),
|
||||
ticket_type_id=data.get("ticket_type_id"),
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
_handlers["adjust-ticket-quantity"] = _adjust_ticket_quantity
|
||||
|
||||
# --- claim-entries-for-order ---
|
||||
async def _claim_entries():
|
||||
data = await request.get_json()
|
||||
await services.calendar.claim_entries_for_order(
|
||||
g.s,
|
||||
data["order_id"],
|
||||
data.get("user_id"),
|
||||
data.get("session_id"),
|
||||
data.get("page_post_id"),
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
_handlers["claim-entries-for-order"] = _claim_entries
|
||||
|
||||
# --- claim-tickets-for-order ---
|
||||
async def _claim_tickets():
|
||||
data = await request.get_json()
|
||||
await services.calendar.claim_tickets_for_order(
|
||||
g.s,
|
||||
data["order_id"],
|
||||
data.get("user_id"),
|
||||
data.get("session_id"),
|
||||
data.get("page_post_id"),
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
_handlers["claim-tickets-for-order"] = _claim_tickets
|
||||
|
||||
# --- confirm-entries-for-order ---
|
||||
async def _confirm_entries():
|
||||
data = await request.get_json()
|
||||
await services.calendar.confirm_entries_for_order(
|
||||
g.s,
|
||||
data["order_id"],
|
||||
data.get("user_id"),
|
||||
data.get("session_id"),
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
_handlers["confirm-entries-for-order"] = _confirm_entries
|
||||
|
||||
# --- confirm-tickets-for-order ---
|
||||
async def _confirm_tickets():
|
||||
data = await request.get_json()
|
||||
await services.calendar.confirm_tickets_for_order(
|
||||
g.s, data["order_id"],
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
_handlers["confirm-tickets-for-order"] = _confirm_tickets
|
||||
|
||||
# --- toggle-entry-post ---
|
||||
async def _toggle_entry_post():
|
||||
data = await request.get_json()
|
||||
is_associated = await services.calendar.toggle_entry_post(
|
||||
g.s,
|
||||
data["entry_id"],
|
||||
data["content_type"],
|
||||
data["content_id"],
|
||||
)
|
||||
return {"is_associated": is_associated}
|
||||
|
||||
_handlers["toggle-entry-post"] = _toggle_entry_post
|
||||
|
||||
# --- adopt-entries-for-user ---
|
||||
async def _adopt_entries():
|
||||
data = await request.get_json()
|
||||
await services.calendar.adopt_entries_for_user(
|
||||
g.s, data["user_id"], data["session_id"],
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
_handlers["adopt-entries-for-user"] = _adopt_entries
|
||||
|
||||
# --- adopt-tickets-for-user ---
|
||||
async def _adopt_tickets():
|
||||
data = await request.get_json()
|
||||
await services.calendar.adopt_tickets_for_user(
|
||||
g.s, data["user_id"], data["session_id"],
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
_handlers["adopt-tickets-for-user"] = _adopt_tickets
|
||||
|
||||
bp, _handlers = create_action_blueprint("events")
|
||||
return bp
|
||||
|
||||
@@ -126,7 +126,7 @@ def register() -> Blueprint:
|
||||
frag_params["session_id"] = ident["session_id"]
|
||||
|
||||
from sx.sx_components import render_ticket_widget
|
||||
widget_html = render_ticket_widget(entry, qty, "/all-tickets/adjust")
|
||||
widget_html = await render_ticket_widget(entry, qty, "/all-tickets/adjust")
|
||||
mini_html = await fetch_fragment("cart", "cart-mini", params=frag_params, required=False)
|
||||
return sx_response(widget_html + (mini_html or ""))
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ def register():
|
||||
@require_admin
|
||||
async def calendar_description_edit(calendar_slug: str, **kwargs):
|
||||
from sx.sx_components import render_calendar_description_edit
|
||||
html = render_calendar_description_edit(g.calendar)
|
||||
html = await render_calendar_description_edit(g.calendar)
|
||||
return sx_response(html)
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ def register():
|
||||
await g.s.flush()
|
||||
|
||||
from sx.sx_components import render_calendar_description
|
||||
html = render_calendar_description(g.calendar, oob=True)
|
||||
html = await render_calendar_description(g.calendar, oob=True)
|
||||
return sx_response(html)
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ def register():
|
||||
@require_admin
|
||||
async def calendar_description_view(calendar_slug: str, **kwargs):
|
||||
from sx.sx_components import render_calendar_description
|
||||
html = render_calendar_description(g.calendar)
|
||||
html = await render_calendar_description(g.calendar)
|
||||
return sx_response(html)
|
||||
|
||||
return bp
|
||||
|
||||
@@ -201,7 +201,7 @@ def register():
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import _calendar_admin_main_panel_html
|
||||
ctx = await get_template_context()
|
||||
html = _calendar_admin_main_panel_html(ctx)
|
||||
html = await _calendar_admin_main_panel_html(ctx)
|
||||
return sx_response(html)
|
||||
|
||||
|
||||
@@ -220,7 +220,7 @@ def register():
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_calendars_list_panel
|
||||
ctx = await get_template_context()
|
||||
html = render_calendars_list_panel(ctx)
|
||||
html = await render_calendars_list_panel(ctx)
|
||||
|
||||
if post_data:
|
||||
from shared.services.entry_associations import get_associated_entries
|
||||
@@ -236,7 +236,7 @@ def register():
|
||||
).scalars().all()
|
||||
|
||||
associated_entries = await get_associated_entries(post_id)
|
||||
nav_oob = render_post_nav_entries_oob(associated_entries, cals, post_data["post"])
|
||||
nav_oob = await render_post_nav_entries_oob(associated_entries, cals, post_data["post"])
|
||||
html = html + nav_oob
|
||||
|
||||
return sx_response(html)
|
||||
|
||||
@@ -259,7 +259,7 @@ def register():
|
||||
}
|
||||
|
||||
from sx.sx_components import render_day_main_panel
|
||||
html = render_day_main_panel(ctx)
|
||||
html = await render_day_main_panel(ctx)
|
||||
mini_html = await fetch_fragment("cart", "cart-mini", params=frag_params, required=False)
|
||||
return sx_response(html + (mini_html or ""))
|
||||
|
||||
@@ -280,12 +280,12 @@ def register():
|
||||
day_slots = list(result.scalars())
|
||||
|
||||
from sx.sx_components import render_entry_add_form
|
||||
return sx_response(render_entry_add_form(g.calendar, day, month, year, day_slots))
|
||||
return sx_response(await render_entry_add_form(g.calendar, day, month, year, day_slots))
|
||||
|
||||
@bp.get("/add-button/")
|
||||
async def add_button(day: int, month: int, year: int, **kwargs):
|
||||
from sx.sx_components import render_entry_add_button
|
||||
return sx_response(render_entry_add_button(g.calendar, day, month, year))
|
||||
return sx_response(await render_entry_add_button(g.calendar, day, month, year))
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -112,7 +112,7 @@ def register():
|
||||
|
||||
# Render OOB nav
|
||||
from sx.sx_components import render_day_entries_nav_oob
|
||||
return render_day_entries_nav_oob(visible.confirmed_entries, calendar, day_date)
|
||||
return await render_day_entries_nav_oob(visible.confirmed_entries, calendar, day_date)
|
||||
|
||||
async def get_post_nav_oob(entry_id: int):
|
||||
"""Helper to generate OOB update for post entries nav when entry state changes"""
|
||||
@@ -149,7 +149,7 @@ def register():
|
||||
|
||||
# Render OOB nav for this post
|
||||
from sx.sx_components import render_post_nav_entries_oob
|
||||
nav_oob = render_post_nav_entries_oob(associated_entries, calendars, post)
|
||||
nav_oob = await render_post_nav_entries_oob(associated_entries, calendars, post)
|
||||
nav_oobs.append(nav_oob)
|
||||
|
||||
return "".join(nav_oobs)
|
||||
@@ -257,7 +257,7 @@ def register():
|
||||
day_slots = list(result.scalars())
|
||||
|
||||
from sx.sx_components import render_entry_edit_form
|
||||
return sx_response(render_entry_edit_form(g.entry, g.calendar, day, month, year, day_slots))
|
||||
return sx_response(await render_entry_edit_form(g.entry, g.calendar, day, month, year, day_slots))
|
||||
|
||||
@bp.put("/")
|
||||
@require_admin
|
||||
@@ -423,7 +423,7 @@ def register():
|
||||
from sx.sx_components import _entry_main_panel_html
|
||||
|
||||
tctx = await get_template_context()
|
||||
html = _entry_main_panel_html(tctx)
|
||||
html = await _entry_main_panel_html(tctx)
|
||||
return sx_response(html + nav_oob)
|
||||
|
||||
|
||||
@@ -449,7 +449,7 @@ def register():
|
||||
# Re-read entry to get updated state
|
||||
await g.s.refresh(g.entry)
|
||||
from sx.sx_components import render_entry_optioned
|
||||
html = render_entry_optioned(g.entry, g.calendar, day, month, year)
|
||||
html = await render_entry_optioned(g.entry, g.calendar, day, month, year)
|
||||
return sx_response(html + day_nav_oob + post_nav_oob)
|
||||
|
||||
@bp.post("/decline/")
|
||||
@@ -474,7 +474,7 @@ def register():
|
||||
# Re-read entry to get updated state
|
||||
await g.s.refresh(g.entry)
|
||||
from sx.sx_components import render_entry_optioned
|
||||
html = render_entry_optioned(g.entry, g.calendar, day, month, year)
|
||||
html = await render_entry_optioned(g.entry, g.calendar, day, month, year)
|
||||
return sx_response(html + day_nav_oob + post_nav_oob)
|
||||
|
||||
@bp.post("/provisional/")
|
||||
@@ -499,7 +499,7 @@ def register():
|
||||
# Re-read entry to get updated state
|
||||
await g.s.refresh(g.entry)
|
||||
from sx.sx_components import render_entry_optioned
|
||||
html = render_entry_optioned(g.entry, g.calendar, day, month, year)
|
||||
html = await render_entry_optioned(g.entry, g.calendar, day, month, year)
|
||||
return sx_response(html + day_nav_oob + post_nav_oob)
|
||||
|
||||
@bp.post("/tickets/")
|
||||
@@ -543,7 +543,7 @@ def register():
|
||||
# Return just the tickets fragment (targeted by hx-target="#entry-tickets-...")
|
||||
await g.s.refresh(g.entry)
|
||||
from sx.sx_components import render_entry_tickets_config
|
||||
html = render_entry_tickets_config(g.entry, g.calendar, request.view_args.get("day"), request.view_args.get("month"), request.view_args.get("year"))
|
||||
html = await render_entry_tickets_config(g.entry, g.calendar, request.view_args.get("day"), request.view_args.get("month"), request.view_args.get("year"))
|
||||
return sx_response(html)
|
||||
|
||||
@bp.get("/posts/search/")
|
||||
@@ -559,7 +559,7 @@ def register():
|
||||
|
||||
va = request.view_args or {}
|
||||
from sx.sx_components import render_post_search_results
|
||||
return sx_response(render_post_search_results(
|
||||
return sx_response(await render_post_search_results(
|
||||
search_posts, query, page, total_pages,
|
||||
g.entry, g.calendar,
|
||||
va.get("day"), va.get("month"), va.get("year"),
|
||||
@@ -594,8 +594,8 @@ def register():
|
||||
# Return updated posts list + OOB nav update
|
||||
from sx.sx_components import render_entry_posts_panel, render_entry_posts_nav_oob
|
||||
va = request.view_args or {}
|
||||
html = render_entry_posts_panel(entry_posts, g.entry, g.calendar, va.get("day"), va.get("month"), va.get("year"))
|
||||
nav_oob = render_entry_posts_nav_oob(entry_posts)
|
||||
html = await render_entry_posts_panel(entry_posts, g.entry, g.calendar, va.get("day"), va.get("month"), va.get("year"))
|
||||
nav_oob = await render_entry_posts_nav_oob(entry_posts)
|
||||
return sx_response(html + nav_oob)
|
||||
|
||||
@bp.delete("/posts/<int:post_id>/")
|
||||
@@ -616,8 +616,8 @@ def register():
|
||||
# Return updated posts list + OOB nav update
|
||||
from sx.sx_components import render_entry_posts_panel, render_entry_posts_nav_oob
|
||||
va = request.view_args or {}
|
||||
html = render_entry_posts_panel(entry_posts, g.entry, g.calendar, va.get("day"), va.get("month"), va.get("year"))
|
||||
nav_oob = render_entry_posts_nav_oob(entry_posts)
|
||||
html = await render_entry_posts_panel(entry_posts, g.entry, g.calendar, va.get("day"), va.get("month"), va.get("year"))
|
||||
nav_oob = await render_entry_posts_nav_oob(entry_posts)
|
||||
return sx_response(html + nav_oob)
|
||||
|
||||
return bp
|
||||
|
||||
@@ -69,7 +69,7 @@ def register():
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_calendars_list_panel
|
||||
ctx = await get_template_context()
|
||||
html = render_calendars_list_panel(ctx)
|
||||
html = await render_calendars_list_panel(ctx)
|
||||
|
||||
# Blog-embedded mode: also update post nav
|
||||
if post_data:
|
||||
@@ -85,7 +85,7 @@ def register():
|
||||
).scalars().all()
|
||||
|
||||
associated_entries = await get_associated_entries(post_id)
|
||||
nav_oob = render_post_nav_entries_oob(associated_entries, cals, post_data["post"])
|
||||
nav_oob = await render_post_nav_entries_oob(associated_entries, cals, post_data["post"])
|
||||
html = html + nav_oob
|
||||
|
||||
return sx_response(html)
|
||||
|
||||
@@ -1,148 +1,14 @@
|
||||
"""Events app data endpoints.
|
||||
|
||||
Exposes read-only JSON queries at ``/internal/data/<query_name>`` for
|
||||
cross-app callers via the internal data client.
|
||||
All queries are defined in ``events/queries.sx``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, g, jsonify, request
|
||||
from quart import Blueprint
|
||||
|
||||
from shared.infrastructure.data_client import DATA_HEADER
|
||||
from shared.contracts.dtos import dto_to_dict
|
||||
from shared.services.registry import services
|
||||
from shared.infrastructure.query_blueprint import create_data_blueprint
|
||||
|
||||
|
||||
def register() -> Blueprint:
|
||||
bp = Blueprint("data", __name__, url_prefix="/internal/data")
|
||||
|
||||
@bp.before_request
|
||||
async def _require_data_header():
|
||||
if not request.headers.get(DATA_HEADER):
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
from shared.infrastructure.internal_auth import validate_internal_request
|
||||
if not validate_internal_request():
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
|
||||
_handlers: dict[str, object] = {}
|
||||
|
||||
@bp.get("/<query_name>")
|
||||
async def handle_query(query_name: str):
|
||||
handler = _handlers.get(query_name)
|
||||
if handler is None:
|
||||
return jsonify({"error": "unknown query"}), 404
|
||||
result = await handler()
|
||||
return jsonify(result)
|
||||
|
||||
# --- pending-entries ---
|
||||
async def _pending_entries():
|
||||
user_id = request.args.get("user_id", type=int)
|
||||
session_id = request.args.get("session_id")
|
||||
entries = await services.calendar.pending_entries(
|
||||
g.s, user_id=user_id, session_id=session_id,
|
||||
)
|
||||
return [dto_to_dict(e) for e in entries]
|
||||
|
||||
_handlers["pending-entries"] = _pending_entries
|
||||
|
||||
# --- pending-tickets ---
|
||||
async def _pending_tickets():
|
||||
user_id = request.args.get("user_id", type=int)
|
||||
session_id = request.args.get("session_id")
|
||||
tickets = await services.calendar.pending_tickets(
|
||||
g.s, user_id=user_id, session_id=session_id,
|
||||
)
|
||||
return [dto_to_dict(t) for t in tickets]
|
||||
|
||||
_handlers["pending-tickets"] = _pending_tickets
|
||||
|
||||
# --- entries-for-page ---
|
||||
async def _entries_for_page():
|
||||
page_id = request.args.get("page_id", type=int)
|
||||
user_id = request.args.get("user_id", type=int)
|
||||
session_id = request.args.get("session_id")
|
||||
entries = await services.calendar.entries_for_page(
|
||||
g.s, page_id, user_id=user_id, session_id=session_id,
|
||||
)
|
||||
return [dto_to_dict(e) for e in entries]
|
||||
|
||||
_handlers["entries-for-page"] = _entries_for_page
|
||||
|
||||
# --- tickets-for-page ---
|
||||
async def _tickets_for_page():
|
||||
page_id = request.args.get("page_id", type=int)
|
||||
user_id = request.args.get("user_id", type=int)
|
||||
session_id = request.args.get("session_id")
|
||||
tickets = await services.calendar.tickets_for_page(
|
||||
g.s, page_id, user_id=user_id, session_id=session_id,
|
||||
)
|
||||
return [dto_to_dict(t) for t in tickets]
|
||||
|
||||
_handlers["tickets-for-page"] = _tickets_for_page
|
||||
|
||||
# --- entries-for-order ---
|
||||
async def _entries_for_order():
|
||||
order_id = request.args.get("order_id", type=int)
|
||||
entries = await services.calendar.get_entries_for_order(g.s, order_id)
|
||||
return [dto_to_dict(e) for e in entries]
|
||||
|
||||
_handlers["entries-for-order"] = _entries_for_order
|
||||
|
||||
# --- tickets-for-order ---
|
||||
async def _tickets_for_order():
|
||||
order_id = request.args.get("order_id", type=int)
|
||||
tickets = await services.calendar.get_tickets_for_order(g.s, order_id)
|
||||
return [dto_to_dict(t) for t in tickets]
|
||||
|
||||
_handlers["tickets-for-order"] = _tickets_for_order
|
||||
|
||||
# --- entry-ids-for-content ---
|
||||
async def _entry_ids_for_content():
|
||||
content_type = request.args.get("content_type", "")
|
||||
content_id = request.args.get("content_id", type=int)
|
||||
ids = await services.calendar.entry_ids_for_content(g.s, content_type, content_id)
|
||||
return list(ids)
|
||||
|
||||
_handlers["entry-ids-for-content"] = _entry_ids_for_content
|
||||
|
||||
# --- associated-entries ---
|
||||
async def _associated_entries():
|
||||
content_type = request.args.get("content_type", "")
|
||||
content_id = request.args.get("content_id", type=int)
|
||||
page = request.args.get("page", 1, type=int)
|
||||
entries, has_more = await services.calendar.associated_entries(
|
||||
g.s, content_type, content_id, page,
|
||||
)
|
||||
return {"entries": [dto_to_dict(e) for e in entries], "has_more": has_more}
|
||||
|
||||
_handlers["associated-entries"] = _associated_entries
|
||||
|
||||
# --- calendars-for-container ---
|
||||
async def _calendars_for_container():
|
||||
container_type = request.args.get("type", "")
|
||||
container_id = request.args.get("id", type=int)
|
||||
calendars = await services.calendar.calendars_for_container(
|
||||
g.s, container_type, container_id,
|
||||
)
|
||||
return [dto_to_dict(c) for c in calendars]
|
||||
|
||||
_handlers["calendars-for-container"] = _calendars_for_container
|
||||
|
||||
# --- visible-entries-for-period ---
|
||||
async def _visible_entries_for_period():
|
||||
from datetime import datetime
|
||||
calendar_id = request.args.get("calendar_id", type=int)
|
||||
period_start = datetime.fromisoformat(request.args.get("period_start", ""))
|
||||
period_end = datetime.fromisoformat(request.args.get("period_end", ""))
|
||||
user_id = request.args.get("user_id", type=int)
|
||||
session_id = request.args.get("session_id")
|
||||
# is_admin determined server-side, never from client params
|
||||
is_admin = False
|
||||
entries = await services.calendar.visible_entries_for_period(
|
||||
g.s, calendar_id, period_start, period_end,
|
||||
user_id=user_id, is_admin=is_admin, session_id=session_id,
|
||||
)
|
||||
return [dto_to_dict(e) for e in entries]
|
||||
|
||||
_handlers["visible-entries-for-period"] = _visible_entries_for_period
|
||||
|
||||
bp, _handlers = create_data_blueprint("events")
|
||||
return bp
|
||||
|
||||
@@ -44,7 +44,7 @@ def register():
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_markets_list_panel
|
||||
ctx = await get_template_context()
|
||||
return sx_response(render_markets_list_panel(ctx))
|
||||
return sx_response(await render_markets_list_panel(ctx))
|
||||
|
||||
@bp.delete("/<market_slug>/")
|
||||
@require_admin
|
||||
@@ -57,6 +57,6 @@ def register():
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_markets_list_panel
|
||||
ctx = await get_template_context()
|
||||
return sx_response(render_markets_list_panel(ctx))
|
||||
return sx_response(await render_markets_list_panel(ctx))
|
||||
|
||||
return bp
|
||||
|
||||
@@ -107,7 +107,7 @@ def register() -> Blueprint:
|
||||
frag_params["session_id"] = ident["session_id"]
|
||||
|
||||
from sx.sx_components import render_ticket_widget
|
||||
widget_html = render_ticket_widget(entry, qty, f"/{g.post_slug}/tickets/adjust")
|
||||
widget_html = await render_ticket_widget(entry, qty, f"/{g.post_slug}/tickets/adjust")
|
||||
mini_html = await fetch_fragment("cart", "cart-mini", params=frag_params, required=False)
|
||||
return sx_response(widget_html + (mini_html or ""))
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ def register():
|
||||
if not slot:
|
||||
return await make_response("Not found", 404)
|
||||
from sx.sx_components import render_slot_edit_form
|
||||
return sx_response(render_slot_edit_form(slot, g.calendar))
|
||||
return sx_response(await render_slot_edit_form(slot, g.calendar))
|
||||
|
||||
@bp.get("/view/")
|
||||
@require_admin
|
||||
@@ -45,7 +45,7 @@ def register():
|
||||
if not slot:
|
||||
return await make_response("Not found", 404)
|
||||
from sx.sx_components import render_slot_main_panel
|
||||
return sx_response(render_slot_main_panel(slot, g.calendar))
|
||||
return sx_response(await render_slot_main_panel(slot, g.calendar))
|
||||
|
||||
@bp.delete("/")
|
||||
@require_admin
|
||||
@@ -54,7 +54,7 @@ def register():
|
||||
await svc_delete_slot(g.s, slot_id)
|
||||
slots = await svc_list_slots(g.s, g.calendar.id)
|
||||
from sx.sx_components import render_slots_table
|
||||
return sx_response(render_slots_table(slots, g.calendar))
|
||||
return sx_response(await render_slots_table(slots, g.calendar))
|
||||
|
||||
@bp.put("/")
|
||||
@require_admin
|
||||
@@ -136,7 +136,7 @@ def register():
|
||||
), 422
|
||||
|
||||
from sx.sx_components import render_slot_main_panel
|
||||
return sx_response(render_slot_main_panel(slot, g.calendar, oob=True))
|
||||
return sx_response(await render_slot_main_panel(slot, g.calendar, oob=True))
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -111,19 +111,19 @@ def register():
|
||||
# Success → re-render the slots table
|
||||
slots = await svc_list_slots(g.s, g.calendar.id)
|
||||
from sx.sx_components import render_slots_table
|
||||
return sx_response(render_slots_table(slots, g.calendar))
|
||||
return sx_response(await render_slots_table(slots, g.calendar))
|
||||
|
||||
|
||||
@bp.get("/add")
|
||||
@require_admin
|
||||
async def add_form(**kwargs):
|
||||
from sx.sx_components import render_slot_add_form
|
||||
return sx_response(render_slot_add_form(g.calendar))
|
||||
return sx_response(await render_slot_add_form(g.calendar))
|
||||
|
||||
@bp.get("/add-button")
|
||||
@require_admin
|
||||
async def add_button(**kwargs):
|
||||
from sx.sx_components import render_slot_add_button
|
||||
return sx_response(render_slot_add_button(g.calendar))
|
||||
return sx_response(await render_slot_add_button(g.calendar))
|
||||
|
||||
return bp
|
||||
|
||||
@@ -54,7 +54,7 @@ def register() -> Blueprint:
|
||||
tickets = await get_tickets_for_entry(g.s, entry_id)
|
||||
|
||||
from sx.sx_components import render_entry_tickets_admin
|
||||
html = render_entry_tickets_admin(entry, tickets)
|
||||
html = await render_entry_tickets_admin(entry, tickets)
|
||||
return sx_response(html)
|
||||
|
||||
@bp.get("/lookup/")
|
||||
@@ -71,9 +71,9 @@ def register() -> Blueprint:
|
||||
ticket = await get_ticket_by_code(g.s, code)
|
||||
from sx.sx_components import render_lookup_result
|
||||
if not ticket:
|
||||
return sx_response(render_lookup_result(None, "Ticket not found"))
|
||||
return sx_response(await render_lookup_result(None, "Ticket not found"))
|
||||
|
||||
return sx_response(render_lookup_result(ticket, None))
|
||||
return sx_response(await render_lookup_result(ticket, None))
|
||||
|
||||
@bp.post("/<code>/checkin/")
|
||||
@require_admin
|
||||
@@ -84,9 +84,9 @@ def register() -> Blueprint:
|
||||
|
||||
from sx.sx_components import render_checkin_result
|
||||
if not success:
|
||||
return sx_response(render_checkin_result(False, error, None))
|
||||
return sx_response(await render_checkin_result(False, error, None))
|
||||
|
||||
ticket = await get_ticket_by_code(g.s, code)
|
||||
return sx_response(render_checkin_result(True, None, ticket))
|
||||
return sx_response(await render_checkin_result(True, None, ticket))
|
||||
|
||||
return bp
|
||||
|
||||
@@ -32,7 +32,7 @@ def register():
|
||||
|
||||
from sx.sx_components import render_ticket_type_edit_form
|
||||
va = request.view_args or {}
|
||||
return sx_response(render_ticket_type_edit_form(
|
||||
return sx_response(await render_ticket_type_edit_form(
|
||||
ticket_type, g.entry, g.calendar,
|
||||
va.get("day"), va.get("month"), va.get("year"),
|
||||
))
|
||||
@@ -47,7 +47,7 @@ def register():
|
||||
|
||||
from sx.sx_components import render_ticket_type_main_panel
|
||||
va = request.view_args or {}
|
||||
return sx_response(render_ticket_type_main_panel(
|
||||
return sx_response(await render_ticket_type_main_panel(
|
||||
ticket_type, g.entry, g.calendar,
|
||||
va.get("day"), va.get("month"), va.get("year"),
|
||||
))
|
||||
@@ -114,7 +114,7 @@ def register():
|
||||
# Return updated view with OOB flag
|
||||
from sx.sx_components import render_ticket_type_main_panel
|
||||
va = request.view_args or {}
|
||||
return sx_response(render_ticket_type_main_panel(
|
||||
return sx_response(await render_ticket_type_main_panel(
|
||||
ticket_type, g.entry, g.calendar,
|
||||
va.get("day"), va.get("month"), va.get("year"),
|
||||
oob=True,
|
||||
@@ -133,7 +133,7 @@ def register():
|
||||
ticket_types = await svc_list_ticket_types(g.s, g.entry.id)
|
||||
from sx.sx_components import render_ticket_types_table
|
||||
va = request.view_args or {}
|
||||
return sx_response(render_ticket_types_table(
|
||||
return sx_response(await render_ticket_types_table(
|
||||
ticket_types, g.entry, g.calendar,
|
||||
va.get("day"), va.get("month"), va.get("year"),
|
||||
))
|
||||
|
||||
@@ -95,7 +95,7 @@ def register():
|
||||
ticket_types = await svc_list_ticket_types(g.s, g.entry.id)
|
||||
from sx.sx_components import render_ticket_types_table
|
||||
va = request.view_args or {}
|
||||
return sx_response(render_ticket_types_table(
|
||||
return sx_response(await render_ticket_types_table(
|
||||
ticket_types, g.entry, g.calendar,
|
||||
va.get("day"), va.get("month"), va.get("year"),
|
||||
))
|
||||
@@ -106,7 +106,7 @@ def register():
|
||||
"""Show the add ticket type form."""
|
||||
from sx.sx_components import render_ticket_type_add_form
|
||||
va = request.view_args or {}
|
||||
return sx_response(render_ticket_type_add_form(
|
||||
return sx_response(await render_ticket_type_add_form(
|
||||
g.entry, g.calendar,
|
||||
va.get("day"), va.get("month"), va.get("year"),
|
||||
))
|
||||
@@ -117,7 +117,7 @@ def register():
|
||||
"""Show the add ticket type button."""
|
||||
from sx.sx_components import render_ticket_type_add_button
|
||||
va = request.view_args or {}
|
||||
return sx_response(render_ticket_type_add_button(
|
||||
return sx_response(await render_ticket_type_add_button(
|
||||
g.entry, g.calendar,
|
||||
va.get("day"), va.get("month"), va.get("year"),
|
||||
))
|
||||
|
||||
@@ -127,7 +127,7 @@ def register() -> Blueprint:
|
||||
cart_count = summary.count + summary.calendar_count + summary.ticket_count
|
||||
|
||||
from sx.sx_components import render_buy_result
|
||||
return sx_response(render_buy_result(entry, created, remaining, cart_count))
|
||||
return sx_response(await render_buy_result(entry, created, remaining, cart_count))
|
||||
|
||||
@bp.post("/adjust/")
|
||||
@clear_cache(tag="calendars", tag_scope="all")
|
||||
@@ -250,7 +250,7 @@ def register() -> Blueprint:
|
||||
cart_count = summary.count + summary.calendar_count + summary.ticket_count
|
||||
|
||||
from sx.sx_components import render_adjust_response
|
||||
return sx_response(render_adjust_response(
|
||||
return sx_response(await render_adjust_response(
|
||||
entry, ticket_remaining, ticket_sold_count,
|
||||
user_ticket_count, user_ticket_counts_by_type, cart_count,
|
||||
))
|
||||
|
||||
57
events/queries.sx
Normal file
57
events/queries.sx
Normal file
@@ -0,0 +1,57 @@
|
||||
;; Events service — inter-service data queries
|
||||
;;
|
||||
;; Each defquery replaces a Python handler in bp/data/routes.py.
|
||||
;; The (service ...) primitive calls the registered CalendarService method
|
||||
;; with g.s (async session) + keyword args, and auto-converts DTOs to dicts.
|
||||
|
||||
(defquery pending-entries (&key user-id session-id)
|
||||
"Calendar entries in pending state for a user or session."
|
||||
(service "calendar" "pending-entries"
|
||||
:user-id user-id :session-id session-id))
|
||||
|
||||
(defquery pending-tickets (&key user-id session-id)
|
||||
"Tickets in pending state for a user or session."
|
||||
(service "calendar" "pending-tickets"
|
||||
:user-id user-id :session-id session-id))
|
||||
|
||||
(defquery entries-for-page (&key page-id user-id session-id)
|
||||
"Calendar entries for a specific page."
|
||||
(service "calendar" "entries-for-page"
|
||||
:page-id page-id :user-id user-id :session-id session-id))
|
||||
|
||||
(defquery tickets-for-page (&key page-id user-id session-id)
|
||||
"Tickets for a specific page."
|
||||
(service "calendar" "tickets-for-page"
|
||||
:page-id page-id :user-id user-id :session-id session-id))
|
||||
|
||||
(defquery entries-for-order (&key order-id)
|
||||
"Calendar entries claimed by an order."
|
||||
(service "calendar" "get-entries-for-order" :order-id order-id))
|
||||
|
||||
(defquery tickets-for-order (&key order-id)
|
||||
"Tickets claimed by an order."
|
||||
(service "calendar" "get-tickets-for-order" :order-id order-id))
|
||||
|
||||
(defquery entry-ids-for-content (&key content-type content-id)
|
||||
"Entry IDs associated with a content item."
|
||||
(service "calendar" "entry-ids-for-content"
|
||||
:content-type content-type :content-id content-id))
|
||||
|
||||
(defquery associated-entries (&key content-type content-id page)
|
||||
"Entries associated with content, paginated."
|
||||
(let ((result (service "calendar" "associated-entries"
|
||||
:content-type content-type :content-id content-id :page page)))
|
||||
{"entries" (nth result 0) "has_more" (nth result 1)}))
|
||||
|
||||
(defquery calendars-for-container (&key type id)
|
||||
"Calendars attached to a container (page, marketplace, etc)."
|
||||
(service "calendar" "calendars-for-container"
|
||||
:container-type type :container-id id))
|
||||
|
||||
(defquery visible-entries-for-period (&key calendar-id period-start period-end user-id session-id)
|
||||
"Visible entries within a date range for a calendar."
|
||||
(service "calendar" "visible-entries-for-period"
|
||||
:calendar-id calendar-id
|
||||
:period-start (parse-datetime period-start)
|
||||
:period-end (parse-datetime period-end)
|
||||
:user-id user-id :is-admin false :session-id session-id))
|
||||
File diff suppressed because it is too large
Load Diff
@@ -44,11 +44,11 @@ async def _cal_admin_full(ctx: dict, **kw: Any) -> str:
|
||||
)
|
||||
ctx = await _ensure_container_nav(ctx)
|
||||
slug = (ctx.get("post") or {}).get("slug", "")
|
||||
root_hdr = root_header_sx(ctx)
|
||||
post_hdr = _post_header_sx(ctx)
|
||||
admin_hdr = post_admin_header_sx(ctx, slug, selected="calendars")
|
||||
child = admin_hdr + _calendar_header_sx(ctx) + _calendar_admin_header_sx(ctx)
|
||||
return root_hdr + post_hdr + header_child_sx(child)
|
||||
root_hdr = await root_header_sx(ctx)
|
||||
post_hdr = await _post_header_sx(ctx)
|
||||
admin_hdr = await post_admin_header_sx(ctx, slug, selected="calendars")
|
||||
child = admin_hdr + await _calendar_header_sx(ctx) + await _calendar_admin_header_sx(ctx)
|
||||
return root_hdr + post_hdr + await header_child_sx(child)
|
||||
|
||||
|
||||
async def _cal_admin_oob(ctx: dict, **kw: Any) -> str:
|
||||
@@ -59,10 +59,10 @@ async def _cal_admin_oob(ctx: dict, **kw: Any) -> str:
|
||||
)
|
||||
ctx = await _ensure_container_nav(ctx)
|
||||
slug = (ctx.get("post") or {}).get("slug", "")
|
||||
oobs = (post_admin_header_sx(ctx, slug, oob=True, selected="calendars")
|
||||
+ _calendar_header_sx(ctx, oob=True))
|
||||
oobs += oob_header_sx("calendar-header-child", "calendar-admin-header-child",
|
||||
_calendar_admin_header_sx(ctx))
|
||||
oobs = (await post_admin_header_sx(ctx, slug, oob=True, selected="calendars")
|
||||
+ await _calendar_header_sx(ctx, oob=True))
|
||||
oobs += await oob_header_sx("calendar-header-child", "calendar-admin-header-child",
|
||||
await _calendar_admin_header_sx(ctx))
|
||||
oobs += _clear_deeper_oob("post-row", "post-header-child",
|
||||
"post-admin-row", "post-admin-header-child",
|
||||
"calendar-row", "calendar-header-child",
|
||||
@@ -83,8 +83,8 @@ async def _slots_oob(ctx: dict, **kw: Any) -> str:
|
||||
)
|
||||
ctx = await _ensure_container_nav({**ctx, "is_admin_section": True})
|
||||
slug = (ctx.get("post") or {}).get("slug", "")
|
||||
oobs = (post_admin_header_sx(ctx, slug, oob=True, selected="calendars")
|
||||
+ _calendar_admin_header_sx(ctx, oob=True))
|
||||
oobs = (await post_admin_header_sx(ctx, slug, oob=True, selected="calendars")
|
||||
+ await _calendar_admin_header_sx(ctx, oob=True))
|
||||
oobs += _clear_deeper_oob("post-row", "post-header-child",
|
||||
"post-admin-row", "post-admin-header-child",
|
||||
"calendar-row", "calendar-header-child",
|
||||
@@ -102,12 +102,12 @@ async def _slot_full(ctx: dict, **kw: Any) -> str:
|
||||
)
|
||||
ctx = await _ensure_container_nav({**ctx, "is_admin_section": True})
|
||||
slug = (ctx.get("post") or {}).get("slug", "")
|
||||
root_hdr = root_header_sx(ctx)
|
||||
post_hdr = _post_header_sx(ctx)
|
||||
admin_hdr = post_admin_header_sx(ctx, slug, selected="calendars")
|
||||
child = (admin_hdr + _calendar_header_sx(ctx)
|
||||
+ _calendar_admin_header_sx(ctx) + _slot_header_html(ctx))
|
||||
return root_hdr + post_hdr + header_child_sx(child)
|
||||
root_hdr = await root_header_sx(ctx)
|
||||
post_hdr = await _post_header_sx(ctx)
|
||||
admin_hdr = await post_admin_header_sx(ctx, slug, selected="calendars")
|
||||
child = (admin_hdr + await _calendar_header_sx(ctx)
|
||||
+ await _calendar_admin_header_sx(ctx) + await _slot_header_html(ctx))
|
||||
return root_hdr + post_hdr + await header_child_sx(child)
|
||||
|
||||
|
||||
async def _slot_oob(ctx: dict, **kw: Any) -> str:
|
||||
@@ -118,10 +118,10 @@ async def _slot_oob(ctx: dict, **kw: Any) -> str:
|
||||
)
|
||||
ctx = await _ensure_container_nav({**ctx, "is_admin_section": True})
|
||||
slug = (ctx.get("post") or {}).get("slug", "")
|
||||
oobs = (post_admin_header_sx(ctx, slug, oob=True, selected="calendars")
|
||||
+ _calendar_admin_header_sx(ctx, oob=True))
|
||||
oobs += oob_header_sx("calendar-admin-header-child", "slot-header-child",
|
||||
_slot_header_html(ctx))
|
||||
oobs = (await post_admin_header_sx(ctx, slug, oob=True, selected="calendars")
|
||||
+ await _calendar_admin_header_sx(ctx, oob=True))
|
||||
oobs += await oob_header_sx("calendar-admin-header-child", "slot-header-child",
|
||||
await _slot_header_html(ctx))
|
||||
oobs += _clear_deeper_oob("post-row", "post-header-child",
|
||||
"post-admin-row", "post-admin-header-child",
|
||||
"calendar-row", "calendar-header-child",
|
||||
@@ -140,12 +140,12 @@ async def _day_admin_full(ctx: dict, **kw: Any) -> str:
|
||||
)
|
||||
ctx = await _ensure_container_nav(ctx)
|
||||
slug = (ctx.get("post") or {}).get("slug", "")
|
||||
root_hdr = root_header_sx(ctx)
|
||||
post_hdr = _post_header_sx(ctx)
|
||||
admin_hdr = post_admin_header_sx(ctx, slug, selected="calendars")
|
||||
child = (admin_hdr + _calendar_header_sx(ctx) + _day_header_sx(ctx)
|
||||
+ _day_admin_header_sx(ctx))
|
||||
return root_hdr + post_hdr + header_child_sx(child)
|
||||
root_hdr = await root_header_sx(ctx)
|
||||
post_hdr = await _post_header_sx(ctx)
|
||||
admin_hdr = await post_admin_header_sx(ctx, slug, selected="calendars")
|
||||
child = (admin_hdr + await _calendar_header_sx(ctx) + await _day_header_sx(ctx)
|
||||
+ await _day_admin_header_sx(ctx))
|
||||
return root_hdr + post_hdr + await header_child_sx(child)
|
||||
|
||||
|
||||
async def _day_admin_oob(ctx: dict, **kw: Any) -> str:
|
||||
@@ -156,10 +156,10 @@ async def _day_admin_oob(ctx: dict, **kw: Any) -> str:
|
||||
)
|
||||
ctx = await _ensure_container_nav(ctx)
|
||||
slug = (ctx.get("post") or {}).get("slug", "")
|
||||
oobs = (post_admin_header_sx(ctx, slug, oob=True, selected="calendars")
|
||||
+ _calendar_header_sx(ctx, oob=True))
|
||||
oobs += oob_header_sx("day-header-child", "day-admin-header-child",
|
||||
_day_admin_header_sx(ctx))
|
||||
oobs = (await post_admin_header_sx(ctx, slug, oob=True, selected="calendars")
|
||||
+ await _calendar_header_sx(ctx, oob=True))
|
||||
oobs += await oob_header_sx("day-header-child", "day-admin-header-child",
|
||||
await _day_admin_header_sx(ctx))
|
||||
oobs += _clear_deeper_oob("post-row", "post-header-child",
|
||||
"post-admin-row", "post-admin-header-child",
|
||||
"calendar-row", "calendar-header-child",
|
||||
@@ -170,26 +170,26 @@ async def _day_admin_oob(ctx: dict, **kw: Any) -> str:
|
||||
|
||||
# --- Entry layout (root + child(post + cal + day + entry), + menu) ---
|
||||
|
||||
def _entry_full(ctx: dict, **kw: Any) -> str:
|
||||
async def _entry_full(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import root_header_sx, header_child_sx
|
||||
from sx.sx_components import (
|
||||
_post_header_sx, _calendar_header_sx,
|
||||
_day_header_sx, _entry_header_html,
|
||||
)
|
||||
root_hdr = root_header_sx(ctx)
|
||||
child = (_post_header_sx(ctx) + _calendar_header_sx(ctx)
|
||||
+ _day_header_sx(ctx) + _entry_header_html(ctx))
|
||||
return root_hdr + header_child_sx(child)
|
||||
root_hdr = await root_header_sx(ctx)
|
||||
child = (await _post_header_sx(ctx) + await _calendar_header_sx(ctx)
|
||||
+ await _day_header_sx(ctx) + await _entry_header_html(ctx))
|
||||
return root_hdr + await header_child_sx(child)
|
||||
|
||||
|
||||
def _entry_oob(ctx: dict, **kw: Any) -> str:
|
||||
async def _entry_oob(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import oob_header_sx
|
||||
from sx.sx_components import (
|
||||
_day_header_sx, _entry_header_html, _clear_deeper_oob,
|
||||
)
|
||||
oobs = _day_header_sx(ctx, oob=True)
|
||||
oobs += oob_header_sx("day-header-child", "entry-header-child",
|
||||
_entry_header_html(ctx))
|
||||
oobs = await _day_header_sx(ctx, oob=True)
|
||||
oobs += await oob_header_sx("day-header-child", "entry-header-child",
|
||||
await _entry_header_html(ctx))
|
||||
oobs += _clear_deeper_oob("post-row", "post-header-child",
|
||||
"calendar-row", "calendar-header-child",
|
||||
"day-row", "day-header-child",
|
||||
@@ -208,12 +208,12 @@ async def _entry_admin_full(ctx: dict, **kw: Any) -> str:
|
||||
)
|
||||
ctx = await _ensure_container_nav(ctx)
|
||||
slug = (ctx.get("post") or {}).get("slug", "")
|
||||
root_hdr = root_header_sx(ctx)
|
||||
post_hdr = _post_header_sx(ctx)
|
||||
admin_hdr = post_admin_header_sx(ctx, slug, selected="calendars")
|
||||
child = (admin_hdr + _calendar_header_sx(ctx) + _day_header_sx(ctx)
|
||||
+ _entry_header_html(ctx) + _entry_admin_header_html(ctx))
|
||||
return root_hdr + post_hdr + header_child_sx(child)
|
||||
root_hdr = await root_header_sx(ctx)
|
||||
post_hdr = await _post_header_sx(ctx)
|
||||
admin_hdr = await post_admin_header_sx(ctx, slug, selected="calendars")
|
||||
child = (admin_hdr + await _calendar_header_sx(ctx) + await _day_header_sx(ctx)
|
||||
+ await _entry_header_html(ctx) + await _entry_admin_header_html(ctx))
|
||||
return root_hdr + post_hdr + await header_child_sx(child)
|
||||
|
||||
|
||||
async def _entry_admin_oob(ctx: dict, **kw: Any) -> str:
|
||||
@@ -224,10 +224,10 @@ async def _entry_admin_oob(ctx: dict, **kw: Any) -> str:
|
||||
)
|
||||
ctx = await _ensure_container_nav(ctx)
|
||||
slug = (ctx.get("post") or {}).get("slug", "")
|
||||
oobs = (post_admin_header_sx(ctx, slug, oob=True, selected="calendars")
|
||||
+ _entry_header_html(ctx, oob=True))
|
||||
oobs += oob_header_sx("entry-header-child", "entry-admin-header-child",
|
||||
_entry_admin_header_html(ctx))
|
||||
oobs = (await post_admin_header_sx(ctx, slug, oob=True, selected="calendars")
|
||||
+ await _entry_header_html(ctx, oob=True))
|
||||
oobs += await oob_header_sx("entry-header-child", "entry-admin-header-child",
|
||||
await _entry_admin_header_html(ctx))
|
||||
oobs += _clear_deeper_oob("post-row", "post-header-child",
|
||||
"post-admin-row", "post-admin-header-child",
|
||||
"calendar-row", "calendar-header-child",
|
||||
@@ -239,75 +239,75 @@ async def _entry_admin_oob(ctx: dict, **kw: Any) -> str:
|
||||
|
||||
# --- Ticket types layout (extends entry admin with ticket-types header, + menu) ---
|
||||
|
||||
def _ticket_types_full(ctx: dict, **kw: Any) -> str:
|
||||
async def _ticket_types_full(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import root_header_sx, header_child_sx
|
||||
from sx.sx_components import (
|
||||
_post_header_sx, _calendar_header_sx, _day_header_sx,
|
||||
_entry_header_html, _entry_admin_header_html,
|
||||
_ticket_types_header_html,
|
||||
)
|
||||
root_hdr = root_header_sx(ctx)
|
||||
child = (_post_header_sx(ctx) + _calendar_header_sx(ctx)
|
||||
+ _day_header_sx(ctx) + _entry_header_html(ctx)
|
||||
+ _entry_admin_header_html(ctx) + _ticket_types_header_html(ctx))
|
||||
return root_hdr + header_child_sx(child)
|
||||
root_hdr = await root_header_sx(ctx)
|
||||
child = (await _post_header_sx(ctx) + await _calendar_header_sx(ctx)
|
||||
+ await _day_header_sx(ctx) + await _entry_header_html(ctx)
|
||||
+ await _entry_admin_header_html(ctx) + await _ticket_types_header_html(ctx))
|
||||
return root_hdr + await header_child_sx(child)
|
||||
|
||||
|
||||
def _ticket_types_oob(ctx: dict, **kw: Any) -> str:
|
||||
async def _ticket_types_oob(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import oob_header_sx
|
||||
from sx.sx_components import (
|
||||
_entry_admin_header_html, _ticket_types_header_html, _clear_deeper_oob,
|
||||
)
|
||||
oobs = _entry_admin_header_html(ctx, oob=True)
|
||||
oobs += oob_header_sx("entry-admin-header-child", "ticket_types-header-child",
|
||||
_ticket_types_header_html(ctx))
|
||||
oobs = await _entry_admin_header_html(ctx, oob=True)
|
||||
oobs += await oob_header_sx("entry-admin-header-child", "ticket_types-header-child",
|
||||
await _ticket_types_header_html(ctx))
|
||||
return oobs
|
||||
|
||||
|
||||
# --- Ticket type detail layout (extends ticket types with ticket-type header, + menu) ---
|
||||
|
||||
def _ticket_type_full(ctx: dict, **kw: Any) -> str:
|
||||
async def _ticket_type_full(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import root_header_sx, header_child_sx
|
||||
from sx.sx_components import (
|
||||
_post_header_sx, _calendar_header_sx, _day_header_sx,
|
||||
_entry_header_html, _entry_admin_header_html,
|
||||
_ticket_types_header_html, _ticket_type_header_html,
|
||||
)
|
||||
root_hdr = root_header_sx(ctx)
|
||||
child = (_post_header_sx(ctx) + _calendar_header_sx(ctx)
|
||||
+ _day_header_sx(ctx) + _entry_header_html(ctx)
|
||||
+ _entry_admin_header_html(ctx) + _ticket_types_header_html(ctx)
|
||||
+ _ticket_type_header_html(ctx))
|
||||
return root_hdr + header_child_sx(child)
|
||||
root_hdr = await root_header_sx(ctx)
|
||||
child = (await _post_header_sx(ctx) + await _calendar_header_sx(ctx)
|
||||
+ await _day_header_sx(ctx) + await _entry_header_html(ctx)
|
||||
+ await _entry_admin_header_html(ctx) + await _ticket_types_header_html(ctx)
|
||||
+ await _ticket_type_header_html(ctx))
|
||||
return root_hdr + await header_child_sx(child)
|
||||
|
||||
|
||||
def _ticket_type_oob(ctx: dict, **kw: Any) -> str:
|
||||
async def _ticket_type_oob(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import oob_header_sx
|
||||
from sx.sx_components import (
|
||||
_ticket_types_header_html, _ticket_type_header_html,
|
||||
)
|
||||
oobs = _ticket_types_header_html(ctx, oob=True)
|
||||
oobs += oob_header_sx("ticket_types-header-child", "ticket_type-header-child",
|
||||
_ticket_type_header_html(ctx))
|
||||
oobs = await _ticket_types_header_html(ctx, oob=True)
|
||||
oobs += await oob_header_sx("ticket_types-header-child", "ticket_type-header-child",
|
||||
await _ticket_type_header_html(ctx))
|
||||
return oobs
|
||||
|
||||
|
||||
# --- Markets layout (root + child(post + markets)) ---
|
||||
|
||||
def _markets_full(ctx: dict, **kw: Any) -> str:
|
||||
async def _markets_full(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import root_header_sx, header_child_sx
|
||||
from sx.sx_components import _post_header_sx, _markets_header_sx
|
||||
root_hdr = root_header_sx(ctx)
|
||||
child = _post_header_sx(ctx) + _markets_header_sx(ctx)
|
||||
return root_hdr + header_child_sx(child)
|
||||
root_hdr = await root_header_sx(ctx)
|
||||
child = await _post_header_sx(ctx) + await _markets_header_sx(ctx)
|
||||
return root_hdr + await header_child_sx(child)
|
||||
|
||||
|
||||
def _markets_oob(ctx: dict, **kw: Any) -> str:
|
||||
async def _markets_oob(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import oob_header_sx
|
||||
from sx.sx_components import _post_header_sx, _markets_header_sx
|
||||
oobs = _post_header_sx(ctx, oob=True)
|
||||
oobs += oob_header_sx("post-header-child", "markets-header-child",
|
||||
_markets_header_sx(ctx))
|
||||
oobs = await _post_header_sx(ctx, oob=True)
|
||||
oobs += await oob_header_sx("post-header-child", "markets-header-child",
|
||||
await _markets_header_sx(ctx))
|
||||
return oobs
|
||||
|
||||
|
||||
@@ -518,7 +518,7 @@ async def _h_calendar_admin_content(calendar_slug=None, **kw):
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import _calendar_admin_main_panel_html
|
||||
ctx = await get_template_context()
|
||||
return _calendar_admin_main_panel_html(ctx)
|
||||
return await _calendar_admin_main_panel_html(ctx)
|
||||
|
||||
|
||||
async def _h_day_admin_content(calendar_slug=None, year=None, month=None, day=None, **kw):
|
||||
@@ -526,7 +526,7 @@ async def _h_day_admin_content(calendar_slug=None, year=None, month=None, day=No
|
||||
if year is not None:
|
||||
await _ensure_day_data(int(year), int(month), int(day))
|
||||
from sx.sx_components import _day_admin_main_panel_html
|
||||
return _day_admin_main_panel_html({})
|
||||
return await _day_admin_main_panel_html({})
|
||||
|
||||
|
||||
async def _h_slots_content(calendar_slug=None, **kw):
|
||||
@@ -537,7 +537,7 @@ async def _h_slots_content(calendar_slug=None, **kw):
|
||||
slots = await svc_list_slots(g.s, calendar.id) if calendar else []
|
||||
_add_to_defpage_ctx(slots=slots)
|
||||
from sx.sx_components import render_slots_table
|
||||
return render_slots_table(slots, calendar)
|
||||
return await render_slots_table(slots, calendar)
|
||||
|
||||
|
||||
async def _h_slot_content(calendar_slug=None, slot_id=None, **kw):
|
||||
@@ -551,7 +551,7 @@ async def _h_slot_content(calendar_slug=None, slot_id=None, **kw):
|
||||
_add_to_defpage_ctx(slot=slot)
|
||||
calendar = getattr(g, "calendar", None)
|
||||
from sx.sx_components import render_slot_main_panel
|
||||
return render_slot_main_panel(slot, calendar)
|
||||
return await render_slot_main_panel(slot, calendar)
|
||||
|
||||
|
||||
async def _h_entry_content(calendar_slug=None, entry_id=None, **kw):
|
||||
@@ -560,7 +560,7 @@ async def _h_entry_content(calendar_slug=None, entry_id=None, **kw):
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import _entry_main_panel_html
|
||||
ctx = await get_template_context()
|
||||
return _entry_main_panel_html(ctx)
|
||||
return await _entry_main_panel_html(ctx)
|
||||
|
||||
|
||||
async def _h_entry_menu(calendar_slug=None, entry_id=None, **kw):
|
||||
@@ -569,7 +569,7 @@ async def _h_entry_menu(calendar_slug=None, entry_id=None, **kw):
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import _entry_nav_html
|
||||
ctx = await get_template_context()
|
||||
return _entry_nav_html(ctx)
|
||||
return await _entry_nav_html(ctx)
|
||||
|
||||
|
||||
async def _h_entry_admin_content(calendar_slug=None, entry_id=None, **kw):
|
||||
@@ -578,12 +578,12 @@ async def _h_entry_admin_content(calendar_slug=None, entry_id=None, **kw):
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import _entry_admin_main_panel_html
|
||||
ctx = await get_template_context()
|
||||
return _entry_admin_main_panel_html(ctx)
|
||||
return await _entry_admin_main_panel_html(ctx)
|
||||
|
||||
|
||||
def _h_admin_menu():
|
||||
from shared.sx.helpers import sx_call
|
||||
return sx_call("events-admin-placeholder-nav")
|
||||
async def _h_admin_menu():
|
||||
from shared.sx.helpers import render_to_sx
|
||||
return await render_to_sx("events-admin-placeholder-nav")
|
||||
|
||||
|
||||
async def _h_ticket_types_content(calendar_slug=None, entry_id=None,
|
||||
@@ -597,7 +597,7 @@ async def _h_ticket_types_content(calendar_slug=None, entry_id=None,
|
||||
ticket_types = await svc_list_ticket_types(g.s, entry.id) if entry else []
|
||||
_add_to_defpage_ctx(ticket_types=ticket_types)
|
||||
from sx.sx_components import render_ticket_types_table
|
||||
return render_ticket_types_table(ticket_types, entry, calendar, day, month, year)
|
||||
return await render_ticket_types_table(ticket_types, entry, calendar, day, month, year)
|
||||
|
||||
|
||||
async def _h_ticket_type_content(calendar_slug=None, entry_id=None,
|
||||
@@ -614,7 +614,7 @@ async def _h_ticket_type_content(calendar_slug=None, entry_id=None,
|
||||
entry = getattr(g, "entry", None)
|
||||
calendar = getattr(g, "calendar", None)
|
||||
from sx.sx_components import render_ticket_type_main_panel
|
||||
return render_ticket_type_main_panel(ticket_type, entry, calendar, day, month, year)
|
||||
return await render_ticket_type_main_panel(ticket_type, entry, calendar, day, month, year)
|
||||
|
||||
|
||||
async def _h_tickets_content(**kw):
|
||||
@@ -630,7 +630,7 @@ async def _h_tickets_content(**kw):
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import _tickets_main_panel_html
|
||||
ctx = await get_template_context()
|
||||
return _tickets_main_panel_html(ctx, tickets)
|
||||
return await _tickets_main_panel_html(ctx, tickets)
|
||||
|
||||
|
||||
async def _h_ticket_detail_content(code=None, **kw):
|
||||
@@ -653,7 +653,7 @@ async def _h_ticket_detail_content(code=None, **kw):
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import _ticket_detail_panel_html
|
||||
ctx = await get_template_context()
|
||||
return _ticket_detail_panel_html(ctx, ticket)
|
||||
return await _ticket_detail_panel_html(ctx, ticket)
|
||||
|
||||
|
||||
async def _h_ticket_admin_content(**kw):
|
||||
@@ -693,11 +693,11 @@ async def _h_ticket_admin_content(**kw):
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import _ticket_admin_main_panel_html
|
||||
ctx = await get_template_context()
|
||||
return _ticket_admin_main_panel_html(ctx, tickets, stats)
|
||||
return await _ticket_admin_main_panel_html(ctx, tickets, stats)
|
||||
|
||||
|
||||
async def _h_markets_content(**kw):
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import _markets_main_panel_html
|
||||
ctx = await get_template_context()
|
||||
return _markets_main_panel_html(ctx)
|
||||
return await _markets_main_panel_html(ctx)
|
||||
|
||||
@@ -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(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(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}
|
||||
@@ -20,3 +20,50 @@
|
||||
|
||||
(defcomp ~federation-notifications-page (&key notifs)
|
||||
(h1 :class "text-2xl font-bold mb-6" "Notifications") notifs)
|
||||
|
||||
;; Assembled notification card — replaces Python _notification_sx
|
||||
(defcomp ~federation-notification-from-data (&key notif)
|
||||
(let* ((from-name (or (get notif "from_actor_name") "?"))
|
||||
(from-username (or (get notif "from_actor_username") ""))
|
||||
(from-domain (or (get notif "from_actor_domain") ""))
|
||||
(from-icon (get notif "from_actor_icon"))
|
||||
(ntype (or (get notif "notification_type") ""))
|
||||
(preview (get notif "target_content_preview"))
|
||||
(created (or (get notif "created_at_formatted") ""))
|
||||
(read (get notif "read"))
|
||||
(app-domain (or (get notif "app_domain") ""))
|
||||
(border (if (not read) " border-l-4 border-l-stone-400" ""))
|
||||
(initial (if (and (not from-icon) from-name)
|
||||
(upper (slice from-name 0 1)) "?"))
|
||||
(action-text (cond
|
||||
((= ntype "follow") (str "followed you"
|
||||
(if (and app-domain (!= app-domain "federation"))
|
||||
(str " on " (escape app-domain)) "")))
|
||||
((= ntype "like") "liked your post")
|
||||
((= ntype "boost") "boosted your post")
|
||||
((= ntype "mention") "mentioned you")
|
||||
((= ntype "reply") "replied to your post")
|
||||
(true ""))))
|
||||
(~federation-notification-card
|
||||
:cls (str "bg-white rounded-lg shadow-sm border border-stone-200 p-4" border)
|
||||
:avatar (~avatar
|
||||
:src from-icon
|
||||
:cls (if from-icon "w-8 h-8 rounded-full"
|
||||
"w-8 h-8 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xs")
|
||||
:initial (when (not from-icon) initial))
|
||||
:from-name (escape from-name)
|
||||
:from-username (escape from-username)
|
||||
:from-domain (if from-domain (str "@" (escape from-domain)) "")
|
||||
:action-text action-text
|
||||
:preview (when preview (~federation-notification-preview :preview (escape preview)))
|
||||
:time created)))
|
||||
|
||||
;; Assembled notifications content — replaces Python _notifications_content_sx
|
||||
(defcomp ~federation-notifications-content (&key notifications)
|
||||
(~federation-notifications-page
|
||||
:notifs (if (empty? notifications)
|
||||
(~empty-state :message "No notifications yet." :cls "text-stone-500")
|
||||
(~federation-notifications-list
|
||||
:items (map (lambda (n)
|
||||
(~federation-notification-from-data :notif n))
|
||||
notifications)))))
|
||||
|
||||
@@ -53,3 +53,40 @@
|
||||
|
||||
(defcomp ~federation-profile-summary-text (&key text)
|
||||
(p :class "mt-2" text))
|
||||
|
||||
;; Assembled actor timeline content — replaces Python _actor_timeline_content_sx
|
||||
(defcomp ~federation-actor-timeline-content (&key remote-actor items is-following actor)
|
||||
(let* ((display-name (or (get remote-actor "display_name") (get remote-actor "preferred_username") ""))
|
||||
(icon-url (get remote-actor "icon_url"))
|
||||
(summary (get remote-actor "summary"))
|
||||
(actor-url (or (get remote-actor "actor_url") ""))
|
||||
(csrf (csrf-token))
|
||||
(initial (if (and (not icon-url) display-name)
|
||||
(upper (slice display-name 0 1)) "?")))
|
||||
(~federation-actor-timeline-layout
|
||||
:header (~federation-actor-profile-header
|
||||
:avatar (~avatar
|
||||
:src icon-url
|
||||
:cls (if icon-url "w-16 h-16 rounded-full"
|
||||
"w-16 h-16 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xl")
|
||||
:initial (when (not icon-url) initial))
|
||||
:display-name (escape display-name)
|
||||
:username (escape (or (get remote-actor "preferred_username") ""))
|
||||
:domain (escape (or (get remote-actor "domain") ""))
|
||||
:summary (when summary (~federation-profile-summary :summary summary))
|
||||
:follow (when actor
|
||||
(if is-following
|
||||
(~federation-follow-form
|
||||
:action (url-for "social.unfollow") :csrf csrf :actor-url actor-url
|
||||
:label "Unfollow"
|
||||
:cls "border border-stone-300 rounded px-4 py-2 hover:bg-stone-100")
|
||||
(~federation-follow-form
|
||||
:action (url-for "social.follow") :csrf csrf :actor-url actor-url
|
||||
:label "Follow"
|
||||
:cls "bg-stone-800 text-white rounded px-4 py-2 hover:bg-stone-700"))))
|
||||
:timeline (~federation-timeline-items
|
||||
:items items :timeline-type "actor" :actor actor
|
||||
:next-url (when (not (empty? items))
|
||||
(url-for "social.actor_timeline_page"
|
||||
:id (get remote-actor "id")
|
||||
:before (get (last items) "before_cursor")))))))
|
||||
|
||||
@@ -60,3 +60,99 @@
|
||||
(h1 :class "text-2xl font-bold mb-6" title " "
|
||||
(span :class "text-stone-400 font-normal" count-str))
|
||||
(div :id "actor-list" items))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Assembled actor card — replaces Python _actor_card_sx
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~federation-actor-card-from-data (&key a actor followed-urls list-type)
|
||||
(let* ((display-name (or (get a "display_name") (get a "preferred_username") ""))
|
||||
(username (or (get a "preferred_username") ""))
|
||||
(domain (or (get a "domain") ""))
|
||||
(icon-url (get a "icon_url"))
|
||||
(actor-url (or (get a "actor_url") ""))
|
||||
(summary (get a "summary"))
|
||||
(aid (get a "id"))
|
||||
(safe-id (replace (replace actor-url "/" "_") ":" "_"))
|
||||
(initial (if (and (not icon-url) (or display-name username))
|
||||
(upper (slice (or display-name username) 0 1)) "?"))
|
||||
(csrf (csrf-token))
|
||||
(is-followed (contains? (or followed-urls (list)) actor-url)))
|
||||
(~federation-actor-card
|
||||
:cls "bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-3 flex items-center gap-4"
|
||||
:id (str "actor-" safe-id)
|
||||
:avatar (~avatar
|
||||
:src icon-url
|
||||
:cls (if icon-url "w-12 h-12 rounded-full"
|
||||
"w-12 h-12 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold")
|
||||
:initial (when (not icon-url) initial))
|
||||
:name (if (and (or (= list-type "following") (= list-type "search")) aid)
|
||||
(~federation-actor-name-link
|
||||
:href (url-for "social.defpage_actor_timeline" :id aid)
|
||||
:name (escape display-name))
|
||||
(~federation-actor-name-link-external
|
||||
:href (str "https://" domain "/@" username)
|
||||
:name (escape display-name)))
|
||||
:username (escape username)
|
||||
:domain (escape domain)
|
||||
:summary (when summary (~federation-actor-summary :summary summary))
|
||||
:button (when actor
|
||||
(if (or (= list-type "following") is-followed)
|
||||
(~federation-unfollow-button
|
||||
:action (url-for "social.unfollow") :csrf csrf :actor-url actor-url)
|
||||
(~federation-follow-button
|
||||
:action (url-for "social.follow") :csrf csrf :actor-url actor-url
|
||||
:label (if (= list-type "followers") "Follow Back" "Follow")))))))
|
||||
|
||||
;; Assembled search content — replaces Python _search_content_sx
|
||||
(defcomp ~federation-search-content (&key query actors total followed-urls actor)
|
||||
(~federation-search-page
|
||||
:search-url (url-for "social.defpage_search")
|
||||
:search-page-url (url-for "social.search_page")
|
||||
:query (escape (or query ""))
|
||||
:info (cond
|
||||
((and query (> total 0))
|
||||
(~federation-search-info
|
||||
:cls "text-sm text-stone-500 mb-4"
|
||||
:text (str total " result" (pluralize total) " for " (escape query))))
|
||||
(query
|
||||
(~federation-search-info
|
||||
:cls "text-stone-500 mb-4"
|
||||
:text (str "No results found for " (escape query))))
|
||||
(true nil))
|
||||
:results (when (not (empty? actors))
|
||||
(<>
|
||||
(map (lambda (a)
|
||||
(~federation-actor-card-from-data
|
||||
:a a :actor actor :followed-urls followed-urls :list-type "search"))
|
||||
actors)
|
||||
(when (>= (len actors) 20)
|
||||
(~federation-scroll-sentinel
|
||||
:url (url-for "social.search_page" :q query :page 2)))))))
|
||||
|
||||
;; Assembled following/followers content — replaces Python _following_content_sx etc.
|
||||
(defcomp ~federation-following-content (&key actors total actor)
|
||||
(~federation-actor-list-page
|
||||
:title "Following" :count-str (str "(" total ")")
|
||||
:items (when (not (empty? actors))
|
||||
(<>
|
||||
(map (lambda (a)
|
||||
(~federation-actor-card-from-data
|
||||
:a a :actor actor :followed-urls (list) :list-type "following"))
|
||||
actors)
|
||||
(when (>= (len actors) 20)
|
||||
(~federation-scroll-sentinel
|
||||
:url (url-for "social.following_list_page" :page 2)))))))
|
||||
|
||||
(defcomp ~federation-followers-content (&key actors total followed-urls actor)
|
||||
(~federation-actor-list-page
|
||||
:title "Followers" :count-str (str "(" total ")")
|
||||
:items (when (not (empty? actors))
|
||||
(<>
|
||||
(map (lambda (a)
|
||||
(~federation-actor-card-from-data
|
||||
:a a :actor actor :followed-urls followed-urls :list-type "followers"))
|
||||
actors)
|
||||
(when (>= (len actors) 20)
|
||||
(~federation-scroll-sentinel
|
||||
:url (url-for "social.followers_list_page" :page 2)))))))
|
||||
|
||||
@@ -110,3 +110,129 @@
|
||||
(option :value "unlisted" "Unlisted")
|
||||
(option :value "followers" "Followers only"))
|
||||
(button :type "submit" :class "bg-stone-800 text-white px-6 py-2 rounded hover:bg-stone-700" "Publish"))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Assembled social nav — replaces Python _social_nav_sx
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~federation-social-nav (&key actor)
|
||||
(if (not actor)
|
||||
(~federation-nav-choose-username :url (url-for "identity.choose_username_form"))
|
||||
(let* ((rp (request-path))
|
||||
(links (list
|
||||
(dict :endpoint "social.defpage_home_timeline" :label "Timeline")
|
||||
(dict :endpoint "social.defpage_public_timeline" :label "Public")
|
||||
(dict :endpoint "social.defpage_compose_form" :label "Compose")
|
||||
(dict :endpoint "social.defpage_following_list" :label "Following")
|
||||
(dict :endpoint "social.defpage_followers_list" :label "Followers")
|
||||
(dict :endpoint "social.defpage_search" :label "Search"))))
|
||||
(~federation-nav-bar
|
||||
:items (<>
|
||||
(map (lambda (lnk)
|
||||
(let* ((href (url-for (get lnk "endpoint")))
|
||||
(bold (if (= rp href) " font-bold" "")))
|
||||
(a :href href
|
||||
:class (str "px-2 py-1 rounded hover:bg-stone-200" bold)
|
||||
(get lnk "label"))))
|
||||
links)
|
||||
(let* ((notif-url (url-for "social.defpage_notifications"))
|
||||
(notif-bold (if (= rp notif-url) " font-bold" "")))
|
||||
(~federation-nav-notification-link
|
||||
:href notif-url
|
||||
:cls (str "px-2 py-1 rounded hover:bg-stone-200 relative" notif-bold)
|
||||
:count-url (url-for "social.notification_count")))
|
||||
(a :href (url-for "activitypub.actor_profile" :username (get actor "preferred_username"))
|
||||
:class "px-2 py-1 rounded hover:bg-stone-200"
|
||||
(str "@" (get actor "preferred_username"))))))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Assembled post card — replaces Python _post_card_sx
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~federation-post-card-from-data (&key item actor)
|
||||
(let* ((boosted-by (get item "boosted_by"))
|
||||
(actor-icon (get item "actor_icon"))
|
||||
(actor-name (or (get item "actor_name") "?"))
|
||||
(actor-username (or (get item "actor_username") ""))
|
||||
(actor-domain (or (get item "actor_domain") ""))
|
||||
(content (or (get item "content") ""))
|
||||
(summary (get item "summary"))
|
||||
(published (or (get item "published") ""))
|
||||
(url (get item "url"))
|
||||
(post-type (or (get item "post_type") ""))
|
||||
(oid (or (get item "object_id") ""))
|
||||
(safe-id (replace (replace oid "/" "_") ":" "_"))
|
||||
(initial (if (and (not actor-icon) actor-name)
|
||||
(upper (slice actor-name 0 1)) "?")))
|
||||
(~federation-post-card
|
||||
:boost (when boosted-by (~federation-boost-label :name (escape boosted-by)))
|
||||
:avatar (~avatar
|
||||
:src actor-icon
|
||||
:cls (if actor-icon "w-10 h-10 rounded-full"
|
||||
"w-10 h-10 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-sm")
|
||||
:initial (when (not actor-icon) initial))
|
||||
:actor-name (escape actor-name)
|
||||
:actor-username (escape actor-username)
|
||||
:domain (if actor-domain (str "@" (escape actor-domain)) "")
|
||||
:time published
|
||||
:content (if summary
|
||||
(~federation-content :content content :summary (escape summary))
|
||||
(~federation-content :content content))
|
||||
:original (when (and url (= post-type "remote"))
|
||||
(~federation-original-link :url url))
|
||||
:interactions (when actor
|
||||
(let* ((csrf (csrf-token))
|
||||
(liked (get item "liked_by_me"))
|
||||
(boosted-me (get item "boosted_by_me"))
|
||||
(lcount (or (get item "like_count") 0))
|
||||
(bcount (or (get item "boost_count") 0))
|
||||
(ainbox (or (get item "author_inbox") ""))
|
||||
(target (str "#interactions-" safe-id)))
|
||||
(div :id (str "interactions-" safe-id)
|
||||
(~federation-interaction-buttons
|
||||
:like (~federation-like-form
|
||||
:action (url-for (if liked "social.unlike" "social.like"))
|
||||
:target target :oid oid :ainbox ainbox :csrf csrf
|
||||
:cls (str "flex items-center gap-1 " (if liked "text-red-500 hover:text-red-600" "hover:text-red-500"))
|
||||
:icon (if liked "\u2665" "\u2661") :count (str lcount))
|
||||
:boost (~federation-boost-form
|
||||
:action (url-for (if boosted-me "social.unboost" "social.boost"))
|
||||
:target target :oid oid :ainbox ainbox :csrf csrf
|
||||
:cls (str "flex items-center gap-1 " (if boosted-me "text-green-600 hover:text-green-700" "hover:text-green-600"))
|
||||
:count (str bcount))
|
||||
:reply (when oid
|
||||
(~federation-reply-link
|
||||
:url (url-for "social.defpage_compose_form" :reply-to oid))))))))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Assembled timeline items — replaces Python _timeline_items_sx
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~federation-timeline-items (&key items timeline-type actor next-url)
|
||||
(<>
|
||||
(map (lambda (item)
|
||||
(~federation-post-card-from-data :item item :actor actor))
|
||||
items)
|
||||
(when next-url
|
||||
(~federation-scroll-sentinel :url next-url))))
|
||||
|
||||
;; Assembled timeline content — replaces Python _timeline_content_sx
|
||||
(defcomp ~federation-timeline-content (&key items timeline-type actor)
|
||||
(let* ((label (if (= timeline-type "home") "Home" "Public")))
|
||||
(~federation-timeline-page
|
||||
:label label
|
||||
:compose (when actor
|
||||
(~federation-compose-button :url (url-for "social.defpage_compose_form")))
|
||||
:timeline (~federation-timeline-items
|
||||
:items items :timeline-type timeline-type :actor actor
|
||||
:next-url (when (not (empty? items))
|
||||
(url-for (str "social." timeline-type "_timeline_page")
|
||||
:before (get (last items) "before_cursor")))))))
|
||||
|
||||
;; Assembled compose content — replaces Python _compose_content_sx
|
||||
(defcomp ~federation-compose-content (&key reply-to)
|
||||
(~federation-compose-form
|
||||
:action (url-for "social.compose_submit")
|
||||
:csrf (csrf-token)
|
||||
:reply (when reply-to
|
||||
(~federation-compose-reply :reply-to (escape reply-to)))))
|
||||
|
||||
@@ -1,742 +0,0 @@
|
||||
"""
|
||||
Federation service s-expression page components.
|
||||
|
||||
Renders social timeline, compose, search, following/followers, notifications,
|
||||
actor profiles, login, and username selection pages.
|
||||
"""
|
||||
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.parser import serialize
|
||||
from shared.sx.helpers import (
|
||||
sx_call, SxExpr,
|
||||
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")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Social header nav
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _social_nav_sx(actor: Any) -> str:
|
||||
"""Build the social header nav bar content."""
|
||||
from quart import url_for, request
|
||||
|
||||
if not actor:
|
||||
choose_url = url_for("identity.choose_username_form")
|
||||
return sx_call("federation-nav-choose-username", url=choose_url)
|
||||
|
||||
links = [
|
||||
("social.defpage_home_timeline", "Timeline"),
|
||||
("social.defpage_public_timeline", "Public"),
|
||||
("social.defpage_compose_form", "Compose"),
|
||||
("social.defpage_following_list", "Following"),
|
||||
("social.defpage_followers_list", "Followers"),
|
||||
("social.defpage_search", "Search"),
|
||||
]
|
||||
|
||||
parts = []
|
||||
for endpoint, label in links:
|
||||
href = url_for(endpoint)
|
||||
bold = " font-bold" if request.path == href else ""
|
||||
cls = f"px-2 py-1 rounded hover:bg-stone-200{bold}"
|
||||
parts.append(f'(a :href {serialize(href)} :class {serialize(cls)} {serialize(label)})')
|
||||
|
||||
# Notifications with live badge
|
||||
notif_url = url_for("social.defpage_notifications")
|
||||
notif_count_url = url_for("social.notification_count")
|
||||
notif_bold = " font-bold" if request.path == notif_url else ""
|
||||
parts.append(sx_call(
|
||||
"federation-nav-notification-link",
|
||||
href=notif_url,
|
||||
cls=f"px-2 py-1 rounded hover:bg-stone-200 relative{notif_bold}",
|
||||
count_url=notif_count_url,
|
||||
))
|
||||
|
||||
# Profile link
|
||||
profile_url = url_for("activitypub.actor_profile", username=actor.preferred_username)
|
||||
parts.append(f'(a :href {serialize(profile_url)} :class "px-2 py-1 rounded hover:bg-stone-200" {serialize("@" + actor.preferred_username)})')
|
||||
|
||||
items_sx = "(<> " + " ".join(parts) + ")"
|
||||
return sx_call("federation-nav-bar", items=SxExpr(items_sx))
|
||||
|
||||
|
||||
def _social_header_sx(actor: Any) -> str:
|
||||
"""Build the social section header row."""
|
||||
nav_sx = _social_nav_sx(actor)
|
||||
return sx_call("federation-social-header", nav=SxExpr(nav_sx))
|
||||
|
||||
|
||||
def _social_page(ctx: dict, actor: Any, *, content: str,
|
||||
title: str = "Rose Ash", meta_html: str = "") -> str:
|
||||
"""Render a social page with header and content."""
|
||||
hdr = root_header_sx(ctx)
|
||||
social_hdr = _social_header_sx(actor)
|
||||
child = header_child_sx(social_hdr)
|
||||
header_rows = "(<> " + hdr + " " + child + ")"
|
||||
return full_page_sx(ctx, header_rows=header_rows, content=content,
|
||||
meta_html=meta_html or f'<title>{escape(title)}</title>')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Post card
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _interaction_buttons_sx(item: Any, actor: Any) -> str:
|
||||
"""Render like/boost/reply buttons for a post."""
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from quart import url_for
|
||||
|
||||
oid = getattr(item, "object_id", "") or ""
|
||||
ainbox = getattr(item, "author_inbox", "") or ""
|
||||
lcount = getattr(item, "like_count", 0) or 0
|
||||
bcount = getattr(item, "boost_count", 0) or 0
|
||||
liked = getattr(item, "liked_by_me", False)
|
||||
boosted = getattr(item, "boosted_by_me", False)
|
||||
csrf = generate_csrf_token()
|
||||
|
||||
safe_id = oid.replace("/", "_").replace(":", "_")
|
||||
target = f"#interactions-{safe_id}"
|
||||
|
||||
if liked:
|
||||
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:
|
||||
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=oid) if oid else ""
|
||||
reply_sx = sx_call("federation-reply-link", url=reply_url) if reply_url else ""
|
||||
|
||||
like_form = sx_call(
|
||||
"federation-like-form",
|
||||
action=like_action, target=target, oid=oid, ainbox=ainbox,
|
||||
csrf=csrf, cls=f"flex items-center gap-1 {like_cls}",
|
||||
icon=like_icon, count=str(lcount),
|
||||
)
|
||||
|
||||
boost_form = sx_call(
|
||||
"federation-boost-form",
|
||||
action=boost_action, target=target, oid=oid, ainbox=ainbox,
|
||||
csrf=csrf, cls=f"flex items-center gap-1 {boost_cls}",
|
||||
count=str(bcount),
|
||||
)
|
||||
|
||||
return sx_call(
|
||||
"federation-interaction-buttons",
|
||||
like=SxExpr(like_form),
|
||||
boost=SxExpr(boost_form),
|
||||
reply=SxExpr(reply_sx) if reply_sx else None,
|
||||
)
|
||||
|
||||
|
||||
def _post_card_sx(item: Any, actor: Any) -> str:
|
||||
"""Render a single timeline post card."""
|
||||
boosted_by = getattr(item, "boosted_by", None)
|
||||
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 = getattr(item, "published", None)
|
||||
url = getattr(item, "url", None)
|
||||
post_type = getattr(item, "post_type", "")
|
||||
|
||||
boost_sx = sx_call(
|
||||
"federation-boost-label", name=str(escape(boosted_by)),
|
||||
) if boosted_by else ""
|
||||
|
||||
initial = actor_name[0].upper() if (not actor_icon and actor_name) else "?"
|
||||
avatar = sx_call(
|
||||
"avatar", src=actor_icon or None,
|
||||
cls="w-10 h-10 rounded-full" if actor_icon else "w-10 h-10 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-sm",
|
||||
initial=None if actor_icon else initial,
|
||||
)
|
||||
|
||||
domain_str = f"@{escape(actor_domain)}" if actor_domain else ""
|
||||
time_str = published.strftime("%b %d, %H:%M") if published else ""
|
||||
|
||||
if summary:
|
||||
content_sx = sx_call(
|
||||
"federation-content",
|
||||
content=content, summary=str(escape(summary)),
|
||||
)
|
||||
else:
|
||||
content_sx = sx_call("federation-content", content=content)
|
||||
|
||||
original_sx = ""
|
||||
if url and post_type == "remote":
|
||||
original_sx = sx_call("federation-original-link", url=url)
|
||||
|
||||
interactions_sx = ""
|
||||
if actor:
|
||||
oid = getattr(item, "object_id", "") or ""
|
||||
safe_id = oid.replace("/", "_").replace(":", "_")
|
||||
interactions_sx = f'(div :id {serialize(f"interactions-{safe_id}")} {_interaction_buttons_sx(item, actor)})'
|
||||
|
||||
return sx_call(
|
||||
"federation-post-card",
|
||||
boost=SxExpr(boost_sx) if boost_sx else None,
|
||||
avatar=SxExpr(avatar),
|
||||
actor_name=str(escape(actor_name)),
|
||||
actor_username=str(escape(actor_username)),
|
||||
domain=domain_str, time=time_str,
|
||||
content=SxExpr(content_sx),
|
||||
original=SxExpr(original_sx) if original_sx else None,
|
||||
interactions=SxExpr(interactions_sx) if interactions_sx else None,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Timeline items (pagination fragment)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _timeline_items_sx(items: list, timeline_type: str, actor: Any,
|
||||
actor_id: int | None = None) -> str:
|
||||
"""Render timeline items with infinite scroll sentinel."""
|
||||
from quart import url_for
|
||||
|
||||
parts = [_post_card_sx(item, actor) for item in items]
|
||||
|
||||
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)
|
||||
parts.append(sx_call("federation-scroll-sentinel", url=next_url))
|
||||
|
||||
return "(<> " + " ".join(parts) + ")" if parts else ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Search results (pagination fragment)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _actor_card_sx(a: Any, actor: Any, followed_urls: set,
|
||||
*, list_type: str = "search") -> str:
|
||||
"""Render a single actor card with follow/unfollow button."""
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from quart import url_for
|
||||
|
||||
csrf = generate_csrf_token()
|
||||
display_name = getattr(a, "display_name", None) or getattr(a, "preferred_username", "")
|
||||
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)
|
||||
aid = getattr(a, "id", None)
|
||||
|
||||
safe_id = actor_url.replace("/", "_").replace(":", "_")
|
||||
|
||||
initial = (display_name or username)[0].upper() if (not icon_url and (display_name or username)) else "?"
|
||||
avatar = sx_call(
|
||||
"avatar", src=icon_url or None,
|
||||
cls="w-12 h-12 rounded-full" if icon_url else "w-12 h-12 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold",
|
||||
initial=None if icon_url else initial,
|
||||
)
|
||||
|
||||
# Name link
|
||||
if (list_type in ("following", "search")) and aid:
|
||||
name_sx = sx_call(
|
||||
"federation-actor-name-link",
|
||||
href=url_for("social.defpage_actor_timeline", id=aid),
|
||||
name=str(escape(display_name)),
|
||||
)
|
||||
else:
|
||||
name_sx = sx_call(
|
||||
"federation-actor-name-link-external",
|
||||
href=f"https://{domain}/@{username}",
|
||||
name=str(escape(display_name)),
|
||||
)
|
||||
|
||||
summary_sx = sx_call("federation-actor-summary", summary=summary) if summary else ""
|
||||
|
||||
# Follow/unfollow button
|
||||
button_sx = ""
|
||||
if actor:
|
||||
is_followed = actor_url in (followed_urls or set())
|
||||
if list_type == "following" or is_followed:
|
||||
button_sx = sx_call(
|
||||
"federation-unfollow-button",
|
||||
action=url_for("social.unfollow"), csrf=csrf, actor_url=actor_url,
|
||||
)
|
||||
else:
|
||||
label = "Follow Back" if list_type == "followers" else "Follow"
|
||||
button_sx = sx_call(
|
||||
"federation-follow-button",
|
||||
action=url_for("social.follow"), csrf=csrf, actor_url=actor_url, label=label,
|
||||
)
|
||||
|
||||
return sx_call(
|
||||
"federation-actor-card",
|
||||
cls="bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-3 flex items-center gap-4",
|
||||
id=f"actor-{safe_id}",
|
||||
avatar=SxExpr(avatar),
|
||||
name=SxExpr(name_sx),
|
||||
username=str(escape(username)), domain=str(escape(domain)),
|
||||
summary=SxExpr(summary_sx) if summary_sx else None,
|
||||
button=SxExpr(button_sx) if button_sx else None,
|
||||
)
|
||||
|
||||
|
||||
def _search_results_sx(actors: list, query: str, page: int,
|
||||
followed_urls: set, actor: Any) -> str:
|
||||
"""Render search results with pagination sentinel."""
|
||||
from quart import url_for
|
||||
|
||||
parts = [_actor_card_sx(a, actor, followed_urls, list_type="search") for a in actors]
|
||||
if len(actors) >= 20:
|
||||
next_url = url_for("social.search_page", q=query, page=page + 1)
|
||||
parts.append(sx_call("federation-scroll-sentinel", url=next_url))
|
||||
return "(<> " + " ".join(parts) + ")" if parts else ""
|
||||
|
||||
|
||||
def _actor_list_items_sx(actors: list, page: int, list_type: str,
|
||||
followed_urls: set, actor: Any) -> str:
|
||||
"""Render actor list items (following/followers) with pagination sentinel."""
|
||||
from quart import url_for
|
||||
|
||||
parts = [_actor_card_sx(a, actor, followed_urls, list_type=list_type) for a in actors]
|
||||
if len(actors) >= 20:
|
||||
next_url = url_for(f"social.{list_type}_list_page", page=page + 1)
|
||||
parts.append(sx_call("federation-scroll-sentinel", url=next_url))
|
||||
return "(<> " + " ".join(parts) + ")" if parts else ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Notification card
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _notification_sx(notif: Any) -> str:
|
||||
"""Render a single notification."""
|
||||
from_name = getattr(notif, "from_actor_name", "?")
|
||||
from_username = getattr(notif, "from_actor_username", "")
|
||||
from_domain = getattr(notif, "from_actor_domain", "")
|
||||
from_icon = getattr(notif, "from_actor_icon", None)
|
||||
ntype = getattr(notif, "notification_type", "")
|
||||
preview = getattr(notif, "target_content_preview", None)
|
||||
created = getattr(notif, "created_at", None)
|
||||
read = getattr(notif, "read", True)
|
||||
app_domain = getattr(notif, "app_domain", "")
|
||||
|
||||
border = " border-l-4 border-l-stone-400" if not read else ""
|
||||
|
||||
initial = from_name[0].upper() if (not from_icon and from_name) else "?"
|
||||
avatar = sx_call(
|
||||
"avatar", src=from_icon or None,
|
||||
cls="w-8 h-8 rounded-full" if from_icon else "w-8 h-8 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xs",
|
||||
initial=None if from_icon else initial,
|
||||
)
|
||||
|
||||
domain_str = f"@{escape(from_domain)}" if from_domain else ""
|
||||
|
||||
type_map = {
|
||||
"follow": "followed you",
|
||||
"like": "liked your post",
|
||||
"boost": "boosted your post",
|
||||
"mention": "mentioned you",
|
||||
"reply": "replied to your post",
|
||||
}
|
||||
action = type_map.get(ntype, "")
|
||||
if ntype == "follow" and app_domain and app_domain != "federation":
|
||||
action += f" on {escape(app_domain)}"
|
||||
|
||||
preview_sx = sx_call(
|
||||
"federation-notification-preview", preview=str(escape(preview)),
|
||||
) if preview else ""
|
||||
time_str = created.strftime("%b %d, %H:%M") if created else ""
|
||||
|
||||
return sx_call(
|
||||
"federation-notification-card",
|
||||
cls=f"bg-white rounded-lg shadow-sm border border-stone-200 p-4{border}",
|
||||
avatar=SxExpr(avatar),
|
||||
from_name=str(escape(from_name)),
|
||||
from_username=str(escape(from_username)),
|
||||
from_domain=domain_str, action_text=action,
|
||||
preview=SxExpr(preview_sx) if preview_sx else None,
|
||||
time=time_str,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Home page
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_federation_home(ctx: dict) -> str:
|
||||
"""Full page: federation home (minimal)."""
|
||||
hdr = root_header_sx(ctx)
|
||||
return full_page_sx(ctx, header_rows=hdr)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Login
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_login_page(ctx: dict) -> str:
|
||||
"""Full page: federation login form."""
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from quart import url_for
|
||||
|
||||
error = ctx.get("error", "")
|
||||
email = ctx.get("email", "")
|
||||
action = url_for("auth.start_login")
|
||||
csrf = generate_csrf_token()
|
||||
|
||||
error_sx = sx_call("auth-error-banner", error=error) if error else ""
|
||||
|
||||
content = sx_call(
|
||||
"auth-login-form",
|
||||
error=SxExpr(error_sx) if error_sx else None,
|
||||
action=action, csrf_token=csrf,
|
||||
email=str(escape(email)),
|
||||
)
|
||||
|
||||
return _social_page(ctx, None, content=content,
|
||||
title="Login \u2014 Rose Ash")
|
||||
|
||||
|
||||
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")
|
||||
|
||||
error_sx = sx_call(
|
||||
"auth-check-email-error", error=str(escape(email_error)),
|
||||
) if email_error else ""
|
||||
|
||||
content = sx_call(
|
||||
"auth-check-email",
|
||||
email=str(escape(email)),
|
||||
error=SxExpr(error_sx) if error_sx else None,
|
||||
)
|
||||
|
||||
return _social_page(ctx, None, content=content,
|
||||
title="Check your email \u2014 Rose Ash")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Content builders (used by defpage before_request)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _timeline_content_sx(items: list, timeline_type: str, actor: Any) -> str:
|
||||
"""Build timeline content SX string."""
|
||||
from quart import url_for
|
||||
|
||||
label = "Home" if timeline_type == "home" else "Public"
|
||||
compose_sx = ""
|
||||
if actor:
|
||||
compose_url = url_for("social.defpage_compose_form")
|
||||
compose_sx = sx_call("federation-compose-button", url=compose_url)
|
||||
|
||||
timeline_sx = _timeline_items_sx(items, timeline_type, actor)
|
||||
|
||||
return sx_call(
|
||||
"federation-timeline-page",
|
||||
label=label,
|
||||
compose=SxExpr(compose_sx) if compose_sx else None,
|
||||
timeline=SxExpr(timeline_sx) if timeline_sx else None,
|
||||
)
|
||||
|
||||
|
||||
async def render_timeline_items(items: list, timeline_type: str,
|
||||
actor: Any, actor_id: int | None = None) -> str:
|
||||
"""Pagination fragment: timeline items."""
|
||||
return _timeline_items_sx(items, timeline_type, actor, actor_id)
|
||||
|
||||
|
||||
def _compose_content_sx(actor: Any, reply_to: str | None) -> str:
|
||||
"""Build compose form content SX string."""
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from quart import url_for
|
||||
|
||||
csrf = generate_csrf_token()
|
||||
action = url_for("social.compose_submit")
|
||||
|
||||
reply_sx = ""
|
||||
if reply_to:
|
||||
reply_sx = sx_call(
|
||||
"federation-compose-reply",
|
||||
reply_to=str(escape(reply_to)),
|
||||
)
|
||||
|
||||
return sx_call(
|
||||
"federation-compose-form",
|
||||
action=action, csrf=csrf,
|
||||
reply=SxExpr(reply_sx) if reply_sx else None,
|
||||
)
|
||||
|
||||
|
||||
def _search_content_sx(query: str, actors: list, total: int,
|
||||
page: int, followed_urls: set, actor: Any) -> str:
|
||||
"""Build search page content SX string."""
|
||||
from quart import url_for
|
||||
|
||||
search_url = url_for("social.defpage_search")
|
||||
search_page_url = url_for("social.search_page")
|
||||
|
||||
results_sx = _search_results_sx(actors, query, page, followed_urls, actor)
|
||||
|
||||
info_sx = ""
|
||||
if query and total:
|
||||
s = "s" if total != 1 else ""
|
||||
info_sx = sx_call(
|
||||
"federation-search-info",
|
||||
cls="text-sm text-stone-500 mb-4",
|
||||
text=f"{total} result{s} for <strong>{escape(query)}</strong>",
|
||||
)
|
||||
elif query:
|
||||
info_sx = sx_call(
|
||||
"federation-search-info",
|
||||
cls="text-stone-500 mb-4",
|
||||
text=f"No results found for <strong>{escape(query)}</strong>",
|
||||
)
|
||||
|
||||
return sx_call(
|
||||
"federation-search-page",
|
||||
search_url=search_url, search_page_url=search_page_url,
|
||||
query=str(escape(query)),
|
||||
info=SxExpr(info_sx) if info_sx else None,
|
||||
results=SxExpr(results_sx) if results_sx else None,
|
||||
)
|
||||
|
||||
|
||||
async def render_search_results(actors: list, query: str, page: int,
|
||||
followed_urls: set, actor: Any) -> str:
|
||||
"""Pagination fragment: search results."""
|
||||
return _search_results_sx(actors, query, page, followed_urls, actor)
|
||||
|
||||
|
||||
def _following_content_sx(actors: list, total: int, actor: Any) -> str:
|
||||
"""Build following list content SX string."""
|
||||
items_sx = _actor_list_items_sx(actors, 1, "following", set(), actor)
|
||||
return sx_call(
|
||||
"federation-actor-list-page",
|
||||
title="Following", count_str=f"({total})",
|
||||
items=SxExpr(items_sx) if items_sx else None,
|
||||
)
|
||||
|
||||
|
||||
async def render_following_items(actors: list, page: int, actor: Any) -> str:
|
||||
"""Pagination fragment: following items."""
|
||||
return _actor_list_items_sx(actors, page, "following", set(), actor)
|
||||
|
||||
|
||||
def _followers_content_sx(actors: list, total: int,
|
||||
followed_urls: set, actor: Any) -> str:
|
||||
"""Build followers list content SX string."""
|
||||
items_sx = _actor_list_items_sx(actors, 1, "followers", followed_urls, actor)
|
||||
return sx_call(
|
||||
"federation-actor-list-page",
|
||||
title="Followers", count_str=f"({total})",
|
||||
items=SxExpr(items_sx) if items_sx else None,
|
||||
)
|
||||
|
||||
|
||||
async def render_followers_items(actors: list, page: int,
|
||||
followed_urls: set, actor: Any) -> str:
|
||||
"""Pagination fragment: followers items."""
|
||||
return _actor_list_items_sx(actors, page, "followers", followed_urls, actor)
|
||||
|
||||
|
||||
def _actor_timeline_content_sx(remote_actor: Any, items: list,
|
||||
is_following: bool, actor: Any) -> str:
|
||||
"""Build actor timeline content SX string."""
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from quart import url_for
|
||||
|
||||
csrf = generate_csrf_token()
|
||||
display_name = remote_actor.display_name or remote_actor.preferred_username
|
||||
icon_url = getattr(remote_actor, "icon_url", None)
|
||||
summary = getattr(remote_actor, "summary", None)
|
||||
actor_url = getattr(remote_actor, "actor_url", "")
|
||||
|
||||
initial = display_name[0].upper() if (not icon_url and display_name) else "?"
|
||||
avatar = sx_call(
|
||||
"avatar", src=icon_url or None,
|
||||
cls="w-16 h-16 rounded-full" if icon_url else "w-16 h-16 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xl",
|
||||
initial=None if icon_url else initial,
|
||||
)
|
||||
|
||||
summary_sx = sx_call("federation-profile-summary", summary=summary) if summary else ""
|
||||
|
||||
follow_sx = ""
|
||||
if actor:
|
||||
if is_following:
|
||||
follow_sx = sx_call(
|
||||
"federation-follow-form",
|
||||
action=url_for("social.unfollow"), csrf=csrf, actor_url=actor_url,
|
||||
label="Unfollow",
|
||||
cls="border border-stone-300 rounded px-4 py-2 hover:bg-stone-100",
|
||||
)
|
||||
else:
|
||||
follow_sx = sx_call(
|
||||
"federation-follow-form",
|
||||
action=url_for("social.follow"), csrf=csrf, actor_url=actor_url,
|
||||
label="Follow",
|
||||
cls="bg-stone-800 text-white rounded px-4 py-2 hover:bg-stone-700",
|
||||
)
|
||||
|
||||
timeline_sx = _timeline_items_sx(items, "actor", actor, remote_actor.id)
|
||||
|
||||
header_sx = sx_call(
|
||||
"federation-actor-profile-header",
|
||||
avatar=SxExpr(avatar),
|
||||
display_name=str(escape(display_name)),
|
||||
username=str(escape(remote_actor.preferred_username)),
|
||||
domain=str(escape(remote_actor.domain)),
|
||||
summary=SxExpr(summary_sx) if summary_sx else None,
|
||||
follow=SxExpr(follow_sx) if follow_sx else None,
|
||||
)
|
||||
|
||||
return sx_call(
|
||||
"federation-actor-timeline-layout",
|
||||
header=SxExpr(header_sx),
|
||||
timeline=SxExpr(timeline_sx) if timeline_sx else None,
|
||||
)
|
||||
|
||||
|
||||
async def render_actor_timeline_items(items: list, actor_id: int,
|
||||
actor: Any) -> str:
|
||||
"""Pagination fragment: actor timeline items."""
|
||||
return _timeline_items_sx(items, "actor", actor, actor_id)
|
||||
|
||||
|
||||
def _notifications_content_sx(notifications: list) -> str:
|
||||
"""Build notifications content SX string."""
|
||||
if not notifications:
|
||||
notif_sx = sx_call("empty-state", message="No notifications yet.",
|
||||
cls="text-stone-500")
|
||||
else:
|
||||
items_sx = "(<> " + " ".join(_notification_sx(n) for n in notifications) + ")"
|
||||
notif_sx = sx_call(
|
||||
"federation-notifications-list",
|
||||
items=SxExpr(items_sx),
|
||||
)
|
||||
|
||||
return sx_call("federation-notifications-page", notifs=SxExpr(notif_sx))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Choose username
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_choose_username_page(ctx: dict) -> str:
|
||||
"""Full page: choose username form."""
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from quart import url_for
|
||||
from shared.config import config
|
||||
|
||||
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 = sx_call("auth-error-banner", error=error) if error else ""
|
||||
|
||||
content = sx_call(
|
||||
"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 _social_page(ctx, actor, content=content,
|
||||
title="Choose Username \u2014 Rose Ash")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Actor profile
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_profile_page(ctx: dict, actor: Any, activities: list,
|
||||
total: int) -> str:
|
||||
"""Full page: actor profile."""
|
||||
from shared.config import config
|
||||
|
||||
ap_domain = config().get("ap_domain", "rose-ash.com")
|
||||
display_name = actor.display_name or actor.preferred_username
|
||||
summary_sx = sx_call(
|
||||
"federation-profile-summary-text", text=str(escape(actor.summary)),
|
||||
) if actor.summary else ""
|
||||
|
||||
activities_sx = ""
|
||||
if activities:
|
||||
parts = []
|
||||
for a in activities:
|
||||
published = a.published.strftime("%Y-%m-%d %H:%M") if a.published else ""
|
||||
obj_type_sx = sx_call(
|
||||
"federation-activity-obj-type", obj_type=a.object_type,
|
||||
) if a.object_type else ""
|
||||
parts.append(sx_call(
|
||||
"federation-activity-card",
|
||||
activity_type=a.activity_type, published=published,
|
||||
obj_type=SxExpr(obj_type_sx) if obj_type_sx else None,
|
||||
))
|
||||
items_sx = "(<> " + " ".join(parts) + ")"
|
||||
activities_sx = sx_call("federation-activities-list", items=SxExpr(items_sx))
|
||||
else:
|
||||
activities_sx = sx_call("federation-activities-empty")
|
||||
|
||||
content = sx_call(
|
||||
"federation-profile-page",
|
||||
display_name=str(escape(display_name)),
|
||||
username=str(escape(actor.preferred_username)),
|
||||
domain=str(escape(ap_domain)),
|
||||
summary=SxExpr(summary_sx) if summary_sx else None,
|
||||
activities_heading=f"Activities ({total})",
|
||||
activities=SxExpr(activities_sx),
|
||||
)
|
||||
|
||||
return _social_page(ctx, actor, content=content,
|
||||
title=f"@{actor.preferred_username} \u2014 Rose Ash")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: POST handler fragment renderers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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 HTMX POST response."""
|
||||
from types import SimpleNamespace
|
||||
item = SimpleNamespace(
|
||||
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,
|
||||
)
|
||||
return _interaction_buttons_sx(item, actor)
|
||||
|
||||
|
||||
def render_actor_card(actor_dto: Any, actor: Any, followed_urls: set,
|
||||
*, list_type: str = "following") -> str:
|
||||
"""Render a single actor card fragment for HTMX POST response."""
|
||||
return _actor_card_sx(actor_dto, actor, 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()
|
||||
|
||||
|
||||
@@ -26,47 +25,71 @@ def _register_federation_layouts() -> None:
|
||||
register_custom_layout("social", _social_full, _social_oob)
|
||||
|
||||
|
||||
def _social_full(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import root_header_sx, header_child_sx
|
||||
from sx.sx_components import _social_header_sx
|
||||
async def _social_full(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import root_header_sx, header_child_sx, render_to_sx
|
||||
from shared.sx.parser import SxExpr
|
||||
|
||||
actor = ctx.get("actor")
|
||||
root_hdr = root_header_sx(ctx)
|
||||
social_hdr = _social_header_sx(actor)
|
||||
child = header_child_sx(social_hdr)
|
||||
actor_data = _serialize_actor(actor) if actor else None
|
||||
nav = await render_to_sx("federation-social-nav", actor=actor_data)
|
||||
social_hdr = await render_to_sx("federation-social-header", nav=SxExpr(nav))
|
||||
root_hdr = await root_header_sx(ctx)
|
||||
child = await header_child_sx(social_hdr)
|
||||
return "(<> " + root_hdr + " " + child + ")"
|
||||
|
||||
|
||||
def _social_oob(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import root_header_sx, sx_call, SxExpr
|
||||
from sx.sx_components import _social_header_sx
|
||||
async def _social_oob(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import root_header_sx, render_to_sx
|
||||
from shared.sx.parser import SxExpr
|
||||
|
||||
actor = ctx.get("actor")
|
||||
social_hdr = _social_header_sx(actor)
|
||||
child_oob = sx_call("oob-header-sx",
|
||||
actor_data = _serialize_actor(actor) if actor else None
|
||||
nav = await render_to_sx("federation-social-nav", actor=actor_data)
|
||||
social_hdr = await render_to_sx("federation-social-header", nav=SxExpr(nav))
|
||||
child_oob = await render_to_sx("oob-header-sx",
|
||||
parent_id="root-header-child",
|
||||
row=SxExpr(social_hdr))
|
||||
root_hdr_oob = root_header_sx(ctx, oob=True)
|
||||
root_hdr_oob = await root_header_sx(ctx, oob=True)
|
||||
return "(<> " + child_oob + " " + root_hdr_oob + ")"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
def _serialize_actor(actor) -> dict | None:
|
||||
"""Serialize an actor profile to a dict for sx defcomps."""
|
||||
from services.federation_page import _serialize_actor as _impl
|
||||
return _impl(actor)
|
||||
|
||||
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_timeline_item(item) -> dict:
|
||||
"""Serialize a timeline item DTO to a dict for sx defcomps."""
|
||||
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."""
|
||||
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():
|
||||
@@ -82,117 +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
|
||||
actor = _require_actor()
|
||||
items = await services.federation.get_home_timeline(g.s, actor.id)
|
||||
from sx.sx_components import _timeline_content_sx
|
||||
return _timeline_content_sx(items, "home", actor)
|
||||
|
||||
|
||||
async def _h_public_timeline_content(**kw):
|
||||
from quart import g
|
||||
from shared.services.registry import services
|
||||
actor = _get_actor()
|
||||
items = await services.federation.get_public_timeline(g.s)
|
||||
from sx.sx_components import _timeline_content_sx
|
||||
return _timeline_content_sx(items, "public", actor)
|
||||
|
||||
|
||||
async def _h_compose_content(**kw):
|
||||
from quart import request
|
||||
actor = _require_actor()
|
||||
from sx.sx_components import _compose_content_sx
|
||||
reply_to = request.args.get("reply_to")
|
||||
return _compose_content_sx(actor, reply_to)
|
||||
|
||||
|
||||
async def _h_search_content(**kw):
|
||||
from quart import g, request
|
||||
from shared.services.registry import services
|
||||
actor = _get_actor()
|
||||
query = request.args.get("q", "").strip()
|
||||
actors_list = []
|
||||
total = 0
|
||||
followed_urls: set[str] = set()
|
||||
if query:
|
||||
actors_list, total = await services.federation.search_actors(g.s, query)
|
||||
if actor:
|
||||
following, _ = await services.federation.get_following(
|
||||
g.s, actor.preferred_username, page=1, per_page=1000,
|
||||
)
|
||||
followed_urls = {a.actor_url for a in following}
|
||||
from sx.sx_components import _search_content_sx
|
||||
return _search_content_sx(query, actors_list, total, 1, followed_urls, actor)
|
||||
|
||||
|
||||
async def _h_following_content(**kw):
|
||||
from quart import g
|
||||
from shared.services.registry import services
|
||||
actor = _require_actor()
|
||||
actors_list, total = await services.federation.get_following(
|
||||
g.s, actor.preferred_username,
|
||||
)
|
||||
from sx.sx_components import _following_content_sx
|
||||
return _following_content_sx(actors_list, total, actor)
|
||||
|
||||
|
||||
async def _h_followers_content(**kw):
|
||||
from quart import g
|
||||
from shared.services.registry import services
|
||||
actor = _require_actor()
|
||||
actors_list, total = await services.federation.get_followers_paginated(
|
||||
g.s, actor.preferred_username,
|
||||
)
|
||||
following, _ = await services.federation.get_following(
|
||||
g.s, actor.preferred_username, page=1, per_page=1000,
|
||||
)
|
||||
followed_urls = {a.actor_url for a in following}
|
||||
from sx.sx_components import _followers_content_sx
|
||||
return _followers_content_sx(actors_list, total, followed_urls, actor)
|
||||
|
||||
|
||||
async def _h_actor_timeline_content(id=None, **kw):
|
||||
from quart import g, abort
|
||||
from shared.services.registry import services
|
||||
actor = _get_actor()
|
||||
actor_id = id
|
||||
from shared.models.federation import RemoteActor
|
||||
from sqlalchemy import select as sa_select
|
||||
remote = (
|
||||
await g.s.execute(
|
||||
sa_select(RemoteActor).where(RemoteActor.id == actor_id)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not remote:
|
||||
abort(404)
|
||||
from shared.services.federation_impl import _remote_actor_to_dto
|
||||
remote_dto = _remote_actor_to_dto(remote)
|
||||
items = await services.federation.get_actor_timeline(g.s, actor_id)
|
||||
is_following = False
|
||||
if actor:
|
||||
from shared.models.federation import APFollowing
|
||||
existing = (
|
||||
await g.s.execute(
|
||||
sa_select(APFollowing).where(
|
||||
APFollowing.actor_profile_id == actor.id,
|
||||
APFollowing.remote_actor_id == actor_id,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
is_following = existing is not None
|
||||
from sx.sx_components import _actor_timeline_content_sx
|
||||
return _actor_timeline_content_sx(remote_dto, items, is_following, actor)
|
||||
|
||||
|
||||
async def _h_notifications_content(**kw):
|
||||
from quart import g
|
||||
from shared.services.registry import services
|
||||
actor = _require_actor()
|
||||
items = await services.federation.get_notifications(g.s, actor.id)
|
||||
await services.federation.mark_notifications_read(g.s, actor.id)
|
||||
from sx.sx_components import _notifications_content_sx
|
||||
return _notifications_content_sx(items)
|
||||
|
||||
@@ -1,49 +1,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))
|
||||
|
||||
8
likes/actions.sx
Normal file
8
likes/actions.sx
Normal file
@@ -0,0 +1,8 @@
|
||||
;; Likes service — inter-service action endpoints
|
||||
|
||||
(defaction toggle (&key user-id target-type target-slug target-id)
|
||||
"Toggle a like on a content item. Returns whether now liked."
|
||||
(let ((liked (service "likes" "toggle"
|
||||
:user-id user-id :target-type target-type
|
||||
:target-slug target-slug :target-id target-id)))
|
||||
{"liked" liked}))
|
||||
@@ -1,81 +1,14 @@
|
||||
"""Likes app action endpoints."""
|
||||
"""Likes app action endpoints.
|
||||
|
||||
All actions are defined in ``likes/actions.sx``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, g, jsonify, request
|
||||
from quart import Blueprint
|
||||
|
||||
from shared.infrastructure.actions import ACTION_HEADER
|
||||
from shared.infrastructure.query_blueprint import create_action_blueprint
|
||||
|
||||
|
||||
def register() -> Blueprint:
|
||||
bp = Blueprint("actions", __name__, url_prefix="/internal/actions")
|
||||
|
||||
@bp.before_request
|
||||
async def _require_action_header():
|
||||
if not request.headers.get(ACTION_HEADER):
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
from shared.infrastructure.internal_auth import validate_internal_request
|
||||
if not validate_internal_request():
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
|
||||
_handlers: dict[str, object] = {}
|
||||
|
||||
@bp.post("/<action_name>")
|
||||
async def handle_action(action_name: str):
|
||||
handler = _handlers.get(action_name)
|
||||
if handler is None:
|
||||
return jsonify({"error": "unknown action"}), 404
|
||||
try:
|
||||
result = await handler()
|
||||
return jsonify(result)
|
||||
except Exception as exc:
|
||||
import logging
|
||||
logging.getLogger(__name__).exception("Action %s failed", action_name)
|
||||
return jsonify({"error": str(exc)}), 500
|
||||
|
||||
# --- toggle ---
|
||||
async def _toggle():
|
||||
"""Toggle a like. Returns {"liked": bool}."""
|
||||
from sqlalchemy import select, update, func
|
||||
from likes.models.like import Like
|
||||
|
||||
data = await request.get_json(force=True)
|
||||
user_id = data["user_id"]
|
||||
target_type = data["target_type"]
|
||||
target_slug = data.get("target_slug")
|
||||
target_id = data.get("target_id")
|
||||
|
||||
filters = [
|
||||
Like.user_id == user_id,
|
||||
Like.target_type == target_type,
|
||||
Like.deleted_at.is_(None),
|
||||
]
|
||||
if target_slug is not None:
|
||||
filters.append(Like.target_slug == target_slug)
|
||||
elif target_id is not None:
|
||||
filters.append(Like.target_id == target_id)
|
||||
else:
|
||||
return {"error": "target_slug or target_id required"}, 400
|
||||
|
||||
existing = await g.s.scalar(select(Like).where(*filters))
|
||||
|
||||
if existing:
|
||||
# Unlike: soft delete
|
||||
await g.s.execute(
|
||||
update(Like).where(Like.id == existing.id).values(deleted_at=func.now())
|
||||
)
|
||||
return {"liked": False}
|
||||
else:
|
||||
# Like: insert new
|
||||
new_like = Like(
|
||||
user_id=user_id,
|
||||
target_type=target_type,
|
||||
target_slug=target_slug,
|
||||
target_id=target_id,
|
||||
)
|
||||
g.s.add(new_like)
|
||||
await g.s.flush()
|
||||
return {"liked": True}
|
||||
|
||||
_handlers["toggle"] = _toggle
|
||||
|
||||
bp, _handlers = create_action_blueprint("likes")
|
||||
return bp
|
||||
|
||||
@@ -1,109 +1,14 @@
|
||||
"""Likes app data endpoints."""
|
||||
"""Likes app data endpoints.
|
||||
|
||||
All queries are defined in ``likes/queries.sx``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, g, jsonify, request
|
||||
from quart import Blueprint
|
||||
|
||||
from shared.infrastructure.data_client import DATA_HEADER
|
||||
from shared.infrastructure.query_blueprint import create_data_blueprint
|
||||
|
||||
|
||||
def register() -> Blueprint:
|
||||
bp = Blueprint("data", __name__, url_prefix="/internal/data")
|
||||
|
||||
@bp.before_request
|
||||
async def _require_data_header():
|
||||
if not request.headers.get(DATA_HEADER):
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
from shared.infrastructure.internal_auth import validate_internal_request
|
||||
if not validate_internal_request():
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
|
||||
_handlers: dict[str, object] = {}
|
||||
|
||||
@bp.get("/<query_name>")
|
||||
async def handle_query(query_name: str):
|
||||
handler = _handlers.get(query_name)
|
||||
if handler is None:
|
||||
return jsonify({"error": "unknown query"}), 404
|
||||
result = await handler()
|
||||
return jsonify(result)
|
||||
|
||||
# --- is-liked ---
|
||||
async def _is_liked():
|
||||
"""Check if a user has liked a specific target."""
|
||||
from sqlalchemy import select
|
||||
from likes.models.like import Like
|
||||
|
||||
user_id = request.args.get("user_id", type=int)
|
||||
target_type = request.args.get("target_type", "")
|
||||
target_slug = request.args.get("target_slug")
|
||||
target_id = request.args.get("target_id", type=int)
|
||||
|
||||
if not user_id or not target_type:
|
||||
return {"liked": False}
|
||||
|
||||
filters = [
|
||||
Like.user_id == user_id,
|
||||
Like.target_type == target_type,
|
||||
Like.deleted_at.is_(None),
|
||||
]
|
||||
if target_slug is not None:
|
||||
filters.append(Like.target_slug == target_slug)
|
||||
elif target_id is not None:
|
||||
filters.append(Like.target_id == target_id)
|
||||
else:
|
||||
return {"liked": False}
|
||||
|
||||
row = await g.s.scalar(select(Like.id).where(*filters))
|
||||
return {"liked": row is not None}
|
||||
|
||||
_handlers["is-liked"] = _is_liked
|
||||
|
||||
# --- liked-slugs ---
|
||||
async def _liked_slugs():
|
||||
"""Return all liked target_slugs for a user + target_type."""
|
||||
from sqlalchemy import select
|
||||
from likes.models.like import Like
|
||||
|
||||
user_id = request.args.get("user_id", type=int)
|
||||
target_type = request.args.get("target_type", "")
|
||||
|
||||
if not user_id or not target_type:
|
||||
return []
|
||||
|
||||
result = await g.s.execute(
|
||||
select(Like.target_slug).where(
|
||||
Like.user_id == user_id,
|
||||
Like.target_type == target_type,
|
||||
Like.target_slug.isnot(None),
|
||||
Like.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
_handlers["liked-slugs"] = _liked_slugs
|
||||
|
||||
# --- liked-ids ---
|
||||
async def _liked_ids():
|
||||
"""Return all liked target_ids for a user + target_type."""
|
||||
from sqlalchemy import select
|
||||
from likes.models.like import Like
|
||||
|
||||
user_id = request.args.get("user_id", type=int)
|
||||
target_type = request.args.get("target_type", "")
|
||||
|
||||
if not user_id or not target_type:
|
||||
return []
|
||||
|
||||
result = await g.s.execute(
|
||||
select(Like.target_id).where(
|
||||
Like.user_id == user_id,
|
||||
Like.target_type == target_type,
|
||||
Like.target_id.isnot(None),
|
||||
Like.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
_handlers["liked-ids"] = _liked_ids
|
||||
|
||||
bp, _handlers = create_data_blueprint("likes")
|
||||
return bp
|
||||
|
||||
18
likes/queries.sx
Normal file
18
likes/queries.sx
Normal file
@@ -0,0 +1,18 @@
|
||||
;; Likes service — inter-service data queries
|
||||
|
||||
(defquery is-liked (&key user-id target-type target-slug target-id)
|
||||
"Check if a user has liked a specific target."
|
||||
(let ((result (service "likes" "is-liked"
|
||||
:user-id user-id :target-type target-type
|
||||
:target-slug target-slug :target-id target-id)))
|
||||
{"liked" result}))
|
||||
|
||||
(defquery liked-slugs (&key user-id target-type)
|
||||
"Return all liked target_slugs for a user + target_type."
|
||||
(service "likes" "liked-slugs"
|
||||
:user-id user-id :target-type target-type))
|
||||
|
||||
(defquery liked-ids (&key user-id target-type)
|
||||
"Return all liked target_ids for a user + target_type."
|
||||
(service "likes" "liked-ids"
|
||||
:user-id user-id :target-type target-type))
|
||||
@@ -4,3 +4,6 @@ from __future__ import annotations
|
||||
|
||||
def register_domain_services() -> None:
|
||||
"""Register services for the likes app."""
|
||||
from shared.services.registry import services
|
||||
from shared.services.likes_impl import SqlLikesService
|
||||
services.likes = SqlLikesService()
|
||||
|
||||
15
market/actions.sx
Normal file
15
market/actions.sx
Normal file
@@ -0,0 +1,15 @@
|
||||
;; Market service — inter-service action endpoints
|
||||
|
||||
(defaction create-marketplace (&key container-type container-id name slug)
|
||||
"Create a new marketplace within a container."
|
||||
(let ((mp (service "market" "create-marketplace"
|
||||
:container-type container-type :container-id container-id
|
||||
:name name :slug slug)))
|
||||
mp))
|
||||
|
||||
(defaction soft-delete-marketplace (&key container-type container-id slug)
|
||||
"Soft-delete a marketplace by slug within a container."
|
||||
(let ((deleted (service "market" "soft-delete-marketplace"
|
||||
:container-type container-type :container-id container-id
|
||||
:slug slug)))
|
||||
{"deleted" deleted}))
|
||||
@@ -1,74 +1,14 @@
|
||||
"""Market app action endpoints.
|
||||
|
||||
Exposes write operations at ``/internal/actions/<action_name>`` for
|
||||
cross-app callers (blog, events) via the internal action client.
|
||||
All actions are defined in ``market/actions.sx``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, g, jsonify, request
|
||||
from quart import Blueprint
|
||||
|
||||
from shared.infrastructure.actions import ACTION_HEADER
|
||||
from shared.services.registry import services
|
||||
from shared.infrastructure.query_blueprint import create_action_blueprint
|
||||
|
||||
|
||||
def register() -> Blueprint:
|
||||
bp = Blueprint("actions", __name__, url_prefix="/internal/actions")
|
||||
|
||||
@bp.before_request
|
||||
async def _require_action_header():
|
||||
if not request.headers.get(ACTION_HEADER):
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
from shared.infrastructure.internal_auth import validate_internal_request
|
||||
if not validate_internal_request():
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
|
||||
_handlers: dict[str, object] = {}
|
||||
|
||||
@bp.post("/<action_name>")
|
||||
async def handle_action(action_name: str):
|
||||
handler = _handlers.get(action_name)
|
||||
if handler is None:
|
||||
return jsonify({"error": "unknown action"}), 404
|
||||
try:
|
||||
result = await handler()
|
||||
return jsonify(result)
|
||||
except Exception as exc:
|
||||
import logging
|
||||
logging.getLogger(__name__).exception("Action %s failed", action_name)
|
||||
return jsonify({"error": str(exc)}), 500
|
||||
|
||||
# --- create-marketplace ---
|
||||
async def _create_marketplace():
|
||||
data = await request.get_json()
|
||||
mp = await services.market.create_marketplace(
|
||||
g.s,
|
||||
data["container_type"],
|
||||
data["container_id"],
|
||||
data["name"],
|
||||
data["slug"],
|
||||
)
|
||||
return {
|
||||
"id": mp.id,
|
||||
"container_type": mp.container_type,
|
||||
"container_id": mp.container_id,
|
||||
"name": mp.name,
|
||||
"slug": mp.slug,
|
||||
"description": mp.description,
|
||||
}
|
||||
|
||||
_handlers["create-marketplace"] = _create_marketplace
|
||||
|
||||
# --- soft-delete-marketplace ---
|
||||
async def _soft_delete_marketplace():
|
||||
data = await request.get_json()
|
||||
deleted = await services.market.soft_delete_marketplace(
|
||||
g.s,
|
||||
data["container_type"],
|
||||
data["container_id"],
|
||||
data["slug"],
|
||||
)
|
||||
return {"deleted": deleted}
|
||||
|
||||
_handlers["soft-delete-marketplace"] = _soft_delete_marketplace
|
||||
|
||||
bp, _handlers = create_action_blueprint("market")
|
||||
return bp
|
||||
|
||||
@@ -1,110 +1,14 @@
|
||||
"""Market app data endpoints.
|
||||
|
||||
Exposes read-only JSON queries at ``/internal/data/<query_name>`` for
|
||||
cross-app callers via the internal data client.
|
||||
All queries are defined in ``market/queries.sx``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, g, jsonify, request
|
||||
from quart import Blueprint
|
||||
|
||||
from shared.infrastructure.data_client import DATA_HEADER
|
||||
from shared.contracts.dtos import dto_to_dict
|
||||
from shared.services.registry import services
|
||||
from shared.infrastructure.query_blueprint import create_data_blueprint
|
||||
|
||||
|
||||
def register() -> Blueprint:
|
||||
bp = Blueprint("data", __name__, url_prefix="/internal/data")
|
||||
|
||||
@bp.before_request
|
||||
async def _require_data_header():
|
||||
if not request.headers.get(DATA_HEADER):
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
from shared.infrastructure.internal_auth import validate_internal_request
|
||||
if not validate_internal_request():
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
|
||||
_handlers: dict[str, object] = {}
|
||||
|
||||
@bp.get("/<query_name>")
|
||||
async def handle_query(query_name: str):
|
||||
handler = _handlers.get(query_name)
|
||||
if handler is None:
|
||||
return jsonify({"error": "unknown query"}), 404
|
||||
result = await handler()
|
||||
return jsonify(result)
|
||||
|
||||
# --- marketplaces-for-container ---
|
||||
async def _marketplaces_for_container():
|
||||
container_type = request.args.get("type", "")
|
||||
container_id = request.args.get("id", type=int)
|
||||
markets = await services.market.marketplaces_for_container(
|
||||
g.s, container_type, container_id,
|
||||
)
|
||||
return [dto_to_dict(m) for m in markets]
|
||||
|
||||
_handlers["marketplaces-for-container"] = _marketplaces_for_container
|
||||
|
||||
# --- products-by-ids ---
|
||||
async def _products_by_ids():
|
||||
"""Return product details for a list of IDs (comma-separated)."""
|
||||
from sqlalchemy import select
|
||||
from shared.models.market import Product
|
||||
|
||||
ids_raw = request.args.get("ids", "")
|
||||
try:
|
||||
ids = [int(x) for x in ids_raw.split(",") if x.strip()]
|
||||
except ValueError:
|
||||
return {"error": "ids must be comma-separated integers"}, 400
|
||||
if not ids:
|
||||
return []
|
||||
|
||||
rows = (await g.s.execute(
|
||||
select(Product).where(Product.id.in_(ids))
|
||||
)).scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": p.id,
|
||||
"title": p.title,
|
||||
"slug": p.slug,
|
||||
"image": p.image,
|
||||
"regular_price": str(p.regular_price) if p.regular_price is not None else None,
|
||||
"special_price": str(p.special_price) if p.special_price is not None else None,
|
||||
}
|
||||
for p in rows
|
||||
]
|
||||
|
||||
_handlers["products-by-ids"] = _products_by_ids
|
||||
|
||||
# --- marketplaces-by-ids ---
|
||||
async def _marketplaces_by_ids():
|
||||
"""Return marketplace data for a list of IDs (comma-separated)."""
|
||||
from sqlalchemy import select
|
||||
from shared.models.market_place import MarketPlace
|
||||
|
||||
ids_raw = request.args.get("ids", "")
|
||||
try:
|
||||
ids = [int(x) for x in ids_raw.split(",") if x.strip()]
|
||||
except ValueError:
|
||||
return {"error": "ids must be comma-separated integers"}, 400
|
||||
if not ids:
|
||||
return []
|
||||
|
||||
rows = (await g.s.execute(
|
||||
select(MarketPlace).where(MarketPlace.id.in_(ids))
|
||||
)).scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": m.id,
|
||||
"name": m.name,
|
||||
"slug": m.slug,
|
||||
"container_type": m.container_type,
|
||||
"container_id": m.container_id,
|
||||
}
|
||||
for m in rows
|
||||
]
|
||||
|
||||
_handlers["marketplaces-by-ids"] = _marketplaces_by_ids
|
||||
|
||||
bp, _handlers = create_data_blueprint("market")
|
||||
return bp
|
||||
|
||||
@@ -129,7 +129,7 @@ def register():
|
||||
from sx.sx_components import render_like_toggle_button
|
||||
|
||||
if not g.user:
|
||||
return sx_response(render_like_toggle_button(product_slug, False), status=403)
|
||||
return sx_response(await render_like_toggle_button(product_slug, False), status=403)
|
||||
|
||||
user_id = g.user.id
|
||||
|
||||
@@ -138,7 +138,7 @@ def register():
|
||||
})
|
||||
liked = result["liked"]
|
||||
|
||||
return sx_response(render_like_toggle_button(product_slug, liked))
|
||||
return sx_response(await render_like_toggle_button(product_slug, liked))
|
||||
|
||||
|
||||
|
||||
@@ -257,7 +257,7 @@ def register():
|
||||
from sx.sx_components import render_cart_added_response
|
||||
item_data = getattr(g, "item_data", {})
|
||||
d = item_data.get("d", {})
|
||||
return sx_response(render_cart_added_response(g.cart, ci_ns, d))
|
||||
return sx_response(await render_cart_added_response(g.cart, ci_ns, d))
|
||||
|
||||
# normal POST: go to cart page
|
||||
from shared.infrastructure.urls import cart_url
|
||||
|
||||
14
market/queries.sx
Normal file
14
market/queries.sx
Normal file
@@ -0,0 +1,14 @@
|
||||
;; Market service — inter-service data queries
|
||||
|
||||
(defquery marketplaces-for-container (&key type id)
|
||||
"Marketplaces attached to a container (page, etc)."
|
||||
(service "market" "marketplaces-for-container"
|
||||
:container-type type :container-id id))
|
||||
|
||||
(defquery products-by-ids (&key ids)
|
||||
"Return product details for comma-separated IDs."
|
||||
(service "market-data" "products-by-ids" :ids (split-ids ids)))
|
||||
|
||||
(defquery marketplaces-by-ids (&key ids)
|
||||
"Return marketplace data for comma-separated IDs."
|
||||
(service "market-data" "marketplaces-by-ids" :ids (split-ids ids)))
|
||||
@@ -14,6 +14,9 @@ def register_domain_services() -> None:
|
||||
|
||||
services.market = SqlMarketService()
|
||||
|
||||
from shared.services.market_data_impl import SqlMarketDataService
|
||||
services.register("market_data", SqlMarketDataService())
|
||||
|
||||
# Federation needed for AP shared infrastructure (activitypub blueprint)
|
||||
if not services.has("federation"):
|
||||
from shared.services.federation_impl import SqlFederationService
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -27,55 +27,55 @@ def _register_market_layouts() -> None:
|
||||
register_custom_layout("market-admin", _market_admin_full, _market_admin_oob)
|
||||
|
||||
|
||||
def _market_full(ctx: dict, **kw: Any) -> str:
|
||||
async def _market_full(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import root_header_sx, header_child_sx
|
||||
from sx.sx_components import _post_header_sx, _market_header_sx
|
||||
|
||||
root_hdr = root_header_sx(ctx)
|
||||
child = "(<> " + _post_header_sx(ctx) + " " + _market_header_sx(ctx) + ")"
|
||||
return "(<> " + root_hdr + " " + header_child_sx(child) + ")"
|
||||
root_hdr = await root_header_sx(ctx)
|
||||
child = "(<> " + await _post_header_sx(ctx) + " " + await _market_header_sx(ctx) + ")"
|
||||
return "(<> " + root_hdr + " " + await header_child_sx(child) + ")"
|
||||
|
||||
|
||||
def _market_oob(ctx: dict, **kw: Any) -> str:
|
||||
async def _market_oob(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import oob_header_sx
|
||||
from sx.sx_components import _post_header_sx, _market_header_sx, _clear_deeper_oob
|
||||
|
||||
oobs = oob_header_sx("post-header-child", "market-header-child",
|
||||
_market_header_sx(ctx))
|
||||
oobs = "(<> " + oobs + " " + _post_header_sx(ctx, oob=True) + " "
|
||||
oobs = await oob_header_sx("post-header-child", "market-header-child",
|
||||
await _market_header_sx(ctx))
|
||||
oobs = "(<> " + oobs + " " + await _post_header_sx(ctx, oob=True) + " "
|
||||
oobs += _clear_deeper_oob("post-row", "post-header-child",
|
||||
"market-row", "market-header-child") + ")"
|
||||
return oobs
|
||||
|
||||
|
||||
def _market_mobile(ctx: dict, **kw: Any) -> str:
|
||||
async def _market_mobile(ctx: dict, **kw: Any) -> str:
|
||||
from sx.sx_components import _mobile_nav_panel_sx
|
||||
return _mobile_nav_panel_sx(ctx)
|
||||
return await _mobile_nav_panel_sx(ctx)
|
||||
|
||||
|
||||
def _market_admin_full(ctx: dict, **kw: Any) -> str:
|
||||
async def _market_admin_full(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import root_header_sx, header_child_sx
|
||||
from sx.sx_components import (
|
||||
_post_header_sx, _market_header_sx, _market_admin_header_sx,
|
||||
)
|
||||
|
||||
selected = kw.get("selected", "")
|
||||
root_hdr = root_header_sx(ctx)
|
||||
child = "(<> " + _post_header_sx(ctx) + " " + _market_header_sx(ctx) + " "
|
||||
child += _market_admin_header_sx(ctx, selected=selected) + ")"
|
||||
return "(<> " + root_hdr + " " + header_child_sx(child) + ")"
|
||||
root_hdr = await root_header_sx(ctx)
|
||||
child = "(<> " + await _post_header_sx(ctx) + " " + await _market_header_sx(ctx) + " "
|
||||
child += await _market_admin_header_sx(ctx, selected=selected) + ")"
|
||||
return "(<> " + root_hdr + " " + await header_child_sx(child) + ")"
|
||||
|
||||
|
||||
def _market_admin_oob(ctx: dict, **kw: Any) -> str:
|
||||
async def _market_admin_oob(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import oob_header_sx
|
||||
from sx.sx_components import (
|
||||
_market_header_sx, _market_admin_header_sx, _clear_deeper_oob,
|
||||
)
|
||||
|
||||
selected = kw.get("selected", "")
|
||||
oobs = "(<> " + _market_header_sx(ctx, oob=True) + " "
|
||||
oobs += oob_header_sx("market-header-child", "market-admin-header-child",
|
||||
_market_admin_header_sx(ctx, selected=selected)) + " "
|
||||
oobs = "(<> " + await _market_header_sx(ctx, oob=True) + " "
|
||||
oobs += await oob_header_sx("market-header-child", "market-admin-header-child",
|
||||
await _market_admin_header_sx(ctx, selected=selected)) + " "
|
||||
oobs += _clear_deeper_oob("post-row", "post-header-child",
|
||||
"market-row", "market-header-child",
|
||||
"market-admin-row", "market-admin-header-child") + ")"
|
||||
@@ -123,14 +123,14 @@ async def _h_all_markets_content(**kw):
|
||||
|
||||
if not markets:
|
||||
from sx.sx_components import _no_markets_sx
|
||||
return _no_markets_sx()
|
||||
return await _no_markets_sx()
|
||||
|
||||
prefix = route_prefix()
|
||||
next_url = prefix + url_for("all_markets.markets_fragment", page=page + 1)
|
||||
|
||||
from sx.sx_components import _market_cards_sx, _markets_grid
|
||||
cards = _market_cards_sx(markets, page_info, page, has_more, next_url)
|
||||
content = _markets_grid(cards)
|
||||
cards = await _market_cards_sx(markets, page_info, page, has_more, next_url)
|
||||
content = await _markets_grid(cards)
|
||||
return "(<> " + content + " " + '(div :class "pb-8")' + ")"
|
||||
|
||||
|
||||
@@ -148,15 +148,15 @@ async def _h_page_markets_content(slug=None, **kw):
|
||||
|
||||
if not markets:
|
||||
from sx.sx_components import _no_markets_sx
|
||||
return _no_markets_sx("No markets for this page")
|
||||
return await _no_markets_sx("No markets for this page")
|
||||
|
||||
prefix = route_prefix()
|
||||
next_url = prefix + url_for("page_markets.markets_fragment", page=page + 1)
|
||||
|
||||
from sx.sx_components import _market_cards_sx, _markets_grid
|
||||
cards = _market_cards_sx(markets, {}, page, has_more, next_url,
|
||||
cards = await _market_cards_sx(markets, {}, page, has_more, next_url,
|
||||
show_page_badge=False, post_slug=post_slug)
|
||||
content = _markets_grid(cards)
|
||||
content = await _markets_grid(cards)
|
||||
return "(<> " + content + " " + '(div :class "pb-8")' + ")"
|
||||
|
||||
|
||||
@@ -168,12 +168,12 @@ async def _h_page_admin_content(slug=None, **kw):
|
||||
return '(div :id "main-panel" ' + content + ')'
|
||||
|
||||
|
||||
def _h_market_home_content(page_slug=None, market_slug=None, **kw):
|
||||
async def _h_market_home_content(page_slug=None, market_slug=None, **kw):
|
||||
from quart import g
|
||||
post_data = getattr(g, "post_data", {})
|
||||
post = post_data.get("post", {})
|
||||
from sx.sx_components import _market_landing_content_sx
|
||||
return _market_landing_content_sx(post)
|
||||
return await _market_landing_content_sx(post)
|
||||
|
||||
|
||||
def _h_market_admin_content(page_slug=None, market_slug=None, **kw):
|
||||
|
||||
4
orders/actions.sx
Normal file
4
orders/actions.sx
Normal file
@@ -0,0 +1,4 @@
|
||||
;; Orders service — inter-service action endpoints
|
||||
;;
|
||||
;; create-order has complex multi-step logic (SumUp checkout creation) —
|
||||
;; remains as Python fallback.
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -1,40 +1,18 @@
|
||||
"""Orders app action endpoints."""
|
||||
"""Orders app action endpoints.
|
||||
|
||||
create-order remains as a Python fallback (complex multi-step SumUp checkout).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, g, jsonify, request
|
||||
from quart import Blueprint, g, request
|
||||
|
||||
from shared.infrastructure.actions import ACTION_HEADER
|
||||
from shared.infrastructure.query_blueprint import create_action_blueprint
|
||||
|
||||
|
||||
def register() -> Blueprint:
|
||||
bp = Blueprint("actions", __name__, url_prefix="/internal/actions")
|
||||
bp, _handlers = create_action_blueprint("orders")
|
||||
|
||||
@bp.before_request
|
||||
async def _require_action_header():
|
||||
if not request.headers.get(ACTION_HEADER):
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
from shared.infrastructure.internal_auth import validate_internal_request
|
||||
if not validate_internal_request():
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
|
||||
_handlers: dict[str, object] = {}
|
||||
|
||||
@bp.post("/<action_name>")
|
||||
async def handle_action(action_name: str):
|
||||
handler = _handlers.get(action_name)
|
||||
if handler is None:
|
||||
return jsonify({"error": "unknown action"}), 404
|
||||
try:
|
||||
result = await handler()
|
||||
return jsonify(result)
|
||||
except Exception as exc:
|
||||
import logging
|
||||
logging.getLogger(__name__).exception("Action %s failed", action_name)
|
||||
return jsonify({"error": str(exc)}), 500
|
||||
|
||||
# --- create-order ---
|
||||
async def _create_order():
|
||||
"""Create an order from cart data. Called by cart during checkout."""
|
||||
from shared.browser.app.payments.sumup import create_checkout as sumup_create_checkout
|
||||
from shared.infrastructure.urls import orders_url
|
||||
from services.checkout import (
|
||||
@@ -75,7 +53,6 @@ def register() -> Blueprint:
|
||||
order.sumup_reference = build_sumup_reference(order.id, page_config=page_config)
|
||||
description = build_sumup_description(cart_items, order.id, ticket_count=len(tickets))
|
||||
|
||||
# Build URLs using orders service's own domain
|
||||
redirect_url = orders_url(f"/checkout/return/{order.id}/")
|
||||
webhook_base_url = orders_url(f"/checkout/webhook/{order.id}/")
|
||||
webhook_url = build_webhook_url(webhook_base_url)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,30 +1,11 @@
|
||||
"""Orders app data endpoints."""
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, g, jsonify, request
|
||||
from quart import Blueprint
|
||||
|
||||
from shared.infrastructure.data_client import DATA_HEADER
|
||||
from shared.infrastructure.query_blueprint import create_data_blueprint
|
||||
|
||||
|
||||
def register() -> Blueprint:
|
||||
bp = Blueprint("data", __name__, url_prefix="/internal/data")
|
||||
|
||||
@bp.before_request
|
||||
async def _require_data_header():
|
||||
if not request.headers.get(DATA_HEADER):
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
from shared.infrastructure.internal_auth import validate_internal_request
|
||||
if not validate_internal_request():
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
|
||||
_handlers: dict[str, object] = {}
|
||||
|
||||
@bp.get("/<query_name>")
|
||||
async def handle_query(query_name: str):
|
||||
handler = _handlers.get(query_name)
|
||||
if handler is None:
|
||||
return jsonify({"error": "unknown query"}), 404
|
||||
result = await handler()
|
||||
return jsonify(result)
|
||||
|
||||
bp, _handlers = create_data_blueprint("orders")
|
||||
return bp
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user