feat(rulebook): service layer — Rulebook CRUD + find_rulebook_by_title
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user