Files
svg-backend/backend/app/api/routes/schemes.py
greebo 7b6c12f924 feat(backend): add publish readiness endpoint and enforce publish gate contract
add backend endpoint for publish readiness checks

enforce publish gate contract before version publication
make publish preconditions explicit and consistent for clients
2026-03-19 20:15:48 +03:00

268 lines
10 KiB
Python

from fastapi import APIRouter, Depends, Query
from app.core.config import settings
from app.repositories.audit import create_audit_event
from app.repositories.scheme_groups import clone_scheme_version_groups
from app.repositories.scheme_seats import clone_scheme_version_seats
from app.repositories.scheme_sectors import clone_scheme_version_sectors
from app.repositories.scheme_versions import (
count_scheme_versions,
create_next_scheme_version_from_current,
get_current_scheme_version,
list_scheme_versions,
)
from app.repositories.schemes import (
count_scheme_records,
get_scheme_record_by_scheme_id,
list_scheme_records,
rollback_scheme_to_version,
unpublish_scheme,
)
from app.schemas.scheme_registry import (
SchemeCurrentResponse,
SchemeDetailResponse,
SchemeListItem,
SchemeListResponse,
SchemePublishResponse,
SchemeRollbackRequest,
SchemeRollbackResponse,
)
from app.schemas.scheme_versions import (
SchemeVersionCreateResponse,
SchemeVersionListItem,
SchemeVersionListResponse,
)
from app.security.auth import require_api_key
from app.services.publish_service import publish_current_draft_scheme
from app.services.scheme_validation import build_scheme_validation_report
router = APIRouter()
@router.get(f"{settings.api_v1_prefix}/schemes", response_model=SchemeListResponse)
async def get_schemes(
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
role: str = Depends(require_api_key),
):
rows = await list_scheme_records(limit=limit, offset=offset)
total = await count_scheme_records()
items = [
SchemeListItem(
scheme_id=row.scheme_id,
source_upload_id=row.source_upload_id,
name=row.name,
status=row.status,
current_version_number=row.current_version_number,
published_at=row.published_at.isoformat() if row.published_at else None,
normalized_elements_count=row.normalized_elements_count,
normalized_seats_count=row.normalized_seats_count,
normalized_groups_count=row.normalized_groups_count,
normalized_sectors_count=row.normalized_sectors_count,
created_at=row.created_at.isoformat(),
)
for row in rows
]
return SchemeListResponse(items=items, total=total)
@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}", response_model=SchemeDetailResponse)
async def get_scheme(scheme_id: str, role: str = Depends(require_api_key)):
row = await get_scheme_record_by_scheme_id(scheme_id)
return SchemeDetailResponse(
scheme_id=row.scheme_id,
source_upload_id=row.source_upload_id,
name=row.name,
status=row.status,
current_version_number=row.current_version_number,
published_at=row.published_at.isoformat() if row.published_at else None,
normalized_elements_count=row.normalized_elements_count,
normalized_seats_count=row.normalized_seats_count,
normalized_groups_count=row.normalized_groups_count,
normalized_sectors_count=row.normalized_sectors_count,
created_at=row.created_at.isoformat(),
)
@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/current", response_model=SchemeCurrentResponse)
async def get_scheme_current(scheme_id: str, role: str = Depends(require_api_key)):
scheme = await get_scheme_record_by_scheme_id(scheme_id)
version = await get_current_scheme_version(
scheme_id=scheme.scheme_id,
current_version_number=scheme.current_version_number,
)
return SchemeCurrentResponse(
scheme_id=version.scheme_id,
scheme_version_id=version.scheme_version_id,
version_number=version.version_number,
status=version.status,
normalized_storage_path=version.normalized_storage_path,
normalized_elements_count=version.normalized_elements_count,
normalized_seats_count=version.normalized_seats_count,
normalized_groups_count=version.normalized_groups_count,
normalized_sectors_count=version.normalized_sectors_count,
created_at=version.created_at.isoformat(),
)
@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/versions", response_model=SchemeVersionListResponse)
async def get_scheme_versions(
scheme_id: str,
limit: int = Query(default=100, ge=1, le=200),
offset: int = Query(default=0, ge=0),
role: str = Depends(require_api_key),
):
rows = await list_scheme_versions(scheme_id=scheme_id, limit=limit, offset=offset)
total = await count_scheme_versions(scheme_id=scheme_id)
items = [
SchemeVersionListItem(
scheme_version_id=row.scheme_version_id,
scheme_id=row.scheme_id,
version_number=row.version_number,
status=row.status,
normalized_storage_path=row.normalized_storage_path,
normalized_elements_count=row.normalized_elements_count,
normalized_seats_count=row.normalized_seats_count,
normalized_groups_count=row.normalized_groups_count,
normalized_sectors_count=row.normalized_sectors_count,
created_at=row.created_at.isoformat(),
)
for row in rows
]
return SchemeVersionListResponse(items=items, total=total)
@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/versions", response_model=SchemeVersionCreateResponse)
async def create_next_scheme_version_endpoint(
scheme_id: str,
expected_current_scheme_version_id: str | None = Query(default=None),
role: str = Depends(require_api_key),
):
current_scheme = await get_scheme_record_by_scheme_id(scheme_id)
current_version = await get_current_scheme_version(
scheme_id=current_scheme.scheme_id,
current_version_number=current_scheme.current_version_number,
)
if expected_current_scheme_version_id and expected_current_scheme_version_id != current_version.scheme_version_id:
from fastapi import HTTPException, status
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={
"code": "stale_current_version",
"message": "Current scheme version changed. Reload scheme state before creating a new version.",
"expected_scheme_version_id": expected_current_scheme_version_id,
"actual_scheme_version_id": current_version.scheme_version_id,
},
)
new_version = await create_next_scheme_version_from_current(scheme_id)
await clone_scheme_version_sectors(
source_scheme_version_id=current_version.scheme_version_id,
target_scheme_version_id=new_version.scheme_version_id,
)
await clone_scheme_version_groups(
source_scheme_version_id=current_version.scheme_version_id,
target_scheme_version_id=new_version.scheme_version_id,
)
await clone_scheme_version_seats(
source_scheme_version_id=current_version.scheme_version_id,
target_scheme_version_id=new_version.scheme_version_id,
)
await create_audit_event(
scheme_id=scheme_id,
event_type="scheme.version.created",
object_type="scheme_version",
object_ref=new_version.scheme_version_id,
details={
"source_scheme_version_id": current_version.scheme_version_id,
"version_number": new_version.version_number,
"normalized_storage_path": new_version.normalized_storage_path,
},
)
return SchemeVersionCreateResponse(
scheme_id=new_version.scheme_id,
scheme_version_id=new_version.scheme_version_id,
version_number=new_version.version_number,
status=new_version.status,
normalized_storage_path=new_version.normalized_storage_path,
)
@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/publish/validation")
async def get_publish_validation(scheme_id: str, role: str = Depends(require_api_key)):
scheme = await get_scheme_record_by_scheme_id(scheme_id)
version = await get_current_scheme_version(
scheme_id=scheme.scheme_id,
current_version_number=scheme.current_version_number,
)
report = await build_scheme_validation_report(
scheme_id=scheme.scheme_id,
scheme_version_id=version.scheme_version_id,
)
return {
"scheme_id": scheme.scheme_id,
"scheme_version_id": version.scheme_version_id,
"report": report,
}
@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/publish")
async def publish_scheme_endpoint(
scheme_id: str,
expected_scheme_version_id: str | None = Query(default=None),
role: str = Depends(require_api_key),
):
return await publish_current_draft_scheme(
scheme_id=scheme_id,
expected_scheme_version_id=expected_scheme_version_id,
)
@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/unpublish", response_model=SchemePublishResponse)
async def unpublish_scheme_endpoint(scheme_id: str, role: str = Depends(require_api_key)):
row = await unpublish_scheme(scheme_id)
await create_audit_event(
scheme_id=row.scheme_id,
event_type="scheme.unpublished",
object_type="scheme",
object_ref=row.scheme_id,
details={"current_version_number": row.current_version_number, "status": row.status},
)
return SchemePublishResponse(
scheme_id=row.scheme_id,
status=row.status,
current_version_number=row.current_version_number,
published_at=row.published_at.isoformat() if row.published_at else None,
)
@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/rollback", response_model=SchemeRollbackResponse)
async def rollback_scheme_endpoint(
scheme_id: str,
payload: SchemeRollbackRequest,
role: str = Depends(require_api_key),
):
row = await rollback_scheme_to_version(
scheme_id=scheme_id,
target_version_number=payload.target_version_number,
)
await create_audit_event(
scheme_id=row.scheme_id,
event_type="scheme.rolled_back",
object_type="scheme_version",
object_ref=str(payload.target_version_number),
details={"current_version_number": row.current_version_number, "status": row.status},
)
return SchemeRollbackResponse(
scheme_id=row.scheme_id,
status=row.status,
current_version_number=row.current_version_number,
published_at=row.published_at.isoformat() if row.published_at else None,
)