Initial glue layer: models, services, handlers, setup

This commit is contained in:
giles
2026-02-11 19:19:05 +00:00
commit f57c765cac
10 changed files with 263 additions and 0 deletions

0
__init__.py Normal file
View File

0
handlers/__init__.py Normal file
View File

View File

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

2
models/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
from .container_relation import ContainerRelation
from .menu_node import MenuNode

View File

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

50
models/menu_node.py Normal file
View File

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

0
services/__init__.py Normal file
View File

33
services/navigation.py Normal file
View File

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

118
services/relationships.py Normal file
View File

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

3
setup.py Normal file
View File

@@ -0,0 +1,3 @@
def register_glue_handlers():
"""Import handlers to trigger registration. Call at app startup."""
import glue.handlers.container_handlers # noqa: F401