"""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() # ── Topic CRUD ────────────────────────────────────────────────────────── from fabledassistant.models.rulebook import RulebookTopic async def _assert_rulebook_owned(session, rulebook_id: int, user_id: int) -> None: """Raise ValueError if rulebook doesn't exist or isn't owned by user. Centralizes ownership check used by all topic/rule operations. """ result = await session.execute( select(Rulebook).where( Rulebook.id == rulebook_id, Rulebook.owner_user_id == user_id, ) ) if result.scalar_one_or_none() is None: raise ValueError(f"rulebook {rulebook_id} not found") async def create_topic( rulebook_id: int, user_id: int, title: str, description: str = "", order_index: int = 0, ) -> RulebookTopic: async with async_session() as session: await _assert_rulebook_owned(session, rulebook_id, user_id) topic = RulebookTopic( rulebook_id=rulebook_id, title=title, description=description or None, order_index=order_index, ) session.add(topic) await session.commit() await session.refresh(topic) return topic async def list_topics(rulebook_id: int, user_id: int) -> list[RulebookTopic]: async with async_session() as session: await _assert_rulebook_owned(session, rulebook_id, user_id) result = await session.execute( select(RulebookTopic) .where(RulebookTopic.rulebook_id == rulebook_id) .order_by(RulebookTopic.order_index, RulebookTopic.title) ) return list(result.scalars().all()) async def get_topic(topic_id: int, user_id: int) -> Optional[RulebookTopic]: """Get a topic, scoped via the rulebook owner.""" async with async_session() as session: result = await session.execute( select(RulebookTopic) .join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id) .where( RulebookTopic.id == topic_id, Rulebook.owner_user_id == user_id, ) ) return result.scalar_one_or_none() async def update_topic( topic_id: int, user_id: int, **fields, ) -> Optional[RulebookTopic]: async with async_session() as session: result = await session.execute( select(RulebookTopic) .join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id) .where( RulebookTopic.id == topic_id, Rulebook.owner_user_id == user_id, ) ) topic = result.scalar_one_or_none() if topic is None: return None allowed = {"title", "description", "order_index"} for key, value in fields.items(): if key in allowed and value is not None: setattr(topic, key, value) await session.commit() await session.refresh(topic) return topic async def delete_topic(topic_id: int, user_id: int) -> None: async with async_session() as session: result = await session.execute( select(RulebookTopic) .join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id) .where( RulebookTopic.id == topic_id, Rulebook.owner_user_id == user_id, ) ) topic = result.scalar_one_or_none() if topic is None: return await session.delete(topic) await session.commit() # ── Rule CRUD ────────────────────────────────────────────────────────── from fabledassistant.models.rulebook import Rule async def _assert_topic_owned(session, topic_id: int, user_id: int) -> None: """Raise ValueError if topic doesn't exist or isn't in user's rulebook.""" result = await session.execute( select(RulebookTopic) .join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id) .where( RulebookTopic.id == topic_id, Rulebook.owner_user_id == user_id, ) ) if result.scalar_one_or_none() is None: raise ValueError(f"topic {topic_id} not found") async def create_rule( topic_id: int, user_id: int, title: str, statement: str, why: str = "", how_to_apply: str = "", order_index: int = 0, ) -> Rule: async with async_session() as session: await _assert_topic_owned(session, topic_id, user_id) rule = Rule( topic_id=topic_id, title=title, statement=statement, why=why or None, how_to_apply=how_to_apply or None, order_index=order_index, ) session.add(rule) await session.commit() await session.refresh(rule) return rule async def list_rules( user_id: int, rulebook_id: int | None = None, topic_id: int | None = None, project_id: int | None = None, ) -> list[Rule]: """List rules filtered by any of the three IDs. All filters are ownership-scoped. project_id resolves rules through project_rulebook_subscriptions. """ from fabledassistant.models.rulebook import project_rulebook_subscriptions async with async_session() as session: stmt = ( select(Rule) .join(RulebookTopic, Rule.topic_id == RulebookTopic.id) .join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id) .where(Rulebook.owner_user_id == user_id) ) if topic_id: stmt = stmt.where(Rule.topic_id == topic_id) if rulebook_id: stmt = stmt.where(RulebookTopic.rulebook_id == rulebook_id) if project_id: stmt = ( stmt.join( project_rulebook_subscriptions, project_rulebook_subscriptions.c.rulebook_id == Rulebook.id, ) .where(project_rulebook_subscriptions.c.project_id == project_id) ) stmt = stmt.order_by( Rulebook.id, RulebookTopic.order_index, Rule.order_index, Rule.title, ) result = await session.execute(stmt) return list(result.scalars().all()) async def get_rule(rule_id: int, user_id: int) -> Optional[Rule]: async with async_session() as session: result = await session.execute( select(Rule) .join(RulebookTopic, Rule.topic_id == RulebookTopic.id) .join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id) .where( Rule.id == rule_id, Rulebook.owner_user_id == user_id, ) ) return result.scalar_one_or_none() async def update_rule(rule_id: int, user_id: int, **fields) -> Optional[Rule]: async with async_session() as session: result = await session.execute( select(Rule) .join(RulebookTopic, Rule.topic_id == RulebookTopic.id) .join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id) .where( Rule.id == rule_id, Rulebook.owner_user_id == user_id, ) ) rule = result.scalar_one_or_none() if rule is None: return None allowed = {"title", "statement", "why", "how_to_apply", "order_index"} for key, value in fields.items(): if key in allowed and value is not None: setattr(rule, key, value) await session.commit() await session.refresh(rule) return rule async def delete_rule(rule_id: int, user_id: int) -> None: async with async_session() as session: result = await session.execute( select(Rule) .join(RulebookTopic, Rule.topic_id == RulebookTopic.id) .join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id) .where( Rule.id == rule_id, Rulebook.owner_user_id == user_id, ) ) rule = result.scalar_one_or_none() if rule is None: return await session.delete(rule) await session.commit()