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:
@@ -4,7 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func
|
||||||
from shared.models.menu_node import MenuNode
|
from shared.models.menu_node import MenuNode
|
||||||
from models.ghost_content import Post
|
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):
|
class MenuItemError(ValueError):
|
||||||
@@ -80,7 +80,10 @@ async def create_menu_item(
|
|||||||
)
|
)
|
||||||
session.add(menu_node)
|
session.add(menu_node)
|
||||||
await session.flush()
|
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
|
return menu_node
|
||||||
|
|
||||||
@@ -131,8 +134,14 @@ async def update_menu_item(
|
|||||||
await session.flush()
|
await session.flush()
|
||||||
|
|
||||||
if post_id is not None and post_id != old_post_id:
|
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 call_action("cart", "detach-child", payload={
|
||||||
await attach_child(session, "page", post_id, "menu_node", menu_node.id)
|
"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
|
return menu_node
|
||||||
|
|
||||||
@@ -145,7 +154,10 @@ async def delete_menu_item(session: AsyncSession, item_id: int) -> bool:
|
|||||||
|
|
||||||
menu_node.deleted_at = func.now()
|
menu_node.deleted_at = func.now()
|
||||||
await session.flush()
|
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
|
return True
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ MODELS = [
|
|||||||
"shared.models.market", # CartItem lives here
|
"shared.models.market", # CartItem lives here
|
||||||
"shared.models.order",
|
"shared.models.order",
|
||||||
"shared.models.page_config",
|
"shared.models.page_config",
|
||||||
|
"shared.models.container_relation",
|
||||||
]
|
]
|
||||||
|
|
||||||
TABLES = frozenset({
|
TABLES = frozenset({
|
||||||
"cart_items", "orders", "order_items", "page_configs",
|
"cart_items", "orders", "order_items", "page_configs", "container_relations",
|
||||||
})
|
})
|
||||||
|
|
||||||
run_alembic(context.config, MODELS, TABLES)
|
run_alembic(context.config, MODELS, TABLES)
|
||||||
|
|||||||
40
cart/alembic/versions/0003_add_container_relations.py
Normal file
40
cart/alembic/versions/0003_add_container_relations.py
Normal 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")
|
||||||
@@ -109,4 +109,47 @@ def register() -> Blueprint:
|
|||||||
|
|
||||||
_handlers["update-page-config"] = _update_page_config
|
_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
|
return bp
|
||||||
|
|||||||
@@ -171,6 +171,32 @@ def register() -> Blueprint:
|
|||||||
|
|
||||||
_handlers["page-configs-batch"] = _page_configs_batch
|
_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
|
return bp
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from models.calendars import Calendar
|
from models.calendars import Calendar
|
||||||
from shared.infrastructure.data_client import fetch_data
|
from shared.infrastructure.data_client import fetch_data
|
||||||
from shared.contracts.dtos import PostDTO, dto_from_dict
|
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 unicodedata
|
||||||
import re
|
import re
|
||||||
|
|
||||||
@@ -71,7 +71,10 @@ async def soft_delete(sess: AsyncSession, post_slug: str, calendar_slug: str) ->
|
|||||||
|
|
||||||
cal.deleted_at = utcnow()
|
cal.deleted_at = utcnow()
|
||||||
await sess.flush()
|
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
|
return True
|
||||||
|
|
||||||
async def create_calendar(sess: AsyncSession, post_id: int, name: str) -> Calendar:
|
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:
|
if existing.deleted_at is not None:
|
||||||
existing.deleted_at = None # revive
|
existing.deleted_at = None # revive
|
||||||
await sess.flush()
|
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
|
return existing
|
||||||
raise CalendarError(f'Calendar with slug "{slug}" already exists for post {post_id}.')
|
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)
|
cal = Calendar(container_type="page", container_id=post_id, name=name, slug=slug)
|
||||||
sess.add(cal)
|
sess.add(cal)
|
||||||
await sess.flush()
|
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
|
return cal
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from shared.models.market import Product
|
|||||||
from shared.models.market_place import MarketPlace
|
from shared.models.market_place import MarketPlace
|
||||||
from shared.browser.app.utils import utcnow
|
from shared.browser.app.utils import utcnow
|
||||||
from shared.contracts.dtos import MarketPlaceDTO, ProductDTO
|
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:
|
def _mp_to_dto(mp: MarketPlace) -> MarketPlaceDTO:
|
||||||
@@ -93,7 +93,10 @@ class SqlMarketService:
|
|||||||
existing.deleted_at = None # revive
|
existing.deleted_at = None # revive
|
||||||
existing.name = name
|
existing.name = name
|
||||||
await session.flush()
|
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)
|
return _mp_to_dto(existing)
|
||||||
raise ValueError(f'Market with slug "{slug}" already exists for this container.')
|
raise ValueError(f'Market with slug "{slug}" already exists for this container.')
|
||||||
|
|
||||||
@@ -103,7 +106,10 @@ class SqlMarketService:
|
|||||||
)
|
)
|
||||||
session.add(market)
|
session.add(market)
|
||||||
await session.flush()
|
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)
|
return _mp_to_dto(market)
|
||||||
|
|
||||||
async def soft_delete_marketplace(
|
async def soft_delete_marketplace(
|
||||||
@@ -124,5 +130,8 @@ class SqlMarketService:
|
|||||||
|
|
||||||
market.deleted_at = utcnow()
|
market.deleted_at = utcnow()
|
||||||
await session.flush()
|
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
|
return True
|
||||||
|
|||||||
Reference in New Issue
Block a user