Files
ss-tools/backend/src/api/routes/clean_release_v2.py
2026-04-19 20:05:45 +03:00

332 lines
10 KiB
Python

# [DEF:CleanReleaseV2Api:Module]
# @COMPLEXITY: 4
# @PURPOSE: Redesigned clean release API for headless candidate lifecycle.
# @LAYER: UI (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 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"])
# [DEF:ApprovalRequest:Class]
# @COMPLEXITY: 1
# @PURPOSE: Schema for approval request payload.
class ApprovalRequest(dict):
pass
# [/DEF:ApprovalRequest:Class]
# [DEF:PublishRequest:Class]
# @COMPLEXITY: 1
# @PURPOSE: Schema for publication request payload.
class PublishRequest(dict):
pass
# [/DEF:PublishRequest:Class]
# [DEF:RevokeRequest:Class]
# @COMPLEXITY: 1
# @PURPOSE: Schema for revocation request payload.
class RevokeRequest(dict):
pass
# [/DEF:RevokeRequest:Class]
# [DEF:register_candidate:Function]
# @COMPLEXITY: 3
# @PURPOSE: Register a new release candidate.
# @PRE: Payload contains required fields (id, version, source_snapshot_ref, created_by).
# @POST: Candidate is saved in repository.
# @RETURN: CandidateDTO
# @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(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),
)
# [/DEF:register_candidate:Function]
# [DEF:import_artifacts:Function]
# @COMPLEXITY: 3
# @PURPOSE: 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"}
# [/DEF:import_artifacts:Function]
# [DEF:build_manifest:Function]
# @COMPLEXITY: 3
# @PURPOSE: Generate distribution manifest for a candidate.
# @PRE: Candidate exists.
# @POST: Manifest is created and saved.
# @RETURN: ManifestDTO
# @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(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,
)
# [/DEF:build_manifest:Function]
# [DEF:approve_candidate_endpoint:Function]
# @COMPLEXITY: 3
# @PURPOSE: 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: # 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}
# [/DEF:approve_candidate_endpoint:Function]
# [DEF:reject_candidate_endpoint:Function]
# @COMPLEXITY: 3
# @PURPOSE: 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: # 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}
# [/DEF:reject_candidate_endpoint:Function]
# [DEF:publish_candidate_endpoint:Function]
# @COMPLEXITY: 3
# @PURPOSE: 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: # 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:publish_candidate_endpoint:Function]
# [DEF:revoke_publication_endpoint:Function]
# @COMPLEXITY: 3
# @PURPOSE: 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: # 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:revoke_publication_endpoint:Function]
# [/DEF:CleanReleaseV2Api:Module]