Domain isolation: replace cross-domain imports with service calls
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 50s
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:
30
app.py
30
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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
25
services/__init__.py
Normal 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
2
shared
Submodule shared updated: ea7dc9723a...70b1c7de10
Reference in New Issue
Block a user