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

3
.gitmodules vendored
View File

@@ -2,3 +2,6 @@
path = shared
url = https://git.rose-ash.com/coop/shared.git
branch = decoupling
[submodule "glue"]
path = glue
url = /root/rose-ash/glue

7
app.py
View File

@@ -24,17 +24,16 @@ async def coop_context() -> dict:
"""
Coop app context processor.
- menu_items: direct DB query (coop owns this data)
- menu_items: direct DB query via glue layer
- cart_count/cart_total: fetched from cart internal API
"""
from shared.infrastructure.context import base_context
from bp.menu_items.services.menu_items import get_all_menu_items
from glue.services.navigation import get_navigation_tree
from shared.infrastructure.internal_api import get as api_get
ctx = await base_context()
# Coop owns menu_items — query directly
ctx["menu_items"] = await get_all_menu_items(g.s)
ctx["menu_items"] = await get_navigation_tree(g.s)
# Cart data from cart app API
cart_data = await api_get("cart", "/internal/cart/summary", forward_session=True)

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

1
glue Submodule

Submodule glue added at f57c765cac

View File

@@ -141,14 +141,6 @@ class Post(Base):
passive_deletes=True,
)
menu_items: Mapped[List["MenuItem"]] = relationship(
"MenuItem",
back_populates="post",
cascade="all, delete-orphan",
passive_deletes=True,
order_by="MenuItem.sort_order",
)
class Author(Base):
__tablename__ = "authors"

View File

@@ -12,21 +12,21 @@
</div>
{# Hidden field for selected post ID - outside form for JS access #}
<input type="hidden" name="post_id" id="selected-post-id" value="{{ menu_item.post_id if menu_item else '' }}" />
<input type="hidden" name="post_id" id="selected-post-id" value="{{ menu_item.container_id if menu_item else '' }}" />
{# Selected page display #}
{% if menu_item %}
<div id="selected-page-display" class="mb-3 p-3 bg-stone-50 rounded flex items-center gap-3">
{% if menu_item.post.feature_image %}
<img src="{{ menu_item.post.feature_image }}"
alt="{{ menu_item.post.title }}"
{% if menu_item.feature_image %}
<img src="{{ menu_item.feature_image }}"
alt="{{ menu_item.label }}"
class="w-10 h-10 rounded-full object-cover" />
{% else %}
<div class="w-10 h-10 rounded-full bg-stone-200"></div>
{% endif %}
<div class="flex-1">
<div class="font-medium">{{ menu_item.post.title }}</div>
<div class="text-xs text-stone-500">{{ menu_item.post.slug }}</div>
<div class="font-medium">{{ menu_item.label }}</div>
<div class="text-xs text-stone-500">{{ menu_item.slug }}</div>
</div>
</div>
{% else %}

View File

@@ -9,9 +9,9 @@
</div>
{# Page image #}
{% if item.post.feature_image %}
<img src="{{ item.post.feature_image }}"
alt="{{ item.post.title }}"
{% if item.feature_image %}
<img src="{{ item.feature_image }}"
alt="{{ item.label }}"
class="w-12 h-12 rounded-full object-cover flex-shrink-0" />
{% else %}
<div class="w-12 h-12 rounded-full bg-stone-200 flex-shrink-0"></div>
@@ -19,8 +19,8 @@
{# Page title #}
<div class="flex-1 min-w-0">
<div class="font-medium truncate">{{ item.post.title }}</div>
<div class="text-xs text-stone-500 truncate">{{ item.post.slug }}</div>
<div class="font-medium truncate">{{ item.label }}</div>
<div class="text-xs text-stone-500 truncate">{{ item.slug }}</div>
</div>
{# Sort order #}
@@ -42,7 +42,7 @@
type="button"
data-confirm
data-confirm-title="Delete menu item?"
data-confirm-text="Remove {{ item.post.title }} from the menu?"
data-confirm-text="Remove {{ item.label }} from the menu?"
data-confirm-icon="warning"
data-confirm-confirm-text="Yes, delete"
data-confirm-cancel-text="Cancel"

View File

@@ -4,11 +4,11 @@
hx-swap-oob="outerHTML">
{% from 'macros/scrolling_menu.html' import scrolling_menu with context %}
{% call(item) scrolling_menu('menu-items-container', menu_items) %}
{% set _href = _app_slugs.get(item.post.slug, coop_url('/' + item.post.slug + '/')) %}
{% set _href = _app_slugs.get(item.slug, coop_url('/' + item.slug + '/')) %}
<a
href="{{ _href }}"
{% if item.post.slug not in _app_slugs %}
hx-get="/{{ item.post.slug }}/"
{% if item.slug not in _app_slugs %}
hx-get="/{{ item.slug }}/"
hx-target="#main-panel"
hx-select="{{ hx_select_search }}"
hx-swap="outerHTML"
@@ -16,14 +16,14 @@
{% endif %}
class="{{styles.nav_button}}"
>
{% if item.post.feature_image %}
<img src="{{ item.post.feature_image }}"
alt="{{ item.post.title }}"
{% if item.feature_image %}
<img src="{{ item.feature_image }}"
alt="{{ item.label }}"
class="w-8 h-8 rounded-full object-cover flex-shrink-0" />
{% else %}
<div class="w-8 h-8 rounded-full bg-stone-200 flex-shrink-0"></div>
{% endif %}
<span>{{ item.post.title }}</span>
<span>{{ item.label }}</span>
</a>
{% endcall %}
</div>