feat(rules): project rule + topic suppressions
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 39s
CI & Build / Python tests (push) Successful in 43s
CI & Build / Build & push image (push) Successful in 1m18s

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:
2026-06-01 02:26:20 -04:00
parent c5469214e3
commit 7861607fb8
15 changed files with 729 additions and 50 deletions
+10 -4
View File
@@ -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