# [DEF:backend.src.api.routes.clean_release_v2:Module] # @TIER: STANDARD # @SEMANTICS: api, clean-release, v2, headless # @PURPOSE: Redesigned clean release API for headless candidate lifecycle. # @LAYER: API from fastapi import APIRouter, Depends, HTTPException, status from typing import List, Dict, Any from datetime import datetime, timezone from ...services.clean_release.approval_service import approve_candidate, reject_candidate from ...services.clean_release.publication_service import publish_candidate, revoke_publication from ...services.clean_release.repository import CleanReleaseRepository from ...dependencies import get_clean_release_repository from ...services.clean_release.enums import CandidateStatus from ...models.clean_release import ReleaseCandidate, CandidateArtifact, DistributionManifest from ...services.clean_release.dto import CandidateDTO, ManifestDTO router = APIRouter(prefix="/api/v2/clean-release", tags=["Clean Release V2"]) class ApprovalRequest(dict): pass class PublishRequest(dict): pass class RevokeRequest(dict): pass @router.post("/candidates", response_model=CandidateDTO, status_code=status.HTTP_201_CREATED) async def register_candidate( payload: Dict[str, Any], repository: CleanReleaseRepository = Depends(get_clean_release_repository) ): candidate = ReleaseCandidate( id=payload["id"], version=payload["version"], source_snapshot_ref=payload["source_snapshot_ref"], created_by=payload["created_by"], created_at=datetime.now(timezone.utc), status=CandidateStatus.DRAFT.value ) repository.save_candidate(candidate) return CandidateDTO( id=candidate.id, version=candidate.version, source_snapshot_ref=candidate.source_snapshot_ref, created_at=candidate.created_at, created_by=candidate.created_by, status=CandidateStatus(candidate.status) ) @router.post("/candidates/{candidate_id}/artifacts") async def import_artifacts( candidate_id: str, payload: Dict[str, Any], repository: CleanReleaseRepository = Depends(get_clean_release_repository) ): candidate = repository.get_candidate(candidate_id) if not candidate: raise HTTPException(status_code=404, detail="Candidate not found") for art_data in payload.get("artifacts", []): artifact = CandidateArtifact( id=art_data["id"], candidate_id=candidate_id, path=art_data["path"], sha256=art_data["sha256"], size=art_data["size"] ) # In a real repo we'd have save_artifact # repository.save_artifact(artifact) pass return {"status": "success"} @router.post("/candidates/{candidate_id}/manifests", response_model=ManifestDTO, status_code=status.HTTP_201_CREATED) async def build_manifest( candidate_id: str, repository: CleanReleaseRepository = Depends(get_clean_release_repository) ): candidate = repository.get_candidate(candidate_id) if not candidate: raise HTTPException(status_code=404, detail="Candidate not found") manifest = DistributionManifest( id=f"manifest-{candidate_id}", candidate_id=candidate_id, manifest_version=1, manifest_digest="hash-123", artifacts_digest="art-hash-123", created_by="system", created_at=datetime.now(timezone.utc), source_snapshot_ref=candidate.source_snapshot_ref, content_json={"items": [], "summary": {}} ) repository.save_manifest(manifest) return ManifestDTO( id=manifest.id, candidate_id=manifest.candidate_id, manifest_version=manifest.manifest_version, manifest_digest=manifest.manifest_digest, artifacts_digest=manifest.artifacts_digest, created_at=manifest.created_at, created_by=manifest.created_by, source_snapshot_ref=manifest.source_snapshot_ref, content_json=manifest.content_json ) @router.post("/candidates/{candidate_id}/approve") async def approve_candidate_endpoint( candidate_id: str, payload: Dict[str, Any], repository: CleanReleaseRepository = Depends(get_clean_release_repository), ): try: decision = approve_candidate( repository=repository, candidate_id=candidate_id, report_id=str(payload["report_id"]), decided_by=str(payload["decided_by"]), comment=payload.get("comment"), ) except Exception as exc: # noqa: BLE001 raise HTTPException(status_code=409, detail={"message": str(exc), "code": "APPROVAL_GATE_ERROR"}) return {"status": "ok", "decision": decision.decision, "decision_id": decision.id} @router.post("/candidates/{candidate_id}/reject") async def reject_candidate_endpoint( candidate_id: str, payload: Dict[str, Any], repository: CleanReleaseRepository = Depends(get_clean_release_repository), ): try: decision = reject_candidate( repository=repository, candidate_id=candidate_id, report_id=str(payload["report_id"]), decided_by=str(payload["decided_by"]), comment=payload.get("comment"), ) except Exception as exc: # noqa: BLE001 raise HTTPException(status_code=409, detail={"message": str(exc), "code": "APPROVAL_GATE_ERROR"}) return {"status": "ok", "decision": decision.decision, "decision_id": decision.id} @router.post("/candidates/{candidate_id}/publish") async def publish_candidate_endpoint( candidate_id: str, payload: Dict[str, Any], repository: CleanReleaseRepository = Depends(get_clean_release_repository), ): try: publication = publish_candidate( repository=repository, candidate_id=candidate_id, report_id=str(payload["report_id"]), published_by=str(payload["published_by"]), target_channel=str(payload["target_channel"]), publication_ref=payload.get("publication_ref"), ) except Exception as exc: # noqa: BLE001 raise HTTPException(status_code=409, detail={"message": str(exc), "code": "PUBLICATION_GATE_ERROR"}) return { "status": "ok", "publication": { "id": publication.id, "candidate_id": publication.candidate_id, "report_id": publication.report_id, "published_by": publication.published_by, "published_at": publication.published_at.isoformat() if publication.published_at else None, "target_channel": publication.target_channel, "publication_ref": publication.publication_ref, "status": publication.status, }, } @router.post("/publications/{publication_id}/revoke") async def revoke_publication_endpoint( publication_id: str, payload: Dict[str, Any], repository: CleanReleaseRepository = Depends(get_clean_release_repository), ): try: publication = revoke_publication( repository=repository, publication_id=publication_id, revoked_by=str(payload["revoked_by"]), comment=payload.get("comment"), ) except Exception as exc: # noqa: BLE001 raise HTTPException(status_code=409, detail={"message": str(exc), "code": "PUBLICATION_GATE_ERROR"}) return { "status": "ok", "publication": { "id": publication.id, "candidate_id": publication.candidate_id, "report_id": publication.report_id, "published_by": publication.published_by, "published_at": publication.published_at.isoformat() if publication.published_at else None, "target_channel": publication.target_channel, "publication_ref": publication.publication_ref, "status": publication.status, }, } # [/DEF:backend.src.api.routes.clean_release_v2:Module]