346 lines
12 KiB
Python
346 lines
12 KiB
Python
"""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()
|