from __future__ import annotations from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func from models.menu_item import MenuItem from models.ghost_content import Post class MenuItemError(ValueError): """Base error for menu item service operations.""" async def get_all_menu_items(session: AsyncSession) -> list[MenuItem]: """ Get all menu items (excluding deleted), ordered by sort_order. Eagerly loads the post relationship. """ 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()) ) 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 result = await session.execute( select(MenuItem) .where(MenuItem.id == item_id, MenuItem.deleted_at.is_(None)) .options(selectinload(MenuItem.post)) ) return result.scalar_one_or_none() async def create_menu_item( session: AsyncSession, post_id: int, sort_order: int | None = None ) -> MenuItem: """ Create a new menu item. If sort_order is not provided, adds to end of list. """ # Verify post exists and is a page post = await session.scalar( select(Post).where(Post.id == post_id) ) if not post: raise MenuItemError(f"Post {post_id} does not exist.") if not post.is_page: raise MenuItemError("Only pages can be added as menu items, not posts.") # 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)) ) sort_order = (max_order or 0) + 1 # Check for duplicate (same post, not deleted) existing = await session.scalar( select(MenuItem).where( MenuItem.post_id == post_id, MenuItem.deleted_at.is_(None) ) ) if existing: raise MenuItemError(f"Menu item for this page already exists.") menu_item = MenuItem( post_id=post_id, sort_order=sort_order ) session.add(menu_item) await session.flush() # Reload with post relationship await session.refresh(menu_item, ["post"]) return menu_item async def update_menu_item( session: AsyncSession, 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: raise MenuItemError(f"Menu item {item_id} not found.") if post_id is not None: # Verify post exists and is a page post = await session.scalar( select(Post).where(Post.id == post_id) ) if not post: raise MenuItemError(f"Post {post_id} does not exist.") 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) existing = await session.scalar( select(MenuItem).where( MenuItem.post_id == post_id, MenuItem.id != item_id, MenuItem.deleted_at.is_(None) ) ) if existing: raise MenuItemError(f"Menu item for this page already exists.") menu_item.post_id = post_id if sort_order is not None: menu_item.sort_order = sort_order await session.flush() await session.refresh(menu_item, ["post"]) return menu_item 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: return False menu_item.deleted_at = func.now() await session.flush() return True async def reorder_menu_items( session: AsyncSession, item_ids: list[int] ) -> list[MenuItem]: """ Reorder menu items by providing a list of IDs in desired order. Updates sort_order for each item. """ 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) await session.flush() return items async def search_pages( session: AsyncSession, query: str, page: int = 1, per_page: int = 10 ) -> tuple[list[Post], int]: """ Search for pages (not posts) by title. Returns (pages, total_count). """ # Build search filter filters = [ Post.is_page == True, # noqa: E712 Post.status == "published", Post.deleted_at.is_(None) ] if query: filters.append(Post.title.ilike(f"%{query}%")) # Get total count count_result = await session.execute( select(func.count(Post.id)).where(*filters) ) total = count_result.scalar() or 0 # Get paginated results offset = (page - 1) * per_page result = await session.execute( select(Post) .where(*filters) .order_by(Post.title.asc()) .limit(per_page) .offset(offset) ) pages = list(result.scalars().all()) return pages, total