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 path = shared
url = https://git.rose-ash.com/coop/shared.git url = https://git.rose-ash.com/coop/shared.git
branch = decoupling 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. 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 - cart_count/cart_total: fetched from cart internal API
""" """
from shared.infrastructure.context import base_context 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 from shared.infrastructure.internal_api import get as api_get
ctx = await base_context() ctx = await base_context()
# Coop owns menu_items — query directly ctx["menu_items"] = await get_navigation_tree(g.s)
ctx["menu_items"] = await get_all_menu_items(g.s)
# Cart data from cart app API # Cart data from cart app API
cart_data = await api_get("cart", "/internal/cart/summary", forward_session=True) 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. Internal JSON API for the coop app.
These endpoints are called by other apps (market, cart) over HTTP 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 __future__ import annotations
from quart import Blueprint, g, jsonify 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 from shared.browser.app.csrf import csrf_exempt
def register() -> Blueprint: def register() -> Blueprint:
bp = Blueprint("coop_api", __name__, url_prefix="/internal") 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>") @bp.get("/post/<slug>")
@csrf_exempt @csrf_exempt
async def post_by_slug(slug: str): 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.ext.asyncio import AsyncSession
from sqlalchemy import select, func 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 from models.ghost_content import Post
@@ -10,30 +10,23 @@ class MenuItemError(ValueError):
"""Base error for menu item service operations.""" """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. Get all menu nodes (excluding deleted), ordered by sort_order.
Eagerly loads the post relationship.
""" """
from sqlalchemy.orm import selectinload
result = await session.execute( result = await session.execute(
select(MenuItem) select(MenuNode)
.where(MenuItem.deleted_at.is_(None)) .where(MenuNode.deleted_at.is_(None), MenuNode.depth == 0)
.options(selectinload(MenuItem.post)) .order_by(MenuNode.sort_order.asc(), MenuNode.id.asc())
.order_by(MenuItem.sort_order.asc(), MenuItem.id.asc())
) )
return list(result.scalars().all()) return list(result.scalars().all())
async def get_menu_item_by_id(session: AsyncSession, item_id: int) -> MenuItem | None: async def get_menu_item_by_id(session: AsyncSession, item_id: int) -> MenuNode | None:
"""Get a menu item by ID (excluding deleted).""" """Get a menu node by ID (excluding deleted)."""
from sqlalchemy.orm import selectinload
result = await session.execute( result = await session.execute(
select(MenuItem) select(MenuNode)
.where(MenuItem.id == item_id, MenuItem.deleted_at.is_(None)) .where(MenuNode.id == item_id, MenuNode.deleted_at.is_(None))
.options(selectinload(MenuItem.post))
) )
return result.scalar_one_or_none() return result.scalar_one_or_none()
@@ -42,9 +35,9 @@ async def create_menu_item(
session: AsyncSession, session: AsyncSession,
post_id: int, post_id: int,
sort_order: int | None = None 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. If sort_order is not provided, adds to end of list.
""" """
# Verify post exists and is a page # 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 no sort_order provided, add to end
if sort_order is None: if sort_order is None:
max_order = await session.scalar( max_order = await session.scalar(
select(func.max(MenuItem.sort_order)) select(func.max(MenuNode.sort_order))
.where(MenuItem.deleted_at.is_(None)) .where(MenuNode.deleted_at.is_(None), MenuNode.depth == 0)
) )
sort_order = (max_order or 0) + 1 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( existing = await session.scalar(
select(MenuItem).where( select(MenuNode).where(
MenuItem.post_id == post_id, MenuNode.container_type == "page",
MenuItem.deleted_at.is_(None) MenuNode.container_id == post_id,
MenuNode.deleted_at.is_(None),
) )
) )
if existing: if existing:
raise MenuItemError(f"Menu item for this page already exists.") raise MenuItemError("Menu item for this page already exists.")
menu_item = MenuItem( menu_node = MenuNode(
post_id=post_id, container_type="page",
sort_order=sort_order 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() await session.flush()
# Reload with post relationship return menu_node
await session.refresh(menu_item, ["post"])
return menu_item
async def update_menu_item( async def update_menu_item(
@@ -93,10 +88,10 @@ async def update_menu_item(
item_id: int, item_id: int,
post_id: int | None = None, post_id: int | None = None,
sort_order: int | None = None sort_order: int | None = None
) -> MenuItem: ) -> MenuNode:
"""Update an existing menu item.""" """Update an existing menu node."""
menu_item = await get_menu_item_by_id(session, item_id) menu_node = await get_menu_item_by_id(session, item_id)
if not menu_item: if not menu_node:
raise MenuItemError(f"Menu item {item_id} not found.") raise MenuItemError(f"Menu item {item_id} not found.")
if post_id is not None: if post_id is not None:
@@ -110,35 +105,38 @@ async def update_menu_item(
if not post.is_page: if not post.is_page:
raise MenuItemError("Only pages can be added as menu items, not posts.") 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( existing = await session.scalar(
select(MenuItem).where( select(MenuNode).where(
MenuItem.post_id == post_id, MenuNode.container_type == "page",
MenuItem.id != item_id, MenuNode.container_id == post_id,
MenuItem.deleted_at.is_(None) MenuNode.id != item_id,
MenuNode.deleted_at.is_(None),
) )
) )
if existing: 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: if sort_order is not None:
menu_item.sort_order = sort_order menu_node.sort_order = sort_order
await session.flush() 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: async def delete_menu_item(session: AsyncSession, item_id: int) -> bool:
"""Soft delete a menu item.""" """Soft delete a menu node."""
menu_item = await get_menu_item_by_id(session, item_id) menu_node = await get_menu_item_by_id(session, item_id)
if not menu_item: if not menu_node:
return False return False
menu_item.deleted_at = func.now() menu_node.deleted_at = func.now()
await session.flush() await session.flush()
return True return True
@@ -147,17 +145,17 @@ async def delete_menu_item(session: AsyncSession, item_id: int) -> bool:
async def reorder_menu_items( async def reorder_menu_items(
session: AsyncSession, session: AsyncSession,
item_ids: list[int] item_ids: list[int]
) -> list[MenuItem]: ) -> list[MenuNode]:
""" """
Reorder menu items by providing a list of IDs in desired order. Reorder menu nodes by providing a list of IDs in desired order.
Updates sort_order for each item. Updates sort_order for each node.
""" """
items = [] items = []
for index, item_id in enumerate(item_ids): for index, item_id in enumerate(item_ids):
menu_item = await get_menu_item_by_id(session, item_id) menu_node = await get_menu_item_by_id(session, item_id)
if menu_item: if menu_node:
menu_item.sort_order = index menu_node.sort_order = index
items.append(menu_item) items.append(menu_node)
await session.flush() await session.flush()
@@ -174,7 +172,6 @@ async def search_pages(
Search for pages (not posts) by title. Search for pages (not posts) by title.
Returns (pages, total_count). Returns (pages, total_count).
""" """
# Build search filter
filters = [ filters = [
Post.is_page == True, # noqa: E712 Post.is_page == True, # noqa: E712
Post.status == "published", Post.status == "published",

1
glue Submodule

Submodule glue added at f57c765cac

View File

@@ -141,14 +141,6 @@ class Post(Base):
passive_deletes=True, 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): class Author(Base):
__tablename__ = "authors" __tablename__ = "authors"

View File

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

View File

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

View File

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