"""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 )