Domain isolation: replace cross-domain imports with service calls
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 50s

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 <noreply@anthropic.com>
This commit is contained in:
giles
2026-02-19 04:30:14 +00:00
parent e1f4471002
commit 4155df7e7c
9 changed files with 89 additions and 203 deletions

30
app.py
View File

@@ -25,30 +25,36 @@ async def coop_context() -> dict:
Coop app context processor. Coop app context processor.
- menu_items: direct DB query via glue layer - 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 shared.infrastructure.context import base_context
from glue.services.navigation import get_navigation_tree from shared.services.navigation import get_navigation_tree
from shared.infrastructure.internal_api import get as api_get from shared.services.registry import services
from shared.infrastructure.cart_identity import current_cart_identity
ctx = await base_context() ctx = await base_context()
ctx["menu_items"] = await get_navigation_tree(g.s) ctx["menu_items"] = await get_navigation_tree(g.s)
# Cart data from cart app API (includes both product + calendar counts) # Cart data via service (replaces cross-app HTTP API)
cart_data = await api_get("cart", "/internal/cart/summary", forward_session=True) ident = current_cart_identity()
if cart_data: summary = await services.cart.cart_summary(
ctx["cart_count"] = cart_data.get("count", 0) + cart_data.get("calendar_count", 0) g.s, user_id=ident["user_id"], session_id=ident["session_id"],
ctx["cart_total"] = cart_data.get("total", 0) + cart_data.get("calendar_total", 0) )
else: ctx["cart_count"] = summary.count + summary.calendar_count
ctx["cart_count"] = 0 ctx["cart_total"] = float(summary.total + summary.calendar_total)
ctx["cart_total"] = 0
return ctx return ctx
def create_app() -> "Quart": 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-specific templates override shared templates
app_templates = str(Path(__file__).resolve().parent / "templates") app_templates = str(Path(__file__).resolve().parent / "templates")

View File

@@ -1,7 +1,7 @@
from ..ghost_db import DBClient # adjust import path from ..ghost_db import DBClient # adjust import path
from sqlalchemy import select from sqlalchemy import select
from models.ghost_content import PostLike from models.ghost_content import PostLike
from shared.models.calendars import CalendarEntry, CalendarEntryPost from shared.services.registry import services
from quart import g from quart import g
async def posts_data( async def posts_data(
@@ -85,29 +85,8 @@ async def posts_data(
for post in posts: for post in posts:
post["is_liked"] = False post["is_liked"] = False
# Fetch associated entries for each post # Fetch associated entries for each post via calendar service
# Get all confirmed entries associated with these posts entries_by_post = await services.calendar.confirmed_entries_for_posts(session, post_ids)
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)
# Add associated_entries to each post # Add associated_entries to each post
for post in posts: for post in posts:

View File

@@ -2,9 +2,9 @@ from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func 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 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): class MenuItemError(ValueError):

View File

@@ -82,7 +82,7 @@ def register():
pc = PageConfig(container_type="page", container_id=post_id, features={}) pc = PageConfig(container_type="page", container_id=post_id, features={})
g.s.add(pc) g.s.add(pc)
await g.s.flush() 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) await attach_child(g.s, "page", post_id, "page_config", pc.id)
# Parse request body # Parse request body
@@ -146,7 +146,7 @@ def register():
pc = PageConfig(container_type="page", container_id=post_id, features={}) pc = PageConfig(container_type="page", container_id=post_id, features={})
g.s.add(pc) g.s.add(pc)
await g.s.flush() 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) await attach_child(g.s, "page", post_id, "page_config", pc.id)
form = await request.form form = await request.form
@@ -603,21 +603,14 @@ def register():
@require_admin @require_admin
async def markets(slug: str): async def markets(slug: str):
"""List markets for this page.""" """List markets for this page."""
from shared.models.market_place import MarketPlace from shared.services.registry import services
from sqlalchemy import select as sa_select
post = (g.post_data or {}).get("post", {}) post = (g.post_data or {}).get("post", {})
post_id = post.get("id") post_id = post.get("id")
if not post_id: if not post_id:
return await make_response("Post not found", 404) return await make_response("Post not found", 404)
page_markets = (await g.s.execute( page_markets = await services.market.marketplaces_for_container(g.s, "page", post_id)
sa_select(MarketPlace).where(
MarketPlace.container_type == "page",
MarketPlace.container_id == post_id,
MarketPlace.deleted_at.is_(None),
).order_by(MarketPlace.name)
)).scalars().all()
html = await render_template( html = await render_template(
"_types/post/admin/_markets_panel.html", "_types/post/admin/_markets_panel.html",
@@ -631,8 +624,7 @@ def register():
async def create_market(slug: str): async def create_market(slug: str):
"""Create a new market for this page.""" """Create a new market for this page."""
from ..services.markets import create_market as _create_market, MarketError from ..services.markets import create_market as _create_market, MarketError
from shared.models.market_place import MarketPlace from shared.services.registry import services
from sqlalchemy import select as sa_select
from quart import jsonify from quart import jsonify
post = (g.post_data or {}).get("post", {}) post = (g.post_data or {}).get("post", {})
@@ -649,13 +641,7 @@ def register():
return jsonify({"error": str(e)}), 400 return jsonify({"error": str(e)}), 400
# Return updated markets list # Return updated markets list
page_markets = (await g.s.execute( page_markets = await services.market.marketplaces_for_container(g.s, "page", post_id)
sa_select(MarketPlace).where(
MarketPlace.container_type == "page",
MarketPlace.container_id == post_id,
MarketPlace.deleted_at.is_(None),
).order_by(MarketPlace.name)
)).scalars().all()
html = await render_template( html = await render_template(
"_types/post/admin/_markets_panel.html", "_types/post/admin/_markets_panel.html",
@@ -669,8 +655,7 @@ def register():
async def delete_market(slug: str, market_slug: str): async def delete_market(slug: str, market_slug: str):
"""Soft-delete a market.""" """Soft-delete a market."""
from ..services.markets import soft_delete_market from ..services.markets import soft_delete_market
from shared.models.market_place import MarketPlace from shared.services.registry import services
from sqlalchemy import select as sa_select
from quart import jsonify from quart import jsonify
post = (g.post_data or {}).get("post", {}) post = (g.post_data or {}).get("post", {})
@@ -681,13 +666,7 @@ def register():
return jsonify({"error": "Market not found"}), 404 return jsonify({"error": "Market not found"}), 404
# Return updated markets list # Return updated markets list
page_markets = (await g.s.execute( page_markets = await services.market.marketplaces_for_container(g.s, "page", post_id)
sa_select(MarketPlace).where(
MarketPlace.container_type == "page",
MarketPlace.container_id == post_id,
MarketPlace.deleted_at.is_(None),
).order_by(MarketPlace.name)
)).scalars().all()
html = await render_template( html = await render_template(
"_types/post/admin/_markets_panel.html", "_types/post/admin/_markets_panel.html",

View File

@@ -11,9 +11,7 @@ from quart import (
) )
from .services.post_data import post_data from .services.post_data import post_data
from .services.post_operations import toggle_post_like from .services.post_operations import toggle_post_like
from shared.models.calendars import Calendar from shared.services.registry import services
from shared.models.market_place import MarketPlace
from sqlalchemy import select
from shared.browser.app.redis_cacher import cache_page, clear_cache from shared.browser.app.redis_cacher import cache_page, clear_cache
@@ -65,24 +63,11 @@ def register():
p_data = getattr(g, "post_data", None) p_data = getattr(g, "post_data", None)
if p_data: if p_data:
from .services.entry_associations import get_associated_entries 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 db_post_id = (g.post_data.get("post") or {}).get("id") # <-- integer
calendars = ( calendars = await services.calendar.calendars_for_container(g.s, "page", db_post_id)
await g.s.execute( markets = await services.market.marketplaces_for_container(g.s, "page", db_post_id)
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()
# Fetch associated entries for nav display # Fetch associated entries for nav display
associated_entries = await get_associated_entries(g.s, db_post_id) associated_entries = await get_associated_entries(g.s, db_post_id)
@@ -95,20 +80,16 @@ def register():
"associated_entries": associated_entries, "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 {} post_dict = p_data.get("post") or {}
if post_dict.get("is_page"): if post_dict.get("is_page"):
page_cart = await api_get( ident = current_cart_identity()
"cart", page_summary = await services.cart.cart_summary(
f"/internal/cart/summary?page_slug={post_dict['slug']}", g.s, user_id=ident["user_id"], session_id=ident["session_id"],
forward_session=True, page_slug=post_dict["slug"],
) )
if page_cart: ctx["page_cart_count"] = page_summary.count + page_summary.calendar_count
ctx["page_cart_count"] = page_cart.get("count", 0) + page_cart.get("calendar_count", 0) ctx["page_cart_total"] = float(page_summary.total + page_summary.calendar_total)
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
return ctx return ctx
else: else:

View File

@@ -1,11 +1,8 @@
from __future__ import annotations from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.sql import func
from shared.models.calendars import CalendarEntry, CalendarEntryPost, Calendar from shared.services.registry import services
from models.ghost_content import Post
async def toggle_entry_association( async def toggle_entry_association(
@@ -17,45 +14,14 @@ async def toggle_entry_association(
Toggle association between a post and calendar entry. Toggle association between a post and calendar entry.
Returns (is_now_associated, error_message). Returns (is_now_associated, error_message).
""" """
# Check if entry exists (don't filter by deleted_at - allow associating with any entry) post = await services.blog.get_post_by_id(session, post_id)
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)
)
if not post: if not post:
return False, "Post not found" return False, "Post not found"
# Check if association already exists is_associated = await services.calendar.toggle_entry_post(
existing = await session.scalar( session, entry_id, "post", post_id,
select(CalendarEntryPost).where(
CalendarEntryPost.entry_id == entry_id,
CalendarEntryPost.content_type == "post",
CalendarEntryPost.content_id == post_id,
CalendarEntryPost.deleted_at.is_(None)
)
) )
return is_associated, None
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
async def get_post_entry_ids( async def get_post_entry_ids(
@@ -66,15 +32,7 @@ async def get_post_entry_ids(
Get all entry IDs associated with this post. Get all entry IDs associated with this post.
Returns a set of entry IDs. Returns a set of entry IDs.
""" """
result = await session.execute( return await services.calendar.entry_ids_for_content(session, "post", post_id)
select(CalendarEntryPost.entry_id)
.where(
CalendarEntryPost.content_type == "post",
CalendarEntryPost.content_id == post_id,
CalendarEntryPost.deleted_at.is_(None)
)
)
return set(result.scalars().all())
async def get_associated_entries( async def get_associated_entries(
@@ -85,59 +43,14 @@ async def get_associated_entries(
) -> dict: ) -> dict:
""" """
Get paginated associated entries for this post. 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 entries, has_more = await services.calendar.associated_entries(
entry_ids_result = await session.execute( session, "post", post_id, page,
select(CalendarEntryPost.entry_id)
.where(
CalendarEntryPost.content_type == "post",
CalendarEntryPost.content_id == post_id,
CalendarEntryPost.deleted_at.is_(None)
)
) )
entry_ids = set(entry_ids_result.scalars().all()) total_count = len(entries) + (page - 1) * per_page
if has_more:
if not entry_ids: total_count += 1 # at least one more
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
return { return {
"entries": entries, "entries": entries,

View File

@@ -7,10 +7,10 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from shared.models.market_place import MarketPlace from shared.models.market_place import MarketPlace
from models.ghost_content import Post
from shared.models.page_config import PageConfig from shared.models.page_config import PageConfig
from shared.browser.app.utils import utcnow 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): 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.") raise MarketError("Market name must not be empty.")
slug = slugify(name) 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: if not post:
raise MarketError(f"Post {post_id} does not exist.") 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: 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 = ( market = (
await sess.execute( await sess.execute(
select(MarketPlace) select(MarketPlace)
.join(Post, MarketPlace.container_id == Post.id)
.where(MarketPlace.container_type == "page")
.where( .where(
Post.slug == post_slug, MarketPlace.container_type == "page",
MarketPlace.container_id == post.id,
MarketPlace.slug == market_slug, MarketPlace.slug == market_slug,
MarketPlace.deleted_at.is_(None), MarketPlace.deleted_at.is_(None),
) )

25
services/__init__.py Normal file
View File

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

2
shared

Submodule shared updated: ea7dc9723a...70b1c7de10