diff --git a/account/services/__init__.py b/account/services/__init__.py index cd62eeb..ce716c7 100644 --- a/account/services/__init__.py +++ b/account/services/__init__.py @@ -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()) diff --git a/account/services/account_page.py b/account/services/account_page.py new file mode 100644 index 0000000..233b23c --- /dev/null +++ b/account/services/account_page.py @@ -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(""), + } diff --git a/account/sxc/pages/__init__.py b/account/sxc/pages/__init__.py index bd960a0..7f624ed 100644 --- a/account/sxc/pages/__init__.py +++ b/account/sxc/pages/__init__.py @@ -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} diff --git a/account/sxc/pages/account.sx b/account/sxc/pages/account.sx index f85e807..751e299 100644 --- a/account/sxc/pages/account.sx +++ b/account/sxc/pages/account.sx @@ -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)) diff --git a/cart/services/__init__.py b/cart/services/__init__.py index f46512d..e1251b7 100644 --- a/cart/services/__init__.py +++ b/cart/services/__init__.py @@ -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()) diff --git a/cart/services/cart_page.py b/cart/services/cart_page.py new file mode 100644 index 0000000..a144108 --- /dev/null +++ b/cart/services/cart_page.py @@ -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} diff --git a/cart/sxc/pages/__init__.py b/cart/sxc/pages/__init__.py index aeaae71..46894a1 100644 --- a/cart/sxc/pages/__init__.py +++ b/cart/sxc/pages/__init__.py @@ -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) diff --git a/cart/sxc/pages/cart.sx b/cart/sxc/pages/cart.sx index 05ada99..e4e83c7 100644 --- a/cart/sxc/pages/cart.sx +++ b/cart/sxc/pages/cart.sx @@ -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 "//" :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 "//admin/" :auth :admin :layout :cart-admin - :content (cart-admin-content)) + :content (~cart-admin-content)) (defpage cart-payments :path "//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)) diff --git a/federation/services/__init__.py b/federation/services/__init__.py index 92f2587..da2dd52 100644 --- a/federation/services/__init__.py +++ b/federation/services/__init__.py @@ -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()) diff --git a/federation/services/federation_page.py b/federation/services/federation_page.py new file mode 100644 index 0000000..4ed8bd6 --- /dev/null +++ b/federation/services/federation_page.py @@ -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} diff --git a/federation/sxc/pages/__init__.py b/federation/sxc/pages/__init__.py index 9442c36..c8c25ed 100644 --- a/federation/sxc/pages/__init__.py +++ b/federation/sxc/pages/__init__.py @@ -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) diff --git a/federation/sxc/pages/social.sx b/federation/sxc/pages/social.sx index fafed16..84e94b5 100644 --- a/federation/sxc/pages/social.sx +++ b/federation/sxc/pages/social.sx @@ -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/" :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))