Fix #214: ChangeProposal (propose-then-confirm)
Implements non-negotiable #1: the AI assistant never writes autonomously. Every assistant/contributor "write" emits a ChangeProposal — a structured diff a human approves, edits, or rejects. Design: docs/design/change-proposal.md. Structural guarantee: a proposal's operations reach the DB ONLY via change_proposal_service.apply(), which requires the actor be an editor and dispatches each op through the normal editing services (person/name/event/ relationship/source/citation create/update/delete) — so every change passes the privacy engine and is audited as the approving human. propose() only inserts a pending row; it performs no domain mutation. Model providers stay read-only, so no model response can mutate tree data. - ChangeProposal model + migration (status pending|applied|rejected, origin assistant|contributor, JSONB operations, reviewer + apply_error). - Service: propose / list / get / apply (with optional edited ops) / reject / delete; a dispatcher mapping ops → editing services. v1 applies ops in order, not cross-op transactional (single-op is atomic; documented). - API /trees/{id}/proposals + a frontend review page (approve/reject; editor- gated) and sidebar entry. Tests: proposal doesn't apply until approved; reject doesn't apply; non-editor member can see but not apply; multi-op; approve-with-edits; apply-error keeps it pending. Full suite 87 passed; single alembic head. Closes #214 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Justin Paul <justin@jpaul.me>
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
"""Change-proposal endpoints: list / create / get / apply / reject / delete.
|
||||
|
||||
Applying a proposal is the only way its operations reach the database, and only
|
||||
an editor can do it (enforced in the service). See docs/design/change-proposal.md.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, status
|
||||
|
||||
from app.api.deps import CurrentUser, SessionDep
|
||||
from app.models.enums import ChangeProposalStatus
|
||||
from app.schemas.change_proposal import (
|
||||
ChangeProposalCreate,
|
||||
ChangeProposalRead,
|
||||
ProposalReview,
|
||||
)
|
||||
from app.services import change_proposal_service, tree_service
|
||||
|
||||
router = APIRouter(prefix="/trees", tags=["proposals"])
|
||||
|
||||
|
||||
@router.get("/{tree_id}/proposals", response_model=list[ChangeProposalRead])
|
||||
async def list_proposals(
|
||||
tree_id: uuid.UUID,
|
||||
session: SessionDep,
|
||||
current: CurrentUser,
|
||||
status: ChangeProposalStatus | None = None,
|
||||
) -> list[ChangeProposalRead]:
|
||||
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||
rows = await change_proposal_service.list_proposals(
|
||||
session, viewer_id=current.id, tree=tree, status=status
|
||||
)
|
||||
return [ChangeProposalRead.model_validate(r) for r in rows]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{tree_id}/proposals", response_model=ChangeProposalRead, status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
async def create_proposal(
|
||||
tree_id: uuid.UUID, data: ChangeProposalCreate, session: SessionDep, current: CurrentUser
|
||||
) -> ChangeProposalRead:
|
||||
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||
operations = [op.model_dump(mode="json") for op in data.operations]
|
||||
cp = await change_proposal_service.propose(
|
||||
session,
|
||||
tree=tree,
|
||||
origin=data.origin,
|
||||
created_by=current.id,
|
||||
summary=data.summary,
|
||||
rationale=data.rationale,
|
||||
operations=operations,
|
||||
)
|
||||
return ChangeProposalRead.model_validate(cp)
|
||||
|
||||
|
||||
@router.get("/{tree_id}/proposals/{proposal_id}", response_model=ChangeProposalRead)
|
||||
async def get_proposal(
|
||||
tree_id: uuid.UUID, proposal_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||
) -> ChangeProposalRead:
|
||||
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||
cp = await change_proposal_service.get_proposal(
|
||||
session, viewer_id=current.id, tree=tree, proposal_id=proposal_id
|
||||
)
|
||||
return ChangeProposalRead.model_validate(cp)
|
||||
|
||||
|
||||
@router.post("/{tree_id}/proposals/{proposal_id}/apply", response_model=ChangeProposalRead)
|
||||
async def apply_proposal(
|
||||
tree_id: uuid.UUID,
|
||||
proposal_id: uuid.UUID,
|
||||
session: SessionDep,
|
||||
current: CurrentUser,
|
||||
data: ProposalReview | None = None,
|
||||
) -> ChangeProposalRead:
|
||||
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||
edited = (
|
||||
[op.model_dump(mode="json") for op in data.operations]
|
||||
if data and data.operations is not None
|
||||
else None
|
||||
)
|
||||
cp = await change_proposal_service.apply(
|
||||
session, actor=current, tree=tree, proposal_id=proposal_id, edited_operations=edited
|
||||
)
|
||||
return ChangeProposalRead.model_validate(cp)
|
||||
|
||||
|
||||
@router.post("/{tree_id}/proposals/{proposal_id}/reject", response_model=ChangeProposalRead)
|
||||
async def reject_proposal(
|
||||
tree_id: uuid.UUID,
|
||||
proposal_id: uuid.UUID,
|
||||
session: SessionDep,
|
||||
current: CurrentUser,
|
||||
data: ProposalReview | None = None,
|
||||
) -> ChangeProposalRead:
|
||||
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||
cp = await change_proposal_service.reject(
|
||||
session,
|
||||
actor=current,
|
||||
tree=tree,
|
||||
proposal_id=proposal_id,
|
||||
note=data.note if data else None,
|
||||
)
|
||||
return ChangeProposalRead.model_validate(cp)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{tree_id}/proposals/{proposal_id}", status_code=status.HTTP_204_NO_CONTENT
|
||||
)
|
||||
async def delete_proposal(
|
||||
tree_id: uuid.UUID, proposal_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||
) -> None:
|
||||
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||
await change_proposal_service.delete_proposal(
|
||||
session, actor=current, tree=tree, proposal_id=proposal_id
|
||||
)
|
||||
Reference in New Issue
Block a user