diff --git a/orders/services/__init__.py b/orders/services/__init__.py index 97c0882..8280e53 100644 --- a/orders/services/__init__.py +++ b/orders/services/__init__.py @@ -4,3 +4,6 @@ from __future__ import annotations def register_domain_services() -> None: """Register services for the orders app.""" + from shared.services.registry import services + from .orders_page import OrdersPageService + services.register("orders_page", OrdersPageService()) diff --git a/orders/services/orders_page.py b/orders/services/orders_page.py new file mode 100644 index 0000000..2acec4d --- /dev/null +++ b/orders/services/orders_page.py @@ -0,0 +1,138 @@ +"""Orders page data service — provides serialized dicts for .sx defpages.""" +from __future__ import annotations + +from sqlalchemy import select, func, or_, cast, String, exists +from sqlalchemy.orm import selectinload + +from shared.models.order import Order, OrderItem +from shared.infrastructure.cart_identity import current_cart_identity +from shared.infrastructure.urls import market_product_url + + +class OrdersPageService: + """Service for orders page data, callable via (service "orders-page" ...).""" + + async def list_page_data(self, session, *, search="", page=1): + """Return orders list + pagination metadata as a dict.""" + PER_PAGE = 10 + ident = current_cart_identity() + if ident["user_id"]: + owner = Order.user_id == ident["user_id"] + elif ident["session_id"]: + owner = Order.session_id == ident["session_id"] + else: + return {"orders": [], "page": 1, "total_pages": 1, + "search": "", "search_count": 0} + + page = max(1, int(page)) + + where = None + if search: + term = f"%{search.strip()}%" + conds = [ + Order.status.ilike(term), + Order.currency.ilike(term), + Order.sumup_checkout_id.ilike(term), + Order.sumup_status.ilike(term), + Order.description.ilike(term), + exists( + select(1).select_from(OrderItem) + .where(OrderItem.order_id == Order.id, + or_(OrderItem.product_title.ilike(term), + OrderItem.product_slug.ilike(term))) + ), + ] + try: + conds.append(Order.id == int(search)) + except (TypeError, ValueError): + conds.append(cast(Order.id, String).ilike(term)) + where = or_(*conds) + + count_q = select(func.count()).select_from(Order).where(owner) + if where is not None: + count_q = count_q.where(where) + total_count = (await session.execute(count_q)).scalar_one() or 0 + total_pages = max(1, (total_count + PER_PAGE - 1) // PER_PAGE) + if page > total_pages: + page = total_pages + + stmt = (select(Order).where(owner) + .order_by(Order.created_at.desc()) + .offset((page - 1) * PER_PAGE).limit(PER_PAGE)) + if where is not None: + stmt = stmt.where(where) + rows = (await session.execute(stmt)).scalars().all() + + orders = [] + for o in rows: + orders.append({ + "id": o.id, + "status": o.status or "pending", + "created_at_formatted": ( + o.created_at.strftime("%-d %b %Y, %H:%M") + if o.created_at else "\u2014"), + "description": o.description or "", + "currency": o.currency or "GBP", + "total_formatted": f"{o.total_amount or 0:.2f}", + }) + + return { + "orders": orders, + "page": page, + "total_pages": total_pages, + "search": search or "", + "search_count": total_count, + } + + async def detail_page_data(self, session, *, order_id=None): + """Return order detail data as a dict.""" + from quart import abort + + if order_id is None: + abort(404) + + ident = current_cart_identity() + if ident["user_id"]: + owner = Order.user_id == ident["user_id"] + elif ident["session_id"]: + owner = Order.session_id == ident["session_id"] + else: + abort(404) + return {} + + result = await session.execute( + select(Order).options(selectinload(Order.items)) + .where(Order.id == int(order_id), owner) + ) + order = result.scalar_one_or_none() + if not order: + abort(404) + return {} + + items = [] + for item in (order.items or []): + items.append({ + "product_url": market_product_url(item.product_slug), + "product_image": item.product_image, + "product_title": item.product_title, + "product_id": item.product_id, + "quantity": item.quantity, + "currency": item.currency, + "unit_price_formatted": f"{item.unit_price or 0:.2f}", + }) + + return { + "order": { + "id": order.id, + "status": order.status or "pending", + "created_at_formatted": ( + order.created_at.strftime("%-d %b %Y, %H:%M") + if order.created_at else "\u2014"), + "description": order.description or "", + "currency": order.currency or "GBP", + "total_formatted": ( + f"{order.total_amount:.2f}" + if order.total_amount else "0.00"), + "items": items, + }, + } diff --git a/orders/sxc/pages/__init__.py b/orders/sxc/pages/__init__.py index 4250a62..97b25ef 100644 --- a/orders/sxc/pages/__init__.py +++ b/orders/sxc/pages/__init__.py @@ -1,13 +1,12 @@ -"""Orders defpage setup — registers layouts, page helpers, and loads .sx pages.""" +"""Orders defpage setup — registers layouts and loads .sx pages.""" from __future__ import annotations from typing import Any def setup_orders_pages() -> None: - """Register orders-specific layouts, page helpers, and load page definitions.""" + """Register orders-specific layouts and load page definitions.""" _register_orders_layouts() - _register_orders_helpers() _load_orders_page_files() @@ -125,325 +124,3 @@ def _as_sx_nav(ctx: dict) -> Any: """Convert account_nav fragment to SxExpr for use in component calls.""" from shared.sx.helpers import _as_sx return _as_sx(ctx.get("account_nav")) - - -# --------------------------------------------------------------------------- -# Page helpers — Python functions callable from defpage expressions -# --------------------------------------------------------------------------- - -def _register_orders_helpers() -> None: - from shared.sx.pages import register_page_helpers - - register_page_helpers("orders", { - # Orders list - "orders-list-content": _h_orders_list_content, - "orders-list-filter": _h_orders_list_filter, - "orders-list-aside": _h_orders_list_aside, - "orders-list-url": _h_orders_list_url, - # Order detail - "order-detail-content": _h_order_detail_content, - "order-detail-filter": _h_order_detail_filter, - "order-detail-url": _h_order_detail_url, - "order-list-url-from-detail": _h_order_list_url_from_detail, - }) - - -async def _ensure_orders_list(): - """Fetch orders list data and store in g.orders_page_data.""" - from quart import g, url_for - if hasattr(g, "orders_page_data"): - return - from sqlalchemy import select, func, or_, cast, String, exists - from shared.models.order import Order, OrderItem - from shared.infrastructure.cart_identity import current_cart_identity - from shared.utils import route_prefix - - ORDERS_PER_PAGE = 10 - ident = current_cart_identity() - if ident["user_id"]: - owner_clause = Order.user_id == ident["user_id"] - elif ident["session_id"]: - owner_clause = Order.session_id == ident["session_id"] - else: - g.orders_page_data = None - return - - from bp.orders.filters.qs import makeqs_factory, decode - q = decode() - page, search = q.page, q.search - if page < 1: - page = 1 - - where_clause = None - if search: - term = f"%{search.strip()}%" - conditions = [ - Order.status.ilike(term), - Order.currency.ilike(term), - Order.sumup_checkout_id.ilike(term), - Order.sumup_status.ilike(term), - Order.description.ilike(term), - ] - conditions.append( - exists( - select(1).select_from(OrderItem) - .where(OrderItem.order_id == Order.id, - or_(OrderItem.product_title.ilike(term), - OrderItem.product_slug.ilike(term))) - ) - ) - try: - search_id = int(search) - except (TypeError, ValueError): - search_id = None - if search_id is not None: - conditions.append(Order.id == search_id) - else: - conditions.append(cast(Order.id, String).ilike(term)) - where_clause = or_(*conditions) - - count_stmt = select(func.count()).select_from(Order).where(owner_clause) - if where_clause is not None: - count_stmt = count_stmt.where(where_clause) - - total_count_result = await g.s.execute(count_stmt) - total_count = total_count_result.scalar_one() or 0 - total_pages = max(1, (total_count + ORDERS_PER_PAGE - 1) // ORDERS_PER_PAGE) - if page > total_pages: - page = total_pages - - offset = (page - 1) * ORDERS_PER_PAGE - stmt = ( - select(Order).where(owner_clause) - .order_by(Order.created_at.desc()) - .offset(offset).limit(ORDERS_PER_PAGE) - ) - if where_clause is not None: - stmt = stmt.where(where_clause) - - result = await g.s.execute(stmt) - orders = result.scalars().all() - pfx = route_prefix() - qs_fn = makeqs_factory() - - g.orders_page_data = { - "orders": orders, - "page": page, - "total_pages": total_pages, - "search": search, - "search_count": total_count, - "url_for_fn": url_for, - "qs_fn": qs_fn, - "list_url": pfx + url_for("defpage_orders_list"), - } - - -async def _ensure_order_detail(order_id): - """Fetch order detail data and store in g.order_detail_data.""" - from quart import g, url_for, abort - if hasattr(g, "order_detail_data"): - return - from sqlalchemy import select - from sqlalchemy.orm import selectinload - from shared.models.order import Order - from shared.infrastructure.cart_identity import current_cart_identity - from shared.utils import route_prefix - from shared.browser.app.csrf import generate_csrf_token - - if order_id is None: - abort(404) - - ident = current_cart_identity() - if ident["user_id"]: - owner = Order.user_id == ident["user_id"] - elif ident["session_id"]: - owner = Order.session_id == ident["session_id"] - else: - abort(404) - return - - result = await g.s.execute( - select(Order).options(selectinload(Order.items)) - .where(Order.id == order_id, owner) - ) - order = result.scalar_one_or_none() - if not order: - abort(404) - return - - pfx = route_prefix() - g.order_detail_data = { - "order": order, - "calendar_entries": None, - "detail_url": pfx + url_for("defpage_order_detail", order_id=order.id), - "list_url": pfx + url_for("defpage_orders_list"), - "recheck_url": pfx + url_for("orders.order.order_recheck", order_id=order.id), - "pay_url": pfx + url_for("orders.order.order_pay", order_id=order.id), - "csrf_token": generate_csrf_token(), - } - - -async def _h_orders_list_content(**kw): - await _ensure_orders_list() - from quart import g - from shared.sx.helpers import render_to_sx - d = getattr(g, "orders_page_data", None) - if not d: - return await render_to_sx("order-empty-state") - - orders = d["orders"] - url_for_fn = d["url_for_fn"] - pfx = d.get("list_url", "/").rsplit("/", 1)[0] if d.get("list_url") else "" - - order_dicts = [] - for o in orders: - order_dicts.append({ - "id": o.id, - "status": o.status or "pending", - "created_at_formatted": o.created_at.strftime("%-d %b %Y, %H:%M") if o.created_at else "\u2014", - "description": o.description or "", - "currency": o.currency or "GBP", - "total_formatted": f"{o.total_amount or 0:.2f}", - }) - - from shared.utils import route_prefix - rpfx = route_prefix() - detail_prefix = rpfx + url_for_fn("defpage_order_detail", order_id=0).rsplit("0/", 1)[0] - rows_url = rpfx + url_for_fn("orders.orders_rows") - - return await render_to_sx("orders-list-content", - orders=order_dicts, - page=d["page"], - total_pages=d["total_pages"], - rows_url=rows_url, - detail_url_prefix=detail_prefix) - - -async def _h_orders_list_filter(**kw): - await _ensure_orders_list() - from quart import g - from shared.sx.helpers import render_to_sx - from shared.sx.page import SEARCH_HEADERS_MOBILE - from shared.sx.parser import SxExpr - d = getattr(g, "orders_page_data", None) - search = d.get("search", "") if d else "" - search_count = d.get("search_count", "") if d else "" - search_mobile = await render_to_sx("search-mobile", - current_local_href="/", - search=search or "", - search_count=search_count or "", - hx_select="#main-panel", - search_headers_mobile=SEARCH_HEADERS_MOBILE, - ) - return await render_to_sx("order-list-header", search_mobile=SxExpr(search_mobile)) - - -async def _h_orders_list_aside(**kw): - await _ensure_orders_list() - from quart import g - from shared.sx.helpers import render_to_sx - from shared.sx.page import SEARCH_HEADERS_DESKTOP - d = getattr(g, "orders_page_data", None) - search = d.get("search", "") if d else "" - search_count = d.get("search_count", "") if d else "" - return await render_to_sx("search-desktop", - current_local_href="/", - search=search or "", - search_count=search_count or "", - hx_select="#main-panel", - search_headers_desktop=SEARCH_HEADERS_DESKTOP, - ) - - -async def _h_orders_list_url(**kw): - await _ensure_orders_list() - from quart import g - d = getattr(g, "orders_page_data", None) - return d["list_url"] if d else "/" - - -async def _h_order_detail_content(order_id=None, **kw): - await _ensure_order_detail(order_id) - from quart import g - from shared.sx.helpers import render_to_sx - from shared.infrastructure.urls import market_product_url - d = getattr(g, "order_detail_data", None) - if not d: - return "" - - order = d["order"] - order_dict = { - "id": order.id, - "status": order.status or "pending", - "created_at_formatted": order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else None, - "description": order.description, - "currency": order.currency, - "total_formatted": f"{order.total_amount:.2f}" if order.total_amount else None, - "items": [ - { - "product_url": market_product_url(item.product_slug), - "product_image": item.product_image, - "product_title": item.product_title, - "product_id": item.product_id, - "quantity": item.quantity, - "currency": item.currency, - "unit_price_formatted": f"{item.unit_price or 0:.2f}", - } - for item in (order.items or []) - ], - } - - cal_entries = d["calendar_entries"] - cal_dicts = None - if cal_entries: - cal_dicts = [] - for e in cal_entries: - ds = e.start_at.strftime("%-d %b %Y, %H:%M") if e.start_at else "" - if e.end_at: - ds += f" \u2013 {e.end_at.strftime('%-d %b %Y, %H:%M')}" - cal_dicts.append({ - "name": e.name, - "state": e.state or "", - "date_str": ds, - "cost_formatted": f"{e.cost or 0:.2f}", - }) - - return await render_to_sx("order-detail-content", - order=order_dict, - calendar_entries=cal_dicts) - - -async def _h_order_detail_filter(order_id=None, **kw): - await _ensure_order_detail(order_id) - from quart import g - from shared.sx.helpers import render_to_sx - d = getattr(g, "order_detail_data", None) - if not d: - return "" - - order = d["order"] - order_dict = { - "status": order.status or "pending", - "created_at_formatted": order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else "\u2014", - } - - return await render_to_sx("order-detail-filter-content", - order=order_dict, - list_url=d["list_url"], - recheck_url=d["recheck_url"], - pay_url=d["pay_url"], - csrf=d["csrf_token"]) - - -async def _h_order_detail_url(order_id=None, **kw): - await _ensure_order_detail(order_id) - from quart import g - d = getattr(g, "order_detail_data", None) - return d["detail_url"] if d else "/" - - -async def _h_order_list_url_from_detail(order_id=None, **kw): - await _ensure_order_detail(order_id) - from quart import g - d = getattr(g, "order_detail_data", None) - return d["list_url"] if d else "/" diff --git a/orders/sxc/pages/orders.sx b/orders/sxc/pages/orders.sx index 4e32cff..f9851c1 100644 --- a/orders/sxc/pages/orders.sx +++ b/orders/sxc/pages/orders.sx @@ -1,4 +1,5 @@ ;; Orders app — declarative page definitions +;; All data fetching via (service ...) IO primitives, no Python helpers. ;; --------------------------------------------------------------------------- ;; Orders list @@ -7,11 +8,34 @@ (defpage orders-list :path "/" :auth :public + :data (service "orders-page" "list-page-data" + :search (or (request-arg "search") "") + :page (or (request-arg "page" "1") "1")) :layout (:orders - :list-url (orders-list-url)) - :filter (orders-list-filter) - :aside (orders-list-aside) - :content (orders-list-content)) + :list-url (str (route-prefix) (url-for "defpage_orders_list"))) + :filter (~order-list-header + :search-mobile (~search-mobile + :current-local-href "/" + :search (or search "") + :search-count (or search-count "") + :hx-select "#main-panel" + :search-headers-mobile "{\"X-Origin\":\"search-mobile\",\"X-Search\":\"true\"}")) + :aside (~search-desktop + :current-local-href "/" + :search (or search "") + :search-count (or search-count "") + :hx-select "#main-panel" + :search-headers-desktop "{\"X-Origin\":\"search-desktop\",\"X-Search\":\"true\"}") + :content (let* ((pfx (route-prefix)) + (detail-url-raw (str pfx (url-for "defpage_order_detail" :order-id 0))) + (detail-prefix (slice detail-url-raw 0 (- (length detail-url-raw) 2))) + (rows-url (str pfx (url-for "orders.orders_rows")))) + (~orders-list-content + :orders orders + :page page + :total-pages total-pages + :rows-url rows-url + :detail-url-prefix detail-prefix))) ;; --------------------------------------------------------------------------- ;; Order detail @@ -20,8 +44,17 @@ (defpage order-detail :path "//" :auth :public + :data (service "orders-page" "detail-page-data" :order-id order-id) :layout (:order-detail - :list-url (order-list-url-from-detail order-id) - :detail-url (order-detail-url order-id)) - :filter (order-detail-filter order-id) - :content (order-detail-content order-id)) + :list-url (str (route-prefix) (url-for "defpage_orders_list")) + :detail-url (str (route-prefix) (url-for "defpage_order_detail" :order-id order-id))) + :filter (let* ((pfx (route-prefix))) + (~order-detail-filter-content + :order order + :list-url (str pfx (url-for "defpage_orders_list")) + :recheck-url (str pfx (url-for "orders.order.order_recheck" :order-id order-id)) + :pay-url (str pfx (url-for "orders.order.order_pay" :order-id order-id)) + :csrf (csrf-token))) + :content (~order-detail-content + :order order + :calendar-entries calendar-entries)) diff --git a/shared/services/registry.py b/shared/services/registry.py index 0f02906..8616664 100644 --- a/shared/services/registry.py +++ b/shared/services/registry.py @@ -15,6 +15,8 @@ Usage:: """ from __future__ import annotations +from typing import Any + from shared.contracts.protocols import ( CalendarService, MarketService, @@ -36,6 +38,7 @@ class _ServiceRegistry: self._market: MarketService | None = None self._cart: CartService | None = None self._federation: FederationService | None = None + self._extra: dict[str, Any] = {} # -- calendar ------------------------------------------------------------- @property @@ -81,10 +84,27 @@ class _ServiceRegistry: def federation(self, impl: FederationService) -> None: self._federation = impl + # -- generic registration -------------------------------------------------- + def register(self, name: str, impl: Any) -> None: + """Register a service by name (for services without typed properties).""" + self._extra[name] = impl + + def __getattr__(self, name: str) -> Any: + # Fallback to _extra dict for dynamically registered services + try: + extra = object.__getattribute__(self, "_extra") + if name in extra: + return extra[name] + except AttributeError: + pass + raise AttributeError(f"No service registered as: {name}") + # -- introspection -------------------------------------------------------- def has(self, name: str) -> bool: """Check whether a domain service is registered.""" - return getattr(self, f"_{name}", None) is not None + if getattr(self, f"_{name}", None) is not None: + return True + return name in self._extra # Module-level singleton — import this everywhere. diff --git a/shared/sx/pages.py b/shared/sx/pages.py index a1cba1d..251370f 100644 --- a/shared/sx/pages.py +++ b/shared/sx/pages.py @@ -184,7 +184,9 @@ async def execute_page( if page_def.data_expr is not None: data_result = await async_eval(page_def.data_expr, env, ctx) if isinstance(data_result, dict): - env.update(data_result) + # Merge with kebab-case keys so SX symbols can reference them + for k, v in data_result.items(): + env[k.replace("_", "-")] = v # Render content slot (required) content_sx = await _eval_slot(page_def.content_expr, env, ctx) diff --git a/shared/sx/primitives_io.py b/shared/sx/primitives_io.py index 8e26854..02ad8f1 100644 --- a/shared/sx/primitives_io.py +++ b/shared/sx/primitives_io.py @@ -43,6 +43,8 @@ IO_PRIMITIVES: frozenset[str] = frozenset({ "g", "csrf-token", "abort", + "url-for", + "route-prefix", }) @@ -345,6 +347,34 @@ async def _io_abort( abort(status, message) +async def _io_url_for( + args: list[Any], kwargs: dict[str, Any], ctx: RequestContext +) -> str: + """``(url-for "endpoint" :key val ...)`` → url_for(endpoint, **kwargs). + + Generates a URL for the given endpoint. Keyword args become URL + parameters (kebab-case converted to snake_case). + """ + if not args: + raise ValueError("url-for requires an endpoint name") + from quart import url_for + endpoint = str(args[0]) + clean = {k.replace("-", "_"): v for k, v in _clean_kwargs(kwargs).items()} + # Convert numeric values for int URL params + for k, v in clean.items(): + if isinstance(v, str) and v.isdigit(): + clean[k] = int(v) + return url_for(endpoint, **clean) + + +async def _io_route_prefix( + args: list[Any], kwargs: dict[str, Any], ctx: RequestContext +) -> str: + """``(route-prefix)`` → current route prefix string.""" + from shared.utils import route_prefix + return route_prefix() + + _IO_HANDLERS: dict[str, Any] = { "frag": _io_frag, "query": _io_query, @@ -359,4 +389,6 @@ _IO_HANDLERS: dict[str, Any] = { "g": _io_g, "csrf-token": _io_csrf_token, "abort": _io_abort, + "url-for": _io_url_for, + "route-prefix": _io_route_prefix, }