feat(rulebook): service layer — Rule CRUD with multi-filter list_rules
This commit is contained in:
@@ -211,3 +211,135 @@ async def delete_topic(topic_id: int, user_id: int) -> None:
|
|||||||
return
|
return
|
||||||
await session.delete(topic)
|
await session.delete(topic)
|
||||||
await session.commit()
|
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()
|
||||||
|
|||||||
@@ -156,3 +156,68 @@ async def test_list_topics_returns_topics_for_owned_rulebook():
|
|||||||
results = await list_topics(rulebook_id=1, user_id=7)
|
results = await list_topics(rulebook_id=1, user_id=7)
|
||||||
assert len(results) == 1
|
assert len(results) == 1
|
||||||
assert results[0].title == "git-workflow"
|
assert results[0].title == "git-workflow"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Rule CRUD ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _fake_rule(id=1, topic_id=10, title="dev is home",
|
||||||
|
statement="Work directly on dev", why="", how_to_apply=""):
|
||||||
|
r = MagicMock()
|
||||||
|
r.id = id
|
||||||
|
r.topic_id = topic_id
|
||||||
|
r.title = title
|
||||||
|
r.statement = statement
|
||||||
|
r.why = why
|
||||||
|
r.how_to_apply = how_to_apply
|
||||||
|
r.order_index = 0
|
||||||
|
r.created_at = datetime.now(timezone.utc)
|
||||||
|
r.updated_at = datetime.now(timezone.utc)
|
||||||
|
r.to_dict.return_value = {
|
||||||
|
"id": id, "topic_id": topic_id, "title": title,
|
||||||
|
"statement": statement, "why": why or "",
|
||||||
|
"how_to_apply": how_to_apply or "", "order_index": 0,
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_rule_requires_owned_topic():
|
||||||
|
mock_session = _make_mock_session()
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.scalar_one_or_none.return_value = None # topic not found
|
||||||
|
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||||
|
with patch("fabledassistant.services.rulebooks.async_session") as mock_cls:
|
||||||
|
mock_cls.return_value = mock_session
|
||||||
|
from fabledassistant.services.rulebooks import create_rule
|
||||||
|
with pytest.raises(ValueError, match="topic .* not found"):
|
||||||
|
await create_rule(
|
||||||
|
topic_id=999, user_id=7, title="x", statement="y",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_rules_filters_by_topic_id():
|
||||||
|
"""list_rules(topic_id=X) returns rules in that topic, ownership-scoped."""
|
||||||
|
rule = _fake_rule(id=1, topic_id=10)
|
||||||
|
mock_session = _make_mock_session()
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.scalars.return_value.all.return_value = [rule]
|
||||||
|
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||||
|
with patch("fabledassistant.services.rulebooks.async_session") as mock_cls:
|
||||||
|
mock_cls.return_value = mock_session
|
||||||
|
from fabledassistant.services.rulebooks import list_rules
|
||||||
|
results = await list_rules(user_id=7, topic_id=10)
|
||||||
|
assert len(results) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_rule_returns_none_when_not_owner():
|
||||||
|
mock_session = _make_mock_session()
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.scalar_one_or_none.return_value = None
|
||||||
|
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||||
|
with patch("fabledassistant.services.rulebooks.async_session") as mock_cls:
|
||||||
|
mock_cls.return_value = mock_session
|
||||||
|
from fabledassistant.services.rulebooks import get_rule
|
||||||
|
result = await get_rule(rule_id=1, user_id=99)
|
||||||
|
assert result is None
|
||||||
|
|||||||
Reference in New Issue
Block a user