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.models.contribution import Contribution, ContributionStatus, ContributionType from api.schemas.contribution import ContributionCreate, ContributionRead, ContributionStatusPatch router = APIRouter(prefix="/contributions", tags=["contributions"]) # Valid forward transitions in the lifecycle _VALID_TRANSITIONS: dict[ContributionStatus, set[ContributionStatus]] = { ContributionStatus.draft: { ContributionStatus.submitted, ContributionStatus.withdrawn, }, ContributionStatus.submitted: { ContributionStatus.acknowledged, ContributionStatus.rejected, ContributionStatus.withdrawn, }, ContributionStatus.acknowledged: { ContributionStatus.accepted, ContributionStatus.rejected, ContributionStatus.withdrawn, }, ContributionStatus.accepted: { ContributionStatus.merged, ContributionStatus.withdrawn, }, ContributionStatus.rejected: set(), ContributionStatus.merged: set(), ContributionStatus.withdrawn: set(), } @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) allowed = _VALID_TRANSITIONS.get(contrib.status, set()) if body.status not in allowed: raise HTTPException( status_code=422, detail=( f"Cannot transition from '{contrib.status}' to '{body.status}'. " f"Allowed: {[s.value for s in allowed] or 'none (terminal state)'}" ), ) 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