From 1f3d98ecc1ad85a0cee9b44760f43a416edd414f Mon Sep 17 00:00:00 2001 From: giles Date: Thu, 26 Feb 2026 17:49:30 +0000 Subject: [PATCH] Move container_relations to cart service for cross-service ownership container_relations is a generic parent/child graph used by blog (menu_nodes), market (marketplaces), and events (calendars). Move it to cart as shared infrastructure. All services now call cart actions (attach-child/detach-child) instead of querying the table directly. Co-Authored-By: Claude Opus 4.6 --- blog/bp/menu_items/services/menu_items.py | 22 +++++++--- cart/alembic/env.py | 3 +- .../versions/0003_add_container_relations.py | 40 +++++++++++++++++ cart/bp/actions/routes.py | 43 +++++++++++++++++++ cart/bp/data/routes.py | 26 +++++++++++ events/bp/calendars/services/calendars.py | 17 ++++++-- shared/services/market_impl.py | 17 ++++++-- 7 files changed, 154 insertions(+), 14 deletions(-) create mode 100644 cart/alembic/versions/0003_add_container_relations.py diff --git a/blog/bp/menu_items/services/menu_items.py b/blog/bp/menu_items/services/menu_items.py index d79c89f..20a142f 100644 --- a/blog/bp/menu_items/services/menu_items.py +++ b/blog/bp/menu_items/services/menu_items.py @@ -4,7 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func from shared.models.menu_node import MenuNode from models.ghost_content import Post -from shared.services.relationships import attach_child, detach_child +from shared.infrastructure.actions import call_action class MenuItemError(ValueError): @@ -80,7 +80,10 @@ async def create_menu_item( ) session.add(menu_node) await session.flush() - await attach_child(session, "page", post_id, "menu_node", menu_node.id) + await call_action("cart", "attach-child", payload={ + "parent_type": "page", "parent_id": post_id, + "child_type": "menu_node", "child_id": menu_node.id, + }) return menu_node @@ -131,8 +134,14 @@ async def update_menu_item( await session.flush() if post_id is not None and post_id != old_post_id: - await detach_child(session, "page", old_post_id, "menu_node", menu_node.id) - await attach_child(session, "page", post_id, "menu_node", menu_node.id) + await call_action("cart", "detach-child", payload={ + "parent_type": "page", "parent_id": old_post_id, + "child_type": "menu_node", "child_id": menu_node.id, + }) + await call_action("cart", "attach-child", payload={ + "parent_type": "page", "parent_id": post_id, + "child_type": "menu_node", "child_id": menu_node.id, + }) return menu_node @@ -145,7 +154,10 @@ async def delete_menu_item(session: AsyncSession, item_id: int) -> bool: menu_node.deleted_at = func.now() await session.flush() - await detach_child(session, "page", menu_node.container_id, "menu_node", menu_node.id) + await call_action("cart", "detach-child", payload={ + "parent_type": "page", "parent_id": menu_node.container_id, + "child_type": "menu_node", "child_id": menu_node.id, + }) return True diff --git a/cart/alembic/env.py b/cart/alembic/env.py index bb5ba8d..7a12099 100644 --- a/cart/alembic/env.py +++ b/cart/alembic/env.py @@ -5,10 +5,11 @@ MODELS = [ "shared.models.market", # CartItem lives here "shared.models.order", "shared.models.page_config", + "shared.models.container_relation", ] TABLES = frozenset({ - "cart_items", "orders", "order_items", "page_configs", + "cart_items", "orders", "order_items", "page_configs", "container_relations", }) run_alembic(context.config, MODELS, TABLES) diff --git a/cart/alembic/versions/0003_add_container_relations.py b/cart/alembic/versions/0003_add_container_relations.py new file mode 100644 index 0000000..7c86df9 --- /dev/null +++ b/cart/alembic/versions/0003_add_container_relations.py @@ -0,0 +1,40 @@ +"""Add container_relations table to cart DB. + +Generic parent/child graph for inter-service container relationships +(page→market, page→calendar, page→menu_node, etc.). + +Revision ID: cart_0003 +Revises: cart_0002 +Create Date: 2026-02-26 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "cart_0003" +down_revision = "cart_0002" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "container_relations", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("parent_type", sa.String(32), nullable=False), + sa.Column("parent_id", sa.Integer, nullable=False), + sa.Column("child_type", sa.String(32), nullable=False), + sa.Column("child_id", sa.Integer, nullable=False), + sa.Column("sort_order", sa.Integer, nullable=False, server_default="0"), + sa.Column("label", sa.String(255), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.UniqueConstraint("parent_type", "parent_id", "child_type", "child_id", + name="uq_container_relations_parent_child"), + ) + op.create_index("ix_container_relations_parent", "container_relations", ["parent_type", "parent_id"]) + op.create_index("ix_container_relations_child", "container_relations", ["child_type", "child_id"]) + + +def downgrade() -> None: + op.drop_table("container_relations") diff --git a/cart/bp/actions/routes.py b/cart/bp/actions/routes.py index 279ee88..4e0adc5 100644 --- a/cart/bp/actions/routes.py +++ b/cart/bp/actions/routes.py @@ -109,4 +109,47 @@ def register() -> Blueprint: _handlers["update-page-config"] = _update_page_config + # --- attach-child --- + async def _attach_child(): + """Create or revive a ContainerRelation.""" + from shared.services.relationships import attach_child + + data = await request.get_json(force=True) + rel = await attach_child( + g.s, + parent_type=data["parent_type"], + parent_id=data["parent_id"], + child_type=data["child_type"], + child_id=data["child_id"], + label=data.get("label"), + sort_order=data.get("sort_order"), + ) + return { + "id": rel.id, + "parent_type": rel.parent_type, + "parent_id": rel.parent_id, + "child_type": rel.child_type, + "child_id": rel.child_id, + "sort_order": rel.sort_order, + } + + _handlers["attach-child"] = _attach_child + + # --- detach-child --- + async def _detach_child(): + """Soft-delete a ContainerRelation.""" + from shared.services.relationships import detach_child + + data = await request.get_json(force=True) + deleted = await detach_child( + g.s, + parent_type=data["parent_type"], + parent_id=data["parent_id"], + child_type=data["child_type"], + child_id=data["child_id"], + ) + return {"deleted": deleted} + + _handlers["detach-child"] = _detach_child + return bp diff --git a/cart/bp/data/routes.py b/cart/bp/data/routes.py index 5a07c7b..b7e42df 100644 --- a/cart/bp/data/routes.py +++ b/cart/bp/data/routes.py @@ -171,6 +171,32 @@ def register() -> Blueprint: _handlers["page-configs-batch"] = _page_configs_batch + # --- get-children --- + async def _get_children(): + """Return ContainerRelation children for a parent.""" + from shared.services.relationships import get_children + + parent_type = request.args.get("parent_type", "") + parent_id = request.args.get("parent_id", type=int) + child_type = request.args.get("child_type") + if not parent_type or parent_id is None: + return [] + rels = await get_children(g.s, parent_type, parent_id, child_type) + return [ + { + "id": r.id, + "parent_type": r.parent_type, + "parent_id": r.parent_id, + "child_type": r.child_type, + "child_id": r.child_id, + "sort_order": r.sort_order, + "label": r.label, + } + for r in rels + ] + + _handlers["get-children"] = _get_children + return bp diff --git a/events/bp/calendars/services/calendars.py b/events/bp/calendars/services/calendars.py index b0db55f..065f4f1 100644 --- a/events/bp/calendars/services/calendars.py +++ b/events/bp/calendars/services/calendars.py @@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from models.calendars import Calendar from shared.infrastructure.data_client import fetch_data from shared.contracts.dtos import PostDTO, dto_from_dict -from shared.services.relationships import attach_child, detach_child +from shared.infrastructure.actions import call_action import unicodedata import re @@ -71,7 +71,10 @@ async def soft_delete(sess: AsyncSession, post_slug: str, calendar_slug: str) -> cal.deleted_at = utcnow() await sess.flush() - await detach_child(sess, "page", cal.container_id, "calendar", cal.id) + await call_action("cart", "detach-child", payload={ + "parent_type": "page", "parent_id": cal.container_id, + "child_type": "calendar", "child_id": cal.id, + }) return True async def create_calendar(sess: AsyncSession, post_id: int, name: str) -> Calendar: @@ -105,14 +108,20 @@ async def create_calendar(sess: AsyncSession, post_id: int, name: str) -> Calend if existing.deleted_at is not None: existing.deleted_at = None # revive await sess.flush() - await attach_child(sess, "page", post_id, "calendar", existing.id) + await call_action("cart", "attach-child", payload={ + "parent_type": "page", "parent_id": post_id, + "child_type": "calendar", "child_id": existing.id, + }) return existing raise CalendarError(f'Calendar with slug "{slug}" already exists for post {post_id}.') cal = Calendar(container_type="page", container_id=post_id, name=name, slug=slug) sess.add(cal) await sess.flush() - await attach_child(sess, "page", post_id, "calendar", cal.id) + await call_action("cart", "attach-child", payload={ + "parent_type": "page", "parent_id": post_id, + "child_type": "calendar", "child_id": cal.id, + }) return cal diff --git a/shared/services/market_impl.py b/shared/services/market_impl.py index 71f8771..f2c3fb7 100644 --- a/shared/services/market_impl.py +++ b/shared/services/market_impl.py @@ -12,7 +12,7 @@ from shared.models.market import Product from shared.models.market_place import MarketPlace from shared.browser.app.utils import utcnow from shared.contracts.dtos import MarketPlaceDTO, ProductDTO -from shared.services.relationships import attach_child, detach_child +from shared.infrastructure.actions import call_action def _mp_to_dto(mp: MarketPlace) -> MarketPlaceDTO: @@ -93,7 +93,10 @@ class SqlMarketService: existing.deleted_at = None # revive existing.name = name await session.flush() - await attach_child(session, container_type, container_id, "market", existing.id) + await call_action("cart", "attach-child", payload={ + "parent_type": container_type, "parent_id": container_id, + "child_type": "market", "child_id": existing.id, + }) return _mp_to_dto(existing) raise ValueError(f'Market with slug "{slug}" already exists for this container.') @@ -103,7 +106,10 @@ class SqlMarketService: ) session.add(market) await session.flush() - await attach_child(session, container_type, container_id, "market", market.id) + await call_action("cart", "attach-child", payload={ + "parent_type": container_type, "parent_id": container_id, + "child_type": "market", "child_id": market.id, + }) return _mp_to_dto(market) async def soft_delete_marketplace( @@ -124,5 +130,8 @@ class SqlMarketService: market.deleted_at = utcnow() await session.flush() - await detach_child(session, container_type, container_id, "market", market.id) + await call_action("cart", "detach-child", payload={ + "parent_type": container_type, "parent_id": container_id, + "child_type": "market", "child_id": market.id, + }) return True