Initial glue layer: models, services, handlers, setup
This commit is contained in:
0
__init__.py
Normal file
0
__init__.py
Normal file
0
handlers/__init__.py
Normal file
0
handlers/__init__.py
Normal file
19
handlers/container_handlers.py
Normal file
19
handlers/container_handlers.py
Normal 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
2
models/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from .container_relation import ContainerRelation
|
||||
from .menu_node import MenuNode
|
||||
38
models/container_relation.py
Normal file
38
models/container_relation.py
Normal 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
50
models/menu_node.py
Normal 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
0
services/__init__.py
Normal file
33
services/navigation.py
Normal file
33
services/navigation.py
Normal 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
118
services/relationships.py
Normal 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
|
||||
Reference in New Issue
Block a user