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,50 +2,18 @@
Internal JSON API for the coop app.
These endpoints are called by other apps (market, cart) over HTTP
to fetch Ghost CMS content and menu items without importing blog services.
to fetch Ghost CMS content without importing blog services.
"""
from __future__ import annotations
from quart import Blueprint, g, jsonify
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from shared.models.menu_item import MenuItem
from shared.browser.app.csrf import csrf_exempt
def register() -> Blueprint:
bp = Blueprint("coop_api", __name__, url_prefix="/internal")
@bp.get("/menu-items")
@csrf_exempt
async def menu_items():
"""
Return all active menu items as lightweight JSON.
Called by market and cart apps to render the nav.
"""
result = await g.s.execute(
select(MenuItem)
.where(MenuItem.deleted_at.is_(None))
.options(selectinload(MenuItem.post))
.order_by(MenuItem.sort_order.asc(), MenuItem.id.asc())
)
items = result.scalars().all()
return jsonify(
[
{
"id": mi.id,
"post": {
"title": mi.post.title if mi.post else None,
"slug": mi.post.slug if mi.post else None,
"feature_image": mi.post.feature_image if mi.post else None,
},
}
for mi in items
]
)
@bp.get("/post/<slug>")
@csrf_exempt
async def post_by_slug(slug: str):

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",