import uuid from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import select, update from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from api.database import get_session from api.models.review_contract import ReviewContract, ReviewReceipt from api.schemas.review_contract import ( ReviewAggregateRead, ReviewContractProject, ReviewContractRead, ReviewReceiptRead, ReviewReceiptSubmit, ) from api.services.review_contracts import ( active_contract, aggregate, digest_document, normalize_contract, utc_now, validate_receipt, ) router = APIRouter(prefix="/review-contracts", tags=["review-contracts"]) @router.post( "/projections", response_model=ReviewContractRead, status_code=status.HTTP_201_CREATED, ) async def project_contract( body: ReviewContractProject, session: AsyncSession = Depends(get_session), ) -> ReviewContract: document, contract_digest = normalize_contract(body.contract) existing = ( await session.execute( select(ReviewContract).where( ReviewContract.contract_digest == contract_digest ) ) ).scalar_one_or_none() if existing: if ( existing.source_repo != body.source.repo or existing.source_path != body.source.path or existing.source_revision != body.source.revision ): raise HTTPException( 409, "contract digest is already projected from a different source" ) return existing contract_key = document["contract_key"] await session.execute( update(ReviewContract) .where( ReviewContract.contract_key == contract_key, ReviewContract.active.is_(True) ) .values(active=False) ) contract = ReviewContract( contract_key=contract_key, schema_version="review-contract/v1", contract_digest=contract_digest, source_repo=body.source.repo, source_path=body.source.path, source_revision=body.source.revision, document=document, active=True, required_for_decision=body.required_for_decision, decision_id=body.decision_id, workplan_id=body.workplan_id, task_id=body.task_id, projected_at=utc_now(), ) session.add(contract) try: await session.commit() except IntegrityError as exc: await session.rollback() raise HTTPException( 409, "source revision is already projected with different content" ) from exc await session.refresh(contract) return contract @router.get("/", response_model=list[ReviewContractRead]) async def list_contracts( contract_key: str | None = Query(None), active: bool | None = Query(None), decision_id: uuid.UUID | None = Query(None), session: AsyncSession = Depends(get_session), ) -> list[ReviewContract]: query = select(ReviewContract).order_by(ReviewContract.projected_at.desc()) if contract_key: query = query.where(ReviewContract.contract_key == contract_key) if active is not None: query = query.where(ReviewContract.active == active) if decision_id: query = query.where(ReviewContract.decision_id == decision_id) return list((await session.execute(query)).scalars()) @router.get("/{contract_key}", response_model=ReviewContractRead) async def get_active_contract( contract_key: str, session: AsyncSession = Depends(get_session), ) -> ReviewContract: return await active_contract(session, contract_key) @router.get("/{contract_key}/aggregate", response_model=ReviewAggregateRead) async def get_aggregate( contract_key: str, session: AsyncSession = Depends(get_session), ) -> ReviewAggregateRead: return await aggregate(session, await active_contract(session, contract_key)) @router.get("/{contract_key}/receipts", response_model=list[ReviewReceiptRead]) async def list_receipts( contract_key: str, include_stale: bool = Query(True), session: AsyncSession = Depends(get_session), ) -> list[ReviewReceipt]: contract = await active_contract(session, contract_key) query = ( select(ReviewReceipt) .join(ReviewContract) .where( ReviewContract.contract_key == contract_key if include_stale else ReviewReceipt.contract_id == contract.id ) .order_by(ReviewReceipt.submitted_at) ) return list((await session.execute(query)).scalars()) @router.post( "/{contract_key}/receipts", response_model=ReviewReceiptRead, status_code=status.HTTP_201_CREATED, ) async def submit_receipt( contract_key: str, body: ReviewReceiptSubmit, session: AsyncSession = Depends(get_session), ) -> ReviewReceipt: contract = await active_contract(session, contract_key) document = validate_receipt(contract, body) receipt_digest = digest_document(document) existing = ( await session.execute( select(ReviewReceipt).where(ReviewReceipt.receipt_digest == receipt_digest) ) ).scalar_one_or_none() if existing: return existing receipt = ReviewReceipt( contract_id=contract.id, owner_id=body.owner_id, actor=body.actor, disposition=body.disposition, contract_digest=body.contract_digest, receipt_digest=receipt_digest, artifact_hashes=body.artifact_hashes, assertion_ids=body.assertion_ids, checks=body.checks, note=body.note, source_repo=body.source.repo, source_path=body.source.path, source_revision=body.source.revision, submitted_at=utc_now(), document=document, ) session.add(receipt) try: await session.commit() except IntegrityError as exc: await session.rollback() raise HTTPException( 409, "receipt source revision already contains different evidence" ) from exc await session.refresh(receipt) return receipt