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:
@@ -80,6 +80,8 @@ async def enter_project(project_id: int) -> dict:
|
||||
"milestone_summary": milestone_summary,
|
||||
"applicable_rules": applicable["rules"],
|
||||
"project_rules": applicable.get("project_rules", []),
|
||||
"suppressed_rules": applicable.get("suppressed_rules", []),
|
||||
"suppressed_topics": applicable.get("suppressed_topics", []),
|
||||
"subscribed_rulebooks": applicable["subscribed_rulebooks"],
|
||||
"applicable_rules_truncated": applicable["truncated"],
|
||||
"open_tasks": [
|
||||
@@ -122,6 +124,8 @@ async def get_project(project_id: int) -> dict:
|
||||
data["applicable_rules_truncated"] = applicable["truncated"]
|
||||
data["subscribed_rulebooks"] = applicable["subscribed_rulebooks"]
|
||||
data["project_rules"] = applicable.get("project_rules", [])
|
||||
data["suppressed_rules"] = applicable.get("suppressed_rules", [])
|
||||
data["suppressed_topics"] = applicable.get("suppressed_topics", [])
|
||||
return data
|
||||
|
||||
|
||||
|
||||
@@ -364,6 +364,62 @@ async def unsubscribe_project_from_rulebook(
|
||||
return {"project_id": project_id, "rulebook_id": rulebook_id, "subscribed": False}
|
||||
|
||||
|
||||
# ── Suppressions — project-level mute of rulebook rules / topics ────────
|
||||
|
||||
async def suppress_rule_for_project(
|
||||
project_id: int, rule_id: int,
|
||||
) -> dict:
|
||||
"""Mute a single rulebook rule for one project.
|
||||
|
||||
The rule stays in its rulebook for other projects; only this project
|
||||
skips it. Idempotent. Use unsuppress_rule_for_project to re-enable.
|
||||
Project-scoped rules (create_project_rule) are NOT suppressible — delete
|
||||
them with delete_rule instead.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
await rulebooks_svc.suppress_rule_for_project(
|
||||
project_id=project_id, rule_id=rule_id, user_id=uid,
|
||||
)
|
||||
return {"project_id": project_id, "rule_id": rule_id, "suppressed": True}
|
||||
|
||||
|
||||
async def unsuppress_rule_for_project(
|
||||
project_id: int, rule_id: int,
|
||||
) -> dict:
|
||||
"""Re-enable a previously-suppressed rule for one project. Idempotent."""
|
||||
uid = current_user_id()
|
||||
await rulebooks_svc.unsuppress_rule_for_project(
|
||||
project_id=project_id, rule_id=rule_id, user_id=uid,
|
||||
)
|
||||
return {"project_id": project_id, "rule_id": rule_id, "suppressed": False}
|
||||
|
||||
|
||||
async def suppress_topic_for_project(
|
||||
project_id: int, topic_id: int,
|
||||
) -> dict:
|
||||
"""Mute every rule under a topic for one project.
|
||||
|
||||
Equivalent to suppressing each rule in the topic individually, but
|
||||
auto-includes new rules added to the topic later. Idempotent.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
await rulebooks_svc.suppress_topic_for_project(
|
||||
project_id=project_id, topic_id=topic_id, user_id=uid,
|
||||
)
|
||||
return {"project_id": project_id, "topic_id": topic_id, "suppressed": True}
|
||||
|
||||
|
||||
async def unsuppress_topic_for_project(
|
||||
project_id: int, topic_id: int,
|
||||
) -> dict:
|
||||
"""Re-enable a previously-suppressed topic for one project. Idempotent."""
|
||||
uid = current_user_id()
|
||||
await rulebooks_svc.unsuppress_topic_for_project(
|
||||
project_id=project_id, topic_id=topic_id, user_id=uid,
|
||||
)
|
||||
return {"project_id": project_id, "topic_id": topic_id, "suppressed": False}
|
||||
|
||||
|
||||
def register(mcp) -> None:
|
||||
for fn in (
|
||||
list_rulebooks, get_rulebook, create_rulebook, update_rulebook, delete_rulebook,
|
||||
@@ -371,5 +427,7 @@ def register(mcp) -> None:
|
||||
list_rules, list_always_on_rules, get_rule,
|
||||
create_rule, create_project_rule, update_rule, delete_rule,
|
||||
subscribe_project_to_rulebook, unsubscribe_project_from_rulebook,
|
||||
suppress_rule_for_project, unsuppress_rule_for_project,
|
||||
suppress_topic_for_project, unsuppress_topic_for_project,
|
||||
):
|
||||
mcp.tool(name=fn.__name__)(fn)
|
||||
|
||||
@@ -83,6 +83,8 @@ async def get_task(task_id: int) -> dict:
|
||||
data["subscribed_rulebooks"] = applicable["subscribed_rulebooks"]
|
||||
data["applicable_rules_truncated"] = applicable["truncated"]
|
||||
data["project_rules"] = applicable.get("project_rules", [])
|
||||
data["suppressed_rules"] = applicable.get("suppressed_rules", [])
|
||||
data["suppressed_topics"] = applicable.get("suppressed_topics", [])
|
||||
return data
|
||||
|
||||
|
||||
|
||||
@@ -131,3 +131,22 @@ project_rulebook_subscriptions = Table(
|
||||
Column("rulebook_id", BigInteger, ForeignKey("rulebooks.id", ondelete="CASCADE"), primary_key=True),
|
||||
Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)),
|
||||
)
|
||||
|
||||
# Suppressions — let a project mute individual rules or whole topics from
|
||||
# rulebooks it subscribes to, without unsubscribing the rulebook itself.
|
||||
# FKs CASCADE so the row vanishes when its parent is removed.
|
||||
project_rule_suppressions = Table(
|
||||
"project_rule_suppressions",
|
||||
Base.metadata,
|
||||
Column("project_id", BigInteger, ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True),
|
||||
Column("rule_id", BigInteger, ForeignKey("rules.id", ondelete="CASCADE"), primary_key=True),
|
||||
Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)),
|
||||
)
|
||||
|
||||
project_topic_suppressions = Table(
|
||||
"project_topic_suppressions",
|
||||
Base.metadata,
|
||||
Column("project_id", BigInteger, ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True),
|
||||
Column("topic_id", BigInteger, ForeignKey("rulebook_topics.id", ondelete="CASCADE"), primary_key=True),
|
||||
Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)),
|
||||
)
|
||||
|
||||
@@ -241,6 +241,54 @@ async def get_project_rules(project_id: int):
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@rulebooks_bp.post("/projects/<int:project_id>/suppressions/rules/<int:rule_id>")
|
||||
@login_required
|
||||
async def suppress_project_rule(project_id: int, rule_id: int):
|
||||
try:
|
||||
await rulebooks_svc.suppress_rule_for_project(
|
||||
project_id=project_id, rule_id=rule_id, user_id=_uid(),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 404
|
||||
return "", 204
|
||||
|
||||
|
||||
@rulebooks_bp.delete("/projects/<int:project_id>/suppressions/rules/<int:rule_id>")
|
||||
@login_required
|
||||
async def unsuppress_project_rule(project_id: int, rule_id: int):
|
||||
try:
|
||||
await rulebooks_svc.unsuppress_rule_for_project(
|
||||
project_id=project_id, rule_id=rule_id, user_id=_uid(),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 404
|
||||
return "", 204
|
||||
|
||||
|
||||
@rulebooks_bp.post("/projects/<int:project_id>/suppressions/topics/<int:topic_id>")
|
||||
@login_required
|
||||
async def suppress_project_topic(project_id: int, topic_id: int):
|
||||
try:
|
||||
await rulebooks_svc.suppress_topic_for_project(
|
||||
project_id=project_id, topic_id=topic_id, user_id=_uid(),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 404
|
||||
return "", 204
|
||||
|
||||
|
||||
@rulebooks_bp.delete("/projects/<int:project_id>/suppressions/topics/<int:topic_id>")
|
||||
@login_required
|
||||
async def unsuppress_project_topic(project_id: int, topic_id: int):
|
||||
try:
|
||||
await rulebooks_svc.unsuppress_topic_for_project(
|
||||
project_id=project_id, topic_id=topic_id, user_id=_uid(),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 404
|
||||
return "", 204
|
||||
|
||||
|
||||
@rulebooks_bp.post("/projects/<int:project_id>/rules")
|
||||
@login_required
|
||||
async def create_project_rule(project_id: int):
|
||||
|
||||
@@ -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