diff --git a/account/app.py b/account/app.py index 5ae8fe3..c7b0783 100644 --- a/account/app.py +++ b/account/app.py @@ -6,7 +6,6 @@ from quart import g, request from jinja2 import FileSystemLoader, ChoiceLoader from shared.infrastructure.factory import create_base_app -from shared.services.registry import services from bp import register_account_bp, register_auth_bp, register_fragments @@ -17,17 +16,23 @@ async def account_context() -> dict: from shared.services.navigation import get_navigation_tree from shared.infrastructure.cart_identity import current_cart_identity from shared.infrastructure.fragments import fetch_fragments + from shared.infrastructure.data_client import fetch_data + from shared.contracts.dtos import CartSummaryDTO, dto_from_dict ctx = await base_context() # Fallback for _nav.html when nav-tree fragment fetch fails ctx["menu_items"] = await get_navigation_tree(g.s) - # Cart data (consistent with all other apps) + # Cart data via internal data endpoint ident = current_cart_identity() - summary = await services.cart.cart_summary( - g.s, user_id=ident["user_id"], session_id=ident["session_id"], - ) + summary_params = {} + if ident["user_id"] is not None: + summary_params["user_id"] = ident["user_id"] + if ident["session_id"] is not None: + summary_params["session_id"] = ident["session_id"] + raw = await fetch_data("cart", "cart-summary", params=summary_params, required=False) + summary = dto_from_dict(CartSummaryDTO, raw) if raw else CartSummaryDTO() ctx["cart_count"] = summary.count + summary.calendar_count + summary.ticket_count ctx["cart_total"] = float(summary.total + summary.calendar_total + summary.ticket_total) diff --git a/account/services/__init__.py b/account/services/__init__.py index 299f0ad..cd62eeb 100644 --- a/account/services/__init__.py +++ b/account/services/__init__.py @@ -5,23 +5,7 @@ from __future__ import annotations def register_domain_services() -> None: """Register services for the account app. - Account needs all domain services since widgets (tickets, bookings) - pull data from blog, calendar, market, cart, and federation. + Account is a consumer-only dashboard app. It has no own domain. + All cross-app data comes via fragments and HTTP data endpoints. """ - from shared.services.registry import services - from shared.services.federation_impl import SqlFederationService - from shared.services.blog_impl import SqlBlogService - from shared.services.calendar_impl import SqlCalendarService - from shared.services.market_impl import SqlMarketService - from shared.services.cart_impl import SqlCartService - - if not services.has("federation"): - services.federation = SqlFederationService() - if not services.has("blog"): - services.blog = SqlBlogService() - if not services.has("calendar"): - services.calendar = SqlCalendarService() - if not services.has("market"): - services.market = SqlMarketService() - if not services.has("cart"): - services.cart = SqlCartService() + pass diff --git a/blog/app.py b/blog/app.py index ff3c753..467cd8c 100644 --- a/blog/app.py +++ b/blog/app.py @@ -16,6 +16,7 @@ from bp import ( register_menu_items, register_snippets, register_fragments, + register_data, ) @@ -28,20 +29,25 @@ async def blog_context() -> dict: """ from shared.infrastructure.context import base_context from shared.services.navigation import get_navigation_tree - from shared.services.registry import services from shared.infrastructure.cart_identity import current_cart_identity from shared.infrastructure.fragments import fetch_fragments + from shared.infrastructure.data_client import fetch_data + from shared.contracts.dtos import CartSummaryDTO, dto_from_dict ctx = await base_context() # Fallback for _nav.html when nav-tree fragment fetch fails ctx["menu_items"] = await get_navigation_tree(g.s) - # Cart data via service (replaces cross-app HTTP API) + # Cart data via internal data endpoint ident = current_cart_identity() - summary = await services.cart.cart_summary( - g.s, user_id=ident["user_id"], session_id=ident["session_id"], - ) + summary_params = {} + if ident["user_id"] is not None: + summary_params["user_id"] = ident["user_id"] + if ident["session_id"] is not None: + summary_params["session_id"] = ident["session_id"] + raw = await fetch_data("cart", "cart-summary", params=summary_params, required=False) + summary = dto_from_dict(CartSummaryDTO, raw) if raw else CartSummaryDTO() ctx["cart_count"] = summary.count + summary.calendar_count + summary.ticket_count ctx["cart_total"] = float(summary.total + summary.calendar_total + summary.ticket_total) @@ -98,6 +104,7 @@ def create_app() -> "Quart": app.register_blueprint(register_menu_items()) app.register_blueprint(register_snippets()) app.register_blueprint(register_fragments()) + app.register_blueprint(register_data()) # --- KV admin endpoints --- @app.get("/settings/kv/") diff --git a/blog/bp/__init__.py b/blog/bp/__init__.py index 59bc262..41b6a3e 100644 --- a/blog/bp/__init__.py +++ b/blog/bp/__init__.py @@ -3,3 +3,4 @@ from .admin.routes import register as register_admin from .menu_items.routes import register as register_menu_items from .snippets.routes import register as register_snippets from .fragments import register_fragments +from .data import register_data diff --git a/blog/bp/data/__init__.py b/blog/bp/data/__init__.py new file mode 100644 index 0000000..89100ea --- /dev/null +++ b/blog/bp/data/__init__.py @@ -0,0 +1 @@ +from .routes import register as register_data diff --git a/blog/bp/data/routes.py b/blog/bp/data/routes.py new file mode 100644 index 0000000..ccb1982 --- /dev/null +++ b/blog/bp/data/routes.py @@ -0,0 +1,74 @@ +"""Blog app data endpoints. + +Exposes read-only JSON queries at ``/internal/data/`` for +cross-app callers via the internal data client. +""" +from __future__ import annotations + +from quart import Blueprint, g, jsonify, request + +from shared.infrastructure.data_client import DATA_HEADER +from shared.contracts.dtos import dto_to_dict +from shared.services.registry import services + + +def register() -> Blueprint: + bp = Blueprint("data", __name__, url_prefix="/internal/data") + + @bp.before_request + async def _require_data_header(): + if not request.headers.get(DATA_HEADER): + return jsonify({"error": "forbidden"}), 403 + + _handlers: dict[str, object] = {} + + @bp.get("/") + async def handle_query(query_name: str): + handler = _handlers.get(query_name) + if handler is None: + return jsonify({"error": "unknown query"}), 404 + result = await handler() + return jsonify(result) + + # --- post-by-slug --- + async def _post_by_slug(): + slug = request.args.get("slug", "") + post = await services.blog.get_post_by_slug(g.s, slug) + if not post: + return None + return dto_to_dict(post) + + _handlers["post-by-slug"] = _post_by_slug + + # --- post-by-id --- + async def _post_by_id(): + post_id = int(request.args.get("id", 0)) + post = await services.blog.get_post_by_id(g.s, post_id) + if not post: + return None + return dto_to_dict(post) + + _handlers["post-by-id"] = _post_by_id + + # --- posts-by-ids --- + async def _posts_by_ids(): + ids_raw = request.args.get("ids", "") + if not ids_raw: + return [] + ids = [int(x.strip()) for x in ids_raw.split(",") if x.strip()] + posts = await services.blog.get_posts_by_ids(g.s, ids) + return [dto_to_dict(p) for p in posts] + + _handlers["posts-by-ids"] = _posts_by_ids + + # --- search-posts --- + async def _search_posts(): + query = request.args.get("query", "") + page = int(request.args.get("page", 1)) + per_page = int(request.args.get("per_page", 10)) + posts, total = await services.blog.search_posts(g.s, query, page, per_page) + return {"posts": [dto_to_dict(p) for p in posts], "total": total} + + _handlers["search-posts"] = _search_posts + + return bp diff --git a/blog/bp/post/admin/routes.py b/blog/bp/post/admin/routes.py index c468a43..e04d479 100644 --- a/blog/bp/post/admin/routes.py +++ b/blog/bp/post/admin/routes.py @@ -193,7 +193,8 @@ def register(): """Show calendar month view for browsing entries""" from shared.models.calendars import Calendar from shared.utils.calendar_helpers import parse_int_arg, add_months, build_calendar_weeks - from shared.services.registry import services + from shared.infrastructure.data_client import fetch_data + from shared.contracts.dtos import CalendarEntryDTO, dto_from_dict from sqlalchemy import select from datetime import datetime, timezone import calendar as pycalendar @@ -228,7 +229,7 @@ def register(): month_name = pycalendar.month_name[month] weekday_names = [pycalendar.day_abbr[i] for i in range(7)] - # Get entries for this month + # Get entries for this month via events data endpoint period_start = datetime(year, month, 1, tzinfo=timezone.utc) next_y, next_m = add_months(year, month, +1) period_end = datetime(next_y, next_m, 1, tzinfo=timezone.utc) @@ -238,10 +239,15 @@ def register(): is_admin = bool(user and getattr(user, "is_admin", False)) session_id = qsession.get("calendar_sid") - month_entries = await services.calendar.visible_entries_for_period( - g.s, calendar_obj.id, period_start, period_end, - user_id=user_id, is_admin=is_admin, session_id=session_id, - ) + raw_entries = await fetch_data("events", "visible-entries-for-period", params={ + "calendar_id": calendar_obj.id, + "period_start": period_start.isoformat(), + "period_end": period_end.isoformat(), + "user_id": user_id, + "is_admin": str(is_admin).lower(), + "session_id": session_id, + }, required=False) or [] + month_entries = [dto_from_dict(CalendarEntryDTO, e) for e in raw_entries] # Get associated entry IDs for this post post_id = g.post_data["post"]["id"] @@ -609,18 +615,24 @@ def register(): return redirect(redirect_url) + async def _fetch_page_markets(post_id): + """Fetch marketplaces for a page via market data endpoint.""" + from shared.infrastructure.data_client import fetch_data + from shared.contracts.dtos import MarketPlaceDTO, dto_from_dict + raw = await fetch_data("market", "marketplaces-for-container", + params={"type": "page", "id": post_id}, required=False) or [] + return [dto_from_dict(MarketPlaceDTO, m) for m in raw] + @bp.get("/markets/") @require_admin async def markets(slug: str): """List markets for this page.""" - from shared.services.registry import services - post = (g.post_data or {}).get("post", {}) post_id = post.get("id") if not post_id: return await make_response("Post not found", 404) - page_markets = await services.market.marketplaces_for_container(g.s, "page", post_id) + page_markets = await _fetch_page_markets(post_id) html = await render_template( "_types/post/admin/_markets_panel.html", @@ -634,7 +646,6 @@ def register(): async def create_market(slug: str): """Create a new market for this page.""" from ..services.markets import create_market as _create_market, MarketError - from shared.services.registry import services from quart import jsonify post = (g.post_data or {}).get("post", {}) @@ -651,7 +662,7 @@ def register(): return jsonify({"error": str(e)}), 400 # Return updated markets list - page_markets = await services.market.marketplaces_for_container(g.s, "page", post_id) + page_markets = await _fetch_page_markets(post_id) html = await render_template( "_types/post/admin/_markets_panel.html", @@ -665,7 +676,6 @@ def register(): async def delete_market(slug: str, market_slug: str): """Soft-delete a market.""" from ..services.markets import soft_delete_market - from shared.services.registry import services from quart import jsonify post = (g.post_data or {}).get("post", {}) @@ -676,7 +686,7 @@ def register(): return jsonify({"error": "Market not found"}), 404 # Return updated markets list - page_markets = await services.market.marketplaces_for_container(g.s, "page", post_id) + page_markets = await _fetch_page_markets(post_id) html = await render_template( "_types/post/admin/_markets_panel.html", diff --git a/blog/bp/post/routes.py b/blog/bp/post/routes.py index 7aa3fb4..ce320a5 100644 --- a/blog/bp/post/routes.py +++ b/blog/bp/post/routes.py @@ -12,7 +12,8 @@ from quart import ( ) from .services.post_data import post_data from .services.post_operations import toggle_post_like -from shared.services.registry import services +from shared.infrastructure.data_client import fetch_data +from shared.contracts.dtos import CartSummaryDTO, dto_from_dict from shared.infrastructure.fragments import fetch_fragment, fetch_fragments from shared.browser.app.redis_cacher import cache_page, clear_cache @@ -92,14 +93,17 @@ def register(): "container_nav_html": container_nav_html, } - # Page cart badge via service + # Page cart badge via HTTP post_dict = p_data.get("post") or {} if post_dict.get("is_page"): ident = current_cart_identity() - page_summary = await services.cart.cart_summary( - g.s, user_id=ident["user_id"], session_id=ident["session_id"], - page_slug=post_dict["slug"], - ) + summary_params = {"page_slug": post_dict["slug"]} + if ident["user_id"] is not None: + summary_params["user_id"] = ident["user_id"] + if ident["session_id"] is not None: + summary_params["session_id"] = ident["session_id"] + raw_summary = await fetch_data("cart", "cart-summary", params=summary_params, required=False) + page_summary = dto_from_dict(CartSummaryDTO, raw_summary) if raw_summary else CartSummaryDTO() ctx["page_cart_count"] = page_summary.count + page_summary.calendar_count + page_summary.ticket_count ctx["page_cart_total"] = float(page_summary.total + page_summary.calendar_total + page_summary.ticket_total) diff --git a/blog/bp/post/services/entry_associations.py b/blog/bp/post/services/entry_associations.py index 5afe195..f11103c 100644 --- a/blog/bp/post/services/entry_associations.py +++ b/blog/bp/post/services/entry_associations.py @@ -2,6 +2,9 @@ from __future__ import annotations from sqlalchemy.ext.asyncio import AsyncSession +from shared.infrastructure.actions import call_action, ActionError +from shared.infrastructure.data_client import fetch_data +from shared.contracts.dtos import CalendarEntryDTO, dto_from_dict from shared.services.registry import services @@ -18,10 +21,13 @@ async def toggle_entry_association( if not post: return False, "Post not found" - is_associated = await services.calendar.toggle_entry_post( - session, entry_id, "post", post_id, - ) - return is_associated, None + try: + result = await call_action("events", "toggle-entry-post", payload={ + "entry_id": entry_id, "content_type": "post", "content_id": post_id, + }) + return result.get("is_associated", False), None + except ActionError as e: + return False, str(e) async def get_post_entry_ids( @@ -32,7 +38,10 @@ async def get_post_entry_ids( Get all entry IDs associated with this post. Returns a set of entry IDs. """ - return await services.calendar.entry_ids_for_content(session, "post", post_id) + raw = await fetch_data("events", "entry-ids-for-content", + params={"content_type": "post", "content_id": post_id}, + required=False) or [] + return set(raw) async def get_associated_entries( @@ -45,12 +54,14 @@ async def get_associated_entries( Get paginated associated entries for this post. Returns dict with entries (CalendarEntryDTOs), total_count, and has_more. """ - entries, has_more = await services.calendar.associated_entries( - session, "post", post_id, page, - ) + raw = await fetch_data("events", "associated-entries", + params={"content_type": "post", "content_id": post_id, "page": page}, + required=False) or {"entries": [], "has_more": False} + entries = [dto_from_dict(CalendarEntryDTO, e) for e in raw.get("entries", [])] + has_more = raw.get("has_more", False) total_count = len(entries) + (page - 1) * per_page if has_more: - total_count += 1 # at least one more + total_count += 1 return { "entries": entries, diff --git a/blog/bp/post/services/markets.py b/blog/bp/post/services/markets.py index c825bb8..49ca157 100644 --- a/blog/bp/post/services/markets.py +++ b/blog/bp/post/services/markets.py @@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from shared.models.page_config import PageConfig from shared.contracts.dtos import MarketPlaceDTO +from shared.infrastructure.actions import call_action, ActionError from shared.services.registry import services @@ -48,8 +49,12 @@ async def create_market(sess: AsyncSession, post_id: int, name: str) -> MarketPl raise MarketError("Market feature is not enabled for this page. Enable it in page settings first.") try: - return await services.market.create_marketplace(sess, "page", post_id, name, slug) - except ValueError as e: + result = await call_action("market", "create-marketplace", payload={ + "container_type": "page", "container_id": post_id, + "name": name, "slug": slug, + }) + return MarketPlaceDTO(**result) + except ActionError as e: raise MarketError(str(e)) from e @@ -58,4 +63,10 @@ async def soft_delete_market(sess: AsyncSession, post_slug: str, market_slug: st if not post: return False - return await services.market.soft_delete_marketplace(sess, "page", post.id, market_slug) + try: + result = await call_action("market", "soft-delete-marketplace", payload={ + "container_type": "page", "container_id": post.id, "slug": market_slug, + }) + return result.get("deleted", False) + except ActionError: + return False diff --git a/blog/services/__init__.py b/blog/services/__init__.py index 11d9769..89c1023 100644 --- a/blog/services/__init__.py +++ b/blog/services/__init__.py @@ -6,23 +6,14 @@ def register_domain_services() -> None: """Register services for the blog app. Blog owns: Post, Tag, Author, PostAuthor, PostTag, PostLike. - Standard deployment registers all 4 services as real DB impls - (shared DB). For composable deployments, swap non-owned services - with stubs from shared.services.stubs. + Cross-app calls go over HTTP via call_action() / fetch_data(). """ from shared.services.registry import services from shared.services.blog_impl import SqlBlogService - from shared.services.calendar_impl import SqlCalendarService - from shared.services.market_impl import SqlMarketService - from shared.services.cart_impl import SqlCartService services.blog = SqlBlogService() - if not services.has("calendar"): - services.calendar = SqlCalendarService() - if not services.has("market"): - services.market = SqlMarketService() - if not services.has("cart"): - services.cart = SqlCartService() + + # Federation needed for AP shared infrastructure (activitypub blueprint) if not services.has("federation"): from shared.services.federation_impl import SqlFederationService services.federation = SqlFederationService() diff --git a/cart/app.py b/cart/app.py index 3f041b2..2a6382c 100644 --- a/cart/app.py +++ b/cart/app.py @@ -16,6 +16,8 @@ from bp import ( register_cart_global, register_orders, register_fragments, + register_actions, + register_data, ) from bp.cart.services import ( get_cart, @@ -135,6 +137,8 @@ def create_app() -> "Quart": app.jinja_env.globals["cart_delete_url"] = lambda product_id: f"/delete/{product_id}/" app.register_blueprint(register_fragments()) + app.register_blueprint(register_actions()) + app.register_blueprint(register_data()) # --- Page slug hydration (follows events/market app pattern) --- @@ -152,10 +156,15 @@ def create_app() -> "Quart": @app.before_request async def hydrate_page(): + from shared.infrastructure.data_client import fetch_data + from shared.contracts.dtos import PostDTO, dto_from_dict slug = getattr(g, "page_slug", None) if not slug: return - post = await services.blog.get_post_by_slug(g.s, slug) + raw = await fetch_data("blog", "post-by-slug", params={"slug": slug}) + if not raw: + abort(404) + post = dto_from_dict(PostDTO, raw) if not post or not post.is_page: abort(404) g.page_post = post diff --git a/cart/bp/__init__.py b/cart/bp/__init__.py index e75b584..43a64c1 100644 --- a/cart/bp/__init__.py +++ b/cart/bp/__init__.py @@ -4,3 +4,5 @@ from .cart.global_routes import register as register_cart_global from .order.routes import register as register_order from .orders.routes import register as register_orders from .fragments import register_fragments +from .actions import register_actions +from .data import register_data diff --git a/cart/bp/actions/__init__.py b/cart/bp/actions/__init__.py new file mode 100644 index 0000000..21a842f --- /dev/null +++ b/cart/bp/actions/__init__.py @@ -0,0 +1 @@ +from .routes import register as register_actions diff --git a/cart/bp/actions/routes.py b/cart/bp/actions/routes.py new file mode 100644 index 0000000..3a33248 --- /dev/null +++ b/cart/bp/actions/routes.py @@ -0,0 +1,42 @@ +"""Cart app action endpoints. + +Exposes write operations at ``/internal/actions/`` for +cross-app callers (login handler) via the internal action client. +""" +from __future__ import annotations + +from quart import Blueprint, g, jsonify, request + +from shared.infrastructure.actions import ACTION_HEADER +from shared.services.registry import services + + +def register() -> Blueprint: + bp = Blueprint("actions", __name__, url_prefix="/internal/actions") + + @bp.before_request + async def _require_action_header(): + if not request.headers.get(ACTION_HEADER): + return jsonify({"error": "forbidden"}), 403 + + _handlers: dict[str, object] = {} + + @bp.post("/") + async def handle_action(action_name: str): + handler = _handlers.get(action_name) + if handler is None: + return jsonify({"error": "unknown action"}), 404 + result = await handler() + return jsonify(result) + + # --- adopt-cart-for-user --- + async def _adopt_cart(): + data = await request.get_json() + await services.cart.adopt_cart_for_user( + g.s, data["user_id"], data["session_id"], + ) + return {"ok": True} + + _handlers["adopt-cart-for-user"] = _adopt_cart + + return bp diff --git a/cart/bp/cart/global_routes.py b/cart/bp/cart/global_routes.py index ba2459f..454010c 100644 --- a/cart/bp/cart/global_routes.py +++ b/cart/bp/cart/global_routes.py @@ -8,7 +8,7 @@ from sqlalchemy import select from shared.models.market import CartItem from shared.models.order import Order from shared.models.market_place import MarketPlace -from shared.services.registry import services +from shared.infrastructure.actions import call_action from .services import ( current_cart_identity, get_cart, @@ -91,13 +91,12 @@ def register(url_prefix: str) -> Blueprint: tt_raw = (form.get("ticket_type_id") or "").strip() ticket_type_id = int(tt_raw) if tt_raw else None - await services.calendar.adjust_ticket_quantity( - g.s, entry_id, count, - user_id=ident["user_id"], - session_id=ident["session_id"], - ticket_type_id=ticket_type_id, - ) - await g.s.flush() + await call_action("events", "adjust-ticket-quantity", payload={ + "entry_id": entry_id, "count": count, + "user_id": ident["user_id"], + "session_id": ident["session_id"], + "ticket_type_id": ticket_type_id, + }) resp = await make_response("", 200) resp.headers["HX-Refresh"] = "true" @@ -256,13 +255,17 @@ def register(url_prefix: str) -> Blueprint: # Resolve page/market slugs so product links render correctly if order.page_config: - post = await services.blog.get_post_by_id(g.s, order.page_config.container_id) + from shared.infrastructure.data_client import fetch_data + from shared.contracts.dtos import CalendarEntryDTO, TicketDTO, dto_from_dict + post = await fetch_data("blog", "post-by-id", + params={"id": order.page_config.container_id}, + required=False) if post: - g.page_slug = post.slug + g.page_slug = post["slug"] result = await g.s.execute( select(MarketPlace).where( MarketPlace.container_type == "page", - MarketPlace.container_id == post.id, + MarketPlace.container_id == post["id"], MarketPlace.deleted_at.is_(None), ).limit(1) ) @@ -278,8 +281,14 @@ def register(url_prefix: str) -> Blueprint: status = (order.status or "pending").lower() - calendar_entries = await services.calendar.get_entries_for_order(g.s, order.id) - order_tickets = await services.calendar.get_tickets_for_order(g.s, order.id) + from shared.infrastructure.data_client import fetch_data + from shared.contracts.dtos import CalendarEntryDTO, TicketDTO, dto_from_dict + raw_entries = await fetch_data("events", "entries-for-order", + params={"order_id": order.id}, required=False) or [] + calendar_entries = [dto_from_dict(CalendarEntryDTO, e) for e in raw_entries] + raw_tickets = await fetch_data("events", "tickets-for-order", + params={"order_id": order.id}, required=False) or [] + order_tickets = [dto_from_dict(TicketDTO, t) for t in raw_tickets] await g.s.flush() html = await render_template( diff --git a/cart/bp/cart/services/calendar_cart.py b/cart/bp/cart/services/calendar_cart.py index febd778..4d2f98c 100644 --- a/cart/bp/cart/services/calendar_cart.py +++ b/cart/bp/cart/services/calendar_cart.py @@ -2,7 +2,8 @@ from __future__ import annotations from decimal import Decimal -from shared.services.registry import services +from shared.infrastructure.data_client import fetch_data +from shared.contracts.dtos import CalendarEntryDTO, TicketDTO, dto_from_dict from .identity import current_cart_identity @@ -12,11 +13,13 @@ async def get_calendar_cart_entries(session): current cart identity (user or anonymous session). """ ident = current_cart_identity() - return await services.calendar.pending_entries( - session, - user_id=ident["user_id"], - session_id=ident["session_id"], - ) + params = {} + if ident["user_id"] is not None: + params["user_id"] = ident["user_id"] + if ident["session_id"] is not None: + params["session_id"] = ident["session_id"] + raw = await fetch_data("events", "pending-entries", params=params, required=False) or [] + return [dto_from_dict(CalendarEntryDTO, e) for e in raw] def calendar_total(entries) -> Decimal: @@ -33,11 +36,13 @@ def calendar_total(entries) -> Decimal: async def get_ticket_cart_entries(session): """Return all reserved tickets (as TicketDTOs) for the current identity.""" ident = current_cart_identity() - return await services.calendar.pending_tickets( - session, - user_id=ident["user_id"], - session_id=ident["session_id"], - ) + params = {} + if ident["user_id"] is not None: + params["user_id"] = ident["user_id"] + if ident["session_id"] is not None: + params["session_id"] = ident["session_id"] + raw = await fetch_data("events", "pending-tickets", params=params, required=False) or [] + return [dto_from_dict(TicketDTO, t) for t in raw] def ticket_total(tickets) -> Decimal: diff --git a/cart/bp/cart/services/check_sumup_status.py b/cart/bp/cart/services/check_sumup_status.py index 269a03d..5b31bbc 100644 --- a/cart/bp/cart/services/check_sumup_status.py +++ b/cart/bp/cart/services/check_sumup_status.py @@ -1,6 +1,6 @@ from shared.browser.app.payments.sumup import get_checkout as sumup_get_checkout from shared.events import emit_activity -from shared.services.registry import services +from shared.infrastructure.actions import call_action from .clear_cart_for_order import clear_cart_for_order @@ -14,10 +14,13 @@ async def check_sumup_status(session, order): if sumup_status == "PAID": if order.status != "paid": order.status = "paid" - await services.calendar.confirm_entries_for_order( - session, order.id, order.user_id, order.session_id - ) - await services.calendar.confirm_tickets_for_order(session, order.id) + await call_action("events", "confirm-entries-for-order", payload={ + "order_id": order.id, "user_id": order.user_id, + "session_id": order.session_id, + }) + await call_action("events", "confirm-tickets-for-order", payload={ + "order_id": order.id, + }) # Clear cart only after payment is confirmed page_post_id = page_config.container_id if page_config else None diff --git a/cart/bp/cart/services/checkout.py b/cart/bp/cart/services/checkout.py index 0db306b..38cf44b 100644 --- a/cart/bp/cart/services/checkout.py +++ b/cart/bp/cart/services/checkout.py @@ -14,7 +14,7 @@ from shared.models.market_place import MarketPlace from shared.config import config from shared.contracts.dtos import CalendarEntryDTO from shared.events import emit_activity -from shared.services.registry import services +from shared.infrastructure.actions import call_action async def find_or_create_cart_item( @@ -156,15 +156,17 @@ async def create_order_from_cart( ) session.add(oi) - # Mark pending calendar entries as "ordered" via calendar service - await services.calendar.claim_entries_for_order( - session, order.id, user_id, session_id, page_post_id - ) + # Mark pending calendar entries as "ordered" via events action endpoint + await call_action("events", "claim-entries-for-order", payload={ + "order_id": order.id, "user_id": user_id, + "session_id": session_id, "page_post_id": page_post_id, + }) # Claim reserved tickets for this order - await services.calendar.claim_tickets_for_order( - session, order.id, user_id, session_id, page_post_id - ) + await call_action("events", "claim-tickets-for-order", payload={ + "order_id": order.id, "user_id": user_id, + "session_id": session_id, "page_post_id": page_post_id, + }) await emit_activity( session, diff --git a/cart/bp/cart/services/page_cart.py b/cart/bp/cart/services/page_cart.py index ce59113..c5e4b7c 100644 --- a/cart/bp/cart/services/page_cart.py +++ b/cart/bp/cart/services/page_cart.py @@ -15,7 +15,8 @@ from sqlalchemy.orm import selectinload from shared.models.market import CartItem from shared.models.market_place import MarketPlace from shared.models.page_config import PageConfig -from shared.services.registry import services +from shared.infrastructure.data_client import fetch_data +from shared.contracts.dtos import CalendarEntryDTO, TicketDTO, PostDTO, dto_from_dict from .identity import current_cart_identity @@ -50,21 +51,25 @@ async def get_cart_for_page(session, post_id: int) -> list[CartItem]: async def get_calendar_entries_for_page(session, post_id: int): """Return pending calendar entries (DTOs) scoped to a specific page.""" ident = current_cart_identity() - return await services.calendar.entries_for_page( - session, post_id, - user_id=ident["user_id"], - session_id=ident["session_id"], - ) + params = {"page_id": post_id} + if ident["user_id"] is not None: + params["user_id"] = ident["user_id"] + if ident["session_id"] is not None: + params["session_id"] = ident["session_id"] + raw = await fetch_data("events", "entries-for-page", params=params, required=False) or [] + return [dto_from_dict(CalendarEntryDTO, e) for e in raw] async def get_tickets_for_page(session, post_id: int): """Return reserved tickets (DTOs) scoped to a specific page.""" ident = current_cart_identity() - return await services.calendar.tickets_for_page( - session, post_id, - user_id=ident["user_id"], - session_id=ident["session_id"], - ) + params = {"page_id": post_id} + if ident["user_id"] is not None: + params["user_id"] = ident["user_id"] + if ident["session_id"] is not None: + params["session_id"] = ident["session_id"] + raw = await fetch_data("events", "tickets-for-page", params=params, required=False) or [] + return [dto_from_dict(TicketDTO, t) for t in raw] async def get_cart_grouped_by_page(session) -> list[dict]: @@ -167,7 +172,11 @@ async def get_cart_grouped_by_page(session) -> list[dict]: configs_by_post: dict[int, PageConfig] = {} if post_ids: - for p in await services.blog.get_posts_by_ids(session, post_ids): + raw_posts = await fetch_data("blog", "posts-by-ids", + params={"ids": ",".join(str(i) for i in post_ids)}, + required=False) or [] + for raw_p in raw_posts: + p = dto_from_dict(PostDTO, raw_p) posts_by_id[p.id] = p pc_result = await session.execute( diff --git a/cart/bp/data/__init__.py b/cart/bp/data/__init__.py new file mode 100644 index 0000000..89100ea --- /dev/null +++ b/cart/bp/data/__init__.py @@ -0,0 +1 @@ +from .routes import register as register_data diff --git a/cart/bp/data/routes.py b/cart/bp/data/routes.py new file mode 100644 index 0000000..2fb32de --- /dev/null +++ b/cart/bp/data/routes.py @@ -0,0 +1,45 @@ +"""Cart app data endpoints. + +Exposes read-only JSON queries at ``/internal/data/`` for +cross-app callers via the internal data client. +""" +from __future__ import annotations + +from quart import Blueprint, g, jsonify, request + +from shared.infrastructure.data_client import DATA_HEADER +from shared.contracts.dtos import dto_to_dict +from shared.services.registry import services + + +def register() -> Blueprint: + bp = Blueprint("data", __name__, url_prefix="/internal/data") + + @bp.before_request + async def _require_data_header(): + if not request.headers.get(DATA_HEADER): + return jsonify({"error": "forbidden"}), 403 + + _handlers: dict[str, object] = {} + + @bp.get("/") + async def handle_query(query_name: str): + handler = _handlers.get(query_name) + if handler is None: + return jsonify({"error": "unknown query"}), 404 + result = await handler() + return jsonify(result) + + # --- cart-summary --- + async def _cart_summary(): + user_id = request.args.get("user_id", type=int) + session_id = request.args.get("session_id") + page_slug = request.args.get("page_slug") + summary = await services.cart.cart_summary( + g.s, user_id=user_id, session_id=session_id, page_slug=page_slug, + ) + return dto_to_dict(summary) + + _handlers["cart-summary"] = _cart_summary + + return bp diff --git a/cart/services/__init__.py b/cart/services/__init__.py index 390cd88..f46512d 100644 --- a/cart/services/__init__.py +++ b/cart/services/__init__.py @@ -6,23 +6,9 @@ def register_domain_services() -> None: """Register services for the cart app. Cart owns: Order, OrderItem. - Standard deployment registers all 4 services as real DB impls - (shared DB). For composable deployments, swap non-owned services - with stubs from shared.services.stubs. + Cross-app calls go over HTTP via call_action() / fetch_data(). """ from shared.services.registry import services - from shared.services.blog_impl import SqlBlogService - from shared.services.calendar_impl import SqlCalendarService - from shared.services.market_impl import SqlMarketService from shared.services.cart_impl import SqlCartService services.cart = SqlCartService() - if not services.has("blog"): - services.blog = SqlBlogService() - if not services.has("calendar"): - services.calendar = SqlCalendarService() - if not services.has("market"): - services.market = SqlMarketService() - if not services.has("federation"): - from shared.services.federation_impl import SqlFederationService - services.federation = SqlFederationService() diff --git a/events/app.py b/events/app.py index 27a420e..0a433dd 100644 --- a/events/app.py +++ b/events/app.py @@ -8,7 +8,7 @@ from jinja2 import FileSystemLoader, ChoiceLoader from shared.infrastructure.factory import create_base_app -from bp import register_all_events, register_calendars, register_markets, register_payments, register_page, register_fragments +from bp import register_all_events, register_calendars, register_markets, register_payments, register_page, register_fragments, register_actions, register_data async def events_context() -> dict: @@ -20,20 +20,25 @@ async def events_context() -> dict: """ from shared.infrastructure.context import base_context from shared.services.navigation import get_navigation_tree - from shared.services.registry import services from shared.infrastructure.cart_identity import current_cart_identity from shared.infrastructure.fragments import fetch_fragments + from shared.infrastructure.data_client import fetch_data + from shared.contracts.dtos import CartSummaryDTO, dto_from_dict ctx = await base_context() # Fallback for _nav.html when nav-tree fragment fetch fails ctx["menu_items"] = await get_navigation_tree(g.s) - # Cart data via service (replaces cross-app HTTP API) + # Cart data via internal data endpoint ident = current_cart_identity() - summary = await services.cart.cart_summary( - g.s, user_id=ident["user_id"], session_id=ident["session_id"], - ) + summary_params = {} + if ident["user_id"] is not None: + summary_params["user_id"] = ident["user_id"] + if ident["session_id"] is not None: + summary_params["session_id"] = ident["session_id"] + raw = await fetch_data("cart", "cart-summary", params=summary_params, required=False) + summary = dto_from_dict(CartSummaryDTO, raw) if raw else CartSummaryDTO() ctx["cart_count"] = summary.count + summary.calendar_count + summary.ticket_count ctx["cart_total"] = float(summary.total + summary.calendar_total + summary.ticket_total) @@ -58,7 +63,6 @@ async def events_context() -> dict: def create_app() -> "Quart": - from shared.services.registry import services from services import register_domain_services app = create_base_app( @@ -105,6 +109,8 @@ def create_app() -> "Quart": ) app.register_blueprint(register_fragments()) + app.register_blueprint(register_actions()) + app.register_blueprint(register_data()) # --- Auto-inject slug into url_for() calls --- @app.url_value_preprocessor @@ -122,31 +128,39 @@ def create_app() -> "Quart": # --- Load post data for slug --- @app.before_request async def hydrate_post(): + from shared.infrastructure.data_client import fetch_data slug = getattr(g, "post_slug", None) if not slug: return - post = await services.blog.get_post_by_slug(g.s, slug) + post = await fetch_data("blog", "post-by-slug", params={"slug": slug}) if not post: abort(404) g.post_data = { "post": { - "id": post.id, - "title": post.title, - "slug": post.slug, - "feature_image": post.feature_image, - "status": post.status, - "visibility": post.visibility, + "id": post["id"], + "title": post["title"], + "slug": post["slug"], + "feature_image": post.get("feature_image"), + "status": post["status"], + "visibility": post["visibility"], }, } @app.context_processor async def inject_post(): + from shared.infrastructure.data_client import fetch_data + from shared.contracts.dtos import CalendarDTO, MarketPlaceDTO, dto_from_dict + from shared.services.registry import services post_data = getattr(g, "post_data", None) if not post_data: return {} post_id = post_data["post"]["id"] + # Calendar data is local (events owns it) calendars = await services.calendar.calendars_for_container(g.s, "page", post_id) - markets = await services.market.marketplaces_for_container(g.s, "page", post_id) + # Market data is cross-app + raw_markets = await fetch_data("market", "marketplaces-for-container", + params={"type": "page", "id": post_id}, required=False) or [] + markets = [dto_from_dict(MarketPlaceDTO, m) for m in raw_markets] return { **post_data, "calendars": calendars, @@ -168,6 +182,7 @@ def create_app() -> "Quart": from quart import jsonify from shared.infrastructure.urls import events_url from shared.infrastructure.oembed import build_oembed_response + from shared.infrastructure.data_client import fetch_data url = request.args.get("url", "") if not url: @@ -178,15 +193,15 @@ def create_app() -> "Quart": if not slug: return jsonify({"error": "could not extract slug"}), 404 - post = await services.blog.get_post_by_slug(g.s, slug) + post = await fetch_data("blog", "post-by-slug", params={"slug": slug}) if not post: return jsonify({"error": "not found"}), 404 resp = build_oembed_response( - title=post.title, + title=post["title"], oembed_type="link", - thumbnail_url=post.feature_image, - url=events_url(f"/{post.slug}"), + thumbnail_url=post.get("feature_image"), + url=events_url(f"/{post['slug']}"), ) return jsonify(resp) diff --git a/events/bp/__init__.py b/events/bp/__init__.py index 68e3b31..c7a6ed2 100644 --- a/events/bp/__init__.py +++ b/events/bp/__init__.py @@ -4,3 +4,5 @@ from .markets.routes import register as register_markets from .payments.routes import register as register_payments from .page.routes import register as register_page from .fragments import register_fragments +from .actions import register_actions +from .data import register_data diff --git a/events/bp/actions/__init__.py b/events/bp/actions/__init__.py new file mode 100644 index 0000000..21a842f --- /dev/null +++ b/events/bp/actions/__init__.py @@ -0,0 +1 @@ +from .routes import register as register_actions diff --git a/events/bp/actions/routes.py b/events/bp/actions/routes.py new file mode 100644 index 0000000..c67f1dd --- /dev/null +++ b/events/bp/actions/routes.py @@ -0,0 +1,131 @@ +"""Events app action endpoints. + +Exposes write operations at ``/internal/actions/`` for +cross-app callers (cart, blog) via the internal action client. +""" +from __future__ import annotations + +from quart import Blueprint, g, jsonify, request + +from shared.infrastructure.actions import ACTION_HEADER +from shared.services.registry import services + + +def register() -> Blueprint: + bp = Blueprint("actions", __name__, url_prefix="/internal/actions") + + @bp.before_request + async def _require_action_header(): + if not request.headers.get(ACTION_HEADER): + return jsonify({"error": "forbidden"}), 403 + + _handlers: dict[str, object] = {} + + @bp.post("/") + async def handle_action(action_name: str): + handler = _handlers.get(action_name) + if handler is None: + return jsonify({"error": "unknown action"}), 404 + result = await handler() + return jsonify(result) + + # --- adjust-ticket-quantity --- + async def _adjust_ticket_quantity(): + data = await request.get_json() + await services.calendar.adjust_ticket_quantity( + g.s, + data["entry_id"], + data["count"], + user_id=data.get("user_id"), + session_id=data.get("session_id"), + ticket_type_id=data.get("ticket_type_id"), + ) + return {"ok": True} + + _handlers["adjust-ticket-quantity"] = _adjust_ticket_quantity + + # --- claim-entries-for-order --- + async def _claim_entries(): + data = await request.get_json() + await services.calendar.claim_entries_for_order( + g.s, + data["order_id"], + data.get("user_id"), + data.get("session_id"), + data.get("page_post_id"), + ) + return {"ok": True} + + _handlers["claim-entries-for-order"] = _claim_entries + + # --- claim-tickets-for-order --- + async def _claim_tickets(): + data = await request.get_json() + await services.calendar.claim_tickets_for_order( + g.s, + data["order_id"], + data.get("user_id"), + data.get("session_id"), + data.get("page_post_id"), + ) + return {"ok": True} + + _handlers["claim-tickets-for-order"] = _claim_tickets + + # --- confirm-entries-for-order --- + async def _confirm_entries(): + data = await request.get_json() + await services.calendar.confirm_entries_for_order( + g.s, + data["order_id"], + data.get("user_id"), + data.get("session_id"), + ) + return {"ok": True} + + _handlers["confirm-entries-for-order"] = _confirm_entries + + # --- confirm-tickets-for-order --- + async def _confirm_tickets(): + data = await request.get_json() + await services.calendar.confirm_tickets_for_order( + g.s, data["order_id"], + ) + return {"ok": True} + + _handlers["confirm-tickets-for-order"] = _confirm_tickets + + # --- toggle-entry-post --- + async def _toggle_entry_post(): + data = await request.get_json() + is_associated = await services.calendar.toggle_entry_post( + g.s, + data["entry_id"], + data["content_type"], + data["content_id"], + ) + return {"is_associated": is_associated} + + _handlers["toggle-entry-post"] = _toggle_entry_post + + # --- adopt-entries-for-user --- + async def _adopt_entries(): + data = await request.get_json() + await services.calendar.adopt_entries_for_user( + g.s, data["user_id"], data["session_id"], + ) + return {"ok": True} + + _handlers["adopt-entries-for-user"] = _adopt_entries + + # --- adopt-tickets-for-user --- + async def _adopt_tickets(): + data = await request.get_json() + await services.calendar.adopt_tickets_for_user( + g.s, data["user_id"], data["session_id"], + ) + return {"ok": True} + + _handlers["adopt-tickets-for-user"] = _adopt_tickets + + return bp diff --git a/events/bp/all_events/routes.py b/events/bp/all_events/routes.py index 58732b8..3e81a49 100644 --- a/events/bp/all_events/routes.py +++ b/events/bp/all_events/routes.py @@ -15,6 +15,8 @@ from quart import Blueprint, g, request, render_template, render_template_string from shared.browser.app.utils.htmx import is_htmx_request from shared.infrastructure.cart_identity import current_cart_identity +from shared.infrastructure.data_client import fetch_data +from shared.contracts.dtos import CartSummaryDTO, PostDTO, dto_from_dict from shared.services.registry import services @@ -47,8 +49,11 @@ def register() -> Blueprint: if e.calendar_container_type == "page" and e.calendar_container_id }) if post_ids: - posts = await services.blog.get_posts_by_ids(g.s, post_ids) - for p in posts: + raw_posts = await fetch_data("blog", "posts-by-ids", + params={"ids": ",".join(str(i) for i in post_ids)}, + required=False) or [] + for raw_p in raw_posts: + p = dto_from_dict(PostDTO, raw_p) page_info[p.id] = {"title": p.title, "slug": p.slug} return entries, has_more, pending_tickets, page_info @@ -121,9 +126,13 @@ def register() -> Blueprint: entry = await services.calendar.entry_by_id(g.s, entry_id) # Updated cart count for OOB mini-cart - summary = await services.cart.cart_summary( - g.s, user_id=ident["user_id"], session_id=ident["session_id"], - ) + summary_params = {} + if ident["user_id"] is not None: + summary_params["user_id"] = ident["user_id"] + if ident["session_id"] is not None: + summary_params["session_id"] = ident["session_id"] + raw_summary = await fetch_data("cart", "cart-summary", params=summary_params, required=False) + summary = dto_from_dict(CartSummaryDTO, raw_summary) if raw_summary else CartSummaryDTO() cart_count = summary.count + summary.calendar_count + summary.ticket_count # Render widget + OOB cart-mini diff --git a/events/bp/calendar_entries/routes.py b/events/bp/calendar_entries/routes.py index b4fdb31..c965d5e 100644 --- a/events/bp/calendar_entries/routes.py +++ b/events/bp/calendar_entries/routes.py @@ -219,13 +219,18 @@ def register(): select(sa_func.count()).select_from(CalendarEntry).where(*cal_filters) ) or 0 - # Get product cart count via service (same DB, no HTTP needed) + # Get product cart count via HTTP from shared.infrastructure.cart_identity import current_cart_identity - from shared.services.registry import services + from shared.infrastructure.data_client import fetch_data + from shared.contracts.dtos import CartSummaryDTO, dto_from_dict ident = current_cart_identity() - cart_summary = await services.cart.cart_summary( - g.s, user_id=ident["user_id"], session_id=ident["session_id"], - ) + summary_params = {} + if ident["user_id"] is not None: + summary_params["user_id"] = ident["user_id"] + if ident["session_id"] is not None: + summary_params["session_id"] = ident["session_id"] + raw_summary = await fetch_data("cart", "cart-summary", params=summary_params, required=False) + cart_summary = dto_from_dict(CartSummaryDTO, raw_summary) if raw_summary else CartSummaryDTO() product_count = cart_summary.count total_count = product_count + cal_count diff --git a/events/bp/calendar_entry/services/post_associations.py b/events/bp/calendar_entry/services/post_associations.py index d96cf7d..d2d06a7 100644 --- a/events/bp/calendar_entry/services/post_associations.py +++ b/events/bp/calendar_entry/services/post_associations.py @@ -5,7 +5,8 @@ from sqlalchemy import select from sqlalchemy.sql import func from models.calendars import CalendarEntry, CalendarEntryPost -from shared.services.registry import services +from shared.infrastructure.data_client import fetch_data +from shared.contracts.dtos import PostDTO, dto_from_dict async def add_post_to_entry( @@ -28,8 +29,8 @@ async def add_post_to_entry( return False, "Calendar entry not found" # Check if post exists - post = await services.blog.get_post_by_id(session, post_id) - if not post: + raw = await fetch_data("blog", "post-by-id", params={"id": post_id}, required=False) + if not raw: return False, "Post not found" # Check if association already exists @@ -103,7 +104,10 @@ async def get_entry_posts( post_ids = list(result.scalars().all()) if not post_ids: return [] - posts = await services.blog.get_posts_by_ids(session, post_ids) + raw_posts = await fetch_data("blog", "posts-by-ids", + params={"ids": ",".join(str(i) for i in post_ids)}, + required=False) or [] + posts = [dto_from_dict(PostDTO, p) for p in raw_posts] return sorted(posts, key=lambda p: (p.title or "")) @@ -118,4 +122,8 @@ async def search_posts( If query is empty, returns all posts in published order. Returns (post_dtos, total_count). """ - return await services.blog.search_posts(session, query, page, per_page) + raw = await fetch_data("blog", "search-posts", + params={"query": query, "page": page, "per_page": per_page}, + required=False) or {"posts": [], "total": 0} + posts = [dto_from_dict(PostDTO, p) for p in raw.get("posts", [])] + return posts, raw.get("total", 0) diff --git a/events/bp/calendars/services/calendars.py b/events/bp/calendars/services/calendars.py index 2e8a94b..b0db55f 100644 --- a/events/bp/calendars/services/calendars.py +++ b/events/bp/calendars/services/calendars.py @@ -4,7 +4,8 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from models.calendars import Calendar -from shared.services.registry import services +from shared.infrastructure.data_client import fetch_data +from shared.contracts.dtos import PostDTO, dto_from_dict from shared.services.relationships import attach_child, detach_child import unicodedata import re @@ -49,7 +50,8 @@ def slugify(value: str, max_len: int = 255) -> str: async def soft_delete(sess: AsyncSession, post_slug: str, calendar_slug: str) -> bool: - post = await services.blog.get_post_by_slug(sess, post_slug) + raw = await fetch_data("blog", "post-by-slug", params={"slug": post_slug}, required=False) + post = dto_from_dict(PostDTO, raw) if raw else None if not post: return False @@ -84,7 +86,8 @@ async def create_calendar(sess: AsyncSession, post_id: int, name: str) -> Calend slug=slugify(name) # Ensure post exists (avoid silent FK errors in some DBs) - post = await services.blog.get_post_by_id(sess, post_id) + raw = await fetch_data("blog", "post-by-id", params={"id": post_id}, required=False) + post = dto_from_dict(PostDTO, raw) if raw else None if not post: raise CalendarError(f"Post {post_id} does not exist.") diff --git a/events/bp/data/__init__.py b/events/bp/data/__init__.py new file mode 100644 index 0000000..89100ea --- /dev/null +++ b/events/bp/data/__init__.py @@ -0,0 +1 @@ +from .routes import register as register_data diff --git a/events/bp/data/routes.py b/events/bp/data/routes.py new file mode 100644 index 0000000..8915082 --- /dev/null +++ b/events/bp/data/routes.py @@ -0,0 +1,144 @@ +"""Events app data endpoints. + +Exposes read-only JSON queries at ``/internal/data/`` for +cross-app callers via the internal data client. +""" +from __future__ import annotations + +from quart import Blueprint, g, jsonify, request + +from shared.infrastructure.data_client import DATA_HEADER +from shared.contracts.dtos import dto_to_dict +from shared.services.registry import services + + +def register() -> Blueprint: + bp = Blueprint("data", __name__, url_prefix="/internal/data") + + @bp.before_request + async def _require_data_header(): + if not request.headers.get(DATA_HEADER): + return jsonify({"error": "forbidden"}), 403 + + _handlers: dict[str, object] = {} + + @bp.get("/") + async def handle_query(query_name: str): + handler = _handlers.get(query_name) + if handler is None: + return jsonify({"error": "unknown query"}), 404 + result = await handler() + return jsonify(result) + + # --- pending-entries --- + async def _pending_entries(): + user_id = request.args.get("user_id", type=int) + session_id = request.args.get("session_id") + entries = await services.calendar.pending_entries( + g.s, user_id=user_id, session_id=session_id, + ) + return [dto_to_dict(e) for e in entries] + + _handlers["pending-entries"] = _pending_entries + + # --- pending-tickets --- + async def _pending_tickets(): + user_id = request.args.get("user_id", type=int) + session_id = request.args.get("session_id") + tickets = await services.calendar.pending_tickets( + g.s, user_id=user_id, session_id=session_id, + ) + return [dto_to_dict(t) for t in tickets] + + _handlers["pending-tickets"] = _pending_tickets + + # --- entries-for-page --- + async def _entries_for_page(): + page_id = request.args.get("page_id", type=int) + user_id = request.args.get("user_id", type=int) + session_id = request.args.get("session_id") + entries = await services.calendar.entries_for_page( + g.s, page_id, user_id=user_id, session_id=session_id, + ) + return [dto_to_dict(e) for e in entries] + + _handlers["entries-for-page"] = _entries_for_page + + # --- tickets-for-page --- + async def _tickets_for_page(): + page_id = request.args.get("page_id", type=int) + user_id = request.args.get("user_id", type=int) + session_id = request.args.get("session_id") + tickets = await services.calendar.tickets_for_page( + g.s, page_id, user_id=user_id, session_id=session_id, + ) + return [dto_to_dict(t) for t in tickets] + + _handlers["tickets-for-page"] = _tickets_for_page + + # --- entries-for-order --- + async def _entries_for_order(): + order_id = request.args.get("order_id", type=int) + entries = await services.calendar.get_entries_for_order(g.s, order_id) + return [dto_to_dict(e) for e in entries] + + _handlers["entries-for-order"] = _entries_for_order + + # --- tickets-for-order --- + async def _tickets_for_order(): + order_id = request.args.get("order_id", type=int) + tickets = await services.calendar.get_tickets_for_order(g.s, order_id) + return [dto_to_dict(t) for t in tickets] + + _handlers["tickets-for-order"] = _tickets_for_order + + # --- entry-ids-for-content --- + async def _entry_ids_for_content(): + content_type = request.args.get("content_type", "") + content_id = request.args.get("content_id", type=int) + ids = await services.calendar.entry_ids_for_content(g.s, content_type, content_id) + return list(ids) + + _handlers["entry-ids-for-content"] = _entry_ids_for_content + + # --- associated-entries --- + async def _associated_entries(): + content_type = request.args.get("content_type", "") + content_id = request.args.get("content_id", type=int) + page = request.args.get("page", 1, type=int) + entries, has_more = await services.calendar.associated_entries( + g.s, content_type, content_id, page, + ) + return {"entries": [dto_to_dict(e) for e in entries], "has_more": has_more} + + _handlers["associated-entries"] = _associated_entries + + # --- calendars-for-container --- + async def _calendars_for_container(): + container_type = request.args.get("type", "") + container_id = request.args.get("id", type=int) + calendars = await services.calendar.calendars_for_container( + g.s, container_type, container_id, + ) + return [dto_to_dict(c) for c in calendars] + + _handlers["calendars-for-container"] = _calendars_for_container + + # --- visible-entries-for-period --- + async def _visible_entries_for_period(): + from datetime import datetime + calendar_id = request.args.get("calendar_id", type=int) + period_start = datetime.fromisoformat(request.args.get("period_start", "")) + period_end = datetime.fromisoformat(request.args.get("period_end", "")) + user_id = request.args.get("user_id", type=int) + is_admin = request.args.get("is_admin", "false").lower() == "true" + session_id = request.args.get("session_id") + entries = await services.calendar.visible_entries_for_period( + g.s, calendar_id, period_start, period_end, + user_id=user_id, is_admin=is_admin, session_id=session_id, + ) + return [dto_to_dict(e) for e in entries] + + _handlers["visible-entries-for-period"] = _visible_entries_for_period + + return bp diff --git a/events/bp/fragments/routes.py b/events/bp/fragments/routes.py index db038c1..9f67b05 100644 --- a/events/bp/fragments/routes.py +++ b/events/bp/fragments/routes.py @@ -9,6 +9,8 @@ from __future__ import annotations from quart import Blueprint, Response, g, render_template, request from shared.infrastructure.fragments import FRAGMENT_HEADER +from shared.infrastructure.data_client import fetch_data +from shared.contracts.dtos import PostDTO, dto_from_dict from shared.services.registry import services @@ -139,7 +141,8 @@ def register(): parts = [] for s in slugs: parts.append(f"") - post = await services.blog.get_post_by_slug(g.s, s) + raw = await fetch_data("blog", "post-by-slug", params={"slug": s}, required=False) + post = dto_from_dict(PostDTO, raw) if raw else None if post: calendars = await services.calendar.calendars_for_container( g.s, "page", post.id, @@ -157,7 +160,8 @@ def register(): # Single mode if not slug: return "" - post = await services.blog.get_post_by_slug(g.s, slug) + raw = await fetch_data("blog", "post-by-slug", params={"slug": slug}, required=False) + post = dto_from_dict(PostDTO, raw) if raw else None if not post: return "" calendars = await services.calendar.calendars_for_container( diff --git a/events/bp/markets/services/markets.py b/events/bp/markets/services/markets.py index 7b0890a..3049e0b 100644 --- a/events/bp/markets/services/markets.py +++ b/events/bp/markets/services/markets.py @@ -5,8 +5,9 @@ import unicodedata from sqlalchemy.ext.asyncio import AsyncSession -from shared.contracts.dtos import MarketPlaceDTO -from shared.services.registry import services +from shared.contracts.dtos import MarketPlaceDTO, PostDTO, dto_from_dict +from shared.infrastructure.actions import call_action, ActionError +from shared.infrastructure.data_client import fetch_data class MarketError(ValueError): @@ -37,21 +38,33 @@ async def create_market(sess: AsyncSession, post_id: int, name: str) -> MarketPl raise MarketError("Market name must not be empty.") slug = slugify(name) - post = await services.blog.get_post_by_id(sess, post_id) + raw = await fetch_data("blog", "post-by-id", params={"id": post_id}, required=False) + post = dto_from_dict(PostDTO, raw) if raw else None if not post: raise MarketError(f"Post {post_id} does not exist.") if not post.is_page: raise MarketError("Markets can only be created on pages, not posts.") try: - return await services.market.create_marketplace(sess, "page", post_id, name, slug) - except ValueError as e: + result = await call_action("market", "create-marketplace", payload={ + "container_type": "page", "container_id": post_id, + "name": name, "slug": slug, + }) + return MarketPlaceDTO(**result) + except ActionError as e: raise MarketError(str(e)) from e async def soft_delete(sess: AsyncSession, post_slug: str, market_slug: str) -> bool: - post = await services.blog.get_post_by_slug(sess, post_slug) + raw = await fetch_data("blog", "post-by-slug", params={"slug": post_slug}, required=False) + post = dto_from_dict(PostDTO, raw) if raw else None if not post: return False - return await services.market.soft_delete_marketplace(sess, "page", post.id, market_slug) + try: + result = await call_action("market", "soft-delete-marketplace", payload={ + "container_type": "page", "container_id": post.id, "slug": market_slug, + }) + return result.get("deleted", False) + except ActionError: + return False diff --git a/events/bp/page/routes.py b/events/bp/page/routes.py index da4fb74..c327bf2 100644 --- a/events/bp/page/routes.py +++ b/events/bp/page/routes.py @@ -12,6 +12,8 @@ from quart import Blueprint, g, request, render_template, render_template_string from shared.browser.app.utils.htmx import is_htmx_request from shared.infrastructure.cart_identity import current_cart_identity +from shared.infrastructure.data_client import fetch_data +from shared.contracts.dtos import CartSummaryDTO, dto_from_dict from shared.services.registry import services @@ -107,9 +109,13 @@ def register() -> Blueprint: entry = await services.calendar.entry_by_id(g.s, entry_id) # Updated cart count for OOB mini-cart - summary = await services.cart.cart_summary( - g.s, user_id=ident["user_id"], session_id=ident["session_id"], - ) + summary_params = {} + if ident["user_id"] is not None: + summary_params["user_id"] = ident["user_id"] + if ident["session_id"] is not None: + summary_params["session_id"] = ident["session_id"] + raw_summary = await fetch_data("cart", "cart-summary", params=summary_params, required=False) + summary = dto_from_dict(CartSummaryDTO, raw_summary) if raw_summary else CartSummaryDTO() cart_count = summary.count + summary.calendar_count + summary.ticket_count # Render widget + OOB cart-mini diff --git a/events/bp/tickets/routes.py b/events/bp/tickets/routes.py index 408eb06..30bab2a 100644 --- a/events/bp/tickets/routes.py +++ b/events/bp/tickets/routes.py @@ -287,10 +287,15 @@ def register() -> Blueprint: ) # Compute cart count for OOB mini-cart update - from shared.services.registry import services - summary = await services.cart.cart_summary( - g.s, user_id=ident["user_id"], session_id=ident["session_id"], - ) + from shared.infrastructure.data_client import fetch_data + from shared.contracts.dtos import CartSummaryDTO, dto_from_dict + summary_params = {} + if ident["user_id"] is not None: + summary_params["user_id"] = ident["user_id"] + if ident["session_id"] is not None: + summary_params["session_id"] = ident["session_id"] + raw_summary = await fetch_data("cart", "cart-summary", params=summary_params, required=False) + summary = dto_from_dict(CartSummaryDTO, raw_summary) if raw_summary else CartSummaryDTO() cart_count = summary.count + summary.calendar_count + summary.ticket_count html = await render_template( diff --git a/events/services/__init__.py b/events/services/__init__.py index e7ddf54..781fed6 100644 --- a/events/services/__init__.py +++ b/events/services/__init__.py @@ -7,23 +7,14 @@ def register_domain_services() -> None: Events owns: Calendar, CalendarEntry, CalendarSlot, TicketType, Ticket, CalendarEntryPost. - Standard deployment registers all 4 services as real DB impls - (shared DB). For composable deployments, swap non-owned services - with stubs from shared.services.stubs. + Cross-app calls go over HTTP via call_action() / fetch_data(). """ from shared.services.registry import services - from shared.services.blog_impl import SqlBlogService from shared.services.calendar_impl import SqlCalendarService - from shared.services.market_impl import SqlMarketService - from shared.services.cart_impl import SqlCartService services.calendar = SqlCalendarService() - if not services.has("blog"): - services.blog = SqlBlogService() - if not services.has("market"): - services.market = SqlMarketService() - if not services.has("cart"): - services.cart = SqlCartService() + + # Federation needed for AP shared infrastructure (activitypub blueprint) if not services.has("federation"): from shared.services.federation_impl import SqlFederationService services.federation = SqlFederationService() diff --git a/federation/app.py b/federation/app.py index 5d68f04..e1a2050 100644 --- a/federation/app.py +++ b/federation/app.py @@ -21,17 +21,23 @@ async def federation_context() -> dict: from shared.services.navigation import get_navigation_tree from shared.infrastructure.cart_identity import current_cart_identity from shared.infrastructure.fragments import fetch_fragments + from shared.infrastructure.data_client import fetch_data + from shared.contracts.dtos import CartSummaryDTO, dto_from_dict ctx = await base_context() # Fallback for _nav.html when nav-tree fragment fetch fails ctx["menu_items"] = await get_navigation_tree(g.s) - # Cart data (consistent with all other apps) + # Cart data via internal data endpoint ident = current_cart_identity() - summary = await services.cart.cart_summary( - g.s, user_id=ident["user_id"], session_id=ident["session_id"], - ) + summary_params = {} + if ident["user_id"] is not None: + summary_params["user_id"] = ident["user_id"] + if ident["session_id"] is not None: + summary_params["session_id"] = ident["session_id"] + raw = await fetch_data("cart", "cart-summary", params=summary_params, required=False) + summary = dto_from_dict(CartSummaryDTO, raw) if raw else CartSummaryDTO() ctx["cart_count"] = summary.count + summary.calendar_count + summary.ticket_count ctx["cart_total"] = float(summary.total + summary.calendar_total + summary.ticket_total) diff --git a/federation/services/__init__.py b/federation/services/__init__.py index e6794e2..92f2587 100644 --- a/federation/services/__init__.py +++ b/federation/services/__init__.py @@ -7,21 +7,9 @@ def register_domain_services() -> None: Federation owns: ActorProfile, APActivity, APFollower, APInboxItem, APAnchor, IPFSPin. - Standard deployment registers all services as real DB impls (shared DB). + Cross-app calls go over HTTP via call_action() / fetch_data(). """ from shared.services.registry import services from shared.services.federation_impl import SqlFederationService - from shared.services.blog_impl import SqlBlogService - from shared.services.calendar_impl import SqlCalendarService - from shared.services.market_impl import SqlMarketService - from shared.services.cart_impl import SqlCartService services.federation = SqlFederationService() - if not services.has("blog"): - services.blog = SqlBlogService() - if not services.has("calendar"): - services.calendar = SqlCalendarService() - if not services.has("market"): - services.market = SqlMarketService() - if not services.has("cart"): - services.cart = SqlCartService() diff --git a/market/app.py b/market/app.py index 76aef1a..f09f396 100644 --- a/market/app.py +++ b/market/app.py @@ -10,7 +10,7 @@ from sqlalchemy import select from shared.infrastructure.factory import create_base_app from shared.config import config -from bp import register_market_bp, register_all_markets, register_page_markets, register_fragments +from bp import register_market_bp, register_all_markets, register_page_markets, register_fragments, register_actions, register_data async def market_context() -> dict: @@ -23,9 +23,10 @@ async def market_context() -> dict: """ from shared.infrastructure.context import base_context from shared.services.navigation import get_navigation_tree - from shared.services.registry import services from shared.infrastructure.cart_identity import current_cart_identity from shared.infrastructure.fragments import fetch_fragments + from shared.infrastructure.data_client import fetch_data + from shared.contracts.dtos import CartSummaryDTO, dto_from_dict from shared.models.market import CartItem from sqlalchemy.orm import selectinload @@ -36,10 +37,14 @@ async def market_context() -> dict: ident = current_cart_identity() - # cart_count/cart_total via service (consistent with blog/events apps) - summary = await services.cart.cart_summary( - g.s, user_id=ident["user_id"], session_id=ident["session_id"], - ) + # cart_count/cart_total via internal data endpoint + summary_params = {} + if ident["user_id"] is not None: + summary_params["user_id"] = ident["user_id"] + if ident["session_id"] is not None: + summary_params["session_id"] = ident["session_id"] + raw = await fetch_data("cart", "cart-summary", params=summary_params, required=False) + summary = dto_from_dict(CartSummaryDTO, raw) if raw else CartSummaryDTO() ctx["cart_count"] = summary.count + summary.calendar_count ctx["cart_total"] = float(summary.total + summary.calendar_total) @@ -80,7 +85,6 @@ async def market_context() -> dict: def create_app() -> "Quart": from models.market_place import MarketPlace - from shared.services.registry import services from services import register_domain_services app = create_base_app( @@ -118,6 +122,8 @@ def create_app() -> "Quart": ) app.register_blueprint(register_fragments()) + app.register_blueprint(register_actions()) + app.register_blueprint(register_data()) # --- Auto-inject slugs into url_for() calls --- @app.url_value_preprocessor @@ -147,26 +153,27 @@ def create_app() -> "Quart": # --- Load post and market data --- @app.before_request async def hydrate_market(): + from shared.infrastructure.data_client import fetch_data post_slug = getattr(g, "post_slug", None) market_slug = getattr(g, "market_slug", None) if not post_slug: return - # Load post by slug via blog service - post = await services.blog.get_post_by_slug(g.s, post_slug) + # Load post by slug via blog data endpoint + post = await fetch_data("blog", "post-by-slug", params={"slug": post_slug}) if not post: abort(404) g.post_data = { "post": { - "id": post.id, - "title": post.title, - "slug": post.slug, - "feature_image": post.feature_image, - "html": post.html, - "status": post.status, - "visibility": post.visibility, - "is_page": post.is_page, + "id": post["id"], + "title": post["title"], + "slug": post["slug"], + "feature_image": post.get("feature_image"), + "html": post.get("html"), + "status": post["status"], + "visibility": post["visibility"], + "is_page": post.get("is_page", False), }, } diff --git a/market/bp/__init__.py b/market/bp/__init__.py index b62b4b6..ab2c010 100644 --- a/market/bp/__init__.py +++ b/market/bp/__init__.py @@ -3,3 +3,5 @@ from .product.routes import register as register_product from .all_markets.routes import register as register_all_markets from .page_markets.routes import register as register_page_markets from .fragments import register_fragments +from .actions import register_actions +from .data import register_data diff --git a/market/bp/actions/__init__.py b/market/bp/actions/__init__.py new file mode 100644 index 0000000..21a842f --- /dev/null +++ b/market/bp/actions/__init__.py @@ -0,0 +1 @@ +from .routes import register as register_actions diff --git a/market/bp/actions/routes.py b/market/bp/actions/routes.py new file mode 100644 index 0000000..f7e1f0b --- /dev/null +++ b/market/bp/actions/routes.py @@ -0,0 +1,66 @@ +"""Market app action endpoints. + +Exposes write operations at ``/internal/actions/`` for +cross-app callers (blog, events) via the internal action client. +""" +from __future__ import annotations + +from quart import Blueprint, g, jsonify, request + +from shared.infrastructure.actions import ACTION_HEADER +from shared.services.registry import services + + +def register() -> Blueprint: + bp = Blueprint("actions", __name__, url_prefix="/internal/actions") + + @bp.before_request + async def _require_action_header(): + if not request.headers.get(ACTION_HEADER): + return jsonify({"error": "forbidden"}), 403 + + _handlers: dict[str, object] = {} + + @bp.post("/") + async def handle_action(action_name: str): + handler = _handlers.get(action_name) + if handler is None: + return jsonify({"error": "unknown action"}), 404 + result = await handler() + return jsonify(result) + + # --- create-marketplace --- + async def _create_marketplace(): + data = await request.get_json() + mp = await services.market.create_marketplace( + g.s, + data["container_type"], + data["container_id"], + data["name"], + data["slug"], + ) + return { + "id": mp.id, + "container_type": mp.container_type, + "container_id": mp.container_id, + "name": mp.name, + "slug": mp.slug, + "description": mp.description, + } + + _handlers["create-marketplace"] = _create_marketplace + + # --- soft-delete-marketplace --- + async def _soft_delete_marketplace(): + data = await request.get_json() + deleted = await services.market.soft_delete_marketplace( + g.s, + data["container_type"], + data["container_id"], + data["slug"], + ) + return {"deleted": deleted} + + _handlers["soft-delete-marketplace"] = _soft_delete_marketplace + + return bp diff --git a/market/bp/all_markets/routes.py b/market/bp/all_markets/routes.py index 0ce086d..66506a2 100644 --- a/market/bp/all_markets/routes.py +++ b/market/bp/all_markets/routes.py @@ -12,6 +12,8 @@ from __future__ import annotations from quart import Blueprint, g, request, render_template, make_response from shared.browser.app.utils.htmx import is_htmx_request +from shared.infrastructure.data_client import fetch_data +from shared.contracts.dtos import PostDTO, dto_from_dict from shared.services.registry import services @@ -32,8 +34,11 @@ def register() -> Blueprint: if m.container_type == "page" }) if post_ids: - posts = await services.blog.get_posts_by_ids(g.s, post_ids) - for p in posts: + raw_posts = await fetch_data("blog", "posts-by-ids", + params={"ids": ",".join(str(i) for i in post_ids)}, + required=False) or [] + for raw_p in raw_posts: + p = dto_from_dict(PostDTO, raw_p) page_info[p.id] = {"title": p.title, "slug": p.slug} return markets, has_more, page_info diff --git a/market/bp/data/__init__.py b/market/bp/data/__init__.py new file mode 100644 index 0000000..89100ea --- /dev/null +++ b/market/bp/data/__init__.py @@ -0,0 +1 @@ +from .routes import register as register_data diff --git a/market/bp/data/routes.py b/market/bp/data/routes.py new file mode 100644 index 0000000..eeabdd9 --- /dev/null +++ b/market/bp/data/routes.py @@ -0,0 +1,44 @@ +"""Market app data endpoints. + +Exposes read-only JSON queries at ``/internal/data/`` for +cross-app callers via the internal data client. +""" +from __future__ import annotations + +from quart import Blueprint, g, jsonify, request + +from shared.infrastructure.data_client import DATA_HEADER +from shared.contracts.dtos import dto_to_dict +from shared.services.registry import services + + +def register() -> Blueprint: + bp = Blueprint("data", __name__, url_prefix="/internal/data") + + @bp.before_request + async def _require_data_header(): + if not request.headers.get(DATA_HEADER): + return jsonify({"error": "forbidden"}), 403 + + _handlers: dict[str, object] = {} + + @bp.get("/") + async def handle_query(query_name: str): + handler = _handlers.get(query_name) + if handler is None: + return jsonify({"error": "unknown query"}), 404 + result = await handler() + return jsonify(result) + + # --- marketplaces-for-container --- + async def _marketplaces_for_container(): + container_type = request.args.get("type", "") + container_id = request.args.get("id", type=int) + markets = await services.market.marketplaces_for_container( + g.s, container_type, container_id, + ) + return [dto_to_dict(m) for m in markets] + + _handlers["marketplaces-for-container"] = _marketplaces_for_container + + return bp diff --git a/market/services/__init__.py b/market/services/__init__.py index 8453359..fcde49c 100644 --- a/market/services/__init__.py +++ b/market/services/__init__.py @@ -7,23 +7,14 @@ def register_domain_services() -> None: Market owns: Product, CartItem, MarketPlace, NavTop, NavSub, Listing, ProductImage. - Standard deployment registers all 4 services as real DB impls - (shared DB). For composable deployments, swap non-owned services - with stubs from shared.services.stubs. + Cross-app calls go over HTTP via call_action() / fetch_data(). """ from shared.services.registry import services - from shared.services.blog_impl import SqlBlogService - from shared.services.calendar_impl import SqlCalendarService from shared.services.market_impl import SqlMarketService - from shared.services.cart_impl import SqlCartService services.market = SqlMarketService() - if not services.has("blog"): - services.blog = SqlBlogService() - if not services.has("calendar"): - services.calendar = SqlCalendarService() - if not services.has("cart"): - services.cart = SqlCartService() + + # Federation needed for AP shared infrastructure (activitypub blueprint) if not services.has("federation"): from shared.services.federation_impl import SqlFederationService services.federation = SqlFederationService() diff --git a/shared/contracts/dtos.py b/shared/contracts/dtos.py index cd5c50d..30ec3d6 100644 --- a/shared/contracts/dtos.py +++ b/shared/contracts/dtos.py @@ -5,11 +5,74 @@ see ORM model instances from another domain — only these DTOs. """ from __future__ import annotations +import dataclasses +import typing from dataclasses import dataclass, field from datetime import datetime from decimal import Decimal +# --------------------------------------------------------------------------- +# Serialization helpers for JSON transport over internal data endpoints +# --------------------------------------------------------------------------- + +def _serialize_value(v): + """Convert a single value to a JSON-safe type.""" + if isinstance(v, datetime): + return v.isoformat() + if isinstance(v, Decimal): + return str(v) + if isinstance(v, set): + return list(v) + if dataclasses.is_dataclass(v) and not isinstance(v, type): + return dto_to_dict(v) + if isinstance(v, list): + return [_serialize_value(item) for item in v] + return v + + +def dto_to_dict(obj) -> dict: + """Convert a frozen DTO dataclass to a JSON-serialisable dict.""" + return {k: _serialize_value(v) for k, v in dataclasses.asdict(obj).items()} + + +def _unwrap_optional(hint): + """Unwrap Optional[X] / X | None to return X.""" + args = getattr(hint, "__args__", ()) + if args: + real = [a for a in args if a is not type(None)] + if real: + return real[0] + return hint + + +def dto_from_dict(cls, data: dict): + """Construct a DTO from a dict, coercing dates and Decimals. + + Uses ``typing.get_type_hints()`` to resolve forward-ref annotations + (from ``from __future__ import annotations``). + """ + if not data: + return None + try: + hints = typing.get_type_hints(cls) + except Exception: + hints = {} + kwargs = {} + for f in dataclasses.fields(cls): + if f.name not in data: + continue + val = data[f.name] + if val is not None and f.name in hints: + hint = _unwrap_optional(hints[f.name]) + if hint is datetime and isinstance(val, str): + val = datetime.fromisoformat(val) + elif hint is Decimal: + val = Decimal(str(val)) + kwargs[f.name] = val + return cls(**kwargs) + + # --------------------------------------------------------------------------- # Blog domain # --------------------------------------------------------------------------- diff --git a/shared/events/handlers/login_handlers.py b/shared/events/handlers/login_handlers.py index d09ce23..5b016f2 100644 --- a/shared/events/handlers/login_handlers.py +++ b/shared/events/handlers/login_handlers.py @@ -1,23 +1,31 @@ from __future__ import annotations +import logging + from sqlalchemy.ext.asyncio import AsyncSession from shared.events import register_activity_handler +from shared.infrastructure.actions import call_action, ActionError from shared.models.federation import APActivity -from shared.services.registry import services + +log = logging.getLogger(__name__) async def on_user_logged_in(activity: APActivity, session: AsyncSession) -> None: data = activity.object_data user_id = data["user_id"] session_id = data["session_id"] + payload = {"user_id": user_id, "session_id": session_id} - if services.has("cart"): - await services.cart.adopt_cart_for_user(session, user_id, session_id) - - if services.has("calendar"): - await services.calendar.adopt_entries_for_user(session, user_id, session_id) - await services.calendar.adopt_tickets_for_user(session, user_id, session_id) + for app, action in [ + ("cart", "adopt-cart-for-user"), + ("events", "adopt-entries-for-user"), + ("events", "adopt-tickets-for-user"), + ]: + try: + await call_action(app, action, payload=payload) + except ActionError: + log.warning("Failed: %s/%s for user %s", app, action, user_id) register_activity_handler("rose:Login", on_user_logged_in) diff --git a/shared/infrastructure/actions.py b/shared/infrastructure/actions.py new file mode 100644 index 0000000..587c8ed --- /dev/null +++ b/shared/infrastructure/actions.py @@ -0,0 +1,89 @@ +"""Internal action client for cross-app write operations. + +Each coop app exposes JSON action endpoints at ``/internal/actions/{name}``. +This module provides helpers to call those endpoints so that callers don't +need direct access to another app's DB session or service layer. + +Failures raise ``ActionError`` so callers can handle or propagate them. +""" +from __future__ import annotations + +import logging +import os + +import httpx + +log = logging.getLogger(__name__) + +# Re-usable async client (created lazily, one per process) +_client: httpx.AsyncClient | None = None + +# Default request timeout (seconds) — longer than fragments since these are writes +_DEFAULT_TIMEOUT = 5.0 + +# Header sent on every action request so providers can gate access. +ACTION_HEADER = "X-Internal-Action" + + +class ActionError(Exception): + """Raised when an internal action call fails.""" + + def __init__(self, message: str, status_code: int = 500, detail: dict | None = None): + super().__init__(message) + self.status_code = status_code + self.detail = detail + + +def _get_client() -> httpx.AsyncClient: + global _client + if _client is None or _client.is_closed: + _client = httpx.AsyncClient( + timeout=httpx.Timeout(_DEFAULT_TIMEOUT), + follow_redirects=False, + ) + return _client + + +def _internal_url(app_name: str) -> str: + """Resolve the Docker-internal base URL for *app_name*.""" + env_key = f"INTERNAL_URL_{app_name.upper()}" + return os.getenv(env_key, f"http://{app_name}:8000").rstrip("/") + + +async def call_action( + app_name: str, + action_name: str, + *, + payload: dict | None = None, + timeout: float = _DEFAULT_TIMEOUT, +) -> dict: + """POST JSON to ``{INTERNAL_URL_APP}/internal/actions/{action_name}``. + + Returns the parsed JSON response on 2xx. + Raises ``ActionError`` on network errors or non-2xx responses. + """ + base = _internal_url(app_name) + url = f"{base}/internal/actions/{action_name}" + try: + resp = await _get_client().post( + url, + json=payload or {}, + headers={ACTION_HEADER: "1"}, + timeout=timeout, + ) + if 200 <= resp.status_code < 300: + return resp.json() + msg = f"Action {app_name}/{action_name} returned {resp.status_code}" + detail = None + try: + detail = resp.json() + except Exception: + pass + log.error(msg) + raise ActionError(msg, status_code=resp.status_code, detail=detail) + except ActionError: + raise + except Exception as exc: + msg = f"Action {app_name}/{action_name} failed: {exc}" + log.error(msg) + raise ActionError(msg) from exc diff --git a/shared/infrastructure/data_client.py b/shared/infrastructure/data_client.py new file mode 100644 index 0000000..58a72fd --- /dev/null +++ b/shared/infrastructure/data_client.py @@ -0,0 +1,91 @@ +"""Internal data client for cross-app read operations. + +Each coop app exposes JSON data endpoints at ``/internal/data/{query}``. +This module provides helpers to fetch that data so that callers don't +need direct access to another app's DB session or service layer. + +Same pattern as the fragment client but returns parsed JSON instead of HTML. +""" +from __future__ import annotations + +import logging +import os + +import httpx + +log = logging.getLogger(__name__) + +# Re-usable async client (created lazily, one per process) +_client: httpx.AsyncClient | None = None + +# Default request timeout (seconds) +_DEFAULT_TIMEOUT = 3.0 + +# Header sent on every data request so providers can gate access. +DATA_HEADER = "X-Internal-Data" + + +class DataError(Exception): + """Raised when an internal data fetch fails.""" + + def __init__(self, message: str, status_code: int = 500): + super().__init__(message) + self.status_code = status_code + + +def _get_client() -> httpx.AsyncClient: + global _client + if _client is None or _client.is_closed: + _client = httpx.AsyncClient( + timeout=httpx.Timeout(_DEFAULT_TIMEOUT), + follow_redirects=False, + ) + return _client + + +def _internal_url(app_name: str) -> str: + """Resolve the Docker-internal base URL for *app_name*.""" + env_key = f"INTERNAL_URL_{app_name.upper()}" + return os.getenv(env_key, f"http://{app_name}:8000").rstrip("/") + + +async def fetch_data( + app_name: str, + query_name: str, + *, + params: dict | None = None, + timeout: float = _DEFAULT_TIMEOUT, + required: bool = True, +) -> dict | list | None: + """GET JSON from ``{INTERNAL_URL_APP}/internal/data/{query_name}``. + + Returns parsed JSON (dict or list) on success. + When *required* is True (default), raises ``DataError`` on failure. + When *required* is False, returns None on failure. + """ + base = _internal_url(app_name) + url = f"{base}/internal/data/{query_name}" + try: + resp = await _get_client().get( + url, + params=params, + headers={DATA_HEADER: "1"}, + timeout=timeout, + ) + if resp.status_code == 200: + return resp.json() + msg = f"Data {app_name}/{query_name} returned {resp.status_code}" + if required: + log.error(msg) + raise DataError(msg, status_code=resp.status_code) + log.warning(msg) + return None + except DataError: + raise + except Exception as exc: + msg = f"Data {app_name}/{query_name} failed: {exc}" + if required: + log.error(msg) + raise DataError(msg) from exc + log.warning(msg) + return None diff --git a/shared/services/cart_impl.py b/shared/services/cart_impl.py index 1438bfa..5ea6a72 100644 --- a/shared/services/cart_impl.py +++ b/shared/services/cart_impl.py @@ -13,7 +13,6 @@ from sqlalchemy.orm import selectinload from shared.models.market import CartItem from shared.models.market_place import MarketPlace -from shared.models.calendars import CalendarEntry, Calendar from shared.contracts.dtos import CartItemDTO, CartSummaryDTO @@ -39,13 +38,16 @@ class SqlCartService: page_slug: str | None = None, ) -> CartSummaryDTO: """Build a lightweight cart summary for the current identity.""" - # Resolve page filter + from shared.infrastructure.data_client import fetch_data + from shared.contracts.dtos import CalendarEntryDTO, TicketDTO, dto_from_dict + + # Resolve page filter via blog data endpoint page_post_id: int | None = None if page_slug: - from shared.services.registry import services - post = await services.blog.get_post_by_slug(session, page_slug) - if post and post.is_page: - page_post_id = post.id + post = await fetch_data("blog", "post-by-slug", + params={"slug": page_slug}, required=False) + if post and post.get("is_page"): + page_post_id = post["id"] # --- product cart --- cart_q = select(CartItem).where(CartItem.deleted_at.is_(None)) @@ -75,37 +77,34 @@ class SqlCartService: if ci.product and (ci.product.special_price or ci.product.regular_price) ) - # --- calendar entries --- - from shared.services.registry import services + # --- calendar entries via events data endpoint --- + cal_params: dict = {} + if user_id is not None: + cal_params["user_id"] = user_id + if session_id is not None: + cal_params["session_id"] = session_id + if page_post_id is not None: - cal_entries = await services.calendar.entries_for_page( - session, page_post_id, - user_id=user_id, - session_id=session_id, - ) + cal_params["page_id"] = page_post_id + raw_entries = await fetch_data("events", "entries-for-page", + params=cal_params, required=False) or [] else: - cal_entries = await services.calendar.pending_entries( - session, - user_id=user_id, - session_id=session_id, - ) + raw_entries = await fetch_data("events", "pending-entries", + params=cal_params, required=False) or [] + cal_entries = [dto_from_dict(CalendarEntryDTO, e) for e in raw_entries] calendar_count = len(cal_entries) calendar_total = sum(Decimal(str(e.cost or 0)) for e in cal_entries if e.cost is not None) - # --- tickets --- + # --- tickets via events data endpoint --- if page_post_id is not None: - tickets = await services.calendar.tickets_for_page( - session, page_post_id, - user_id=user_id, - session_id=session_id, - ) + raw_tickets = await fetch_data("events", "tickets-for-page", + params=cal_params, required=False) or [] else: - tickets = await services.calendar.pending_tickets( - session, - user_id=user_id, - session_id=session_id, - ) + tk_params = {k: v for k, v in cal_params.items() if k != "page_id"} + raw_tickets = await fetch_data("events", "pending-tickets", + params=tk_params, required=False) or [] + tickets = [dto_from_dict(TicketDTO, t) for t in raw_tickets] ticket_count = len(tickets) ticket_total = sum(Decimal(str(t.price or 0)) for t in tickets) diff --git a/shared/services/registry.py b/shared/services/registry.py index 23d559b..f28c538 100644 --- a/shared/services/registry.py +++ b/shared/services/registry.py @@ -1,18 +1,17 @@ """Typed singleton registry for domain services. +Each app registers ONLY its own domain service. Cross-app calls go +over HTTP via ``call_action()`` (writes) and ``fetch_data()`` (reads). + Usage:: from shared.services.registry import services - # Register at app startup + # Register at app startup (own domain only) services.blog = SqlBlogService() - # Query anywhere - if services.has("calendar"): - entries = await services.calendar.pending_entries(session, ...) - - # Or use stubs for absent domains - summary = await services.cart.cart_summary(session, ...) + # Use locally within the owning app + post = await services.blog.get_post_by_slug(session, slug) """ from __future__ import annotations diff --git a/shared/services/stubs.py b/shared/services/stubs.py index eb46cca..748423c 100644 --- a/shared/services/stubs.py +++ b/shared/services/stubs.py @@ -1,204 +1,15 @@ -"""No-op stub services for absent domains. +"""No-op stub services. -When an app starts without a particular domain, it registers the stub -so that ``services.X.method()`` returns empty/None rather than crashing. +Cross-app calls now go over HTTP via call_action() / fetch_data(). +Stubs are no longer needed for the 4 main domains (blog, calendar, +market, cart). Only StubFederationService remains as a safety net +for apps that conditionally load AP infrastructure. """ from __future__ import annotations -from decimal import Decimal - -from sqlalchemy.ext.asyncio import AsyncSession - -from shared.contracts.dtos import ( - PostDTO, - CalendarDTO, - CalendarEntryDTO, - TicketDTO, - MarketPlaceDTO, - ProductDTO, - CartItemDTO, - CartSummaryDTO, - ActorProfileDTO, - APActivityDTO, - APFollowerDTO, -) - - -class StubBlogService: - async def get_post_by_slug(self, session: AsyncSession, slug: str) -> PostDTO | None: - return None - - async def get_post_by_id(self, session: AsyncSession, id: int) -> PostDTO | None: - return None - - async def get_posts_by_ids(self, session: AsyncSession, ids: list[int]) -> list[PostDTO]: - return [] - - async def search_posts(self, session, query, page=1, per_page=10): - return [], 0 - - -class StubCalendarService: - async def calendars_for_container( - self, session: AsyncSession, container_type: str, container_id: int, - ) -> list[CalendarDTO]: - return [] - - async def pending_entries( - self, session: AsyncSession, *, user_id: int | None, session_id: str | None, - ) -> list[CalendarEntryDTO]: - return [] - - async def entries_for_page( - self, session: AsyncSession, page_id: int, *, user_id: int | None, session_id: str | None, - ) -> list[CalendarEntryDTO]: - return [] - - async def entry_by_id(self, session: AsyncSession, entry_id: int) -> CalendarEntryDTO | None: - return None - - async def associated_entries( - self, session: AsyncSession, content_type: str, content_id: int, page: int, - ) -> tuple[list[CalendarEntryDTO], bool]: - return [], False - - async def toggle_entry_post( - self, session: AsyncSession, entry_id: int, content_type: str, content_id: int, - ) -> bool: - return False - - async def adopt_entries_for_user( - self, session: AsyncSession, user_id: int, session_id: str, - ) -> None: - pass - - async def claim_entries_for_order( - self, session: AsyncSession, order_id: int, user_id: int | None, - session_id: str | None, page_post_id: int | None, - ) -> None: - pass - - async def confirm_entries_for_order( - self, session: AsyncSession, order_id: int, user_id: int | None, - session_id: str | None, - ) -> None: - pass - - async def get_entries_for_order( - self, session: AsyncSession, order_id: int, - ) -> list[CalendarEntryDTO]: - return [] - - async def user_tickets( - self, session: AsyncSession, *, user_id: int, - ) -> list[TicketDTO]: - return [] - - async def user_bookings( - self, session: AsyncSession, *, user_id: int, - ) -> list[CalendarEntryDTO]: - return [] - - async def confirmed_entries_for_posts( - self, session: AsyncSession, post_ids: list[int], - ) -> dict[int, list[CalendarEntryDTO]]: - return {} - - async def pending_tickets( - self, session: AsyncSession, *, user_id: int | None, session_id: str | None, - ) -> list[TicketDTO]: - return [] - - async def tickets_for_page( - self, session: AsyncSession, page_id: int, *, user_id: int | None, session_id: str | None, - ) -> list[TicketDTO]: - return [] - - async def claim_tickets_for_order( - self, session: AsyncSession, order_id: int, user_id: int | None, - session_id: str | None, page_post_id: int | None, - ) -> None: - pass - - async def confirm_tickets_for_order( - self, session: AsyncSession, order_id: int, - ) -> None: - pass - - async def get_tickets_for_order( - self, session: AsyncSession, order_id: int, - ) -> list[TicketDTO]: - return [] - - async def adopt_tickets_for_user( - self, session: AsyncSession, user_id: int, session_id: str, - ) -> None: - pass - - async def adjust_ticket_quantity( - self, session, entry_id, count, *, user_id, session_id, ticket_type_id=None, - ) -> int: - return 0 - - async def upcoming_entries_for_container(self, session, container_type, container_id, *, page=1, per_page=20): - return [], False - - async def entry_ids_for_content(self, session, content_type, content_id): - return set() - - async def visible_entries_for_period(self, session, calendar_id, period_start, period_end, *, user_id, is_admin, session_id): - return [] - - -class StubMarketService: - async def marketplaces_for_container( - self, session: AsyncSession, container_type: str, container_id: int, - ) -> list[MarketPlaceDTO]: - return [] - - async def list_marketplaces( - self, session: AsyncSession, - container_type: str | None = None, container_id: int | None = None, - *, page: int = 1, per_page: int = 20, - ) -> tuple[list[MarketPlaceDTO], bool]: - return [], False - - async def product_by_id(self, session: AsyncSession, product_id: int) -> ProductDTO | None: - return None - - async def create_marketplace( - self, session: AsyncSession, container_type: str, container_id: int, - name: str, slug: str, - ) -> MarketPlaceDTO: - raise RuntimeError("MarketService not available") - - async def soft_delete_marketplace( - self, session: AsyncSession, container_type: str, container_id: int, - slug: str, - ) -> bool: - return False - - -class StubCartService: - async def cart_summary( - self, session: AsyncSession, *, user_id: int | None, session_id: str | None, - page_slug: str | None = None, - ) -> CartSummaryDTO: - return CartSummaryDTO() - - async def cart_items( - self, session: AsyncSession, *, user_id: int | None, session_id: str | None, - ) -> list[CartItemDTO]: - return [] - - async def adopt_cart_for_user( - self, session: AsyncSession, user_id: int, session_id: str, - ) -> None: - pass - class StubFederationService: - """No-op federation stub for apps that don't own federation.""" + """No-op federation stub for apps that don't load AP.""" async def get_actor_by_username(self, session, username): return None @@ -206,109 +17,16 @@ class StubFederationService: async def get_actor_by_user_id(self, session, user_id): return None - async def create_actor(self, session, user_id, preferred_username, - display_name=None, summary=None): - raise RuntimeError("FederationService not available") - - async def username_available(self, session, username): - return False - async def publish_activity(self, session, *, actor_user_id, activity_type, object_type, object_data, source_type=None, source_id=None): return None - async def get_activity(self, session, activity_id): - return None - - async def get_outbox(self, session, username, page=1, per_page=20, origin_app=None): - return [], 0 - async def get_activity_for_source(self, session, source_type, source_id): return None async def count_activities_for_source(self, session, source_type, source_id, *, activity_type): return 0 - async def get_followers(self, session, username, app_domain=None): - return [] - - async def add_follower(self, session, username, follower_acct, follower_inbox, - follower_actor_url, follower_public_key=None, - app_domain="federation"): - raise RuntimeError("FederationService not available") - - async def remove_follower(self, session, username, follower_acct, app_domain="federation"): - return False - - async def get_or_fetch_remote_actor(self, session, actor_url): - return None - - async def search_remote_actor(self, session, acct): - return None - - async def search_actors(self, session, query, page=1, limit=20): - return [], 0 - - async def send_follow(self, session, local_username, remote_actor_url): - raise RuntimeError("FederationService not available") - - async def get_following(self, session, username, page=1, per_page=20): - return [], 0 - - async def get_followers_paginated(self, session, username, page=1, per_page=20): - return [], 0 - - async def accept_follow_response(self, session, local_username, remote_actor_url): - pass - - async def unfollow(self, session, local_username, remote_actor_url): - pass - - async def ingest_remote_post(self, session, remote_actor_id, activity_json, object_json): - pass - - async def delete_remote_post(self, session, object_id): - pass - - async def get_remote_post(self, session, object_id): - return None - - async def get_home_timeline(self, session, actor_profile_id, before=None, limit=20): - return [] - - async def get_public_timeline(self, session, before=None, limit=20): - return [] - - async def get_actor_timeline(self, session, remote_actor_id, before=None, limit=20): - return [] - - async def create_local_post(self, session, actor_profile_id, content, visibility="public", in_reply_to=None): - raise RuntimeError("FederationService not available") - - async def delete_local_post(self, session, actor_profile_id, post_id): - raise RuntimeError("FederationService not available") - - async def like_post(self, session, actor_profile_id, object_id, author_inbox): - pass - - async def unlike_post(self, session, actor_profile_id, object_id, author_inbox): - pass - - async def boost_post(self, session, actor_profile_id, object_id, author_inbox): - pass - - async def unboost_post(self, session, actor_profile_id, object_id, author_inbox): - pass - - async def get_notifications(self, session, actor_profile_id, before=None, limit=20): - return [] - - async def unread_notification_count(self, session, actor_profile_id): - return 0 - - async def mark_notifications_read(self, session, actor_profile_id): - pass - async def get_stats(self, session): return {"actors": 0, "activities": 0, "followers": 0}