From 382d1b7c7aae3c7f211532abedd8baa5ef43e6f3 Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 1 Mar 2026 13:28:11 +0000 Subject: [PATCH] Decouple blog models and BlogService from shared layer Move Post/Author/Tag/PostAuthor/PostTag/PostUser models from shared/models/ghost_content.py to blog/models/content.py so blog-domain models no longer live in the shared layer. Replace the shared SqlBlogService + BlogService protocol with a blog-local singleton (blog_service), and switch entry_associations.py from direct DB access to HTTP fetch_data("blog", "post-by-id") to respect the inter-service boundary. Co-Authored-By: Claude Opus 4.6 --- blog/alembic/env.py | 2 +- blog/app.py | 4 +- blog/bp/data/routes.py | 10 +-- blog/bp/fragments/routes.py | 6 +- blog/bp/post/admin/routes.py | 10 +-- blog/bp/post/services/markets.py | 6 +- blog/models/__init__.py | 2 +- .../models/content.py | 4 +- blog/models/ghost_content.py | 4 +- blog/scripts/final_ghost_sync.py | 2 +- blog/services/__init__.py | 68 +++++++++++++++++-- events/bp/calendar/routes.py | 2 +- events/bp/calendar_entry/routes.py | 2 +- events/bp/calendars/routes.py | 2 +- shared/contracts/__init__.py | 2 - shared/contracts/protocols.py | 12 ---- shared/models/__init__.py | 1 - shared/services/blog_impl.py | 65 ------------------ shared/services/entry_associations.py | 8 +-- shared/services/registry.py | 17 +---- 20 files changed, 93 insertions(+), 136 deletions(-) rename shared/models/ghost_content.py => blog/models/content.py (99%) delete mode 100644 shared/services/blog_impl.py diff --git a/blog/alembic/env.py b/blog/alembic/env.py index 1c280e3..737c027 100644 --- a/blog/alembic/env.py +++ b/blog/alembic/env.py @@ -2,7 +2,7 @@ from alembic import context from shared.db.alembic_env import run_alembic MODELS = [ - "shared.models.ghost_content", + "blog.models.content", "shared.models.kv", "shared.models.menu_item", "shared.models.menu_node", diff --git a/blog/app.py b/blog/app.py index 38fb151..91d1258 100644 --- a/blog/app.py +++ b/blog/app.py @@ -134,7 +134,7 @@ def create_app() -> "Quart": async def oembed(): from urllib.parse import urlparse from quart import jsonify - from shared.services.registry import services + from services import blog_service from shared.infrastructure.urls import blog_url from shared.infrastructure.oembed import build_oembed_response @@ -147,7 +147,7 @@ def create_app() -> "Quart": if not slug: return jsonify({"error": "could not extract slug"}), 404 - post = await services.blog.get_post_by_slug(g.s, slug) + post = await blog_service.get_post_by_slug(g.s, slug) if not post: return jsonify({"error": "not found"}), 404 diff --git a/blog/bp/data/routes.py b/blog/bp/data/routes.py index 4fa5533..b76f2b1 100644 --- a/blog/bp/data/routes.py +++ b/blog/bp/data/routes.py @@ -9,7 +9,7 @@ from quart import Blueprint, g, jsonify, request from shared.infrastructure.data_client import DATA_HEADER from shared.contracts.dtos import dto_to_dict -from shared.services.registry import services +from services import blog_service def register() -> Blueprint: @@ -36,7 +36,7 @@ def register() -> Blueprint: # --- post-by-slug --- async def _post_by_slug(): slug = request.args.get("slug", "") - post = await services.blog.get_post_by_slug(g.s, slug) + post = await blog_service.get_post_by_slug(g.s, slug) if not post: return None return dto_to_dict(post) @@ -46,7 +46,7 @@ def register() -> Blueprint: # --- post-by-id --- async def _post_by_id(): post_id = int(request.args.get("id", 0)) - post = await services.blog.get_post_by_id(g.s, post_id) + post = await blog_service.get_post_by_id(g.s, post_id) if not post: return None return dto_to_dict(post) @@ -59,7 +59,7 @@ def register() -> Blueprint: if not ids_raw: return [] ids = [int(x.strip()) for x in ids_raw.split(",") if x.strip()] - posts = await services.blog.get_posts_by_ids(g.s, ids) + posts = await blog_service.get_posts_by_ids(g.s, ids) return [dto_to_dict(p) for p in posts] _handlers["posts-by-ids"] = _posts_by_ids @@ -69,7 +69,7 @@ def register() -> Blueprint: query = request.args.get("query", "") page = int(request.args.get("page", 1)) per_page = int(request.args.get("per_page", 10)) - posts, total = await services.blog.search_posts(g.s, query, page, per_page) + posts, total = await blog_service.search_posts(g.s, query, page, per_page) return {"posts": [dto_to_dict(p) for p in posts], "total": total} _handlers["search-posts"] = _search_posts diff --git a/blog/bp/fragments/routes.py b/blog/bp/fragments/routes.py index 7e16a44..dffc8b9 100644 --- a/blog/bp/fragments/routes.py +++ b/blog/bp/fragments/routes.py @@ -125,7 +125,7 @@ def register(): data_app="blog") async def _link_card_handler(): - from shared.services.registry import services + from services import blog_service from shared.infrastructure.urls import blog_url slug = request.args.get("slug", "") @@ -137,7 +137,7 @@ def register(): parts = [] for s in slugs: parts.append(f"") - post = await services.blog.get_post_by_slug(g.s, s) + post = await blog_service.get_post_by_slug(g.s, s) if post: parts.append(_blog_link_card_sx(post, blog_url(f"/{post.slug}"))) return "\n".join(parts) @@ -145,7 +145,7 @@ def register(): # Single mode if not slug: return "" - post = await services.blog.get_post_by_slug(g.s, slug) + post = await blog_service.get_post_by_slug(g.s, slug) if not post: return "" return _blog_link_card_sx(post, blog_url(f"/{post.slug}")) diff --git a/blog/bp/post/admin/routes.py b/blog/bp/post/admin/routes.py index c8f7d22..f813ed8 100644 --- a/blog/bp/post/admin/routes.py +++ b/blog/bp/post/admin/routes.py @@ -264,7 +264,7 @@ def register(): # Get associated entry IDs for this post post_id = g.post_data["post"]["id"] - associated_entry_ids = await get_post_entry_ids(g.s, post_id) + associated_entry_ids = await get_post_entry_ids(post_id) html = await render_template( "_types/post/admin/_calendar_view.html", @@ -293,7 +293,7 @@ def register(): from sqlalchemy import select post_id = g.post_data["post"]["id"] - associated_entry_ids = await get_post_entry_ids(g.s, post_id) + associated_entry_ids = await get_post_entry_ids(post_id) # Load ALL calendars (not just this post's calendars) result = await g.s.execute( @@ -332,7 +332,7 @@ def register(): from quart import jsonify post_id = g.post_data["post"]["id"] - is_associated, error = await toggle_entry_association(g.s, post_id, entry_id) + is_associated, error = await toggle_entry_association(post_id, entry_id) if error: return jsonify({"message": error, "errors": {}}), 400 @@ -340,7 +340,7 @@ def register(): await g.s.flush() # Return updated association status - associated_entry_ids = await get_post_entry_ids(g.s, post_id) + associated_entry_ids = await get_post_entry_ids(post_id) # Load ALL calendars result = await g.s.execute( @@ -355,7 +355,7 @@ def register(): await g.s.refresh(calendar, ["entries", "post"]) # Fetch associated entries for nav display - associated_entries = await get_associated_entries(g.s, post_id) + associated_entries = await get_associated_entries(post_id) # Load calendars for this post (for nav display) calendars = ( diff --git a/blog/bp/post/services/markets.py b/blog/bp/post/services/markets.py index b2fbf70..1f1e064 100644 --- a/blog/bp/post/services/markets.py +++ b/blog/bp/post/services/markets.py @@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from shared.contracts.dtos import MarketPlaceDTO from shared.infrastructure.actions import call_action, ActionError -from shared.services.registry import services +from services import blog_service class MarketError(ValueError): @@ -33,7 +33,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 services.blog.get_post_by_id(sess, post_id) + post = await blog_service.get_post_by_id(sess, post_id) if not post: raise MarketError(f"Post {post_id} does not exist.") @@ -57,7 +57,7 @@ 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) + post = await blog_service.get_post_by_slug(sess, post_slug) if not post: return False diff --git a/blog/models/__init__.py b/blog/models/__init__.py index 6ec724d..0d028c3 100644 --- a/blog/models/__init__.py +++ b/blog/models/__init__.py @@ -1,4 +1,4 @@ -from .ghost_content import Post, Author, Tag, PostAuthor, PostTag +from .content import Post, Author, Tag, PostAuthor, PostTag, PostUser from .snippet import Snippet from .tag_group import TagGroup, TagGroupTag diff --git a/shared/models/ghost_content.py b/blog/models/content.py similarity index 99% rename from shared/models/ghost_content.py rename to blog/models/content.py index 3b49f94..e3999ae 100644 --- a/shared/models/ghost_content.py +++ b/blog/models/content.py @@ -118,7 +118,7 @@ class Post(Base): secondary="post_authors", primaryjoin="Post.id==post_authors.c.post_id", secondaryjoin="Author.id==post_authors.c.author_id", - back_populates="posts", + back_populates="authors", order_by="PostAuthor.sort_order", ) @@ -204,5 +204,3 @@ class PostUser(Base): Integer, primary_key=True, index=True, ) sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False) - - diff --git a/blog/models/ghost_content.py b/blog/models/ghost_content.py index 5343814..87201cd 100644 --- a/blog/models/ghost_content.py +++ b/blog/models/ghost_content.py @@ -1,3 +1,3 @@ -from shared.models.ghost_content import ( # noqa: F401 - Tag, Post, Author, PostAuthor, PostTag, +from .content import ( # noqa: F401 + Tag, Post, Author, PostAuthor, PostTag, PostUser, ) diff --git a/blog/scripts/final_ghost_sync.py b/blog/scripts/final_ghost_sync.py index 2ec9ac5..bdb16ce 100644 --- a/blog/scripts/final_ghost_sync.py +++ b/blog/scripts/final_ghost_sync.py @@ -29,7 +29,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "shared") from shared.db.base import Base # noqa: E402 from shared.db.session import get_session, get_account_session, _engine # noqa: E402 from shared.infrastructure.ghost_admin_token import make_ghost_admin_jwt # noqa: E402 -from shared.models.ghost_content import Post, Author, Tag, PostUser, PostAuthor # noqa: E402 +from blog.models.content import Post, Author, Tag, PostUser, PostAuthor # noqa: E402 from shared.models.user import User # noqa: E402 logging.basicConfig( diff --git a/blog/services/__init__.py b/blog/services/__init__.py index 495a897..5b7f51f 100644 --- a/blog/services/__init__.py +++ b/blog/services/__init__.py @@ -1,6 +1,68 @@ """Blog app service registration.""" from __future__ import annotations +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from shared.contracts.dtos import PostDTO + +from models.content import Post + + +def _post_to_dto(post: Post) -> PostDTO: + return PostDTO( + id=post.id, + slug=post.slug, + title=post.title, + status=post.status, + visibility=post.visibility, + is_page=post.is_page, + feature_image=post.feature_image, + html=post.html, + excerpt=post.excerpt, + custom_excerpt=post.custom_excerpt, + published_at=post.published_at, + ) + + +class SqlBlogService: + async def get_post_by_slug(self, session: AsyncSession, slug: str) -> PostDTO | None: + post = ( + await session.execute(select(Post).where(Post.slug == slug)) + ).scalar_one_or_none() + return _post_to_dto(post) if post else None + + async def get_post_by_id(self, session: AsyncSession, id: int) -> PostDTO | None: + post = ( + await session.execute(select(Post).where(Post.id == id)) + ).scalar_one_or_none() + return _post_to_dto(post) if post else None + + async def get_posts_by_ids(self, session: AsyncSession, ids: list[int]) -> list[PostDTO]: + if not ids: + return [] + result = await session.execute(select(Post).where(Post.id.in_(ids))) + return [_post_to_dto(p) for p in result.scalars().all()] + + async def search_posts( + self, session: AsyncSession, query: str, page: int = 1, per_page: int = 10, + ) -> tuple[list[PostDTO], int]: + if query: + 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: + count_stmt = select(func.count(Post.id)) + posts_stmt = select(Post).order_by(Post.published_at.desc().nullslast()) + + total = (await session.execute(count_stmt)).scalar() or 0 + offset = (page - 1) * per_page + result = await session.execute(posts_stmt.limit(per_page).offset(offset)) + return [_post_to_dto(p) for p in result.scalars().all()], total + + +# Module-level singleton — import this in blog code. +blog_service = SqlBlogService() + def register_domain_services() -> None: """Register services for the blog app. @@ -8,12 +70,8 @@ def register_domain_services() -> None: Blog owns: Post, Tag, Author, PostAuthor, PostTag. Cross-app calls go over HTTP via call_action() / fetch_data(). """ - from shared.services.registry import services - from shared.services.blog_impl import SqlBlogService - - services.blog = SqlBlogService() - # Federation needed for AP shared infrastructure (activitypub blueprint) + from shared.services.registry import services if not services.has("federation"): from shared.services.federation_impl import SqlFederationService services.federation = SqlFederationService() diff --git a/events/bp/calendar/routes.py b/events/bp/calendar/routes.py index a6fc8ed..4cffc4d 100644 --- a/events/bp/calendar/routes.py +++ b/events/bp/calendar/routes.py @@ -235,7 +235,7 @@ def register(): ) ).scalars().all() - associated_entries = await get_associated_entries(g.s, post_id) + associated_entries = await get_associated_entries(post_id) nav_oob = render_post_nav_entries_oob(associated_entries, cals, post_data["post"]) html = html + nav_oob diff --git a/events/bp/calendar_entry/routes.py b/events/bp/calendar_entry/routes.py index e3cd542..f1fb5e7 100644 --- a/events/bp/calendar_entry/routes.py +++ b/events/bp/calendar_entry/routes.py @@ -135,7 +135,7 @@ def register(): for post in entry_posts: # Get associated entries for this post from shared.services.entry_associations import get_associated_entries - associated_entries = await get_associated_entries(g.s, post.id) + associated_entries = await get_associated_entries(post.id) # Load calendars for this post from models.calendars import Calendar diff --git a/events/bp/calendars/routes.py b/events/bp/calendars/routes.py index 98eb03b..cec8afc 100644 --- a/events/bp/calendars/routes.py +++ b/events/bp/calendars/routes.py @@ -84,7 +84,7 @@ def register(): ) ).scalars().all() - associated_entries = await get_associated_entries(g.s, post_id) + associated_entries = await get_associated_entries(post_id) nav_oob = render_post_nav_entries_oob(associated_entries, cals, post_data["post"]) html = html + nav_oob diff --git a/shared/contracts/__init__.py b/shared/contracts/__init__.py index d8cf7bc..41996da 100644 --- a/shared/contracts/__init__.py +++ b/shared/contracts/__init__.py @@ -10,7 +10,6 @@ from .dtos import ( CartSummaryDTO, ) from .protocols import ( - BlogService, CalendarService, MarketService, CartService, @@ -24,7 +23,6 @@ __all__ = [ "ProductDTO", "CartItemDTO", "CartSummaryDTO", - "BlogService", "CalendarService", "MarketService", "CartService", diff --git a/shared/contracts/protocols.py b/shared/contracts/protocols.py index 02d1262..d8cbbd0 100644 --- a/shared/contracts/protocols.py +++ b/shared/contracts/protocols.py @@ -11,7 +11,6 @@ from typing import Protocol, runtime_checkable from sqlalchemy.ext.asyncio import AsyncSession from .dtos import ( - PostDTO, CalendarDTO, CalendarEntryDTO, TicketDTO, @@ -29,17 +28,6 @@ from .dtos import ( ) -@runtime_checkable -class BlogService(Protocol): - async def get_post_by_slug(self, session: AsyncSession, slug: str) -> PostDTO | None: ... - async def get_post_by_id(self, session: AsyncSession, id: int) -> PostDTO | None: ... - async def get_posts_by_ids(self, session: AsyncSession, ids: list[int]) -> list[PostDTO]: ... - - async def search_posts( - self, session: AsyncSession, query: str, page: int = 1, per_page: int = 10, - ) -> tuple[list[PostDTO], int]: ... - - @runtime_checkable class CalendarService(Protocol): async def calendars_for_container( diff --git a/shared/models/__init__.py b/shared/models/__init__.py index 8562f6c..0b27959 100644 --- a/shared/models/__init__.py +++ b/shared/models/__init__.py @@ -10,7 +10,6 @@ from .ghost_membership_entities import ( GhostNewsletter, UserNewsletter, GhostTier, GhostSubscription, ) -from .ghost_content import Tag, Post, Author, PostAuthor, PostTag from .page_config import PageConfig from .order import Order, OrderItem from .market import ( diff --git a/shared/services/blog_impl.py b/shared/services/blog_impl.py deleted file mode 100644 index cbdcca8..0000000 --- a/shared/services/blog_impl.py +++ /dev/null @@ -1,65 +0,0 @@ -"""SQL-backed BlogService implementation. - -Queries ``shared.models.ghost_content.Post`` — only this module may read -blog-domain tables on behalf of other domains. -""" -from __future__ import annotations - -from sqlalchemy import select, func -from sqlalchemy.ext.asyncio import AsyncSession - -from shared.models.ghost_content import Post -from shared.contracts.dtos import PostDTO - - -def _post_to_dto(post: Post) -> PostDTO: - return PostDTO( - id=post.id, - slug=post.slug, - title=post.title, - status=post.status, - visibility=post.visibility, - is_page=post.is_page, - feature_image=post.feature_image, - html=post.html, - excerpt=post.excerpt, - custom_excerpt=post.custom_excerpt, - published_at=post.published_at, - ) - - -class SqlBlogService: - async def get_post_by_slug(self, session: AsyncSession, slug: str) -> PostDTO | None: - post = ( - await session.execute(select(Post).where(Post.slug == slug)) - ).scalar_one_or_none() - return _post_to_dto(post) if post else None - - async def get_post_by_id(self, session: AsyncSession, id: int) -> PostDTO | None: - post = ( - await session.execute(select(Post).where(Post.id == id)) - ).scalar_one_or_none() - return _post_to_dto(post) if post else None - - async def get_posts_by_ids(self, session: AsyncSession, ids: list[int]) -> list[PostDTO]: - if not ids: - return [] - result = await session.execute(select(Post).where(Post.id.in_(ids))) - return [_post_to_dto(p) for p in result.scalars().all()] - - async def search_posts( - self, session: AsyncSession, query: str, page: int = 1, per_page: int = 10, - ) -> tuple[list[PostDTO], int]: - """Search posts by title with pagination. Not part of the Protocol - (admin-only use in events), but provided for convenience.""" - if query: - 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: - count_stmt = select(func.count(Post.id)) - posts_stmt = select(Post).order_by(Post.published_at.desc().nullslast()) - - total = (await session.execute(count_stmt)).scalar() or 0 - offset = (page - 1) * per_page - result = await session.execute(posts_stmt.limit(per_page).offset(offset)) - return [_post_to_dto(p) for p in result.scalars().all()], total diff --git a/shared/services/entry_associations.py b/shared/services/entry_associations.py index 3052fc7..3dbc442 100644 --- a/shared/services/entry_associations.py +++ b/shared/services/entry_associations.py @@ -4,16 +4,12 @@ Only uses HTTP-based fetch_data/call_action, no direct DB access. """ from __future__ import annotations -from sqlalchemy.ext.asyncio import AsyncSession - from shared.infrastructure.actions import call_action, ActionError from shared.infrastructure.data_client import fetch_data from shared.contracts.dtos import CalendarEntryDTO, dto_from_dict -from shared.services.registry import services async def toggle_entry_association( - session: AsyncSession, post_id: int, entry_id: int ) -> tuple[bool, str | None]: @@ -21,7 +17,7 @@ async def toggle_entry_association( Toggle association between a post and calendar entry. Returns (is_now_associated, error_message). """ - post = await services.blog.get_post_by_id(session, post_id) + post = await fetch_data("blog", "post-by-id", params={"id": post_id}, required=False) if not post: return False, "Post not found" @@ -35,7 +31,6 @@ async def toggle_entry_association( async def get_post_entry_ids( - session: AsyncSession, post_id: int ) -> set[int]: """ @@ -49,7 +44,6 @@ async def get_post_entry_ids( async def get_associated_entries( - session: AsyncSession, post_id: int, page: int = 1, per_page: int = 10 diff --git a/shared/services/registry.py b/shared/services/registry.py index f28c538..0f02906 100644 --- a/shared/services/registry.py +++ b/shared/services/registry.py @@ -8,15 +8,14 @@ Usage:: from shared.services.registry import services # Register at app startup (own domain only) - services.blog = SqlBlogService() + services.calendar = SqlCalendarService() # Use locally within the owning app - post = await services.blog.get_post_by_slug(session, slug) + cals = await services.calendar.calendars_for_container(session, "page", page_id) """ from __future__ import annotations from shared.contracts.protocols import ( - BlogService, CalendarService, MarketService, CartService, @@ -33,23 +32,11 @@ class _ServiceRegistry: """ def __init__(self) -> None: - self._blog: BlogService | None = None self._calendar: CalendarService | None = None self._market: MarketService | None = None self._cart: CartService | None = None self._federation: FederationService | None = None - # -- blog ----------------------------------------------------------------- - @property - def blog(self) -> BlogService: - if self._blog is None: - raise RuntimeError("BlogService not registered") - return self._blog - - @blog.setter - def blog(self, impl: BlogService) -> None: - self._blog = impl - # -- calendar ------------------------------------------------------------- @property def calendar(self) -> CalendarService: