feat(rulebook): service layer — subscriptions + get_applicable_rules

This commit is contained in:
2026-05-27 21:19:14 -04:00
parent d3833ba5a4
commit 45fe198d54
2 changed files with 179 additions and 0 deletions
+113
View File
@@ -343,3 +343,116 @@ async def delete_rule(rule_id: int, user_id: int) -> None:
return
await session.delete(rule)
await session.commit()
# ── Subscriptions + get_applicable_rules ───────────────────────────────
from sqlalchemy import insert, delete as sql_delete
async def subscribe_project(
project_id: int, rulebook_id: int, user_id: int,
) -> None:
"""Add a subscription. Idempotent — duplicates raise; we swallow."""
from fabledassistant.models.rulebook import project_rulebook_subscriptions
async with async_session() as session:
await _assert_rulebook_owned(session, rulebook_id, user_id)
# ON CONFLICT DO NOTHING via try/except to keep dialect-agnostic.
try:
await session.execute(
insert(project_rulebook_subscriptions).values(
project_id=project_id, rulebook_id=rulebook_id,
)
)
await session.commit()
except Exception:
await session.rollback() # PK collision = already subscribed; fine.
async def unsubscribe_project(
project_id: int, rulebook_id: int, user_id: int,
) -> None:
from fabledassistant.models.rulebook import project_rulebook_subscriptions
async with async_session() as session:
await _assert_rulebook_owned(session, rulebook_id, user_id)
await session.execute(
sql_delete(project_rulebook_subscriptions).where(
project_rulebook_subscriptions.c.project_id == project_id,
project_rulebook_subscriptions.c.rulebook_id == rulebook_id,
)
)
await session.commit()
async def get_applicable_rules(
project_id: int, user_id: int, limit: int = 50,
) -> dict:
"""Return rules applicable to a project via its subscriptions.
Shape:
{
"rules": [{id, title, statement, topic_title, rulebook_title}, ...],
"truncated": bool,
"subscribed_rulebooks": [{id, title}, ...]
}
"""
from fabledassistant.models.rulebook import project_rulebook_subscriptions
async with async_session() as session:
# Subscribed rulebooks for the project (ownership-scoped).
sub_q = (
select(Rulebook.id, Rulebook.title)
.join(
project_rulebook_subscriptions,
project_rulebook_subscriptions.c.rulebook_id == Rulebook.id,
)
.where(
project_rulebook_subscriptions.c.project_id == project_id,
Rulebook.owner_user_id == user_id,
)
.order_by(Rulebook.title)
)
sub_rows = (await session.execute(sub_q)).all()
subscribed_rulebooks = [
{"id": rb_id, "title": rb_title} for rb_id, rb_title in sub_rows
]
# Applicable rules (limit + 1 so we can detect truncation).
rules_q = (
select(
Rule.id, Rule.title, Rule.statement,
RulebookTopic.title.label("topic_title"),
Rulebook.title.label("rulebook_title"),
)
.join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
.join(
project_rulebook_subscriptions,
project_rulebook_subscriptions.c.rulebook_id == Rulebook.id,
)
.where(
project_rulebook_subscriptions.c.project_id == project_id,
Rulebook.owner_user_id == user_id,
)
.order_by(
Rulebook.id, RulebookTopic.order_index, Rule.order_index, Rule.title,
)
.limit(limit + 1)
)
rule_rows = (await session.execute(rules_q)).all()
truncated = len(rule_rows) > limit
rules = [
{
"id": rid, "title": rtitle, "statement": stmt,
"topic_title": tt, "rulebook_title": rbt,
}
for rid, rtitle, stmt, tt, rbt in rule_rows[:limit]
]
return {
"rules": rules,
"truncated": truncated,
"subscribed_rulebooks": subscribed_rulebooks,
}