Files
svg-backend/backend/app/api/routes/schemes.py
greebo c7c9184a71 feat: add optimistic concurrency guards for draft editor, pricing and publish flows
add optimistic concurrency guards via expected scheme version id

protect draft editor, pricing snapshot, remap and publish flows from stale mutations
protect version creation from stale current version state

keep backward compatibility with optional query guards

verify 409 conflict behavior for stale clients and 200 for valid flows
2026-03-19 18:58:03 +03:00

270 lines
10 KiB
Python

from fastapi import APIRouter, Depends, HTTPException, Query, status
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 is not None
and expected_current_scheme_version_id != current_version.scheme_version_id
):
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,
)