import uuid from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from api.database import get_session from api.flow_defs import assertion_result_to_dict, evaluate_transition, flow_result_to_dict from api.models.contribution import Contribution, ContributionStatus, ContributionType from api.schemas.contribution import ContributionCreate, ContributionRead, ContributionStatusPatch router = APIRouter(prefix="/contributions", tags=["contributions"]) @router.get("/", response_model=list[ContributionRead]) async def list_contributions( type: ContributionType | None = Query(None), status: ContributionStatus | None = Query(None), target_repo: str | None = Query(None), session: AsyncSession = Depends(get_session), ) -> list[Contribution]: q = select(Contribution).order_by(Contribution.created_at.desc()) if type is not None: q = q.where(Contribution.type == type) if status is not None: q = q.where(Contribution.status == status) if target_repo: q = q.where(Contribution.target_repo == target_repo) result = await session.execute(q) return list(result.scalars().all()) @router.post("/", response_model=ContributionRead, status_code=status.HTTP_201_CREATED) async def create_contribution( body: ContributionCreate, session: AsyncSession = Depends(get_session), ) -> Contribution: contrib = Contribution( type=body.type, target_org=body.target_org, target_repo=body.target_repo, slug=body.slug, title=body.title, body_path=body.body_path, related_topic_id=body.related_topic_id, related_workstream_id=body.related_workstream_id, notes=body.notes, status=ContributionStatus.draft, ) session.add(contrib) await session.commit() await session.refresh(contrib) return contrib @router.get("/{contribution_id}", response_model=ContributionRead) async def get_contribution( contribution_id: uuid.UUID, session: AsyncSession = Depends(get_session), ) -> Contribution: return await _get_or_404(contribution_id, session) @router.patch("/{contribution_id}/status", response_model=ContributionRead) async def patch_contribution_status( contribution_id: uuid.UUID, body: ContributionStatusPatch, session: AsyncSession = Depends(get_session), ) -> Contribution: contrib = await _get_or_404(contribution_id, session) current = _status_value(contrib.status) target = _status_value(body.status) can_reach, failures, flow_result = evaluate_transition( "contribution", current, target, ) if not can_reach: raise HTTPException( status_code=422, detail={ "message": f"Cannot transition from '{current}' to '{target}'.", "current_workstation": current, "target_workstation": target, "blocking_assertions": [ assertion_result_to_dict(item) for item in failures ], "flow_result": flow_result_to_dict(flow_result), }, ) contrib.status = body.status if body.notes: contrib.notes = body.notes now = datetime.now(tz=timezone.utc) if body.status == ContributionStatus.submitted: contrib.submitted_at = now elif body.status in ( ContributionStatus.accepted, ContributionStatus.rejected, ContributionStatus.merged, ContributionStatus.withdrawn, ): contrib.resolved_at = now await session.commit() await session.refresh(contrib) return contrib @router.delete("/{contribution_id}", status_code=status.HTTP_204_NO_CONTENT) async def withdraw_contribution( contribution_id: uuid.UUID, session: AsyncSession = Depends(get_session), ) -> None: """Soft-delete: sets status to 'withdrawn'.""" contrib = await _get_or_404(contribution_id, session) if contrib.status == ContributionStatus.withdrawn: return # idempotent if contrib.status in (ContributionStatus.merged, ContributionStatus.rejected): raise HTTPException( status_code=409, detail=f"Cannot withdraw a contribution with status '{contrib.status}'.", ) contrib.status = ContributionStatus.withdrawn contrib.resolved_at = datetime.now(tz=timezone.utc) await session.commit() async def _get_or_404(contribution_id: uuid.UUID, session: AsyncSession) -> Contribution: result = await session.execute( select(Contribution).where(Contribution.id == contribution_id) ) contrib = result.scalar_one_or_none() if contrib is None: raise HTTPException(status_code=404, detail=f"Contribution '{contribution_id}' not found") return contrib def _status_value(status: ContributionStatus | str) -> str: return status.value if isinstance(status, ContributionStatus) else str(status)