Eliminate Python page helpers from account, federation, and cart
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m8s

All three services now fetch page data via (service ...) IO primitives
in .sx defpages instead of Python middleman functions.

- Account: newsletters-data → AccountPageService.newsletters_data
- Federation: 8 page helpers → FederationPageService methods
  (timeline, compose, search, following, followers, notifications)
- Cart: 4 page helpers → CartPageService methods
  (overview, page-cart, admin, payments)
- Serializers moved to service modules, thin delegates kept for routes
- ~520 lines of Python page helpers removed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 02:01:50 +00:00
parent fb8f115acb
commit 418ac9424f
12 changed files with 520 additions and 521 deletions

View File

@@ -3,9 +3,7 @@ from __future__ import annotations
def register_domain_services() -> None:
"""Register services for the account app.
Account is a consumer-only dashboard app. It has no own domain.
All cross-app data comes via fragments and HTTP data endpoints.
"""
pass
"""Register services for the account app."""
from shared.services.registry import services
from .account_page import AccountPageService
services.register("account_page", AccountPageService())

View 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(""),
}

View File

@@ -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()
@@ -90,50 +89,3 @@ def _as_sx_nav(ctx: dict) -> Any:
return _as_sx(ctx.get("account_nav"))
# ---------------------------------------------------------------------------
# Page helpers
# ---------------------------------------------------------------------------
def _register_account_helpers() -> None:
from shared.sx.pages import register_page_helpers
register_page_helpers("account", {
"newsletters-data": _h_newsletters_data,
})
async def _h_newsletters_data(**kw):
"""Fetch newsletter data — returns dict merged into defpage env."""
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": {"id": nl.id, "name": nl.name, "description": nl.description},
"un": {"newsletter_id": un.newsletter_id, "subscribed": un.subscribed} if un else None,
"subscribed": un.subscribed if un else False,
})
account_url = getattr(g, "_account_url", None)
if account_url is None:
from shared.infrastructure.urls import account_url as _account_url
account_url = _account_url
account_url_str = account_url("") if callable(account_url) else str(account_url or "")
return {"newsletter-list": newsletter_list, "account-url": account_url_str}

View File

@@ -18,7 +18,7 @@
:path "/newsletters/"
:auth :login
:layout :account
:data (newsletters-data)
:data (service "account-page" "newsletters-data")
:content (~account-newsletters-content
:newsletter-list newsletter-list
:account-url account-url))

View File

@@ -12,3 +12,6 @@ def register_domain_services() -> None:
from shared.services.cart_impl import SqlCartService
services.cart = SqlCartService()
from .cart_page import CartPageService
services.register("cart_page", CartPageService())

187
cart/services/cart_page.py Normal file
View 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}

View File

