feat(family): an idea links to the rule topic holding its norms - set_family_topic, the readouts name the rules, the idea row shows the link (milestone 463 step 1 gap, found in step 7, #4993)
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 1m9s
CI & Build / integration (push) Successful in 1m21s
CI & Build / Python tests (push) Successful in 2m2s
CI & Build / Build & push image (push) Successful in 1m29s

Step 1 added family_ideas.topic_id but nothing could set it, so the note<->topic
link the milestone promised existed only as a column. set_family_topic links or
unlinks (0) a topic in a rulebook the caller owns, one idea per topic; the
version does not move. get_family_idea and get_family_adoption list the topic's
rules; list_family_ideas and the Family page name the topic. The model
docstrings no longer claim the topic's rules are platform-scoped in retrieval.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 14:27:33 -04:00
co-authored by Claude Opus 5.5
parent b8efff43b4
commit 07169e1274
10 changed files with 195 additions and 8 deletions
+60
View File
@@ -52,7 +52,9 @@ from scribe.models.family import (
)
from scribe.models.note import Note
from scribe.models.project import Project
from scribe.models.rulebook import Rule
from scribe.services import access
from scribe.services import rulebooks as rulebooks_svc
logger = logging.getLogger(__name__)
@@ -140,6 +142,26 @@ async def _platform_slugs(session, note_id: int) -> list[str]:
return list(rows.scalars().all())
async def _topic(session, user_id: int, topic_id: int | None, *, rules: bool) -> dict | None:
"""The rule topic holding an idea's binding norms, for a reader. Rulebooks
belong to their owner, so a topic the reader does not own reads as no
topic."""
if not topic_id:
return None
topic = await rulebooks_svc.get_topic(topic_id, user_id)
if topic is None:
return None
out = {"id": topic.id, "title": topic.title, "rulebook_id": topic.rulebook_id}
if rules:
out["rules"] = [
{"id": r.id, "title": r.title} for r in (await session.execute(
select(Rule).where(Rule.topic_id == topic.id, Rule.deleted_at.is_(None))
.order_by(Rule.order_index.asc(), Rule.id.asc())
)).scalars().all()
]
return out
async def _snapshot(session, idea: FamilyIdea | None) -> dict | None:
"""The idea's state, as a decision's before/after. Slugs, never ids: an id
inside JSON cannot be remapped by a restore (the #3182 trap)."""
@@ -187,6 +209,7 @@ async def get_idea(user_id: int, note_id: int) -> dict | None:
"is_task": note.is_task,
"project_id": note.project_id,
"platforms": await _platform_slugs(session, note_id),
"topic": await _topic(session, user_id, idea.topic_id, rules=True),
"adoptions": counts,
"decisions": [_decision_dict(d) for d in decisions],
"undoable_decision_id": _undoable_id(decisions),
@@ -226,6 +249,7 @@ async def list_ideas(
"is_task": note_status is not None,
"project_id": project_id,
"platforms": await _platform_slugs(session, idea.note_id),
"topic": await _topic(session, user_id, idea.topic_id, rules=False),
})
out.append(d)
return out
@@ -677,6 +701,42 @@ async def revise(
"ledger_rows_opened": opened, "ledger_rows_closed": closed}
async def set_topic(user_id: int, note_id: int, topic_id: int) -> dict:
"""Link an idea to the rule topic holding its binding norms, or unlink it
with topic_id 0. The note carries the idea; the topic carries the rules a
project on its platforms must follow. One idea per topic, since a topic's
norms belong to one standard.
Not a change of substance, so the version does not move: the norms were
rules before the link and are the same rules after it. Re-linking is the
undo, as with the references."""
if not await access.can_write_note(user_id, note_id):
raise ValueError(f"note {note_id} not found or no write access")
async with async_session() as session:
idea = await session.get(FamilyIdea, note_id)
if idea is None:
raise ValueError(f"#{note_id} is not a family idea")
if topic_id:
if await rulebooks_svc.get_topic(topic_id, user_id) is None:
raise ValueError(f"topic {topic_id} not found in a rulebook you own")
holder = (await session.execute(
select(FamilyIdea.note_id, Note.title)
.join(Note, Note.id == FamilyIdea.note_id)
.where(FamilyIdea.topic_id == topic_id, FamilyIdea.note_id != note_id)
)).first()
if holder is not None:
raise ValueError(
f"topic {topic_id} already carries the norms of #{holder[0]} "
f"“{holder[1]}” — unlink it there first, or merge the two ideas"
)
idea.topic_id = topic_id or None
idea.updated_at = _now()
await session.commit()
out = idea.to_dict()
out["topic"] = await _topic(session, user_id, idea.topic_id, rules=True)
return out
async def _existing_decision_ids(ids: list[int]) -> list[int]:
ids = [int(i) for i in ids if i]
if not ids: