From f57c765cac299b32b66b08c99c2c6a85ca52e0ea Mon Sep 17 00:00:00 2001 From: giles Date: Wed, 11 Feb 2026 19:19:05 +0000 Subject: [PATCH] Initial glue layer: models, services, handlers, setup --- __init__.py | 0 handlers/__init__.py | 0 handlers/container_handlers.py | 19 ++++++ models/__init__.py | 2 + models/container_relation.py | 38 +++++++++++ models/menu_node.py | 50 ++++++++++++++ services/__init__.py | 0 services/navigation.py | 33 +++++++++ services/relationships.py | 118 +++++++++++++++++++++++++++++++++ setup.py | 3 + 10 files changed, 263 insertions(+) create mode 100644 __init__.py create mode 100644 handlers/__init__.py create mode 100644 handlers/container_handlers.py create mode 100644 models/__init__.py create mode 100644 models/container_relation.py create mode 100644 models/menu_node.py create mode 100644 services/__init__.py create mode 100644 services/navigation.py create mode 100644 services/relationships.py create mode 100644 setup.py diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/handlers/__init__.py b/handlers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/handlers/container_handlers.py b/handlers/container_handlers.py new file mode 100644 index 0000000..0a23bd2 --- /dev/null +++ b/handlers/container_handlers.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from sqlalchemy.ext.asyncio import AsyncSession + +from shared.events import register_handler +from shared.models.domain_event import DomainEvent +from glue.services.navigation import rebuild_navigation + + +async def on_child_attached(event: DomainEvent, session: AsyncSession) -> None: + await rebuild_navigation(session) + + +async def on_child_detached(event: DomainEvent, session: AsyncSession) -> None: + await rebuild_navigation(session) + + +register_handler("container.child_attached", on_child_attached) +register_handler("container.child_detached", on_child_detached) diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..6ec7359 --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,2 @@ +from .container_relation import ContainerRelation +from .menu_node import MenuNode diff --git a/models/container_relation.py b/models/container_relation.py new file mode 100644 index 0000000..ecafaba --- /dev/null +++ b/models/container_relation.py @@ -0,0 +1,38 @@ +from datetime import datetime +from typing import Optional +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy import Integer, String, DateTime, Index, UniqueConstraint, func +from shared.db.base import Base + + +class ContainerRelation(Base): + __tablename__ = "container_relations" + + __table_args__ = ( + UniqueConstraint( + "parent_type", "parent_id", "child_type", "child_id", + name="uq_container_relations_parent_child", + ), + Index("ix_container_relations_parent", "parent_type", "parent_id"), + Index("ix_container_relations_child", "child_type", "child_id"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + + parent_type: Mapped[str] = mapped_column(String(32), nullable=False) + parent_id: Mapped[int] = mapped_column(Integer, nullable=False) + child_type: Mapped[str] = mapped_column(String(32), nullable=False) + child_id: Mapped[int] = mapped_column(Integer, nullable=False) + + sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + label: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + deleted_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) diff --git a/models/menu_node.py b/models/menu_node.py new file mode 100644 index 0000000..d4b49cc --- /dev/null +++ b/models/menu_node.py @@ -0,0 +1,50 @@ +from datetime import datetime +from typing import Optional +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy import Integer, String, Text, DateTime, ForeignKey, Index, func +from shared.db.base import Base + + +class MenuNode(Base): + __tablename__ = "menu_nodes" + + __table_args__ = ( + Index("ix_menu_nodes_container", "container_type", "container_id"), + Index("ix_menu_nodes_parent_id", "parent_id"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + + container_type: Mapped[str] = mapped_column(String(32), nullable=False) + container_id: Mapped[int] = mapped_column(Integer, nullable=False) + + parent_id: Mapped[Optional[int]] = mapped_column( + Integer, + ForeignKey("menu_nodes.id", ondelete="SET NULL"), + nullable=True, + ) + + sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + depth: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + + label: Mapped[str] = mapped_column(String(255), nullable=False) + slug: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + href: Mapped[Optional[str]] = mapped_column(String(1024), nullable=True) + icon: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) + feature_image: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + deleted_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/navigation.py b/services/navigation.py new file mode 100644 index 0000000..ae4005f --- /dev/null +++ b/services/navigation.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from glue.models.menu_node import MenuNode +from glue.models.container_relation import ContainerRelation + + +async def get_navigation_tree(session: AsyncSession) -> list[MenuNode]: + """ + Return top-level menu nodes ordered by sort_order. + + All apps call this directly (shared DB) — no more HTTP API. + """ + result = await session.execute( + select(MenuNode) + .where(MenuNode.deleted_at.is_(None), MenuNode.depth == 0) + .order_by(MenuNode.sort_order.asc(), MenuNode.id.asc()) + ) + return list(result.scalars().all()) + + +async def rebuild_navigation(session: AsyncSession) -> None: + """ + Rebuild menu_nodes from container_relations. + + Called by event handlers when relationships change. + Currently a no-op placeholder — menu nodes are managed directly + by the admin UI. When the full relationship-driven nav is needed, + this will sync ContainerRelation → MenuNode. + """ + pass diff --git a/services/relationships.py b/services/relationships.py new file mode 100644 index 0000000..b0aa5f4 --- /dev/null +++ b/services/relationships.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from shared.events import emit_event +from glue.models.container_relation import ContainerRelation + + +async def attach_child( + session: AsyncSession, + parent_type: str, + parent_id: int, + child_type: str, + child_id: int, + label: str | None = None, + sort_order: int | None = None, +) -> ContainerRelation: + """ + Create a ContainerRelation and emit container.child_attached event. + """ + if sort_order is None: + max_order = await session.scalar( + select(func.max(ContainerRelation.sort_order)).where( + ContainerRelation.parent_type == parent_type, + ContainerRelation.parent_id == parent_id, + ContainerRelation.deleted_at.is_(None), + ) + ) + sort_order = (max_order or 0) + 1 + + rel = ContainerRelation( + parent_type=parent_type, + parent_id=parent_id, + child_type=child_type, + child_id=child_id, + label=label, + sort_order=sort_order, + ) + session.add(rel) + await session.flush() + + await emit_event( + session, + event_type="container.child_attached", + aggregate_type="container_relation", + aggregate_id=rel.id, + payload={ + "parent_type": parent_type, + "parent_id": parent_id, + "child_type": child_type, + "child_id": child_id, + }, + ) + + return rel + + +async def get_children( + session: AsyncSession, + parent_type: str, + parent_id: int, + child_type: str | None = None, +) -> list[ContainerRelation]: + """Query children of a container, optionally filtered by child_type.""" + stmt = select(ContainerRelation).where( + ContainerRelation.parent_type == parent_type, + ContainerRelation.parent_id == parent_id, + ContainerRelation.deleted_at.is_(None), + ) + if child_type is not None: + stmt = stmt.where(ContainerRelation.child_type == child_type) + + stmt = stmt.order_by( + ContainerRelation.sort_order.asc(), ContainerRelation.id.asc() + ) + result = await session.execute(stmt) + return list(result.scalars().all()) + + +async def detach_child( + session: AsyncSession, + parent_type: str, + parent_id: int, + child_type: str, + child_id: int, +) -> bool: + """Soft-delete a ContainerRelation and emit container.child_detached event.""" + result = await session.execute( + select(ContainerRelation).where( + ContainerRelation.parent_type == parent_type, + ContainerRelation.parent_id == parent_id, + ContainerRelation.child_type == child_type, + ContainerRelation.child_id == child_id, + ContainerRelation.deleted_at.is_(None), + ) + ) + rel = result.scalar_one_or_none() + if not rel: + return False + + rel.deleted_at = func.now() + await session.flush() + + await emit_event( + session, + event_type="container.child_detached", + aggregate_type="container_relation", + aggregate_id=rel.id, + payload={ + "parent_type": parent_type, + "parent_id": parent_id, + "child_type": child_type, + "child_id": child_id, + }, + ) + + return True diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..18cd5c4 --- /dev/null +++ b/setup.py @@ -0,0 +1,3 @@ +def register_glue_handlers(): + """Import handlers to trigger registration. Call at app startup.""" + import glue.handlers.container_handlers # noqa: F401