state-hub/api/routers/review_contracts.py
tegwick 598f6418e7
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 25s
feat(review): add multi-owner contracts and receipts
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a023c0-a0a3-7c03-b395-5a0d2757214d
2026-08-22 20:57:51 +02:00

190 lines
6 KiB
Python

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