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 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@ from alembic import context
|
|||||||
from shared.db.alembic_env import run_alembic
|
from shared.db.alembic_env import run_alembic
|
||||||
|
|
||||||
MODELS = [
|
MODELS = [
|
||||||
"shared.models.ghost_content",
|
"blog.models.content",
|
||||||
"shared.models.kv",
|
"shared.models.kv",
|
||||||
"shared.models.menu_item",
|
"shared.models.menu_item",
|
||||||
"shared.models.menu_node",
|
"shared.models.menu_node",
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ def create_app() -> "Quart":
|
|||||||
async def oembed():
|
async def oembed():
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from quart import jsonify
|
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.urls import blog_url
|
||||||
from shared.infrastructure.oembed import build_oembed_response
|
from shared.infrastructure.oembed import build_oembed_response
|
||||||
|
|
||||||
@@ -147,7 +147,7 @@ def create_app() -> "Quart":
|
|||||||
if not slug:
|
if not slug:
|
||||||
return jsonify({"error": "could not extract slug"}), 404
|
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:
|
if not post:
|
||||||
return jsonify({"error": "not found"}), 404
|
return jsonify({"error": "not found"}), 404
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from quart import Blueprint, g, jsonify, request
|
|||||||
|
|
||||||
from shared.infrastructure.data_client import DATA_HEADER
|
from shared.infrastructure.data_client import DATA_HEADER
|
||||||
from shared.contracts.dtos import dto_to_dict
|
from shared.contracts.dtos import dto_to_dict
|
||||||
from shared.services.registry import services
|
from services import blog_service
|
||||||
|
|
||||||
|
|
||||||
def register() -> Blueprint:
|
def register() -> Blueprint:
|
||||||
@@ -36,7 +36,7 @@ def register() -> Blueprint:
|
|||||||
# --- post-by-slug ---
|
# --- post-by-slug ---
|
||||||
async def _post_by_slug():
|
async def _post_by_slug():
|
||||||
slug = request.args.get("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:
|
if not post:
|
||||||
return None
|
return None
|
||||||
return dto_to_dict(post)
|
return dto_to_dict(post)
|
||||||
@@ -46,7 +46,7 @@ def register() -> Blueprint:
|
|||||||
# --- post-by-id ---
|
# --- post-by-id ---
|
||||||
async def _post_by_id():
|
async def _post_by_id():
|
||||||
post_id = int(request.args.get("id", 0))
|
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:
|
if not post:
|
||||||
return None
|
return None
|
||||||
return dto_to_dict(post)
|
return dto_to_dict(post)
|
||||||
@@ -59,7 +59,7 @@ def register() -> Blueprint:
|
|||||||
if not ids_raw:
|
if not ids_raw:
|
||||||
return []
|
return []
|
||||||
ids = [int(x.strip()) for x in ids_raw.split(",") if x.strip()]
|
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]
|
return [dto_to_dict(p) for p in posts]
|
||||||
|
|
||||||
_handlers["posts-by-ids"] = _posts_by_ids
|
_handlers["posts-by-ids"] = _posts_by_ids
|
||||||
@@ -69,7 +69,7 @@ def register() -> Blueprint:
|
|||||||
query = request.args.get("query", "")
|
query = request.args.get("query", "")
|
||||||
page = int(request.args.get("page", 1))
|
page = int(request.args.get("page", 1))
|
||||||
per_page = int(request.args.get("per_page", 10))
|
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}
|
return {"posts": [dto_to_dict(p) for p in posts], "total": total}
|
||||||
|
|
||||||
_handlers["search-posts"] = _search_posts
|
_handlers["search-posts"] = _search_posts
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ def register():
|
|||||||
data_app="blog")
|
data_app="blog")
|
||||||
|
|
||||||
async def _link_card_handler():
|
async def _link_card_handler():
|
||||||
from shared.services.registry import services
|
from services import blog_service
|
||||||
from shared.infrastructure.urls import blog_url
|
from shared.infrastructure.urls import blog_url
|
||||||
|
|
||||||
slug = request.args.get("slug", "")
|
slug = request.args.get("slug", "")
|
||||||
@@ -137,7 +137,7 @@ def register():
|
|||||||
parts = []
|
parts = []
|
||||||
for s in slugs:
|
for s in slugs:
|
||||||
parts.append(f"<!-- fragment:{s} -->")
|
parts.append(f"<!-- fragment:{s} -->")
|
||||||
post = await services.blog.get_post_by_slug(g.s, s)
|
post = await blog_service.get_post_by_slug(g.s, s)
|
||||||
if post:
|
if post:
|
||||||
parts.append(_blog_link_card_sx(post, blog_url(f"/{post.slug}")))
|
parts.append(_blog_link_card_sx(post, blog_url(f"/{post.slug}")))
|
||||||
return "\n".join(parts)
|
return "\n".join(parts)
|
||||||
@@ -145,7 +145,7 @@ def register():
|
|||||||
# Single mode
|
# Single mode
|
||||||
if not slug:
|
if not slug:
|
||||||
return ""
|
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:
|
if not post:
|
||||||
return ""
|
return ""
|
||||||
return _blog_link_card_sx(post, blog_url(f"/{post.slug}"))
|
return _blog_link_card_sx(post, blog_url(f"/{post.slug}"))
|
||||||
|
|||||||
@@ -264,7 +264,7 @@ def register():
|
|||||||
|
|
||||||
# Get associated entry IDs for this post
|
# Get associated entry IDs for this post
|
||||||
post_id = g.post_data["post"]["id"]
|
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(
|
html = await render_template(
|
||||||
"_types/post/admin/_calendar_view.html",
|
"_types/post/admin/_calendar_view.html",
|
||||||
@@ -293,7 +293,7 @@ def register():
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
post_id = g.post_data["post"]["id"]
|
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)
|
# Load ALL calendars (not just this post's calendars)
|
||||||
result = await g.s.execute(
|
result = await g.s.execute(
|
||||||
@@ -332,7 +332,7 @@ def register():
|
|||||||
from quart import jsonify
|
from quart import jsonify
|
||||||
|
|
||||||
post_id = g.post_data["post"]["id"]
|
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:
|
if error:
|
||||||
return jsonify({"message": error, "errors": {}}), 400
|
return jsonify({"message": error, "errors": {}}), 400
|
||||||
@@ -340,7 +340,7 @@ def register():
|
|||||||
await g.s.flush()
|
await g.s.flush()
|
||||||
|
|
||||||
# Return updated association status
|
# 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
|
# Load ALL calendars
|
||||||
result = await g.s.execute(
|
result = await g.s.execute(
|
||||||
@@ -355,7 +355,7 @@ def register():
|
|||||||
await g.s.refresh(calendar, ["entries", "post"])
|
await g.s.refresh(calendar, ["entries", "post"])
|
||||||
|
|
||||||
# Fetch associated entries for nav display
|
# 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)
|
# Load calendars for this post (for nav display)
|
||||||
calendars = (
|
calendars = (
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from shared.contracts.dtos import MarketPlaceDTO
|
from shared.contracts.dtos import MarketPlaceDTO
|
||||||
from shared.infrastructure.actions import call_action, ActionError
|
from shared.infrastructure.actions import call_action, ActionError
|
||||||
from shared.services.registry import services
|
from services import blog_service
|
||||||
|
|
||||||
|
|
||||||
class MarketError(ValueError):
|
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.")
|
raise MarketError("Market name must not be empty.")
|
||||||
slug = slugify(name)
|
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:
|
if not post:
|
||||||
raise MarketError(f"Post {post_id} does not exist.")
|
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:
|
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:
|
if not post:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -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 .snippet import Snippet
|
||||||
from .tag_group import TagGroup, TagGroupTag
|
from .tag_group import TagGroup, TagGroupTag
|
||||||
|
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ class Post(Base):
|
|||||||
secondary="post_authors",
|
secondary="post_authors",
|
||||||
primaryjoin="Post.id==post_authors.c.post_id",
|
primaryjoin="Post.id==post_authors.c.post_id",
|
||||||
secondaryjoin="Author.id==post_authors.c.author_id",
|
secondaryjoin="Author.id==post_authors.c.author_id",
|
||||||
back_populates="posts",
|
back_populates="authors",
|
||||||
order_by="PostAuthor.sort_order",
|
order_by="PostAuthor.sort_order",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -204,5 +204,3 @@ class PostUser(Base):
|
|||||||
Integer, primary_key=True, index=True,
|
Integer, primary_key=True, index=True,
|
||||||
)
|
)
|
||||||
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
from shared.models.ghost_content import ( # noqa: F401
|
from .content import ( # noqa: F401
|
||||||
Tag, Post, Author, PostAuthor, PostTag,
|
Tag, Post, Author, PostAuthor, PostTag, PostUser,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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.base import Base # noqa: E402
|
||||||
from shared.db.session import get_session, get_account_session, _engine # 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.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
|
from shared.models.user import User # noqa: E402
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
|
|||||||
@@ -1,6 +1,68 @@
|
|||||||
"""Blog app service registration."""
|
"""Blog app service registration."""
|
||||||
from __future__ import annotations
|
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:
|
def register_domain_services() -> None:
|
||||||
"""Register services for the blog app.
|
"""Register services for the blog app.
|
||||||
@@ -8,12 +70,8 @@ def register_domain_services() -> None:
|
|||||||
Blog owns: Post, Tag, Author, PostAuthor, PostTag.
|
Blog owns: Post, Tag, Author, PostAuthor, PostTag.
|
||||||
Cross-app calls go over HTTP via call_action() / fetch_data().
|
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)
|
# Federation needed for AP shared infrastructure (activitypub blueprint)
|
||||||
|
from shared.services.registry import services
|
||||||
if not services.has("federation"):
|
if not services.has("federation"):
|
||||||
from shared.services.federation_impl import SqlFederationService
|
from shared.services.federation_impl import SqlFederationService
|
||||||
services.federation = SqlFederationService()
|
services.federation = SqlFederationService()
|
||||||
|
|||||||
@@ -235,7 +235,7 @@ def register():
|
|||||||
)
|
)
|
||||||
).scalars().all()
|
).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"])
|
nav_oob = render_post_nav_entries_oob(associated_entries, cals, post_data["post"])
|
||||||
html = html + nav_oob
|
html = html + nav_oob
|
||||||
|
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ def register():
|
|||||||
for post in entry_posts:
|
for post in entry_posts:
|
||||||
# Get associated entries for this post
|
# Get associated entries for this post
|
||||||
from shared.services.entry_associations import get_associated_entries
|
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
|
# Load calendars for this post
|
||||||
from models.calendars import Calendar
|
from models.calendars import Calendar
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ def register():
|
|||||||
)
|
)
|
||||||
).scalars().all()
|
).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"])
|
nav_oob = render_post_nav_entries_oob(associated_entries, cals, post_data["post"])
|
||||||
html = html + nav_oob
|
html = html + nav_oob
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ from .dtos import (
|
|||||||
CartSummaryDTO,
|
CartSummaryDTO,
|
||||||
)
|
)
|
||||||
from .protocols import (
|
from .protocols import (
|
||||||
BlogService,
|
|
||||||
CalendarService,
|
CalendarService,
|
||||||
MarketService,
|
MarketService,
|
||||||
CartService,
|
CartService,
|
||||||
@@ -24,7 +23,6 @@ __all__ = [
|
|||||||
"ProductDTO",
|
"ProductDTO",
|
||||||
"CartItemDTO",
|
"CartItemDTO",
|
||||||
"CartSummaryDTO",
|
"CartSummaryDTO",
|
||||||
"BlogService",
|
|
||||||
"CalendarService",
|
"CalendarService",
|
||||||
"MarketService",
|
"MarketService",
|
||||||
"CartService",
|
"CartService",
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ from typing import Protocol, runtime_checkable
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from .dtos import (
|
from .dtos import (
|
||||||
PostDTO,
|
|
||||||
CalendarDTO,
|
CalendarDTO,
|
||||||
CalendarEntryDTO,
|
CalendarEntryDTO,
|
||||||
TicketDTO,
|
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
|
@runtime_checkable
|
||||||
class CalendarService(Protocol):
|
class CalendarService(Protocol):
|
||||||
async def calendars_for_container(
|
async def calendars_for_container(
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ from .ghost_membership_entities import (
|
|||||||
GhostNewsletter, UserNewsletter,
|
GhostNewsletter, UserNewsletter,
|
||||||
GhostTier, GhostSubscription,
|
GhostTier, GhostSubscription,
|
||||||
)
|
)
|
||||||
from .ghost_content import Tag, Post, Author, PostAuthor, PostTag
|
|
||||||
from .page_config import PageConfig
|
from .page_config import PageConfig
|
||||||
from .order import Order, OrderItem
|
from .order import Order, OrderItem
|
||||||
from .market import (
|
from .market import (
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -4,16 +4,12 @@ Only uses HTTP-based fetch_data/call_action, no direct DB access.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from shared.infrastructure.actions import call_action, ActionError
|
from shared.infrastructure.actions import call_action, ActionError
|
||||||
from shared.infrastructure.data_client import fetch_data
|
from shared.infrastructure.data_client import fetch_data
|
||||||
from shared.contracts.dtos import CalendarEntryDTO, dto_from_dict
|
from shared.contracts.dtos import CalendarEntryDTO, dto_from_dict
|
||||||
from shared.services.registry import services
|
|
||||||
|
|
||||||
|
|
||||||
async def toggle_entry_association(
|
async def toggle_entry_association(
|
||||||
session: AsyncSession,
|
|
||||||
post_id: int,
|
post_id: int,
|
||||||
entry_id: int
|
entry_id: int
|
||||||
) -> tuple[bool, str | None]:
|
) -> tuple[bool, str | None]:
|
||||||
@@ -21,7 +17,7 @@ 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).
|
||||||
"""
|
"""
|
||||||
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:
|
if not post:
|
||||||
return False, "Post not found"
|
return False, "Post not found"
|
||||||
|
|
||||||
@@ -35,7 +31,6 @@ async def toggle_entry_association(
|
|||||||
|
|
||||||
|
|
||||||
async def get_post_entry_ids(
|
async def get_post_entry_ids(
|
||||||
session: AsyncSession,
|
|
||||||
post_id: int
|
post_id: int
|
||||||
) -> set[int]:
|
) -> set[int]:
|
||||||
"""
|
"""
|
||||||
@@ -49,7 +44,6 @@ async def get_post_entry_ids(
|
|||||||
|
|
||||||
|
|
||||||
async def get_associated_entries(
|
async def get_associated_entries(
|
||||||
session: AsyncSession,
|
|
||||||
post_id: int,
|
post_id: int,
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
per_page: int = 10
|
per_page: int = 10
|
||||||
|
|||||||
@@ -8,15 +8,14 @@ Usage::
|
|||||||
from shared.services.registry import services
|
from shared.services.registry import services
|
||||||
|
|
||||||
# Register at app startup (own domain only)
|
# Register at app startup (own domain only)
|
||||||
services.blog = SqlBlogService()
|
services.calendar = SqlCalendarService()
|
||||||
|
|
||||||
# Use locally within the owning app
|
# 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 __future__ import annotations
|
||||||
|
|
||||||
from shared.contracts.protocols import (
|
from shared.contracts.protocols import (
|
||||||
BlogService,
|
|
||||||
CalendarService,
|
CalendarService,
|
||||||
MarketService,
|
MarketService,
|
||||||
CartService,
|
CartService,
|
||||||
@@ -33,23 +32,11 @@ class _ServiceRegistry:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._blog: BlogService | None = None
|
|
||||||
self._calendar: CalendarService | None = None
|
self._calendar: CalendarService | None = None
|
||||||
self._market: MarketService | None = None
|
self._market: MarketService | None = None
|
||||||
self._cart: CartService | None = None
|
self._cart: CartService | None = None
|
||||||
self._federation: FederationService | 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 -------------------------------------------------------------
|
# -- calendar -------------------------------------------------------------
|
||||||
@property
|
@property
|
||||||
def calendar(self) -> CalendarService:
|
def calendar(self) -> CalendarService:
|
||||||
|
|||||||
Reference in New Issue
Block a user