Files
ss-tools/backend/src/api/routes/clean_release_v2.py
2026-05-26 09:30:41 +03:00

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