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 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 17:49:30 +00:00
parent dd52417241
commit 1f3d98ecc1
7 changed files with 154 additions and 14 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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")

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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