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

Replace direct Post, MarketPlace, Calendar model queries and HTTP API
calls with typed service calls. Events registers all 4 services via
domain_services_fn with has() guards.

Key changes:
- app.py: use domain_services_fn, Post/Calendar/MarketPlace queries
  → services.blog/calendar/market, HTTP cart API → services.cart
- calendars/markets services: Post → services.blog
- post_associations: Post → services.blog, direct queries → services
- markets routes: remove unused MarketPlace import
- glue imports → shared imports throughout

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
giles
2026-02-19 04:30:20 +00:00
parent 7b15f37686
commit f1b5aeac53
7 changed files with 82 additions and 89 deletions

55
app.py
View File

@@ -5,7 +5,6 @@ from pathlib import Path
from quart import g, abort from quart import g, abort
from jinja2 import FileSystemLoader, ChoiceLoader from jinja2 import FileSystemLoader, ChoiceLoader
from sqlalchemy import select
from shared.infrastructure.factory import create_base_app from shared.infrastructure.factory import create_base_app
@@ -17,34 +16,36 @@ async def events_context() -> dict:
Events app context processor. Events 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 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":
from shared.models.ghost_content import Post from services import register_domain_services
from models.calendars import Calendar
from shared.models.market_place import MarketPlace
app = create_base_app("events", context_fn=events_context) app = create_base_app(
"events",
context_fn=events_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")
@@ -90,11 +91,7 @@ def create_app() -> "Quart":
slug = getattr(g, "post_slug", None) slug = getattr(g, "post_slug", None)
if not slug: if not slug:
return return
post = ( post = await services.blog.get_post_by_slug(g.s, slug)
await g.s.execute(
select(Post).where(Post.slug == slug)
)
).scalar_one_or_none()
if not post: if not post:
abort(404) abort(404)
g.post_data = { g.post_data = {
@@ -114,20 +111,8 @@ def create_app() -> "Quart":
if not post_data: if not post_data:
return {} return {}
post_id = post_data["post"]["id"] post_id = post_data["post"]["id"]
calendars = ( calendars = await services.calendar.calendars_for_container(g.s, "page", post_id)
await g.s.execute( markets = await services.market.marketplaces_for_container(g.s, "page", post_id)
select(Calendar)
.where(Calendar.container_type == "page", Calendar.container_id == 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 == post_id, MarketPlace.deleted_at.is_(None))
.order_by(MarketPlace.name.asc())
)
).scalars().all()
return { return {
**post_data, **post_data,
"calendars": calendars, "calendars": calendars,

View File

@@ -5,7 +5,7 @@ from sqlalchemy import select
from sqlalchemy.sql import func from sqlalchemy.sql import func
from models.calendars import CalendarEntry, CalendarEntryPost from models.calendars import CalendarEntry, CalendarEntryPost
from shared.models.ghost_content import Post from shared.services.registry import services
async def add_post_to_entry( async def add_post_to_entry(
@@ -28,9 +28,7 @@ async def add_post_to_entry(
return False, "Calendar entry not found" return False, "Calendar entry not found"
# Check if post exists # Check if post exists
post = await session.scalar( post = await services.blog.get_post_by_id(session, post_id)
select(Post).where(Post.id == post_id)
)
if not post: if not post:
return False, "Post not found" return False, "Post not found"
@@ -91,20 +89,22 @@ async def remove_post_from_entry(
async def get_entry_posts( async def get_entry_posts(
session: AsyncSession, session: AsyncSession,
entry_id: int entry_id: int
) -> list[Post]: ) -> list:
""" """
Get all posts associated with a calendar entry. Get all posts (as PostDTOs) associated with a calendar entry.
""" """
result = await session.execute( result = await session.execute(
select(Post) select(CalendarEntryPost.content_id).where(
.join(CalendarEntryPost, (CalendarEntryPost.content_id == Post.id) & (CalendarEntryPost.content_type == "post"))
.where(
CalendarEntryPost.entry_id == entry_id, CalendarEntryPost.entry_id == entry_id,
CalendarEntryPost.deleted_at.is_(None) CalendarEntryPost.content_type == "post",
CalendarEntryPost.deleted_at.is_(None),
) )
.order_by(Post.title)
) )
return list(result.scalars().all()) post_ids = list(result.scalars().all())
if not post_ids:
return []
posts = await services.blog.get_posts_by_ids(session, post_ids)
return sorted(posts, key=lambda p: (p.title or ""))
async def search_posts( async def search_posts(
@@ -112,29 +112,10 @@ async def search_posts(
query: str, query: str,
page: int = 1, page: int = 1,
per_page: int = 10 per_page: int = 10
) -> tuple[list[Post], int]: ) -> tuple[list, int]:
""" """
Search for posts by title with pagination. Search for posts by title with pagination.
If query is empty, returns all posts in published order. If query is empty, returns all posts in published order.
Returns (posts, total_count). Returns (post_dtos, total_count).
""" """
# Build base query return await services.blog.search_posts(session, query, page, per_page)
if query:
# Search by title
count_stmt = select(func.count(Post.id)).where(Post.title.ilike(f"%{query}%"))
posts_stmt = select(Post).where(Post.title.ilike(f"%{query}%")).order_by(Post.title)
else:
# All posts in published order (newest first)
count_stmt = select(func.count(Post.id))
posts_stmt = select(Post).order_by(Post.published_at.desc().nullslast())
# Count total
count_result = await session.execute(count_stmt)
total = count_result.scalar() or 0
# Get paginated results
offset = (page - 1) * per_page
result = await session.execute(
posts_stmt.limit(per_page).offset(offset)
)
return list(result.scalars().all()), total

View File

@@ -4,8 +4,8 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from models.calendars import Calendar from models.calendars import Calendar
from shared.models.ghost_content import Post # for FK existence checks from shared.services.registry import services
from glue.services.relationships import attach_child, detach_child from shared.services.relationships import attach_child, detach_child
import unicodedata import unicodedata
import re import re
@@ -49,13 +49,15 @@ def slugify(value: str, max_len: int = 255) -> str:
async def soft_delete(sess: AsyncSession, post_slug: str, calendar_slug: str) -> bool: async def soft_delete(sess: AsyncSession, post_slug: str, calendar_slug: str) -> bool:
post = await services.blog.get_post_by_slug(sess, post_slug)
if not post:
return False
cal = ( cal = (
await sess.execute( await sess.execute(
select(Calendar) select(Calendar).where(
.join(Post, Calendar.container_id == Post.id) Calendar.container_type == "page",
.where(Calendar.container_type == "page") Calendar.container_id == post.id,
.where(
Post.slug == post_slug,
Calendar.slug == calendar_slug, Calendar.slug == calendar_slug,
Calendar.deleted_at.is_(None), Calendar.deleted_at.is_(None),
) )
@@ -82,7 +84,7 @@ async def create_calendar(sess: AsyncSession, post_id: int, name: str) -> Calend
slug=slugify(name) slug=slugify(name)
# Ensure post exists (avoid silent FK errors in some DBs) # Ensure post exists (avoid silent FK errors in some DBs)
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 CalendarError(f"Post {post_id} does not exist.") raise CalendarError(f"Post {post_id} does not exist.")

View File

@@ -3,9 +3,6 @@ from __future__ import annotations
from quart import ( from quart import (
request, render_template, make_response, Blueprint, g request, render_template, make_response, Blueprint, g
) )
from sqlalchemy import select
from shared.models.market_place import MarketPlace
from .services.markets import ( from .services.markets import (
create_market as svc_create_market, create_market as svc_create_market,

View File

@@ -7,9 +7,9 @@ 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 shared.models.ghost_content import Post
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):
@@ -40,7 +40,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.")
if not post.is_page: if not post.is_page:
@@ -68,13 +68,15 @@ async def create_market(sess: AsyncSession, post_id: int, name: str) -> MarketPl
async def soft_delete(sess: AsyncSession, post_slug: str, market_slug: str) -> bool: async def soft_delete(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).where(
.join(Post, MarketPlace.container_id == Post.id) MarketPlace.container_type == "page",
.where(MarketPlace.container_type == "page") MarketPlace.container_id == post.id,
.where(
Post.slug == post_slug,
MarketPlace.slug == market_slug, MarketPlace.slug == market_slug,
MarketPlace.deleted_at.is_(None), MarketPlace.deleted_at.is_(None),
) )

26
services/__init__.py Normal file
View File

@@ -0,0 +1,26 @@
"""Events app service registration."""
from __future__ import annotations
def register_domain_services() -> None:
"""Register services for the events app.
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.
"""
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()

2
shared

Submodule shared updated: ea7dc9723a...70b1c7de10