diff --git a/.gitmodules b/.gitmodules index b509b5a..9ead19f 100644 --- a/.gitmodules +++ b/.gitmodules @@ -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 diff --git a/app.py b/app.py index ada3fcc..acc41cc 100644 --- a/app.py +++ b/app.py @@ -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) diff --git a/bp/coop_api.py b/bp/coop_api.py index 250acf5..86cbaf2 100644 --- a/bp/coop_api.py +++ b/bp/coop_api.py @@ -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/") @csrf_exempt async def post_by_slug(slug: str): diff --git a/bp/menu_items/services/menu_items.py b/bp/menu_items/services/menu_items.py index bc5aca4..5622dbd 100644 --- a/bp/menu_items/services/menu_items.py +++ b/bp/menu_items/services/menu_items.py @@ -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", diff --git a/glue b/glue new file mode 160000 index 0000000..f57c765 --- /dev/null +++ b/glue @@ -0,0 +1 @@ +Subproject commit f57c765cac299b32b66b08c99c2c6a85ca52e0ea diff --git a/models/ghost_content.py b/models/ghost_content.py index a24c03b..197f651 100644 --- a/models/ghost_content.py +++ b/models/ghost_content.py @@ -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" diff --git a/templates/_types/menu_items/_form.html b/templates/_types/menu_items/_form.html index 15bb404..8eed1c0 100644 --- a/templates/_types/menu_items/_form.html +++ b/templates/_types/menu_items/_form.html @@ -12,21 +12,21 @@ {# Hidden field for selected post ID - outside form for JS access #} - + {# Selected page display #} {% if menu_item %}
- {% if menu_item.post.feature_image %} - {{ menu_item.post.title }} {% else %}
{% endif %}
-
{{ menu_item.post.title }}
-
{{ menu_item.post.slug }}
+
{{ menu_item.label }}
+
{{ menu_item.slug }}
{% else %} diff --git a/templates/_types/menu_items/_list.html b/templates/_types/menu_items/_list.html index 70f676c..3892f07 100644 --- a/templates/_types/menu_items/_list.html +++ b/templates/_types/menu_items/_list.html @@ -9,9 +9,9 @@ {# Page image #} - {% if item.post.feature_image %} - {{ item.post.title }} {% else %}
@@ -19,8 +19,8 @@ {# Page title #}
-
{{ item.post.title }}
-
{{ item.post.slug }}
+
{{ item.label }}
+
{{ item.slug }}
{# 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" diff --git a/templates/_types/menu_items/_nav_oob.html b/templates/_types/menu_items/_nav_oob.html index 364d417..3dfe411 100644 --- a/templates/_types/menu_items/_nav_oob.html +++ b/templates/_types/menu_items/_nav_oob.html @@ -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 + '/')) %} - {% if item.post.feature_image %} - {{ item.post.title }} {% else %}
{% endif %} - {{ item.post.title }} + {{ item.label }}
{% endcall %}