"""Rulebook / topic / rule service layer — single source of truth used by both routes/rulebooks.py and mcp/tools/rulebooks.py. Ownership enforcement: every function takes user_id and scopes through rulebooks.owner_user_id. Functions return models or model.to_dict() output depending on the caller's needs (mirroring services/events.py pattern). """ from __future__ import annotations import logging from typing import Optional from sqlalchemy import select from fabledassistant.models import async_session from fabledassistant.models.rulebook import Rulebook logger = logging.getLogger(__name__) # ── Rulebook CRUD ──────────────────────────────────────────────────────── async def create_rulebook( user_id: int, title: str, description: str = "", ) -> Rulebook: """Create a new rulebook owned by user_id. Returns the persisted model.""" async with async_session() as session: rb = Rulebook( owner_user_id=user_id, title=title, description=description or None, ) session.add(rb) await session.commit() await session.refresh(rb) return rb async def list_rulebooks(user_id: int) -> list[Rulebook]: """List rulebooks owned by user_id, ordered by title.""" async with async_session() as session: result = await session.execute( select(Rulebook) .where(Rulebook.owner_user_id == user_id) .order_by(Rulebook.title) ) return list(result.scalars().all()) async def get_rulebook(rulebook_id: int, user_id: int) -> Optional[Rulebook]: """Get a rulebook by id, scoped to user_id. None if not owned or not found.""" async with async_session() as session: result = await session.execute( select(Rulebook).where( Rulebook.id == rulebook_id, Rulebook.owner_user_id == user_id, ) ) return result.scalar_one_or_none() async def update_rulebook( rulebook_id: int, user_id: int, **fields, ) -> Optional[Rulebook]: """Partial update. Returns updated rulebook or None if not found.""" async with async_session() as session: result = await session.execute( select(Rulebook).where( Rulebook.id == rulebook_id, Rulebook.owner_user_id == user_id, ) ) rb = result.scalar_one_or_none() if rb is None: return None allowed = {"title", "description"} for key, value in fields.items(): if key in allowed and value is not None: setattr(rb, key, value) await session.commit() await session.refresh(rb) return rb async def delete_rulebook(rulebook_id: int, user_id: int) -> None: """Delete a rulebook. Cascade-deletes topics, rules, subscriptions.""" async with async_session() as session: result = await session.execute( select(Rulebook).where( Rulebook.id == rulebook_id, Rulebook.owner_user_id == user_id, ) ) rb = result.scalar_one_or_none() if rb is None: return await session.delete(rb) await session.commit() async def find_rulebook_by_title( user_id: int, title: str, ) -> Optional[Rulebook]: """Used by the port script for the dupe-guard. None if not found.""" async with async_session() as session: result = await session.execute( select(Rulebook).where( Rulebook.owner_user_id == user_id, Rulebook.title == title, ) ) return result.scalar_one_or_none()