Merge pull request 'Merge dev → main: a family idea links to the rule topic holding its norms' (#207) from dev into main
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Successful in 1m26s
CI & Build / Python tests (push) Successful in 2m10s
CI & Build / Build & push image (push) Successful in 1m7s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Successful in 1m26s
CI & Build / Python tests (push) Successful in 2m10s
CI & Build / Build & push image (push) Successful in 1m7s
This commit was merged in pull request #207.
This commit is contained in:
@@ -17,12 +17,23 @@ export interface Criterion {
|
|||||||
test: string;
|
test: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IdeaTopic {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
rulebook_id: number;
|
||||||
|
/** Present on a single idea's read, not on the list. */
|
||||||
|
rules?: { id: number; title: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface FamilyIdea {
|
export interface FamilyIdea {
|
||||||
note_id: number;
|
note_id: number;
|
||||||
status: IdeaStatus;
|
status: IdeaStatus;
|
||||||
applies_when: string;
|
applies_when: string;
|
||||||
canon_version: number;
|
canon_version: number;
|
||||||
topic_id: number | null;
|
topic_id: number | null;
|
||||||
|
/** The rule topic holding the idea's binding norms; null when none is
|
||||||
|
* linked or the reader does not own its rulebook. */
|
||||||
|
topic: IdeaTopic | null;
|
||||||
title: string;
|
title: string;
|
||||||
note_type: string;
|
note_type: string;
|
||||||
is_task: boolean;
|
is_task: boolean;
|
||||||
|
|||||||
@@ -259,6 +259,12 @@ onMounted(load);
|
|||||||
<div v-if="idea.platforms.length" class="fam-platforms">
|
<div v-if="idea.platforms.length" class="fam-platforms">
|
||||||
<code v-for="p in idea.platforms" :key="p" class="fam-chip">{{ p }}</code>
|
<code v-for="p in idea.platforms" :key="p" class="fam-chip">{{ p }}</code>
|
||||||
</div>
|
</div>
|
||||||
|
<p v-if="idea.topic" class="fam-muted fam-topic">
|
||||||
|
Binding rules:
|
||||||
|
<router-link
|
||||||
|
:to="{ path: '/rules', query: { rb: String(idea.topic.rulebook_id), topic: String(idea.topic.id) } }"
|
||||||
|
>{{ idea.topic.title }}</router-link>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
v-if="idea.status !== 'retired'"
|
v-if="idea.status !== 'retired'"
|
||||||
@@ -376,6 +382,7 @@ onMounted(load);
|
|||||||
.fam-row-title { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.25rem 0.5rem; }
|
.fam-row-title { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.25rem 0.5rem; }
|
||||||
.fam-applies { margin: 0.3rem 0 0; font-size: 0.85rem; color: var(--fs-text-secondary); }
|
.fam-applies { margin: 0.3rem 0 0; font-size: 0.85rem; color: var(--fs-text-secondary); }
|
||||||
.fam-platforms { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-top: 0.35rem; }
|
.fam-platforms { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-top: 0.35rem; }
|
||||||
|
.fam-topic { margin: 0.35rem 0 0; }
|
||||||
.fam-chip {
|
.fam-chip {
|
||||||
font-family: var(--fs-font-mono);
|
font-family: var(--fs-font-mono);
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "scribe",
|
"name": "scribe",
|
||||||
"description": "Scribe for Claude Code: connects the scribe MCP server, adds the hooks that deliver live project state and relevant records at the right moment, ships the shared client-neutral Scribe skills (using-scribe, writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code, shape-accounting, family-canon), and syncs your saved Scribe Processes as skills (/scribe:sync).",
|
"description": "Scribe for Claude Code: connects the scribe MCP server, adds the hooks that deliver live project state and relevant records at the right moment, ships the shared client-neutral Scribe skills (using-scribe, writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code, shape-accounting, family-canon), and syncs your saved Scribe Processes as skills (/scribe:sync).",
|
||||||
"version": "2026.10.06.1726",
|
"version": "2026.10.06.1827",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Bryan Van Deusen"
|
"name": "Bryan Van Deusen"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ wrote what you wrote:
|
|||||||
needs support you can name; **one criterion with no support vetoes it**.
|
needs support you can name; **one criterion with no support vetoes it**.
|
||||||
3. Promote it, or leave it a candidate with the reason. A held candidate is
|
3. Promote it, or leave it a candidate with the reason. A held candidate is
|
||||||
precedent too, so the reason is the record — never skip writing it.
|
precedent too, so the reason is the record — never skip writing it.
|
||||||
|
4. If rules already bind the idea — a rule topic written for it — link that
|
||||||
|
topic with `set_family_topic`. The note teaches; the topic's rules bind,
|
||||||
|
and every assessment then reads them beside the idea.
|
||||||
|
|
||||||
A milestone closing on a platform project hints without recording anything:
|
A milestone closing on a platform project hints without recording anything:
|
||||||
ask whether what it built is something every project on the platform will
|
ask whether what it built is something every project on the platform will
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ _WRITE_TOOLS = frozenset({
|
|||||||
"undo_family_decision",
|
"undo_family_decision",
|
||||||
# family canon — the adoption ledger
|
# family canon — the adoption ledger
|
||||||
"revise_family_idea", "assess_family_adoption", "resolve_family_conflict",
|
"revise_family_idea", "assess_family_adoption", "resolve_family_conflict",
|
||||||
"set_family_references",
|
"set_family_references", "set_family_topic",
|
||||||
"bind_repo", "unbind_repo",
|
"bind_repo", "unbind_repo",
|
||||||
# snippets, processes, the shape ledger
|
# snippets, processes, the shape ledger
|
||||||
"create_snippet", "update_snippet", "delete_snippet", "verify_snippet",
|
"create_snippet", "update_snippet", "delete_snippet", "verify_snippet",
|
||||||
|
|||||||
@@ -372,12 +372,31 @@ async def set_family_references(note_id: int, snippet_ids: list[int]) -> dict:
|
|||||||
return {"references": refs}
|
return {"references": refs}
|
||||||
|
|
||||||
|
|
||||||
|
async def set_family_topic(note_id: int, topic_id: int) -> dict:
|
||||||
|
"""Link a family idea to the rule topic that holds its binding norms — the
|
||||||
|
rules every project on its platforms follows. The note says when the idea
|
||||||
|
applies and why; the topic's rules say what must hold. One idea per topic.
|
||||||
|
get_family_idea and get_family_adoption then list those rules, so an
|
||||||
|
assessment judges against them.
|
||||||
|
|
||||||
|
Use it when an idea already has rules written for it, or when you write
|
||||||
|
rules that exist to carry one idea. The version does not move: linking
|
||||||
|
changes where the norms are found, not what they say.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
note_id: the family idea.
|
||||||
|
topic_id: a topic in a rulebook you own (list_rulebooks,
|
||||||
|
list_topics). 0 unlinks.
|
||||||
|
"""
|
||||||
|
return await family_svc.set_topic(current_user_id(), note_id, topic_id)
|
||||||
|
|
||||||
|
|
||||||
def register(mcp) -> None:
|
def register(mcp) -> None:
|
||||||
for fn in (
|
for fn in (
|
||||||
list_family_ideas, get_family_idea, propose_family_idea,
|
list_family_ideas, get_family_idea, propose_family_idea,
|
||||||
promote_family_idea, retire_family_idea, undo_family_decision,
|
promote_family_idea, retire_family_idea, undo_family_decision,
|
||||||
list_family_decisions, revise_family_idea, get_family_adoption,
|
list_family_decisions, revise_family_idea, get_family_adoption,
|
||||||
list_family_adoptions, assess_family_adoption, resolve_family_conflict,
|
list_family_adoptions, assess_family_adoption, resolve_family_conflict,
|
||||||
set_family_references,
|
set_family_references, set_family_topic,
|
||||||
):
|
):
|
||||||
mcp.tool(name=fn.__name__)(fn)
|
mcp.tool(name=fn.__name__)(fn)
|
||||||
|
|||||||
@@ -23,8 +23,9 @@ The tables, and the one job each does:
|
|||||||
- `family_ideas` — a note's family state: candidate, canon or retired, its
|
- `family_ideas` — a note's family state: candidate, canon or retired, its
|
||||||
applicability test, its canon version, its linked rule topic.
|
applicability test, its canon version, its linked rule topic.
|
||||||
- `family_idea_platforms` — the platforms an idea is for. The ONLY scope
|
- `family_idea_platforms` — the platforms an idea is for. The ONLY scope
|
||||||
source: a linked topic takes its scope from here rather than carrying its
|
source: a linked topic carries no platforms of its own, so the two can
|
||||||
own, so the two can never disagree.
|
never disagree. The scope decides which projects owe the idea an answer;
|
||||||
|
the topic's rules still reach a session by ordinary rule retrieval.
|
||||||
- `family_idea_references` — the reference implementations.
|
- `family_idea_references` — the reference implementations.
|
||||||
- `family_adoptions` — one row per (project, idea): the project's answer.
|
- `family_adoptions` — one row per (project, idea): the project's answer.
|
||||||
- `family_decisions` — the append-only log of every promotion and every
|
- `family_decisions` — the append-only log of every promotion and every
|
||||||
@@ -165,8 +166,9 @@ class FamilyIdea(Base, TimestampMixin):
|
|||||||
comparing the two numbers, never stored as a flag that could go stale.
|
comparing the two numbers, never stored as a flag that could go stale.
|
||||||
|
|
||||||
`topic_id` is the rule topic holding the idea's binding norms, if it has
|
`topic_id` is the rule topic holding the idea's binding norms, if it has
|
||||||
any (the note↔topic link #3236 asked for). The topic takes its scope from
|
any (the note↔topic link #3236 asked for), set by `set_topic`. The topic
|
||||||
this idea; it never carries platforms of its own.
|
never carries platforms of its own; the idea's readouts list its rules,
|
||||||
|
so an assessment judges a project against them.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__tablename__ = "family_ideas"
|
__tablename__ = "family_ideas"
|
||||||
|
|||||||
@@ -52,7 +52,9 @@ from scribe.models.family import (
|
|||||||
)
|
)
|
||||||
from scribe.models.note import Note
|
from scribe.models.note import Note
|
||||||
from scribe.models.project import Project
|
from scribe.models.project import Project
|
||||||
|
from scribe.models.rulebook import Rule
|
||||||
from scribe.services import access
|
from scribe.services import access
|
||||||
|
from scribe.services import rulebooks as rulebooks_svc
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -140,6 +142,26 @@ async def _platform_slugs(session, note_id: int) -> list[str]:
|
|||||||
return list(rows.scalars().all())
|
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:
|
async def _snapshot(session, idea: FamilyIdea | None) -> dict | None:
|
||||||
"""The idea's state, as a decision's before/after. Slugs, never ids: an id
|
"""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)."""
|
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,
|
"is_task": note.is_task,
|
||||||
"project_id": note.project_id,
|
"project_id": note.project_id,
|
||||||
"platforms": await _platform_slugs(session, note_id),
|
"platforms": await _platform_slugs(session, note_id),
|
||||||
|
"topic": await _topic(session, user_id, idea.topic_id, rules=True),
|
||||||
"adoptions": counts,
|
"adoptions": counts,
|
||||||
"decisions": [_decision_dict(d) for d in decisions],
|
"decisions": [_decision_dict(d) for d in decisions],
|
||||||
"undoable_decision_id": _undoable_id(decisions),
|
"undoable_decision_id": _undoable_id(decisions),
|
||||||
@@ -226,6 +249,7 @@ async def list_ideas(
|
|||||||
"is_task": note_status is not None,
|
"is_task": note_status is not None,
|
||||||
"project_id": project_id,
|
"project_id": project_id,
|
||||||
"platforms": await _platform_slugs(session, idea.note_id),
|
"platforms": await _platform_slugs(session, idea.note_id),
|
||||||
|
"topic": await _topic(session, user_id, idea.topic_id, rules=False),
|
||||||
})
|
})
|
||||||
out.append(d)
|
out.append(d)
|
||||||
return out
|
return out
|
||||||
@@ -677,6 +701,42 @@ async def revise(
|
|||||||
"ledger_rows_opened": opened, "ledger_rows_closed": closed}
|
"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]:
|
async def _existing_decision_ids(ids: list[int]) -> list[int]:
|
||||||
ids = [int(i) for i in ids if i]
|
ids = [int(i) for i in ids if i]
|
||||||
if not ids:
|
if not ids:
|
||||||
|
|||||||
@@ -181,6 +181,6 @@ def test_the_outcomes_the_agent_is_told_are_the_ones_enforced():
|
|||||||
def test_every_adoption_tool_is_classified():
|
def test_every_adoption_tool_is_classified():
|
||||||
reads = {"get_family_adoption", "list_family_adoptions"}
|
reads = {"get_family_adoption", "list_family_adoptions"}
|
||||||
writes = {"assess_family_adoption", "resolve_family_conflict", "revise_family_idea",
|
writes = {"assess_family_adoption", "resolve_family_conflict", "revise_family_idea",
|
||||||
"set_family_references"}
|
"set_family_references", "set_family_topic"}
|
||||||
assert reads <= _READ_ONLY_TOOLS
|
assert reads <= _READ_ONLY_TOOLS
|
||||||
assert writes <= _WRITE_TOOLS
|
assert writes <= _WRITE_TOOLS
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ test here (`_no_meaning`); the repeat tests stub it to one hit, to pin what
|
|||||||
the trigger does WITH a hit. The ranking itself is the shared semantic
|
the trigger does WITH a hit. The ranking itself is the shared semantic
|
||||||
search, tested where that lives.
|
search, tested where that lives.
|
||||||
"""
|
"""
|
||||||
|
import uuid
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -24,6 +25,7 @@ from scribe.models.family import (
|
|||||||
from scribe.models.milestone import Milestone
|
from scribe.models.milestone import Milestone
|
||||||
from scribe.models.note import Note
|
from scribe.models.note import Note
|
||||||
from scribe.models.project import Project
|
from scribe.models.project import Project
|
||||||
|
from scribe.models.rulebook import Rule, Rulebook, RulebookTopic
|
||||||
from scribe.models.user import User
|
from scribe.models.user import User
|
||||||
from scribe.services import family as family_svc
|
from scribe.services import family as family_svc
|
||||||
from tests.helpers import ensure_user
|
from tests.helpers import ensure_user
|
||||||
@@ -291,3 +293,86 @@ async def test_a_milestone_closing_on_a_platform_asks_and_one_off_platform_does_
|
|||||||
assert hint and "Android app" in hint
|
assert hint and "Android app" in hint
|
||||||
assert await family_svc.milestone_trigger(family["owner"], off_platform) is None
|
assert await family_svc.milestone_trigger(family["owner"], off_platform) is None
|
||||||
assert await family_svc.milestone_is_open(family["owner"], on_platform.id) is True
|
assert await family_svc.milestone_is_open(family["owner"], on_platform.id) is True
|
||||||
|
|
||||||
|
|
||||||
|
# --- the note↔topic link ----------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def topics(family):
|
||||||
|
"""A topic of the owner's with two rules, a second empty one, and one in
|
||||||
|
the outsider's rulebook. Titles carry a nonce: rulebooks are not purged
|
||||||
|
with the notes, and a topic's title is unique within its rulebook."""
|
||||||
|
nonce = uuid.uuid4().hex[:8]
|
||||||
|
async with async_session() as s:
|
||||||
|
mine = Rulebook(owner_user_id=family["owner"], title=f"family norms {nonce}")
|
||||||
|
theirs = Rulebook(owner_user_id=family["outsider"], title=f"their norms {nonce}")
|
||||||
|
s.add_all([mine, theirs])
|
||||||
|
await s.flush()
|
||||||
|
norms = RulebookTopic(rulebook_id=mine.id, title="distribution")
|
||||||
|
spare = RulebookTopic(rulebook_id=mine.id, title="spare")
|
||||||
|
foreign = RulebookTopic(rulebook_id=theirs.id, title="theirs")
|
||||||
|
s.add_all([norms, spare, foreign])
|
||||||
|
await s.flush()
|
||||||
|
s.add_all([
|
||||||
|
Rule(topic_id=norms.id, title="One release key", statement="Sign with one key.",
|
||||||
|
order_index=0),
|
||||||
|
Rule(topic_id=norms.id, title="No debug signer", statement="Check the signer.",
|
||||||
|
order_index=1),
|
||||||
|
])
|
||||||
|
await s.commit()
|
||||||
|
return {"norms": norms.id, "spare": spare.id, "foreign": foreign.id,
|
||||||
|
"rulebook": mine.id}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_an_idea_links_to_its_topic_and_its_reads_name_the_rules(family, topics):
|
||||||
|
await _promote(family)
|
||||||
|
out = await family_svc.set_topic(family["owner"], family["note"], topics["norms"])
|
||||||
|
assert out["topic_id"] == topics["norms"]
|
||||||
|
assert [r["title"] for r in out["topic"]["rules"]] == ["One release key", "No debug signer"]
|
||||||
|
# Where the norms are found is not what they say: the version holds.
|
||||||
|
assert out["canon_version"] == 1
|
||||||
|
|
||||||
|
idea = await family_svc.get_idea(family["owner"], family["note"])
|
||||||
|
assert idea["topic"]["rulebook_id"] == topics["rulebook"]
|
||||||
|
assert len(idea["topic"]["rules"]) == 2
|
||||||
|
[listed] = [i for i in await family_svc.list_ideas(family["owner"])
|
||||||
|
if i["note_id"] == family["note"]]
|
||||||
|
assert listed["topic"] == {"id": topics["norms"], "title": "distribution",
|
||||||
|
"rulebook_id": topics["rulebook"]}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_zero_unlinks_the_topic(family, topics):
|
||||||
|
await _promote(family)
|
||||||
|
await family_svc.set_topic(family["owner"], family["note"], topics["norms"])
|
||||||
|
out = await family_svc.set_topic(family["owner"], family["note"], 0)
|
||||||
|
assert out["topic_id"] is None and out["topic"] is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_topic_outside_the_callers_rulebooks_is_refused(family, topics):
|
||||||
|
await _promote(family)
|
||||||
|
with pytest.raises(ValueError, match="rulebook you own"):
|
||||||
|
await family_svc.set_topic(family["owner"], family["note"], topics["foreign"])
|
||||||
|
assert (await _idea(family["note"])).topic_id is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_one_topic_carries_one_ideas_norms(family, topics):
|
||||||
|
await _promote(family)
|
||||||
|
await family_svc.set_topic(family["owner"], family["note"], topics["norms"])
|
||||||
|
async with async_session() as s:
|
||||||
|
other = Note(user_id=family["owner"], project_id=family["b"], title="Another idea",
|
||||||
|
body="something else every Android app needs")
|
||||||
|
s.add(other)
|
||||||
|
await s.flush()
|
||||||
|
s.add(FamilyIdea(note_id=other.id, status="candidate"))
|
||||||
|
await s.commit()
|
||||||
|
other_id = other.id
|
||||||
|
with pytest.raises(ValueError, match="already carries the norms"):
|
||||||
|
await family_svc.set_topic(family["owner"], other_id, topics["norms"])
|
||||||
|
# Re-linking the same idea to its own topic is not a conflict.
|
||||||
|
await family_svc.set_topic(family["owner"], family["note"], topics["norms"])
|
||||||
|
|
||||||
|
|
||||||
|
async def test_someone_who_cannot_write_the_idea_cannot_link_it(family, topics):
|
||||||
|
await _promote(family)
|
||||||
|
with pytest.raises(ValueError, match="no write access"):
|
||||||
|
await family_svc.set_topic(family["outsider"], family["note"], topics["spare"])
|
||||||
|
|||||||
Reference in New Issue
Block a user