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.
- 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")

View File

@@ -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:

View File

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

View File

@@ -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",

View File

@@ -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:

View File

@@ -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,

View File

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

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