321 lines
10 KiB
Python
321 lines
10 KiB
Python
# #region CleanReleaseV2Api [C:4] [TYPE Module] [SEMANTICS fastapi, clean-release, candidate, lifecycle, api]
|
|
# @BRIEF Redesigned clean release API for headless candidate lifecycle.
|
|
# @LAYER API
|
|
# @RELATION DEPENDS_ON -> [CleanReleaseRepository]
|
|
# @RELATION CALLS -> [approve_candidate]
|
|
# @RELATION CALLS -> [publish_candidate]
|
|
# @PRE Clean release repository dependency is available for candidate lifecycle endpoints.
|
|
# @POST Candidate registration, approval, publication, and revocation routes are registered without behavior changes.
|
|
# @SIDE_EFFECT Persists candidate lifecycle state through clean release services and repository adapters.
|
|
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
|
|
from ...dependencies import get_clean_release_repository
|
|
from ...models.clean_release import (
|
|
CandidateArtifact,
|
|
DistributionManifest,
|
|
ReleaseCandidate,
|
|
)
|
|
from ...services.clean_release.approval_service import (
|
|
approve_candidate,
|
|
reject_candidate,
|
|
)
|
|
from ...services.clean_release.dto import CandidateDTO, ManifestDTO
|
|
from ...services.clean_release.enums import CandidateStatus
|
|
from ...services.clean_release.publication_service import (
|
|
publish_candidate,
|
|
revoke_publication,
|
|
)
|
|
from ...services.clean_release.repository import CleanReleaseRepository
|
|
|
|
router = APIRouter(prefix="/api/v2/clean-release", tags=["Clean Release V2"])
|
|
|
|
|
|
# #region ApprovalRequest [C:1] [TYPE Class]
|
|
# @BRIEF Schema for approval request payload.
|
|
class ApprovalRequest(dict):
|
|
pass
|
|
|
|
|
|
# #endregion ApprovalRequest
|
|
|
|
|
|
# #region PublishRequest [C:1] [TYPE Class]
|
|
# @BRIEF Schema for publication request payload.
|
|
class PublishRequest(dict):
|
|
pass
|
|
|
|
|
|
# #endregion PublishRequest
|
|
|
|
|
|
# #region RevokeRequest [C:1] [TYPE Class]
|
|
# @BRIEF Schema for revocation request payload.
|
|
class RevokeRequest(dict):
|
|
pass
|
|
|
|
|
|
# #endregion RevokeRequest
|
|
|
|
|
|
# #region register_candidate [C:3] [TYPE Function]
|
|
# @BRIEF Register a new release candidate.
|
|
# @PRE Payload contains required fields (id, version, source_snapshot_ref, created_by).
|
|
# @POST Candidate is saved in repository.
|
|
# @RELATION DEPENDS_ON -> [CleanReleaseRepository]
|
|
# @RELATION DEPENDS_ON -> [clean_release_dto]
|
|
@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(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),
|
|
)
|
|
|
|
|
|
# #endregion register_candidate
|
|
|
|
|
|
# #region import_artifacts [C:3] [TYPE Function]
|
|
# @BRIEF Associate artifacts with a release candidate.
|
|
# @PRE Candidate exists.
|
|
# @POST Artifacts are processed (placeholder).
|
|
# @RELATION DEPENDS_ON -> [CleanReleaseRepository]
|
|
@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"}
|
|
|
|
|
|
# #endregion import_artifacts
|
|
|
|
|
|
# #region build_manifest [C:3] [TYPE Function]
|
|
# @BRIEF Generate distribution manifest for a candidate.
|
|
# @PRE Candidate exists.
|
|
# @POST Manifest is created and saved.
|
|
# @RELATION DEPENDS_ON -> [CleanReleaseRepository]
|
|
@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(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,
|
|
)
|
|
|
|
|
|
# #endregion build_manifest
|
|
|
|
|
|
# #region approve_candidate_endpoint [C:3] [TYPE Function]
|
|
# @BRIEF Endpoint to record candidate approval.
|
|
# @RELATION CALLS -> [approve_candidate]
|
|
@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:
|
|
raise HTTPException(
|
|
status_code=409, detail={"message": str(exc), "code": "APPROVAL_GATE_ERROR"}
|
|
)
|
|
|
|
return {"status": "ok", "decision": decision.decision, "decision_id": decision.id}
|
|
|
|
|
|
# #endregion approve_candidate_endpoint
|
|
|
|
|
|
# #region reject_candidate_endpoint [C:3] [TYPE Function]
|
|
# @BRIEF Endpoint to record candidate rejection.
|
|
# @RELATION CALLS -> [reject_candidate]
|
|
@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:
|
|
raise HTTPException(
|
|
status_code=409, detail={"message": str(exc), "code": "APPROVAL_GATE_ERROR"}
|
|
)
|
|
|
|
return {"status": "ok", "decision": decision.decision, "decision_id": decision.id}
|
|
|
|
|
|
# #endregion reject_candidate_endpoint
|
|
|
|
|
|
# #region publish_candidate_endpoint [C:3] [TYPE Function]
|
|
# @BRIEF Endpoint to publish an approved candidate.
|
|
# @RELATION CALLS -> [publish_candidate]
|
|
@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:
|
|
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,
|
|
},
|
|
}
|
|
|
|
|
|
# #endregion publish_candidate_endpoint
|
|
|
|
|
|
# #region revoke_publication_endpoint [C:3] [TYPE Function]
|
|
# @BRIEF Endpoint to revoke a previous publication.
|
|
# @RELATION CALLS -> [revoke_publication]
|
|
@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:
|
|
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,
|
|
},
|
|
}
|
|
|
|
|
|
# #endregion revoke_publication_endpoint
|
|
|
|
# #endregion CleanReleaseV2Api
|