feat(rules): project rule + topic suppressions
Lets a project mute individual rules or whole topics from rulebooks it subscribes to, without unsubscribing the rulebook. Two new association tables (migration 0060), 4 MCP tools (suppress/unsuppress × rule/topic), 4 REST endpoints, and an inline "× skip" affordance plus collapsed "Suppressed (N)" section in the project's Rules tab. get_applicable_rules now emits suppressed_rules and suppressed_topics (detail objects with rulebook/topic context, not just IDs) so the UI can render the suppressed list without a follow-up lookup. The main rules projection grew topic_id and rulebook_id columns for the per-row suppress affordance. Project deletion cascades the suppression rows via hard DELETE — they are pure associations with no soft-delete column, and restoring a deleted project should start fresh, not inherit stale mutes. Project-scoped rules (Rule.project_id) are deliberately not suppressible — delete them with delete_rule instead. Implements plan-task #187. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -59,6 +59,8 @@ async def start_planning(user_id: int, project_id: int, title: str) -> dict:
|
||||
"subscribed_rulebooks": applicable["subscribed_rulebooks"],
|
||||
"applicable_rules_truncated": applicable["truncated"],
|
||||
"project_rules": applicable.get("project_rules", []),
|
||||
"suppressed_rules": applicable.get("suppressed_rules", []),
|
||||
"suppressed_topics": applicable.get("suppressed_topics", []),
|
||||
"project_goal": getattr(project, "goal", "") or "",
|
||||
"open_task_count": open_count,
|
||||
}
|
||||
|
||||
@@ -256,6 +256,30 @@ async def _assert_project_owned(session, project_id: int, user_id: int) -> None:
|
||||
raise ValueError(f"project {project_id} not found")
|
||||
|
||||
|
||||
async def _assert_rulebook_rule_owned(session, rule_id: int, user_id: int) -> None:
|
||||
"""Raise ValueError if rule isn't a rulebook rule the user owns.
|
||||
|
||||
Project-scoped rules (Rule.project_id set, topic_id NULL) are NOT
|
||||
suppressible — they belong to the project; delete them instead. This
|
||||
helper deliberately excludes them.
|
||||
"""
|
||||
from fabledassistant.models.rulebook import Rule
|
||||
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.deleted_at.is_(None),
|
||||
RulebookTopic.deleted_at.is_(None),
|
||||
Rulebook.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
if result.scalar_one_or_none() is None:
|
||||
raise ValueError(f"rule {rule_id} not found or not a rulebook rule")
|
||||
|
||||
|
||||
async def create_rule(
|
||||
topic_id: int, user_id: int, title: str, statement: str,
|
||||
why: str = "", how_to_apply: str = "", order_index: int = 0,
|
||||
@@ -503,24 +527,115 @@ async def unsubscribe_project(
|
||||
await session.commit()
|
||||
|
||||
|
||||
# ── Suppressions — project-level mute of rulebook rules / topics ────────
|
||||
|
||||
async def suppress_rule_for_project(
|
||||
project_id: int, rule_id: int, user_id: int,
|
||||
) -> None:
|
||||
"""Mute one rulebook rule for one project. Idempotent."""
|
||||
from fabledassistant.models.rulebook import project_rule_suppressions
|
||||
|
||||
async with async_session() as session:
|
||||
await _assert_project_owned(session, project_id, user_id)
|
||||
await _assert_rulebook_rule_owned(session, rule_id, user_id)
|
||||
try:
|
||||
await session.execute(
|
||||
insert(project_rule_suppressions).values(
|
||||
project_id=project_id, rule_id=rule_id,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback() # PK collision = already suppressed; fine.
|
||||
|
||||
|
||||
async def unsuppress_rule_for_project(
|
||||
project_id: int, rule_id: int, user_id: int,
|
||||
) -> None:
|
||||
"""Unmute one rulebook rule for one project. Idempotent."""
|
||||
from fabledassistant.models.rulebook import project_rule_suppressions
|
||||
|
||||
async with async_session() as session:
|
||||
await _assert_project_owned(session, project_id, user_id)
|
||||
await session.execute(
|
||||
sql_delete(project_rule_suppressions).where(
|
||||
project_rule_suppressions.c.project_id == project_id,
|
||||
project_rule_suppressions.c.rule_id == rule_id,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def suppress_topic_for_project(
|
||||
project_id: int, topic_id: int, user_id: int,
|
||||
) -> None:
|
||||
"""Mute every rule under one topic for one project. Idempotent."""
|
||||
from fabledassistant.models.rulebook import project_topic_suppressions
|
||||
|
||||
async with async_session() as session:
|
||||
await _assert_project_owned(session, project_id, user_id)
|
||||
await _assert_topic_owned(session, topic_id, user_id)
|
||||
try:
|
||||
await session.execute(
|
||||
insert(project_topic_suppressions).values(
|
||||
project_id=project_id, topic_id=topic_id,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
|
||||
|
||||
async def unsuppress_topic_for_project(
|
||||
project_id: int, topic_id: int, user_id: int,
|
||||
) -> None:
|
||||
"""Unmute a topic for one project. Idempotent."""
|
||||
from fabledassistant.models.rulebook import project_topic_suppressions
|
||||
|
||||
async with async_session() as session:
|
||||
await _assert_project_owned(session, project_id, user_id)
|
||||
await session.execute(
|
||||
sql_delete(project_topic_suppressions).where(
|
||||
project_topic_suppressions.c.project_id == project_id,
|
||||
project_topic_suppressions.c.topic_id == topic_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 — both via rulebook subscriptions
|
||||
and project-scoped rules (Rule.project_id matches).
|
||||
and project-scoped rules (Rule.project_id matches), with suppressed rules
|
||||
and suppressed topics filtered out.
|
||||
|
||||
Shape:
|
||||
{
|
||||
"rules": [{id, title, statement, topic_title, rulebook_title}, ...],
|
||||
"rules": [{id, title, statement,
|
||||
topic_id, topic_title,
|
||||
rulebook_id, rulebook_title}, ...],
|
||||
"project_rules": [{id, title, statement}, ...],
|
||||
"suppressed_rules": [{id, title,
|
||||
topic_id, topic_title,
|
||||
rulebook_id, rulebook_title}, ...],
|
||||
"suppressed_topics": [{id, title,
|
||||
rulebook_id, rulebook_title}, ...],
|
||||
"truncated": bool,
|
||||
"subscribed_rulebooks": [{id, title}, ...]
|
||||
}
|
||||
|
||||
`rules` is the subscription-derived set (legacy shape preserved).
|
||||
`project_rules` is the project-scoped set; empty list when none exist.
|
||||
`rules` is the subscription-derived set with project-level suppressions
|
||||
applied. `project_rules` is the project-scoped set (never suppressed —
|
||||
delete instead). `suppressed_rules` / `suppressed_topics` carry the
|
||||
titles + rulebook context callers need to display what was filtered
|
||||
without round-tripping for names.
|
||||
"""
|
||||
from fabledassistant.models.rulebook import project_rulebook_subscriptions
|
||||
from fabledassistant.models.rulebook import (
|
||||
project_rulebook_subscriptions,
|
||||
project_rule_suppressions,
|
||||
project_topic_suppressions,
|
||||
)
|
||||
|
||||
async with async_session() as session:
|
||||
# Subscribed rulebooks for the project (ownership-scoped).
|
||||
@@ -542,11 +657,64 @@ async def get_applicable_rules(
|
||||
{"id": rb_id, "title": rb_title} for rb_id, rb_title in sub_rows
|
||||
]
|
||||
|
||||
# Applicable rules (limit + 1 so we can detect truncation).
|
||||
# Suppressed rules — joined to topic + rulebook so callers can render
|
||||
# context without a follow-up lookup. Ownership-scoped via rulebook.
|
||||
suppressed_rules_q = (
|
||||
select(
|
||||
Rule.id, Rule.title,
|
||||
RulebookTopic.id.label("topic_id"),
|
||||
RulebookTopic.title.label("topic_title"),
|
||||
Rulebook.id.label("rulebook_id"),
|
||||
Rulebook.title.label("rulebook_title"),
|
||||
)
|
||||
.join(project_rule_suppressions, project_rule_suppressions.c.rule_id == Rule.id)
|
||||
.join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
|
||||
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
|
||||
.where(
|
||||
project_rule_suppressions.c.project_id == project_id,
|
||||
Rulebook.owner_user_id == user_id,
|
||||
)
|
||||
.order_by(Rulebook.title, RulebookTopic.title, Rule.title)
|
||||
)
|
||||
suppressed_rule_rows = (await session.execute(suppressed_rules_q)).all()
|
||||
suppressed_rules = [
|
||||
{"id": rid, "title": rt, "topic_id": ti, "topic_title": tt,
|
||||
"rulebook_id": rbi, "rulebook_title": rbt}
|
||||
for rid, rt, ti, tt, rbi, rbt in suppressed_rule_rows
|
||||
]
|
||||
suppressed_rule_ids = [r["id"] for r in suppressed_rules]
|
||||
|
||||
# Suppressed topics — joined to rulebook for context.
|
||||
suppressed_topics_q = (
|
||||
select(
|
||||
RulebookTopic.id, RulebookTopic.title,
|
||||
Rulebook.id.label("rulebook_id"),
|
||||
Rulebook.title.label("rulebook_title"),
|
||||
)
|
||||
.join(project_topic_suppressions, project_topic_suppressions.c.topic_id == RulebookTopic.id)
|
||||
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
|
||||
.where(
|
||||
project_topic_suppressions.c.project_id == project_id,
|
||||
Rulebook.owner_user_id == user_id,
|
||||
)
|
||||
.order_by(Rulebook.title, RulebookTopic.title)
|
||||
)
|
||||
suppressed_topic_rows = (await session.execute(suppressed_topics_q)).all()
|
||||
suppressed_topics = [
|
||||
{"id": tid, "title": tt, "rulebook_id": rbi, "rulebook_title": rbt}
|
||||
for tid, tt, rbi, rbt in suppressed_topic_rows
|
||||
]
|
||||
suppressed_topic_ids = [t["id"] for t in suppressed_topics]
|
||||
|
||||
# Applicable rules (limit + 1 so we can detect truncation). Filter
|
||||
# in SQL so truncation reflects the post-suppression count, not the
|
||||
# raw subscription count.
|
||||
rules_q = (
|
||||
select(
|
||||
Rule.id, Rule.title, Rule.statement,
|
||||
RulebookTopic.id.label("topic_id"),
|
||||
RulebookTopic.title.label("topic_title"),
|
||||
Rulebook.id.label("rulebook_id"),
|
||||
Rulebook.title.label("rulebook_title"),
|
||||
)
|
||||
.join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
|
||||
@@ -567,14 +735,19 @@ async def get_applicable_rules(
|
||||
)
|
||||
.limit(limit + 1)
|
||||
)
|
||||
if suppressed_rule_ids:
|
||||
rules_q = rules_q.where(Rule.id.notin_(suppressed_rule_ids))
|
||||
if suppressed_topic_ids:
|
||||
rules_q = rules_q.where(Rule.topic_id.notin_(suppressed_topic_ids))
|
||||
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,
|
||||
"topic_id": ti, "topic_title": tt,
|
||||
"rulebook_id": rbi, "rulebook_title": rbt,
|
||||
}
|
||||
for rid, rtitle, stmt, tt, rbt in rule_rows[:limit]
|
||||
for rid, rtitle, stmt, ti, tt, rbi, rbt in rule_rows[:limit]
|
||||
]
|
||||
|
||||
# Project-scoped rules — verifies ownership via Project.user_id.
|
||||
@@ -599,6 +772,8 @@ async def get_applicable_rules(
|
||||
return {
|
||||
"rules": rules,
|
||||
"project_rules": project_rules,
|
||||
"suppressed_rules": suppressed_rules,
|
||||
"suppressed_topics": suppressed_topics,
|
||||
"truncated": truncated,
|
||||
"subscribed_rulebooks": subscribed_rulebooks,
|
||||
}
|
||||
|
||||
@@ -55,6 +55,23 @@ async def _cascade(session, user_id: int, etype: str, eid: int, batch: str, now)
|
||||
await _set(session, Milestone, [Milestone.user_id == user_id, Milestone.project_id == eid], batch, now)
|
||||
# Project-scoped rules cascade with the project they're attached to.
|
||||
await _set(session, Rule, [Rule.project_id == eid], batch, now)
|
||||
# Suppressions are pure associations (no deleted_at) — hard-delete
|
||||
# them here so restoring the project doesn't bring stale mutes back.
|
||||
# FK CASCADE would handle a full DELETE on the project row, but the
|
||||
# soft-delete path keeps the project row alive; this guarantees the
|
||||
# rows are gone whether or not the project ever gets purged.
|
||||
from sqlalchemy import delete as _sql_delete
|
||||
from fabledassistant.models.rulebook import (
|
||||
project_rule_suppressions, project_topic_suppressions,
|
||||
)
|
||||
await session.execute(
|
||||
_sql_delete(project_rule_suppressions)
|
||||
.where(project_rule_suppressions.c.project_id == eid)
|
||||
)
|
||||
await session.execute(
|
||||
_sql_delete(project_topic_suppressions)
|
||||
.where(project_topic_suppressions.c.project_id == eid)
|
||||
)
|
||||
await _set(session, Project, [Project.user_id == user_id, Project.id == eid], batch, now)
|
||||
elif etype == "milestone":
|
||||
await _set(session, Note, [Note.user_id == user_id, Note.milestone_id == eid], batch, now)
|
||||
|
||||
Reference in New Issue
Block a user