From 7861607fb842cbea9c99bdde44c3ba960a6c2c3c Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 1 Jun 2026 02:26:20 -0400 Subject: [PATCH] feat(rules): project rule + topic suppressions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../0060_project_rule_suppressions.py | 73 +++++++ frontend/src/api/rulebooks.ts | 34 ++++ .../src/components/rules/ProjectRulesTab.vue | 165 +++++++++++++-- src/fabledassistant/mcp/tools/projects.py | 4 + src/fabledassistant/mcp/tools/rulebooks.py | 58 ++++++ src/fabledassistant/mcp/tools/tasks.py | 2 + src/fabledassistant/models/rulebook.py | 19 ++ src/fabledassistant/routes/rulebooks.py | 48 +++++ src/fabledassistant/services/planning.py | 2 + src/fabledassistant/services/rulebooks.py | 191 +++++++++++++++++- src/fabledassistant/services/trash.py | 17 ++ tests/test_mcp_tool_rulebooks.py | 50 ++++- tests/test_routes_rulebooks.py | 25 +++ tests/test_services_rulebooks.py | 77 +++++-- tests/test_services_trash.py | 14 +- 15 files changed, 729 insertions(+), 50 deletions(-) create mode 100644 alembic/versions/0060_project_rule_suppressions.py diff --git a/alembic/versions/0060_project_rule_suppressions.py b/alembic/versions/0060_project_rule_suppressions.py new file mode 100644 index 0000000..99edc3d --- /dev/null +++ b/alembic/versions/0060_project_rule_suppressions.py @@ -0,0 +1,73 @@ +"""project rule + topic suppressions + +Revision ID: 0060 +Revises: 0059 +Create Date: 2026-06-01 + +Lets a project mute specific rules or whole topics from rulebooks it +subscribes to, without unsubscribing the rulebook. Two pure many-to-many +association tables; FKs CASCADE so removing a project / rule / topic +cleans the suppression rows automatically. +""" +from alembic import op +import sqlalchemy as sa + + +revision = "0060" +down_revision = "0059" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "project_rule_suppressions", + sa.Column( + "project_id", + sa.BigInteger(), + sa.ForeignKey("projects.id", ondelete="CASCADE"), + primary_key=True, + nullable=False, + ), + sa.Column( + "rule_id", + sa.BigInteger(), + sa.ForeignKey("rules.id", ondelete="CASCADE"), + primary_key=True, + nullable=False, + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + ) + op.create_table( + "project_topic_suppressions", + sa.Column( + "project_id", + sa.BigInteger(), + sa.ForeignKey("projects.id", ondelete="CASCADE"), + primary_key=True, + nullable=False, + ), + sa.Column( + "topic_id", + sa.BigInteger(), + sa.ForeignKey("rulebook_topics.id", ondelete="CASCADE"), + primary_key=True, + nullable=False, + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + ) + + +def downgrade() -> None: + op.drop_table("project_topic_suppressions") + op.drop_table("project_rule_suppressions") diff --git a/frontend/src/api/rulebooks.ts b/frontend/src/api/rulebooks.ts index d048290..858d854 100644 --- a/frontend/src/api/rulebooks.ts +++ b/frontend/src/api/rulebooks.ts @@ -45,7 +45,9 @@ export interface ApplicableRules { id: number; title: string; statement: string; + topic_id: number; topic_title: string; + rulebook_id: number; rulebook_title: string; }[]; project_rules: { @@ -53,6 +55,20 @@ export interface ApplicableRules { title: string; statement: string; }[]; + suppressed_rules: { + id: number; + title: string; + topic_id: number; + topic_title: string; + rulebook_id: number; + rulebook_title: string; + }[]; + suppressed_topics: { + id: number; + title: string; + rulebook_id: number; + rulebook_title: string; + }[]; truncated: boolean; subscribed_rulebooks: { id: number; title: string }[]; } @@ -147,3 +163,21 @@ export async function createProjectRule( ): Promise { return apiPost(`/api/projects/${projectId}/rules`, data); } + +// ── Suppressions ─────────────────────────────────────────────────── + +export async function suppressRuleForProject(projectId: number, ruleId: number): Promise { + await apiPost(`/api/projects/${projectId}/suppressions/rules/${ruleId}`, {}); +} + +export async function unsuppressRuleForProject(projectId: number, ruleId: number): Promise { + return apiDelete(`/api/projects/${projectId}/suppressions/rules/${ruleId}`); +} + +export async function suppressTopicForProject(projectId: number, topicId: number): Promise { + await apiPost(`/api/projects/${projectId}/suppressions/topics/${topicId}`, {}); +} + +export async function unsuppressTopicForProject(projectId: number, topicId: number): Promise { + return apiDelete(`/api/projects/${projectId}/suppressions/topics/${topicId}`); +} diff --git a/frontend/src/components/rules/ProjectRulesTab.vue b/frontend/src/components/rules/ProjectRulesTab.vue index 65e4f24..424b390 100644 --- a/frontend/src/components/rules/ProjectRulesTab.vue +++ b/frontend/src/components/rules/ProjectRulesTab.vue @@ -4,6 +4,8 @@ import { useRouter } from "vue-router"; import { getProjectApplicableRules, subscribeProject, unsubscribeProject, listRulebooks, getRule, createProjectRule, deleteRule, + suppressRuleForProject, unsuppressRuleForProject, + suppressTopicForProject, unsuppressTopicForProject, } from "@/api/rulebooks"; import type { ApplicableRules, Rulebook } from "@/api/rulebooks"; @@ -62,18 +64,32 @@ function openInRulesView(rulebookId: number, ruleId?: number) { router.push({ path: "/rules", query }); } -function groupByRulebookAndTopic(rules: ApplicableRules["rules"]) { - const grouped: Record> = {}; - for (const r of rules) { - if (!grouped[r.rulebook_title]) grouped[r.rulebook_title] = {}; - if (!grouped[r.rulebook_title][r.topic_title]) grouped[r.rulebook_title][r.topic_title] = []; - grouped[r.rulebook_title][r.topic_title].push(r); - } - return grouped; +interface TopicGroup { + topic_id: number; + topic_title: string; + rules: ApplicableRules["rules"]; } - -function rulebookIdForTitle(title: string): number | undefined { - return applicable.value?.subscribed_rulebooks.find((rb) => rb.title === title)?.id; +interface RulebookGroup { + rulebook_id: number; + rulebook_title: string; + topics: TopicGroup[]; +} +function groupByRulebookAndTopic(rules: ApplicableRules["rules"]): RulebookGroup[] { + const byRulebook = new Map(); + for (const r of rules) { + let rb = byRulebook.get(r.rulebook_id); + if (!rb) { + rb = { rulebook_id: r.rulebook_id, rulebook_title: r.rulebook_title, topics: [] }; + byRulebook.set(r.rulebook_id, rb); + } + let topic = rb.topics.find((t) => t.topic_id === r.topic_id); + if (!topic) { + topic = { topic_id: r.topic_id, topic_title: r.topic_title, rules: [] }; + rb.topics.push(topic); + } + topic.rules.push(r); + } + return Array.from(byRulebook.values()); } async function submitProjectRule() { @@ -96,6 +112,28 @@ async function removeProjectRule(ruleId: number) { await load(); } +const showSuppressed = ref(false); + +async function suppressRule(ruleId: number) { + await suppressRuleForProject(props.projectId, ruleId); + await load(); +} + +async function unsuppressRule(ruleId: number) { + await unsuppressRuleForProject(props.projectId, ruleId); + await load(); +} + +async function suppressTopic(topicId: number) { + await suppressTopicForProject(props.projectId, topicId); + await load(); +} + +async function unsuppressTopic(topicId: number) { + await unsuppressTopicForProject(props.projectId, topicId); + await load(); +} + onMounted(async () => { await load(); await loadAllRulebooks(); @@ -204,18 +242,32 @@ watch(() => props.projectId, load); Rulebooks.

-

{{ rbTitle }}

-
-
{{ topicTitle }}
+

{{ rb.rulebook_title }}

+
+
+ {{ topic.topic_title }} + +
    -
  • -
    - {{ r.title }} - {{ r.statement }} +
  • +
    +
    + {{ r.title }} + {{ r.statement }} +
    +
    @@ -226,7 +278,7 @@ watch(() => props.projectId, load);
    @@ -239,6 +291,32 @@ watch(() => props.projectId, load); Truncated at 50 rules — there are more applicable rules.

    + +
    + +
    +
      +
    • + topic + {{ t.rulebook_title }} → {{ t.title }} + +
    • +
    +
      +
    • + rule + {{ r.rulebook_title }} → {{ r.topic_title }} → {{ r.title }} + +
    • +
    +
    +
    @@ -312,4 +390,49 @@ ul { list-style: none; padding: 0; margin: 0; } background: none; border: none; cursor: pointer; color: var(--color-destructive, #b85a4a); padding: 0.5rem 0 0 0; } +/* Per-rule / per-topic suppress affordance — quiet by default, reveal on hover */ +.topic-group h5 { + display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; +} +.rule-head { + display: flex; justify-content: space-between; align-items: flex-start; gap: 0.5rem; +} +.rule-head-text { flex: 1; cursor: pointer; } +.skip-btn { + background: none; border: none; cursor: pointer; + color: var(--color-muted, #888); font-size: 0.75rem; + padding: 0.1rem 0.4rem; opacity: 0; transition: opacity 0.15s; + white-space: nowrap; +} +.topic-group h5:hover .skip-btn, +.rule:hover .skip-btn, +.skip-btn:focus { opacity: 1; } +.skip-btn:hover { color: var(--color-destructive, #b85a4a); } +/* Suppressed section */ +.suppressed { margin-top: 1.5rem; } +.suppressed-toggle { + display: flex; align-items: center; gap: 0.4rem; + background: none; border: none; cursor: pointer; + font-size: 0.85rem; opacity: 0.7; padding: 0.25rem 0; color: inherit; +} +.suppressed-toggle:hover { opacity: 1; } +.suppressed-toggle .caret { font-size: 0.7em; } +.suppressed-body { margin-top: 0.5rem; } +.suppressed-list { padding-left: 0; } +.suppressed-list li { + display: flex; align-items: center; gap: 0.5rem; + padding: 0.25rem 0; opacity: 0.75; +} +.suppressed-kind { + font-size: 0.7em; text-transform: uppercase; letter-spacing: 0.05em; + padding: 0.1rem 0.4rem; border-radius: 3px; + background: var(--color-bg, #111113); + border: 1px solid var(--color-border, #2a2a2e); +} +.suppressed-path { flex: 1; } +.reenable-btn { + background: none; border: none; cursor: pointer; + color: var(--color-primary, #6366f1); font-size: 0.85em; +} +.reenable-btn:hover { text-decoration: underline; } diff --git a/src/fabledassistant/mcp/tools/projects.py b/src/fabledassistant/mcp/tools/projects.py index d31c47c..422a11a 100644 --- a/src/fabledassistant/mcp/tools/projects.py +++ b/src/fabledassistant/mcp/tools/projects.py @@ -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 diff --git a/src/fabledassistant/mcp/tools/rulebooks.py b/src/fabledassistant/mcp/tools/rulebooks.py index 809fc46..fabb55e 100644 --- a/src/fabledassistant/mcp/tools/rulebooks.py +++ b/src/fabledassistant/mcp/tools/rulebooks.py @@ -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) diff --git a/src/fabledassistant/mcp/tools/tasks.py b/src/fabledassistant/mcp/tools/tasks.py index ebe603f..056c769 100644 --- a/src/fabledassistant/mcp/tools/tasks.py +++ b/src/fabledassistant/mcp/tools/tasks.py @@ -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 diff --git a/src/fabledassistant/models/rulebook.py b/src/fabledassistant/models/rulebook.py index e382bb5..e65f9b9 100644 --- a/src/fabledassistant/models/rulebook.py +++ b/src/fabledassistant/models/rulebook.py @@ -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)), +) diff --git a/src/fabledassistant/routes/rulebooks.py b/src/fabledassistant/routes/rulebooks.py index 8404072..4f63d76 100644 --- a/src/fabledassistant/routes/rulebooks.py +++ b/src/fabledassistant/routes/rulebooks.py @@ -241,6 +241,54 @@ async def get_project_rules(project_id: int): return jsonify(result) +@rulebooks_bp.post("/projects//suppressions/rules/") +@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//suppressions/rules/") +@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//suppressions/topics/") +@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//suppressions/topics/") +@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//rules") @login_required async def create_project_rule(project_id: int): diff --git a/src/fabledassistant/services/planning.py b/src/fabledassistant/services/planning.py index 0385a42..3fa2f08 100644 --- a/src/fabledassistant/services/planning.py +++ b/src/fabledassistant/services/planning.py @@ -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, } diff --git a/src/fabledassistant/services/rulebooks.py b/src/fabledassistant/services/rulebooks.py index 8ea04e2..658f6f5 100644 --- a/src/fabledassistant/services/rulebooks.py +++ b/src/fabledassistant/services/rulebooks.py @@ -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, } diff --git a/src/fabledassistant/services/trash.py b/src/fabledassistant/services/trash.py index 30a9072..92ab824 100644 --- a/src/fabledassistant/services/trash.py +++ b/src/fabledassistant/services/trash.py @@ -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) diff --git a/tests/test_mcp_tool_rulebooks.py b/tests/test_mcp_tool_rulebooks.py index f2b3495..7c480e2 100644 --- a/tests/test_mcp_tool_rulebooks.py +++ b/tests/test_mcp_tool_rulebooks.py @@ -179,13 +179,17 @@ def test_register_attaches_all_sixteen_tools(): return decorator register(FakeMCP()) - assert len(registered) == 18 + assert len(registered) == 22 # spot-check a few names assert "list_rulebooks" in registered assert "create_rule" in registered assert "subscribe_project_to_rulebook" in registered assert "list_always_on_rules" in registered assert "create_project_rule" in registered + assert "suppress_rule_for_project" in registered + assert "unsuppress_rule_for_project" in registered + assert "suppress_topic_for_project" in registered + assert "unsuppress_topic_for_project" in registered @pytest.mark.asyncio @@ -284,3 +288,47 @@ async def test_create_project_rule_uses_explicit_title_when_given(): ) kwargs = mock.call_args.kwargs assert kwargs["title"] == "no auto-docstrings" + + +@pytest.mark.asyncio +async def test_suppress_rule_for_project_passes_through(): + mock = AsyncMock(return_value=None) + with patch("fabledassistant.mcp.tools.rulebooks.rulebooks_svc.suppress_rule_for_project", mock): + from fabledassistant.mcp.tools.rulebooks import suppress_rule_for_project + out = await suppress_rule_for_project(project_id=3, rule_id=17) + kwargs = mock.call_args.kwargs + assert kwargs == {"project_id": 3, "rule_id": 17, "user_id": 7} + assert out == {"project_id": 3, "rule_id": 17, "suppressed": True} + + +@pytest.mark.asyncio +async def test_unsuppress_rule_for_project_passes_through(): + mock = AsyncMock(return_value=None) + with patch("fabledassistant.mcp.tools.rulebooks.rulebooks_svc.unsuppress_rule_for_project", mock): + from fabledassistant.mcp.tools.rulebooks import unsuppress_rule_for_project + out = await unsuppress_rule_for_project(project_id=3, rule_id=17) + kwargs = mock.call_args.kwargs + assert kwargs == {"project_id": 3, "rule_id": 17, "user_id": 7} + assert out == {"project_id": 3, "rule_id": 17, "suppressed": False} + + +@pytest.mark.asyncio +async def test_suppress_topic_for_project_passes_through(): + mock = AsyncMock(return_value=None) + with patch("fabledassistant.mcp.tools.rulebooks.rulebooks_svc.suppress_topic_for_project", mock): + from fabledassistant.mcp.tools.rulebooks import suppress_topic_for_project + out = await suppress_topic_for_project(project_id=3, topic_id=22) + kwargs = mock.call_args.kwargs + assert kwargs == {"project_id": 3, "topic_id": 22, "user_id": 7} + assert out == {"project_id": 3, "topic_id": 22, "suppressed": True} + + +@pytest.mark.asyncio +async def test_unsuppress_topic_for_project_passes_through(): + mock = AsyncMock(return_value=None) + with patch("fabledassistant.mcp.tools.rulebooks.rulebooks_svc.unsuppress_topic_for_project", mock): + from fabledassistant.mcp.tools.rulebooks import unsuppress_topic_for_project + out = await unsuppress_topic_for_project(project_id=3, topic_id=22) + kwargs = mock.call_args.kwargs + assert kwargs == {"project_id": 3, "topic_id": 22, "user_id": 7} + assert out == {"project_id": 3, "topic_id": 22, "suppressed": False} diff --git a/tests/test_routes_rulebooks.py b/tests/test_routes_rulebooks.py index 891f95d..f647b00 100644 --- a/tests/test_routes_rulebooks.py +++ b/tests/test_routes_rulebooks.py @@ -48,6 +48,8 @@ def test_service_signatures_require_user_id(): "list_rules", "list_always_on_rules", "get_rule", "update_rule", "delete_rule", "subscribe_project", "unsubscribe_project", "get_applicable_rules", + "suppress_rule_for_project", "unsuppress_rule_for_project", + "suppress_topic_for_project", "unsuppress_topic_for_project", ): sig = inspect.signature(getattr(svc, fn_name)) assert "user_id" in sig.parameters, f"{fn_name} missing user_id param" @@ -67,6 +69,29 @@ def test_create_project_rule_route_exists(): assert callable(getattr(rb_routes, "create_project_rule")) +def test_suppression_route_handlers_exist(): + """The 4 suppression endpoint handlers are registered as Python callables.""" + from fabledassistant.routes import rulebooks as rb_routes + for name in ( + "suppress_project_rule", "unsuppress_project_rule", + "suppress_project_topic", "unsuppress_project_topic", + ): + assert callable(getattr(rb_routes, name)), f"missing route handler: {name}" + + +def test_suppression_association_tables_declared(): + """Migration 0060 created two new association tables; the models module + must declare matching Table() objects so the rest of the service layer + can reference them via .c..""" + from fabledassistant.models import rulebook as rb_models + for tbl_name in ("project_rule_suppressions", "project_topic_suppressions"): + tbl = getattr(rb_models, tbl_name, None) + assert tbl is not None, f"models.rulebook missing {tbl_name}" + cols = {c.name for c in tbl.columns} + assert "project_id" in cols + assert "rule_id" in cols or "topic_id" in cols + + def test_rulebook_model_carries_always_on(): """Migration 0058 added rulebooks.always_on — verify the model declares it.""" from fabledassistant.models.rulebook import Rulebook diff --git a/tests/test_services_rulebooks.py b/tests/test_services_rulebooks.py index babed27..45e5605 100644 --- a/tests/test_services_rulebooks.py +++ b/tests/test_services_rulebooks.py @@ -241,20 +241,31 @@ async def test_subscribe_project_requires_owned_rulebook(): ) +def _empty(): + """A MagicMock result whose .all() returns [] (or .scalars().all() returns []).""" + r = MagicMock() + r.all.return_value = [] + r.scalars.return_value.all.return_value = [] + return r + + @pytest.mark.asyncio async def test_get_applicable_rules_returns_shape(): - """get_applicable_rules returns {rules, project_rules, truncated, subscribed_rulebooks}.""" + """get_applicable_rules returns the full projection — including the + new suppression fields and rulebook/topic IDs on each rule.""" mock_session = _make_mock_session() sub_result = MagicMock() sub_result.all.return_value = [(1, "FabledSword family")] rules_result = MagicMock() rules_result.all.return_value = [ - (i, f"Rule {i}", f"Statement {i}", "git-workflow", "FabledSword family") + # (rule_id, title, statement, topic_id, topic_title, rulebook_id, rulebook_title) + (i, f"Rule {i}", f"Statement {i}", 2, "git-workflow", 1, "FabledSword family") for i in range(50) ] - proj_rules_result = MagicMock() - proj_rules_result.all.return_value = [] - mock_session.execute = AsyncMock(side_effect=[sub_result, rules_result, proj_rules_result]) + # Execute order: sub_q, suppressed_rules_q, suppressed_topics_q, rules_q, proj_rules_q + mock_session.execute = AsyncMock(side_effect=[ + sub_result, _empty(), _empty(), rules_result, _empty(), + ]) with patch("fabledassistant.services.rulebooks.async_session") as mock_cls: mock_cls.return_value = mock_session @@ -263,12 +274,18 @@ async def test_get_applicable_rules_returns_shape(): assert "rules" in result assert "project_rules" in result + assert "suppressed_rules" in result + assert "suppressed_topics" in result assert "truncated" in result assert "subscribed_rulebooks" in result assert result["subscribed_rulebooks"] == [{"id": 1, "title": "FabledSword family"}] assert len(result["rules"]) == 50 + assert result["rules"][0]["topic_id"] == 2 + assert result["rules"][0]["rulebook_id"] == 1 assert result["project_rules"] == [] - assert result["truncated"] is False # exactly 50, not over + assert result["suppressed_rules"] == [] + assert result["suppressed_topics"] == [] + assert result["truncated"] is False @pytest.mark.asyncio @@ -278,13 +295,12 @@ async def test_get_applicable_rules_truncates_when_over_limit(): sub_result = MagicMock() sub_result.all.return_value = [] rules_result = MagicMock() - # 51 rows means truncation detected rules_result.all.return_value = [ - (i, f"r{i}", "stmt", "topic", "rb") for i in range(51) + (i, f"r{i}", "stmt", 2, "topic", 1, "rb") for i in range(51) ] - proj_rules_result = MagicMock() - proj_rules_result.all.return_value = [] - mock_session.execute = AsyncMock(side_effect=[sub_result, rules_result, proj_rules_result]) + mock_session.execute = AsyncMock(side_effect=[ + sub_result, _empty(), _empty(), rules_result, _empty(), + ]) with patch("fabledassistant.services.rulebooks.async_session") as mock_cls: mock_cls.return_value = mock_session @@ -299,16 +315,14 @@ async def test_get_applicable_rules_truncates_when_over_limit(): async def test_get_applicable_rules_includes_project_scoped_rules(): """Project-scoped rules surface in the project_rules field.""" mock_session = _make_mock_session() - sub_result = MagicMock() - sub_result.all.return_value = [] - rules_result = MagicMock() - rules_result.all.return_value = [] proj_rules_result = MagicMock() proj_rules_result.all.return_value = [ (100, "Use alembic", "Always run migrations via alembic, never raw SQL."), (101, "PR-bound", "Land schema changes in their own PR."), ] - mock_session.execute = AsyncMock(side_effect=[sub_result, rules_result, proj_rules_result]) + mock_session.execute = AsyncMock(side_effect=[ + _empty(), _empty(), _empty(), _empty(), proj_rules_result, + ]) with patch("fabledassistant.services.rulebooks.async_session") as mock_cls: mock_cls.return_value = mock_session @@ -318,3 +332,34 @@ async def test_get_applicable_rules_includes_project_scoped_rules(): assert len(result["project_rules"]) == 2 assert result["project_rules"][0]["title"] == "Use alembic" assert result["project_rules"][1]["id"] == 101 + + +@pytest.mark.asyncio +async def test_get_applicable_rules_surfaces_suppressed_with_context(): + """Suppressed rules and topics come back with full title + rulebook context + so the UI can render them without an extra round-trip.""" + mock_session = _make_mock_session() + suppressed_rules_result = MagicMock() + suppressed_rules_result.all.return_value = [ + # (rule_id, title, topic_id, topic_title, rulebook_id, rulebook_title) + (17, "Old rule", 5, "old-topic", 1, "FabledSword family"), + ] + suppressed_topics_result = MagicMock() + suppressed_topics_result.all.return_value = [ + # (topic_id, topic_title, rulebook_id, rulebook_title) + (22, "design-system", 1, "FabledSword family"), + ] + mock_session.execute = AsyncMock(side_effect=[ + _empty(), suppressed_rules_result, suppressed_topics_result, _empty(), _empty(), + ]) + + with patch("fabledassistant.services.rulebooks.async_session") as mock_cls: + mock_cls.return_value = mock_session + from fabledassistant.services.rulebooks import get_applicable_rules + result = await get_applicable_rules(project_id=3, user_id=7) + + assert len(result["suppressed_rules"]) == 1 + assert result["suppressed_rules"][0]["id"] == 17 + assert result["suppressed_rules"][0]["rulebook_title"] == "FabledSword family" + assert len(result["suppressed_topics"]) == 1 + assert result["suppressed_topics"][0]["title"] == "design-system" diff --git a/tests/test_services_trash.py b/tests/test_services_trash.py index 09af133..0586ec9 100644 --- a/tests/test_services_trash.py +++ b/tests/test_services_trash.py @@ -48,18 +48,24 @@ async def test_delete_returns_none_when_not_found(): @pytest.mark.asyncio -async def test_delete_project_cascades_to_notes_milestones_and_project_rules(): +async def test_delete_project_cascades_to_notes_milestones_project_rules_and_suppressions(): session = _make_mock_session() - # exists-check + 4 cascade updates (notes, milestones, project-scoped rules, project) + # exists-check + 6 cascade ops: + # notes (soft) → milestones (soft) → project-scoped rules (soft) → + # project_rule_suppressions (hard DELETE) → project_topic_suppressions (hard DELETE) → + # project (soft) session.execute = AsyncMock(side_effect=[ - _exists_result(True), MagicMock(), MagicMock(), MagicMock(), MagicMock(), + _exists_result(True), + MagicMock(), MagicMock(), MagicMock(), + MagicMock(), MagicMock(), + MagicMock(), ]) with patch("fabledassistant.services.trash.async_session") as cls: cls.return_value = session from fabledassistant.services.trash import delete batch = await delete(user_id=1, entity_type="project", entity_id=3) assert isinstance(batch, str) - assert session.execute.await_count == 5 + assert session.execute.await_count == 7 @pytest.mark.asyncio