@@ -1,4 +1,4 @@
"""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
@@ -8,9 +8,8 @@ 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()
@@ -118,7 +117,7 @@ async def _cart_page_admin_header_sx(ctx: dict, page_post: Any, *, oob: bool = F
# ---------------------------------------------------------------------------
# Order serialization helpers
# Order serialization helpers (used by route render functions below)
# ---------------------------------------------------------------------------
def _serialize_order(order: Any) -> dict:
@@ -352,239 +351,3 @@ async def _cart_admin_oob(ctx: dict, **kw: Any) -> str:
return await _cart_page_admin_header_sx(ctx, page_post, oob=True, selected=selected)
# ---------------------------------------------------------------------------
# Page helpers
# ---------------------------------------------------------------------------
def _register_cart_helpers() -> None:
from shared.sx.pages import register_page_helpers
register_page_helpers("cart", {
"overview-content": _h_overview_content,
"page-cart-content": _h_page_cart_content,
"cart-admin-content": _h_cart_admin_content,
"cart-payments-content": _h_cart_payments_content,
})
# ---------------------------------------------------------------------------
# Serialization helpers
# ---------------------------------------------------------------------------
def _serialize_cart_item(item: Any) -> dict:
"""Serialize a cart item + product for SX defcomps."""
from quart import url_for
from shared.infrastructure.urls import market_product_url
p = item.product if hasattr(item, "product") else item
slug = p.slug if hasattr(p, "slug") else ""
unit_price = getattr(p, "special_price", None) or getattr(p, "regular_price", None)
currency = getattr(p, "regular_price_currency", "GBP") or "GBP"
return {
"slug": slug,
"title": p.title if hasattr(p, "title") else "",
"image": p.image if hasattr(p, "image") else None,
"brand": getattr(p, "brand", None),
"is_deleted": getattr(item, "is_deleted", False),
"unit_price": float(unit_price) if unit_price else None,
"special_price": float(p.special_price) if getattr(p, "special_price", None) else None,
"regular_price": float(p.regular_price) if getattr(p, "regular_price", None) else None,
"currency": currency,
"quantity": item.quantity,
"product_id": p.id,
"product_url": market_product_url(slug),
"qty_url": url_for("cart_global.update_quantity", product_id=p.id),
}
def _serialize_cal_entry(e: Any) -> dict:
"""Serialize a calendar entry for SX defcomps."""
name = getattr(e, "name", None) or getattr(e, "calendar_name", "")
start = e.start_at if hasattr(e, "start_at") else ""
end = getattr(e, "end_at", None)
cost = getattr(e, "cost", 0) or 0
end_str = f" \u2013 {end}" if end else ""
return {
"name": name,
"date_str": f"{start}{end_str}",
"cost": float(cost),
}
def _serialize_ticket_group(tg: Any) -> dict:
"""Serialize a ticket group for SX defcomps."""
name = tg.entry_name if hasattr(tg, "entry_name") else tg.get("entry_name", "")
tt_name = tg.ticket_type_name if hasattr(tg, "ticket_type_name") else tg.get("ticket_type_name", "")
price = tg.price if hasattr(tg, "price") else tg.get("price", 0)
quantity = tg.quantity if hasattr(tg, "quantity") else tg.get("quantity", 0)
line_total = tg.line_total if hasattr(tg, "line_total") else tg.get("line_total", 0)
entry_id = tg.entry_id if hasattr(tg, "entry_id") else tg.get("entry_id", "")
tt_id = tg.ticket_type_id if hasattr(tg, "ticket_type_id") else tg.get("ticket_type_id", "")
start_at = tg.entry_start_at if hasattr(tg, "entry_start_at") else tg.get("entry_start_at")
end_at = tg.entry_end_at if hasattr(tg, "entry_end_at") else tg.get("entry_end_at")
date_str = start_at.strftime("%-d %b %Y, %H:%M") if start_at else ""
if end_at:
date_str += f" \u2013 {end_at.strftime('%-d %b %Y, %H:%M')}"
return {
"entry_name": name,
"ticket_type_name": tt_name or None,
"price": float(price or 0),
"quantity": quantity,
"line_total": float(line_total or 0),
"entry_id": entry_id,
"ticket_type_id": tt_id or None,
"date_str": date_str,
}
def _serialize_page_group(grp: Any) -> dict:
"""Serialize a page group for SX defcomps."""
post = grp.get("post") if isinstance(grp, dict) else getattr(grp, "post", None)
cart_items = grp.get("cart_items", []) if isinstance(grp, dict) else getattr(grp, "cart_items", [])
cal_entries = grp.get("calendar_entries", []) if isinstance(grp, dict) else getattr(grp, "calendar_entries", [])
tickets = grp.get("tickets", []) if isinstance(grp, dict) else getattr(grp, "tickets", [])
if not cart_items and not cal_entries and not tickets:
return None
post_data = None
if post:
post_data = {
"slug": post.slug if hasattr(post, "slug") else post.get("slug", ""),
"title": post.title if hasattr(post, "title") else post.get("title", ""),
"feature_image": post.feature_image if hasattr(post, "feature_image") else post.get("feature_image"),
}
market_place = grp.get("market_place") if isinstance(grp, dict) else getattr(grp, "market_place", None)
mp_data = None
if market_place:
mp_data = {"name": market_place.name if hasattr(market_place, "name") else market_place.get("name", "")}
return {
"post": post_data,
"product_count": grp.get("product_count", 0) if isinstance(grp, dict) else getattr(grp, "product_count", 0),
"calendar_count": grp.get("calendar_count", 0) if isinstance(grp, dict) else getattr(grp, "calendar_count", 0),
"ticket_count": grp.get("ticket_count", 0) if isinstance(grp, dict) else getattr(grp, "ticket_count", 0),
"total": float(grp.get("total", 0) if isinstance(grp, dict) else getattr(grp, "total", 0)),
"market_place": mp_data,
}
def _build_summary_data(ctx: dict, cart: list, cal_entries: list, tickets: list,
total_fn, cal_total_fn, ticket_total_fn) -> dict:
"""Build cart summary data dict for SX defcomps."""
from quart import g, request, url_for
from shared.infrastructure.urls import login_url
from shared.utils import route_prefix
product_qty = sum(ci.quantity for ci in cart) if cart else 0
ticket_qty = len(tickets) if tickets else 0
item_count = product_qty + ticket_qty
product_total = total_fn(cart) or 0
cal_total = cal_total_fn(cal_entries) or 0
tk_total = ticket_total_fn(tickets) or 0
grand = float(product_total) + float(cal_total) + float(tk_total)
symbol = "\u00a3"
if cart and hasattr(cart[0], "product") and getattr(cart[0].product, "regular_price_currency", None):
cur = cart[0].product.regular_price_currency
symbol = "\u00a3" if cur == "GBP" else cur
user = getattr(g, "user", None)
page_post = ctx.get("page_post")
result = {
"item_count": item_count,
"grand_total": grand,
"symbol": symbol,
"is_logged_in": bool(user),
}
if user:
if page_post:
action = url_for("page_cart.page_checkout")
else:
action = url_for("cart_global.checkout")
result["checkout_action"] = route_prefix() + action
result["user_email"] = user.email
else:
result["login_href"] = login_url(request.url)
return result
# ---------------------------------------------------------------------------
# Page helper implementations
# ---------------------------------------------------------------------------
async def _h_overview_content(**kw):
from quart import g
from shared.sx.helpers import render_to_sx
from shared.infrastructure.urls import cart_url
from bp.cart.services import get_cart_grouped_by_page
page_groups = await get_cart_grouped_by_page(g.s)
grp_dicts = [d for d in (_serialize_page_group(grp) for grp in page_groups) if d]
return await render_to_sx("cart-overview-content",
page_groups=grp_dicts,
cart_url_base=cart_url(""))
async def _h_page_cart_content(page_slug=None, **kw):
from quart import g
from shared.sx.helpers import render_to_sx
from shared.sx.parser import SxExpr
from shared.sx.page import get_template_context
from bp.cart.services import total, calendar_total, ticket_total
from bp.cart.services.page_cart import (
get_cart_for_page, get_calendar_entries_for_page, get_tickets_for_page,
)
from bp.cart.services.ticket_groups import group_tickets
post = g.page_post
cart = await get_cart_for_page(g.s, post.id)
cal_entries = await get_calendar_entries_for_page(g.s, post.id)
page_tickets = await get_tickets_for_page(g.s, post.id)
ticket_groups = group_tickets(page_tickets)
ctx = await get_template_context()
sd = _build_summary_data(ctx, cart, cal_entries, page_tickets,
total, calendar_total, ticket_total)
summary_sx = await render_to_sx("cart-summary-from-data",
item_count=sd["item_count"],
grand_total=sd["grand_total"],
symbol=sd["symbol"],
is_logged_in=sd["is_logged_in"],
checkout_action=sd.get("checkout_action"),
login_href=sd.get("login_href"),
user_email=sd.get("user_email"))
return await render_to_sx("cart-page-cart-content",
cart_items=[_serialize_cart_item(i) for i in cart],
cal_entries=[_serialize_cal_entry(e) for e in cal_entries],
ticket_groups=[_serialize_ticket_group(tg) for tg in ticket_groups],
summary=SxExpr(summary_sx))
async def _h_cart_admin_content(page_slug=None, **kw):
return '(~cart-admin-content)'
async def _h_cart_payments_content(page_slug=None, **kw):
from shared.sx.page import get_template_context
from shared.sx.helpers import render_to_sx
ctx = await get_template_context()
page_config = ctx.get("page_config")
pc_data = None
if page_config:
pc_data = {
"sumup_api_key": bool(getattr(page_config, "sumup_api_key", None)),
"sumup_merchant_code": getattr(page_config, "sumup_merchant_code", None) or "",
"sumup_checkout_prefix": getattr(page_config, "sumup_checkout_prefix", None) or "",
}
return await render_to_sx("cart-payments-content",
page_config=pc_data)

View File

@@ -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))

View File

@@ -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())

View 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}

View File

@@ -1,13 +1,12 @@
"""Federation defpage setup — registers layouts, page helpers, and loads .sx pages."""
"""Federation defpage setup — registers layouts and loads .sx pages."""
from __future__ import annotations
from typing import Any
def setup_federation_pages() -> None:
"""Register federation-specific layouts, page helpers, and load page definitions."""
"""Register federation-specific layouts and load page definitions."""
_register_federation_layouts()
_register_federation_helpers()
_load_federation_page_files()
@@ -55,74 +54,25 @@ async def _social_oob(ctx: dict, **kw: Any) -> str:
# ---------------------------------------------------------------------------
# Page helpers
# Serializers and helpers — still used by layouts and route handlers
# ---------------------------------------------------------------------------
def _register_federation_helpers() -> None:
from shared.sx.pages import register_page_helpers
register_page_helpers("federation", {
"home-timeline-content": _h_home_timeline_content,
"public-timeline-content": _h_public_timeline_content,
"compose-content": _h_compose_content,
"search-content": _h_search_content,
"following-content": _h_following_content,
"followers-content": _h_followers_content,
"actor-timeline-content": _h_actor_timeline_content,
"notifications-content": _h_notifications_content,
})
def _serialize_actor(actor) -> dict | None:
"""Serialize an actor profile to a dict for sx defcomps."""
if not actor:
return None
return {
"id": actor.id,
"preferred_username": actor.preferred_username,
"display_name": getattr(actor, "display_name", None),
"icon_url": getattr(actor, "icon_url", None),
"summary": getattr(actor, "summary", None),
"actor_url": getattr(actor, "actor_url", ""),
"domain": getattr(actor, "domain", ""),
}
from services.federation_page import _serialize_actor as _impl
return _impl(actor)
def _serialize_timeline_item(item) -> dict:
"""Serialize a timeline item DTO to a dict for sx defcomps."""
published = getattr(item, "published", None)
return {
"object_id": getattr(item, "object_id", "") or "",
"author_inbox": getattr(item, "author_inbox", "") or "",
"actor_icon": getattr(item, "actor_icon", None),
"actor_name": getattr(item, "actor_name", "?"),
"actor_username": getattr(item, "actor_username", ""),
"actor_domain": getattr(item, "actor_domain", ""),
"content": getattr(item, "content", ""),
"summary": getattr(item, "summary", None),
"published": published.strftime("%b %d, %H:%M") if published else "",
"before_cursor": published.isoformat() if published else "",
"url": getattr(item, "url", None),
"post_type": getattr(item, "post_type", ""),
"boosted_by": getattr(item, "boosted_by", None),
"like_count": getattr(item, "like_count", 0) or 0,
"boost_count": getattr(item, "boost_count", 0) or 0,
"liked_by_me": getattr(item, "liked_by_me", False),
"boosted_by_me": getattr(item, "boosted_by_me", False),
}
from services.federation_page import _serialize_timeline_item as _impl
return _impl(item)
def _serialize_remote_actor(a) -> dict:
"""Serialize a remote actor DTO to a dict for sx defcomps."""
return {
"id": getattr(a, "id", None),
"display_name": getattr(a, "display_name", None) or getattr(a, "preferred_username", ""),
"preferred_username": getattr(a, "preferred_username", ""),
"domain": getattr(a, "domain", ""),
"icon_url": getattr(a, "icon_url", None),
"actor_url": getattr(a, "actor_url", ""),
"summary": getattr(a, "summary", None),
}
from services.federation_page import _serialize_remote_actor as _impl
return _impl(a)
async def _social_page(ctx: dict, actor, *, content: str,
@@ -155,156 +105,3 @@ def _require_actor():
if not actor:
abort(403, "You need to choose a federation username first")
return actor
async def _h_home_timeline_content(**kw):
from quart import g
from shared.services.registry import services
from shared.sx.helpers import render_to_sx
actor = _require_actor()
items = await services.federation.get_home_timeline(g.s, actor.id)
return await render_to_sx("federation-timeline-content",
items=[_serialize_timeline_item(i) for i in items],
timeline_type="home",
actor=_serialize_actor(actor))
async def _h_public_timeline_content(**kw):
from quart import g
from shared.services.registry import services
from shared.sx.helpers import render_to_sx
actor = _get_actor()
items = await services.federation.get_public_timeline(g.s)
return await render_to_sx("federation-timeline-content",
items=[_serialize_timeline_item(i) for i in items],
timeline_type="public",
actor=_serialize_actor(actor))
async def _h_compose_content(**kw):
from quart import request
from shared.sx.helpers import render_to_sx
_require_actor()
reply_to = request.args.get("reply_to")
return await render_to_sx("federation-compose-content",
reply_to=reply_to or None)
async def _h_search_content(**kw):
from quart import g, request
from shared.services.registry import services
from shared.sx.helpers import render_to_sx
actor = _get_actor()
query = request.args.get("q", "").strip()
actors_list = []
total = 0
followed_urls: set[str] = set()
if query:
actors_list, total = await services.federation.search_actors(g.s, query)
if actor:
following, _ = await services.federation.get_following(
g.s, actor.preferred_username, page=1, per_page=1000,
)
followed_urls = {a.actor_url for a in following}
return await render_to_sx("federation-search-content",
query=query,
actors=[_serialize_remote_actor(a) for a in actors_list],
total=total,
followed_urls=list(followed_urls),
actor=_serialize_actor(actor))
async def _h_following_content(**kw):
from quart import g
from shared.services.registry import services
from shared.sx.helpers import render_to_sx
actor = _require_actor()
actors_list, total = await services.federation.get_following(
g.s, actor.preferred_username,
)
return await render_to_sx("federation-following-content",
actors=[_serialize_remote_actor(a) for a in actors_list],
total=total,
actor=_serialize_actor(actor))
async def _h_followers_content(**kw):
from quart import g
from shared.services.registry import services
from shared.sx.helpers import render_to_sx
actor = _require_actor()
actors_list, total = await services.federation.get_followers_paginated(
g.s, actor.preferred_username,
)
following, _ = await services.federation.get_following(
g.s, actor.preferred_username, page=1, per_page=1000,
)
followed_urls = {a.actor_url for a in following}
return await render_to_sx("federation-followers-content",
actors=[_serialize_remote_actor(a) for a in actors_list],
total=total,
followed_urls=list(followed_urls),
actor=_serialize_actor(actor))
async def _h_actor_timeline_content(id=None, **kw):
from quart import g, abort
from shared.services.registry import services
from shared.sx.helpers import render_to_sx
actor = _get_actor()
actor_id = id
from shared.models.federation import RemoteActor
from sqlalchemy import select as sa_select
remote = (
await g.s.execute(
sa_select(RemoteActor).where(RemoteActor.id == actor_id)
)
).scalar_one_or_none()
if not remote:
abort(404)
from shared.services.federation_impl import _remote_actor_to_dto
remote_dto = _remote_actor_to_dto(remote)
items = await services.federation.get_actor_timeline(g.s, actor_id)
is_following = False
if actor:
from shared.models.federation import APFollowing
existing = (
await g.s.execute(
sa_select(APFollowing).where(
APFollowing.actor_profile_id == actor.id,
APFollowing.remote_actor_id == actor_id,
)
)
).scalar_one_or_none()
is_following = existing is not None
return await render_to_sx("federation-actor-timeline-content",
remote_actor=_serialize_remote_actor(remote_dto),
items=[_serialize_timeline_item(i) for i in items],
is_following=is_following,
actor=_serialize_actor(actor))
async def _h_notifications_content(**kw):
from quart import g
from shared.services.registry import services
from shared.sx.helpers import render_to_sx
actor = _require_actor()
items = await services.federation.get_notifications(g.s, actor.id)
await services.federation.mark_notifications_read(g.s, actor.id)
notif_dicts = []
for n in items:
created = getattr(n, "created_at", None)
notif_dicts.append({
"from_actor_name": getattr(n, "from_actor_name", "?"),
"from_actor_username": getattr(n, "from_actor_username", ""),
"from_actor_domain": getattr(n, "from_actor_domain", ""),
"from_actor_icon": getattr(n, "from_actor_icon", None),
"notification_type": getattr(n, "notification_type", ""),
"target_content_preview": getattr(n, "target_content_preview", None),
"created_at_formatted": created.strftime("%b %d, %H:%M") if created else "",
"read": getattr(n, "read", True),
"app_domain": getattr(n, "app_domain", ""),
})
return await render_to_sx("federation-notifications-content",
notifications=notif_dicts)

View File

@@ -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))