Add glue layer: MenuNode replaces MenuItem, remove /internal/menu-items API
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m4s

- Context processor: get_navigation_tree() replaces get_all_menu_items()
- Menu admin service: MenuItem → MenuNode (container_type/container_id pattern)
- Remove /internal/menu-items endpoint (other apps query menu_nodes directly)
- Remove menu_items relationship from Post model
- Templates: item.post.X → item.X
- Add glue submodule

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
giles
2026-02-11 23:37:43 +00:00
parent da3481196b
commit 05d9e70e8a
9 changed files with 84 additions and 124 deletions

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from shared.models.menu_item import MenuItem
from glue.models.menu_node import MenuNode
from models.ghost_content import Post
@@ -10,30 +10,23 @@ class MenuItemError(ValueError):
"""Base error for menu item service operations."""
async def get_all_menu_items(session: AsyncSession) -> list[MenuItem]:
async def get_all_menu_items(session: AsyncSession) -> list[MenuNode]:
"""
Get all menu items (excluding deleted), ordered by sort_order.
Eagerly loads the post relationship.
Get all menu nodes (excluding deleted), ordered by sort_order.
"""
from sqlalchemy.orm import selectinload
result = await session.execute(
select(MenuItem)
.where(MenuItem.deleted_at.is_(None))
.options(selectinload(MenuItem.post))
.order_by(MenuItem.sort_order.asc(), MenuItem.id.asc())
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 get_menu_item_by_id(session: AsyncSession, item_id: int) -> MenuItem | None:
"""Get a menu item by ID (excluding deleted)."""
from sqlalchemy.orm import selectinload
async def get_menu_item_by_id(session: AsyncSession, item_id: int) -> MenuNode | None:
"""Get a menu node by ID (excluding deleted)."""
result = await session.execute(
select(MenuItem)
.where(MenuItem.id == item_id, MenuItem.deleted_at.is_(None))
.options(selectinload(MenuItem.post))
select(MenuNode)
.where(MenuNode.id == item_id, MenuNode.deleted_at.is_(None))
)
return result.scalar_one_or_none()
@@ -42,9 +35,9 @@ async def create_menu_item(
session: AsyncSession,
post_id: int,
sort_order: int | None = None
) -> MenuItem:
) -> MenuNode:
"""
Create a new menu item.
Create a MenuNode + ContainerRelation for a page.
If sort_order is not provided, adds to end of list.
"""
# Verify post exists and is a page
@@ -60,32 +53,34 @@ async def create_menu_item(
# If no sort_order provided, add to end
if sort_order is None:
max_order = await session.scalar(
select(func.max(MenuItem.sort_order))
.where(MenuItem.deleted_at.is_(None))
select(func.max(MenuNode.sort_order))
.where(MenuNode.deleted_at.is_(None), MenuNode.depth == 0)
)
sort_order = (max_order or 0) + 1
# Check for duplicate (same post, not deleted)
# Check for duplicate (same page, not deleted)
existing = await session.scalar(
select(MenuItem).where(
MenuItem.post_id == post_id,
MenuItem.deleted_at.is_(None)
select(MenuNode).where(
MenuNode.container_type == "page",
MenuNode.container_id == post_id,
MenuNode.deleted_at.is_(None),
)
)
if existing:
raise MenuItemError(f"Menu item for this page already exists.")
raise MenuItemError("Menu item for this page already exists.")
menu_item = MenuItem(
post_id=post_id,
sort_order=sort_order
menu_node = MenuNode(
container_type="page",
container_id=post_id,
label=post.title,
slug=post.slug,
feature_image=post.feature_image,
sort_order=sort_order,
)
session.add(menu_item)
session.add(menu_node)
await session.flush()
# Reload with post relationship
await session.refresh(menu_item, ["post"])
return menu_item
return menu_node
async def update_menu_item(
@@ -93,10 +88,10 @@ async def update_menu_item(
item_id: int,
post_id: int | None = None,
sort_order: int | None = None
) -> MenuItem:
"""Update an existing menu item."""
menu_item = await get_menu_item_by_id(session, item_id)
if not menu_item:
) -> MenuNode:
"""Update an existing menu node."""
menu_node = await get_menu_item_by_id(session, item_id)
if not menu_node:
raise MenuItemError(f"Menu item {item_id} not found.")
if post_id is not None:
@@ -110,35 +105,38 @@ async def update_menu_item(
if not post.is_page:
raise MenuItemError("Only pages can be added as menu items, not posts.")
# Check for duplicate (same post, different menu item)
# Check for duplicate (same page, different menu node)
existing = await session.scalar(
select(MenuItem).where(
MenuItem.post_id == post_id,
MenuItem.id != item_id,
MenuItem.deleted_at.is_(None)
select(MenuNode).where(
MenuNode.container_type == "page",
MenuNode.container_id == post_id,
MenuNode.id != item_id,
MenuNode.deleted_at.is_(None),
)
)
if existing:
raise MenuItemError(f"Menu item for this page already exists.")
raise MenuItemError("Menu item for this page already exists.")
menu_item.post_id = post_id
menu_node.container_id = post_id
menu_node.label = post.title
menu_node.slug = post.slug
menu_node.feature_image = post.feature_image
if sort_order is not None:
menu_item.sort_order = sort_order
menu_node.sort_order = sort_order
await session.flush()
await session.refresh(menu_item, ["post"])
return menu_item
return menu_node
async def delete_menu_item(session: AsyncSession, item_id: int) -> bool:
"""Soft delete a menu item."""
menu_item = await get_menu_item_by_id(session, item_id)
if not menu_item:
"""Soft delete a menu node."""
menu_node = await get_menu_item_by_id(session, item_id)
if not menu_node:
return False
menu_item.deleted_at = func.now()
menu_node.deleted_at = func.now()
await session.flush()
return True
@@ -147,17 +145,17 @@ async def delete_menu_item(session: AsyncSession, item_id: int) -> bool:
async def reorder_menu_items(
session: AsyncSession,
item_ids: list[int]
) -> list[MenuItem]:
) -> list[MenuNode]:
"""
Reorder menu items by providing a list of IDs in desired order.
Updates sort_order for each item.
Reorder menu nodes by providing a list of IDs in desired order.
Updates sort_order for each node.
"""
items = []
for index, item_id in enumerate(item_ids):
menu_item = await get_menu_item_by_id(session, item_id)
if menu_item:
menu_item.sort_order = index
items.append(menu_item)
menu_node = await get_menu_item_by_id(session, item_id)
if menu_node:
menu_node.sort_order = index
items.append(menu_node)
await session.flush()
@@ -174,7 +172,6 @@ async def search_pages(
Search for pages (not posts) by title.
Returns (pages, total_count).
"""
# Build search filter
filters = [
Post.is_page == True, # noqa: E712
Post.status == "published",