"""ChangeProposal lifecycle: propose (assistant/contributor) → review → apply/reject. The structural guarantee (CLAUDE.md #1): a proposal's operations are executed ONLY by ``apply()``, which requires the actor be an editor and dispatches every op through the normal editing services — so each change passes the privacy engine and is audited as the approving human. ``propose()`` only inserts a pending row; it performs no domain mutation. See docs/design/change-proposal.md. """ import uuid from datetime import UTC, datetime from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.models.change_proposal import ChangeProposal from app.models.enums import ( ChangeProposalOrigin, ChangeProposalStatus, CitationConfidence, ParentChildQualifier, RelationshipType, ) from app.models.tree import Tree from app.models.user import User from app.services import ( citation_service, event_service, name_service, person_service, privacy, relationship_service, source_service, ) from app.services.exceptions import Conflict, Forbidden, NotFound def _now() -> datetime: return datetime.now(UTC) def _uuid(v) -> uuid.UUID | None: return uuid.UUID(str(v)) if v else None async def _require_editor(session: AsyncSession, *, actor: User, tree: Tree) -> None: if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree): raise Forbidden("not an editor of this tree") async def _require_member(session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree) -> None: # Proposals can reference unredacted facts → members only. if await privacy.get_membership_role(session, viewer_id, tree.id) is None: raise Forbidden("only members can see change proposals") async def _load( session: AsyncSession, tree: Tree, proposal_id: uuid.UUID ) -> ChangeProposal: cp = ( await session.execute( select(ChangeProposal).where( ChangeProposal.id == proposal_id, ChangeProposal.tree_id == tree.id, ChangeProposal.deleted_at.is_(None), ) ) ).scalar_one_or_none() if cp is None: raise NotFound("proposal not found") return cp async def propose( session: AsyncSession, *, tree: Tree, origin: ChangeProposalOrigin, created_by: uuid.UUID | None, summary: str, rationale: str | None, operations: list[dict], ) -> ChangeProposal: """Insert a pending proposal. The ONLY mutation here is the proposal row — no tree data changes. (No edit-rights check: proposing isn't writing.)""" cp = ChangeProposal( tree_id=tree.id, origin=origin, created_by_user_id=created_by, summary=summary, rationale=rationale, operations=operations, status=ChangeProposalStatus.pending, ) session.add(cp) await session.commit() await session.refresh(cp) return cp async def list_proposals( session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, status: ChangeProposalStatus | None = None, ) -> list[ChangeProposal]: await _require_member(session, viewer_id=viewer_id, tree=tree) stmt = select(ChangeProposal).where( ChangeProposal.tree_id == tree.id, ChangeProposal.deleted_at.is_(None) ) if status is not None: stmt = stmt.where(ChangeProposal.status == status) stmt = stmt.order_by(ChangeProposal.created_at.desc()) return list((await session.execute(stmt)).scalars().all()) async def get_proposal( session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, proposal_id: uuid.UUID ) -> ChangeProposal: await _require_member(session, viewer_id=viewer_id, tree=tree) return await _load(session, tree, proposal_id) async def reject( session: AsyncSession, *, actor: User, tree: Tree, proposal_id: uuid.UUID, note: str | None = None, ) -> ChangeProposal: await _require_editor(session, actor=actor, tree=tree) cp = await _load(session, tree, proposal_id) if cp.status is not ChangeProposalStatus.pending: raise Conflict("proposal is not pending") cp.status = ChangeProposalStatus.rejected cp.reviewed_by_user_id = actor.id cp.reviewed_at = _now() cp.review_note = note await session.commit() await session.refresh(cp) return cp async def apply( session: AsyncSession, *, actor: User, tree: Tree, proposal_id: uuid.UUID, edited_operations: list[dict] | None = None, ) -> ChangeProposal: await _require_editor(session, actor=actor, tree=tree) cp = await _load(session, tree, proposal_id) if cp.status is not ChangeProposalStatus.pending: raise Conflict("proposal is not pending") ops = edited_operations if edited_operations is not None else list(cp.operations) try: for op in ops: await _dispatch(session, actor=actor, tree=tree, op=op) except Conflict: raise except Exception as exc: # noqa: BLE001 — record the failure on the proposal err = f"{type(exc).__name__}: {exc}"[:2000] # The editing services raise (NotFound/Forbidden/validation) before # committing, so the transaction is clean — record the error and commit. # If a later op did write before failing, those ops already committed # (v1 isn't cross-op transactional; see the design note). cp = await _load(session, tree, proposal_id) cp.apply_error = err await session.commit() raise Conflict(f"could not apply proposal: {err}") from exc if edited_operations is not None: cp.operations = edited_operations cp.status = ChangeProposalStatus.applied cp.reviewed_by_user_id = actor.id cp.reviewed_at = _now() cp.apply_error = None await session.commit() await session.refresh(cp) return cp async def delete_proposal( session: AsyncSession, *, actor: User, tree: Tree, proposal_id: uuid.UUID ) -> None: await _require_editor(session, actor=actor, tree=tree) cp = await _load(session, tree, proposal_id) cp.deleted_at = _now() await session.commit() def _bad(entity_type: str, action: str) -> Conflict: return Conflict(f"unsupported operation '{action}' on '{entity_type}'") async def _dispatch(session: AsyncSession, *, actor: User, tree: Tree, op: dict) -> None: """Route one operation through the matching editing service (privacy + audit).""" et = op.get("entity_type") action = op.get("op") payload = op.get("payload") or {} eid = op.get("entity_id") if et == "person": if action == "create": await person_service.create_person( session, actor=actor, tree=tree, given=payload.get("given"), surname=payload.get("surname"), gender=payload.get("gender"), is_living=payload.get("is_living"), notes=payload.get("notes"), ) elif action == "update": await person_service.update_person( session, actor=actor, tree=tree, person_id=_uuid(eid), changes=payload ) elif action == "delete": await person_service.delete_person( session, actor=actor, tree=tree, person_id=_uuid(eid), cascade=bool(payload.get("cascade", False)), ) else: raise _bad(et, action) elif et == "event": if action == "create": await event_service.create_event( session, actor=actor, tree=tree, event_type=payload["event_type"], person_id=_uuid(payload.get("person_id")), relationship_id=_uuid(payload.get("relationship_id")), date_value=payload.get("date_value"), date_precision=payload.get("date_precision"), detail=payload.get("detail"), notes=payload.get("notes"), ) elif action == "update": await event_service.update_event( session, actor=actor, tree=tree, event_id=_uuid(eid), changes=payload ) elif action == "delete": await event_service.delete_event( session, actor=actor, tree=tree, event_id=_uuid(eid) ) else: raise _bad(et, action) elif et == "relationship": if action == "create": await relationship_service.create_relationship( session, actor=actor, tree=tree, type=RelationshipType(payload["type"]), person_from_id=_uuid(payload["person_from_id"]), person_to_id=_uuid(payload["person_to_id"]), qualifier=ParentChildQualifier(payload["qualifier"]) if payload.get("qualifier") else None, notes=payload.get("notes"), ) elif action == "delete": await relationship_service.delete_relationship( session, actor=actor, tree=tree, relationship_id=_uuid(eid) ) else: raise _bad(et, action) elif et == "name": if action == "create": await name_service.create_name( session, actor=actor, tree=tree, person_id=_uuid(payload["person_id"]), name_type=payload.get("name_type", "birth"), given=payload.get("given"), surname=payload.get("surname"), prefix=payload.get("prefix"), suffix=payload.get("suffix"), nickname=payload.get("nickname"), is_primary=bool(payload.get("is_primary", False)), ) elif action == "update": changes = {k: v for k, v in payload.items() if k != "person_id"} await name_service.update_name( session, actor=actor, tree=tree, person_id=_uuid(payload["person_id"]), name_id=_uuid(eid), changes=changes, ) elif action == "delete": await name_service.delete_name( session, actor=actor, tree=tree, person_id=_uuid(payload["person_id"]), name_id=_uuid(eid), ) else: raise _bad(et, action) elif et == "source": if action == "create": await source_service.create_source( session, actor=actor, tree=tree, title=payload["title"], author=payload.get("author"), source_type=payload.get("source_type"), repository=payload.get("repository"), url=payload.get("url"), citation_text=payload.get("citation_text"), publication_info=payload.get("publication_info"), quality_note=payload.get("quality_note"), ) elif action == "delete": await source_service.delete_source( session, actor=actor, tree=tree, source_id=_uuid(eid) ) else: raise _bad(et, action) elif et == "citation": if action == "create": await citation_service.create_citation( session, actor=actor, tree=tree, source_id=_uuid(payload["source_id"]), person_id=_uuid(payload.get("person_id")), event_id=_uuid(payload.get("event_id")), name_id=_uuid(payload.get("name_id")), relationship_id=_uuid(payload.get("relationship_id")), page=payload.get("page"), detail=payload.get("detail"), confidence=CitationConfidence(payload["confidence"]) if payload.get("confidence") else None, ) elif action == "delete": await citation_service.delete_citation( session, actor=actor, tree=tree, citation_id=_uuid(eid) ) else: raise _bad(et, action) else: raise Conflict(f"unsupported entity type '{et}'")