From 4155df7e7cad2888a6fb20de678ff3b53b8e73eb Mon Sep 17 00:00:00 2001 From: giles Date: Thu, 19 Feb 2026 04:30:14 +0000 Subject: [PATCH] Domain isolation: replace cross-domain imports with service calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace direct Calendar, MarketPlace, and Post model queries with typed service calls (services.blog, services.calendar, services.market, services.cart). Blog registers all 4 services via domain_services_fn with has() guards for composable deployment. Key changes: - app.py: use domain_services_fn instead of inline service registration - admin routes: MarketPlace queries → services.market.marketplaces_for_container() - entry_associations: CalendarEntryPost → services.calendar.entry_ids_for_content() - markets service: Post query → services.blog.get_post_by_id/slug() - posts_data, post routes: use calendar/market/cart services - menu_items: glue imports → shared imports Co-Authored-By: Claude Opus 4.6 --- app.py | 30 ++++--- bp/blog/services/posts_data.py | 27 +----- bp/menu_items/services/menu_items.py | 4 +- bp/post/admin/routes.py | 37 ++------- bp/post/routes.py | 41 +++------ bp/post/services/entry_associations.py | 111 +++---------------------- bp/post/services/markets.py | 15 ++-- services/__init__.py | 25 ++++++ shared | 2 +- 9 files changed, 89 insertions(+), 203 deletions(-) create mode 100644 services/__init__.py diff --git a/app.py b/app.py index 28c13e5..e29e494 100644 --- a/app.py +++ b/app.py @@ -25,30 +25,36 @@ async def coop_context() -> dict: Coop app context processor. - menu_items: direct DB query via glue layer - - cart_count/cart_total: fetched from cart internal API + - cart_count/cart_total: via cart service (shared DB) """ from shared.infrastructure.context import base_context - from glue.services.navigation import get_navigation_tree - from shared.infrastructure.internal_api import get as api_get + from shared.services.navigation import get_navigation_tree + from shared.services.registry import services + from shared.infrastructure.cart_identity import current_cart_identity ctx = await base_context() ctx["menu_items"] = await get_navigation_tree(g.s) - # Cart data from cart app API (includes both product + calendar counts) - cart_data = await api_get("cart", "/internal/cart/summary", forward_session=True) - if cart_data: - ctx["cart_count"] = cart_data.get("count", 0) + cart_data.get("calendar_count", 0) - ctx["cart_total"] = cart_data.get("total", 0) + cart_data.get("calendar_total", 0) - else: - ctx["cart_count"] = 0 - ctx["cart_total"] = 0 + # Cart data via service (replaces cross-app HTTP API) + ident = current_cart_identity() + summary = await services.cart.cart_summary( + g.s, user_id=ident["user_id"], session_id=ident["session_id"], + ) + ctx["cart_count"] = summary.count + summary.calendar_count + ctx["cart_total"] = float(summary.total + summary.calendar_total) return ctx def create_app() -> "Quart": - app = create_base_app("coop", context_fn=coop_context) + from services import register_domain_services + + app = create_base_app( + "coop", + context_fn=coop_context, + domain_services_fn=register_domain_services, + ) # App-specific templates override shared templates app_templates = str(Path(__file__).resolve().parent / "templates") diff --git a/bp/blog/services/posts_data.py b/bp/blog/services/posts_data.py index 411b3cf..3214f18 100644 --- a/bp/blog/services/posts_data.py +++ b/bp/blog/services/posts_data.py @@ -1,7 +1,7 @@ from ..ghost_db import DBClient # adjust import path from sqlalchemy import select from models.ghost_content import PostLike -from shared.models.calendars import CalendarEntry, CalendarEntryPost +from shared.services.registry import services from quart import g async def posts_data( @@ -85,29 +85,8 @@ async def posts_data( for post in posts: post["is_liked"] = False - # Fetch associated entries for each post - # Get all confirmed entries associated with these posts - from sqlalchemy.orm import selectinload - entries_result = await session.execute( - select(CalendarEntry, CalendarEntryPost.content_id) - .join(CalendarEntryPost, CalendarEntry.id == CalendarEntryPost.entry_id) - .options(selectinload(CalendarEntry.calendar)) # Eagerly load calendar - .where( - CalendarEntryPost.content_type == "post", - CalendarEntryPost.content_id.in_(post_ids), - CalendarEntryPost.deleted_at.is_(None), - CalendarEntry.deleted_at.is_(None), - CalendarEntry.state == "confirmed" - ) - .order_by(CalendarEntry.start_at.asc()) - ) - - # Group entries by post_id - entries_by_post = {} - for entry, post_id in entries_result: - if post_id not in entries_by_post: - entries_by_post[post_id] = [] - entries_by_post[post_id].append(entry) + # Fetch associated entries for each post via calendar service + entries_by_post = await services.calendar.confirmed_entries_for_posts(session, post_ids) # Add associated_entries to each post for post in posts: diff --git a/bp/menu_items/services/menu_items.py b/bp/menu_items/services/menu_items.py index 60dfbe2..d79c89f 100644 --- a/bp/menu_items/services/menu_items.py +++ b/bp/menu_items/services/menu_items.py @@ -2,9 +2,9 @@ from __future__ import annotations from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func -from glue.models.menu_node import MenuNode +from shared.models.menu_node import MenuNode from models.ghost_content import Post -from glue.services.relationships import attach_child, detach_child +from shared.services.relationships import attach_child, detach_child class MenuItemError(ValueError): diff --git a/bp/post/admin/routes.py b/bp/post/admin/routes.py index 8007456..362b2f0 100644 --- a/bp/post/admin/routes.py +++ b/bp/post/admin/routes.py @@ -82,7 +82,7 @@ def register(): pc = PageConfig(container_type="page", container_id=post_id, features={}) g.s.add(pc) await g.s.flush() - from glue.services.relationships import attach_child + from shared.services.relationships import attach_child await attach_child(g.s, "page", post_id, "page_config", pc.id) # Parse request body @@ -146,7 +146,7 @@ def register(): pc = PageConfig(container_type="page", container_id=post_id, features={}) g.s.add(pc) await g.s.flush() - from glue.services.relationships import attach_child + from shared.services.relationships import attach_child await attach_child(g.s, "page", post_id, "page_config", pc.id) form = await request.form @@ -603,21 +603,14 @@ def register(): @require_admin async def markets(slug: str): """List markets for this page.""" - from shared.models.market_place import MarketPlace - from sqlalchemy import select as sa_select + 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 g.s.execute( - sa_select(MarketPlace).where( - MarketPlace.container_type == "page", - MarketPlace.container_id == post_id, - MarketPlace.deleted_at.is_(None), - ).order_by(MarketPlace.name) - )).scalars().all() + page_markets = await services.market.marketplaces_for_container(g.s, "page", post_id) html = await render_template( "_types/post/admin/_markets_panel.html", @@ -631,8 +624,7 @@ 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.models.market_place import MarketPlace - from sqlalchemy import select as sa_select + from shared.services.registry import services from quart import jsonify post = (g.post_data or {}).get("post", {}) @@ -649,13 +641,7 @@ def register(): return jsonify({"error": str(e)}), 400 # Return updated markets list - page_markets = (await g.s.execute( - sa_select(MarketPlace).where( - MarketPlace.container_type == "page", - MarketPlace.container_id == post_id, - MarketPlace.deleted_at.is_(None), - ).order_by(MarketPlace.name) - )).scalars().all() + page_markets = await services.market.marketplaces_for_container(g.s, "page", post_id) html = await render_template( "_types/post/admin/_markets_panel.html", @@ -669,8 +655,7 @@ def register(): async def delete_market(slug: str, market_slug: str): """Soft-delete a market.""" from ..services.markets import soft_delete_market - from shared.models.market_place import MarketPlace - from sqlalchemy import select as sa_select + from shared.services.registry import services from quart import jsonify post = (g.post_data or {}).get("post", {}) @@ -681,13 +666,7 @@ def register(): return jsonify({"error": "Market not found"}), 404 # Return updated markets list - page_markets = (await g.s.execute( - sa_select(MarketPlace).where( - MarketPlace.container_type == "page", - MarketPlace.container_id == post_id, - MarketPlace.deleted_at.is_(None), - ).order_by(MarketPlace.name) - )).scalars().all() + page_markets = await services.market.marketplaces_for_container(g.s, "page", post_id) html = await render_template( "_types/post/admin/_markets_panel.html", diff --git a/bp/post/routes.py b/bp/post/routes.py index 9bccc63..7cf725b 100644 --- a/bp/post/routes.py +++ b/bp/post/routes.py @@ -11,9 +11,7 @@ from quart import ( ) from .services.post_data import post_data from .services.post_operations import toggle_post_like -from shared.models.calendars import Calendar -from shared.models.market_place import MarketPlace -from sqlalchemy import select +from shared.services.registry import services from shared.browser.app.redis_cacher import cache_page, clear_cache @@ -65,24 +63,11 @@ def register(): p_data = getattr(g, "post_data", None) if p_data: from .services.entry_associations import get_associated_entries - from shared.infrastructure.internal_api import get as api_get + from shared.infrastructure.cart_identity import current_cart_identity db_post_id = (g.post_data.get("post") or {}).get("id") # <-- integer - calendars = ( - await g.s.execute( - select(Calendar) - .where(Calendar.container_type == "page", Calendar.container_id == db_post_id, Calendar.deleted_at.is_(None)) - .order_by(Calendar.name.asc()) - ) - ).scalars().all() - - markets = ( - await g.s.execute( - select(MarketPlace) - .where(MarketPlace.container_type == "page", MarketPlace.container_id == db_post_id, MarketPlace.deleted_at.is_(None)) - .order_by(MarketPlace.name.asc()) - ) - ).scalars().all() + calendars = await services.calendar.calendars_for_container(g.s, "page", db_post_id) + markets = await services.market.marketplaces_for_container(g.s, "page", db_post_id) # Fetch associated entries for nav display associated_entries = await get_associated_entries(g.s, db_post_id) @@ -95,20 +80,16 @@ def register(): "associated_entries": associated_entries, } - # Page cart badge: fetch page-scoped cart count for pages + # Page cart badge via service (replaces cross-app HTTP API) post_dict = p_data.get("post") or {} if post_dict.get("is_page"): - page_cart = await api_get( - "cart", - f"/internal/cart/summary?page_slug={post_dict['slug']}", - forward_session=True, + 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"], ) - if page_cart: - ctx["page_cart_count"] = page_cart.get("count", 0) + page_cart.get("calendar_count", 0) - ctx["page_cart_total"] = page_cart.get("total", 0) + page_cart.get("calendar_total", 0) - else: - ctx["page_cart_count"] = 0 - ctx["page_cart_total"] = 0 + ctx["page_cart_count"] = page_summary.count + page_summary.calendar_count + ctx["page_cart_total"] = float(page_summary.total + page_summary.calendar_total) return ctx else: diff --git a/bp/post/services/entry_associations.py b/bp/post/services/entry_associations.py index 3603960..5afe195 100644 --- a/bp/post/services/entry_associations.py +++ b/bp/post/services/entry_associations.py @@ -1,11 +1,8 @@ from __future__ import annotations from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select -from sqlalchemy.sql import func -from shared.models.calendars import CalendarEntry, CalendarEntryPost, Calendar -from models.ghost_content import Post +from shared.services.registry import services async def toggle_entry_association( @@ -17,45 +14,14 @@ async def toggle_entry_association( Toggle association between a post and calendar entry. Returns (is_now_associated, error_message). """ - # Check if entry exists (don't filter by deleted_at - allow associating with any entry) - entry = await session.scalar( - select(CalendarEntry).where(CalendarEntry.id == entry_id) - ) - if not entry: - return False, f"Calendar entry {entry_id} not found in database" - - # Check if post exists - post = await session.scalar( - select(Post).where(Post.id == post_id) - ) + post = await services.blog.get_post_by_id(session, post_id) if not post: return False, "Post not found" - # Check if association already exists - existing = await session.scalar( - select(CalendarEntryPost).where( - CalendarEntryPost.entry_id == entry_id, - CalendarEntryPost.content_type == "post", - CalendarEntryPost.content_id == post_id, - CalendarEntryPost.deleted_at.is_(None) - ) + is_associated = await services.calendar.toggle_entry_post( + session, entry_id, "post", post_id, ) - - if existing: - # Remove association (soft delete) - existing.deleted_at = func.now() - await session.flush() - return False, None - else: - # Create association - association = CalendarEntryPost( - entry_id=entry_id, - content_type="post", - content_id=post_id - ) - session.add(association) - await session.flush() - return True, None + return is_associated, None async def get_post_entry_ids( @@ -66,15 +32,7 @@ async def get_post_entry_ids( Get all entry IDs associated with this post. Returns a set of entry IDs. """ - result = await session.execute( - select(CalendarEntryPost.entry_id) - .where( - CalendarEntryPost.content_type == "post", - CalendarEntryPost.content_id == post_id, - CalendarEntryPost.deleted_at.is_(None) - ) - ) - return set(result.scalars().all()) + return await services.calendar.entry_ids_for_content(session, "post", post_id) async def get_associated_entries( @@ -85,59 +43,14 @@ async def get_associated_entries( ) -> dict: """ Get paginated associated entries for this post. - Returns dict with entries, total_count, and has_more. + Returns dict with entries (CalendarEntryDTOs), total_count, and has_more. """ - # Get all associated entry IDs - entry_ids_result = await session.execute( - select(CalendarEntryPost.entry_id) - .where( - CalendarEntryPost.content_type == "post", - CalendarEntryPost.content_id == post_id, - CalendarEntryPost.deleted_at.is_(None) - ) + entries, has_more = await services.calendar.associated_entries( + session, "post", post_id, page, ) - entry_ids = set(entry_ids_result.scalars().all()) - - if not entry_ids: - return { - "entries": [], - "total_count": 0, - "has_more": False, - "page": page, - } - - # Get total count - from sqlalchemy import func - total_count = len(entry_ids) - - # Get paginated entries ordered by start_at desc - # Only include confirmed entries - offset = (page - 1) * per_page - result = await session.execute( - select(CalendarEntry) - .where( - CalendarEntry.id.in_(entry_ids), - CalendarEntry.deleted_at.is_(None), - CalendarEntry.state == "confirmed" # Only confirmed entries in nav - ) - .order_by(CalendarEntry.start_at.desc()) - .limit(per_page) - .offset(offset) - ) - entries = result.scalars().all() - - # Recalculate total_count based on confirmed entries only - total_count = len(entries) + offset # Rough estimate - if len(entries) < per_page: - total_count = offset + len(entries) - - # Load calendar relationship for each entry - for entry in entries: - await session.refresh(entry, ["calendar"]) - if entry.calendar: - await session.refresh(entry.calendar, ["post"]) - - has_more = len(entries) == per_page # More accurate check + total_count = len(entries) + (page - 1) * per_page + if has_more: + total_count += 1 # at least one more return { "entries": entries, diff --git a/bp/post/services/markets.py b/bp/post/services/markets.py index 66628f3..948d986 100644 --- a/bp/post/services/markets.py +++ b/bp/post/services/markets.py @@ -7,10 +7,10 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from shared.models.market_place import MarketPlace -from models.ghost_content import Post from shared.models.page_config import PageConfig from shared.browser.app.utils import utcnow -from glue.services.relationships import attach_child, detach_child +from shared.services.registry import services +from shared.services.relationships import attach_child, detach_child class MarketError(ValueError): @@ -36,7 +36,7 @@ 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 sess.execute(select(Post).where(Post.id == post_id))).scalar_one_or_none() + post = await services.blog.get_post_by_id(sess, post_id) if not post: raise MarketError(f"Post {post_id} does not exist.") @@ -71,13 +71,16 @@ async def create_market(sess: AsyncSession, post_id: int, name: str) -> MarketPl async def soft_delete_market(sess: AsyncSession, post_slug: str, market_slug: str) -> bool: + post = await services.blog.get_post_by_slug(sess, post_slug) + if not post: + return False + market = ( await sess.execute( select(MarketPlace) - .join(Post, MarketPlace.container_id == Post.id) - .where(MarketPlace.container_type == "page") .where( - Post.slug == post_slug, + MarketPlace.container_type == "page", + MarketPlace.container_id == post.id, MarketPlace.slug == market_slug, MarketPlace.deleted_at.is_(None), ) diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..f72f967 --- /dev/null +++ b/services/__init__.py @@ -0,0 +1,25 @@ +"""Blog (coop) app service registration.""" +from __future__ import annotations + + +def register_domain_services() -> None: + """Register services for the blog (coop) 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. + """ + 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() diff --git a/shared b/shared index ea7dc97..70b1c7d 160000 --- a/shared +++ b/shared @@ -1 +1 @@ -Subproject commit ea7dc9723a8f86b82b42ee9631fca096d0ec11bb +Subproject commit 70b1c7de1007f1759a20a5d17eaab83f96d26b72