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
This commit is contained in:
greebo
2026-03-19 18:58:03 +03:00
parent 76710372c4
commit c7c9184a71
8 changed files with 410 additions and 70 deletions

View File

@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Query
from app.core.config import settings
from app.repositories.audit import create_audit_event
@@ -152,9 +152,13 @@ async def get_draft_compare_preview(
async def create_draft_sector(
scheme_id: str,
payload: CreateSectorRequest,
expected_scheme_version_id: str | None = Query(default=None),
role: str = Depends(require_api_key),
):
scheme, version = await get_current_draft_context(scheme_id)
scheme, version = await get_current_draft_context(
scheme_id,
expected_scheme_version_id=expected_scheme_version_id,
)
row = await create_scheme_version_sector(
scheme_id=scheme.scheme_id,
@@ -191,9 +195,13 @@ async def create_draft_sector(
async def create_draft_group(
scheme_id: str,
payload: CreateGroupRequest,
expected_scheme_version_id: str | None = Query(default=None),
role: str = Depends(require_api_key),
):
scheme, version = await get_current_draft_context(scheme_id)
scheme, version = await get_current_draft_context(
scheme_id,
expected_scheme_version_id=expected_scheme_version_id,
)
row = await create_scheme_version_group(
scheme_id=scheme.scheme_id,
@@ -230,9 +238,13 @@ async def create_draft_group(
async def delete_draft_sector(
scheme_id: str,
sector_record_id: str,
expected_scheme_version_id: str | None = Query(default=None),
role: str = Depends(require_api_key),
):
scheme, version = await get_current_draft_context(scheme_id)
scheme, version = await get_current_draft_context(
scheme_id,
expected_scheme_version_id=expected_scheme_version_id,
)
await delete_scheme_version_sector_by_record_id(
scheme_version_id=version.scheme_version_id,
@@ -259,9 +271,13 @@ async def delete_draft_sector(
async def delete_draft_group(
scheme_id: str,
group_record_id: str,
expected_scheme_version_id: str | None = Query(default=None),
role: str = Depends(require_api_key),
):
scheme, version = await get_current_draft_context(scheme_id)
scheme, version = await get_current_draft_context(
scheme_id,
expected_scheme_version_id=expected_scheme_version_id,
)
await delete_scheme_version_group_by_record_id(
scheme_version_id=version.scheme_version_id,
@@ -289,9 +305,13 @@ async def patch_draft_seat(
scheme_id: str,
seat_record_id: str,
payload: SeatPatchRequest,
expected_scheme_version_id: str | None = Query(default=None),
role: str = Depends(require_api_key),
):
scheme, version = await get_current_draft_context(scheme_id)
scheme, version = await get_current_draft_context(
scheme_id,
expected_scheme_version_id=expected_scheme_version_id,
)
await validate_single_seat_patch_uniqueness(
scheme_version_id=version.scheme_version_id,
@@ -340,9 +360,13 @@ async def patch_draft_seat(
async def bulk_patch_draft_seats(
scheme_id: str,
payload: BulkSeatPatchRequest,
expected_scheme_version_id: str | None = Query(default=None),
role: str = Depends(require_api_key),
):
scheme, version = await get_current_draft_context(scheme_id)
scheme, version = await get_current_draft_context(
scheme_id,
expected_scheme_version_id=expected_scheme_version_id,
)
items = [item.model_dump() for item in payload.items]
await validate_bulk_seat_patch_uniqueness(
@@ -389,9 +413,13 @@ async def patch_draft_sector(
scheme_id: str,
sector_record_id: str,
payload: SectorPatchRequest,
expected_scheme_version_id: str | None = Query(default=None),
role: str = Depends(require_api_key),
):
scheme, version = await get_current_draft_context(scheme_id)
scheme, version = await get_current_draft_context(
scheme_id,
expected_scheme_version_id=expected_scheme_version_id,
)
await validate_sector_patch_uniqueness(
scheme_version_id=version.scheme_version_id,
@@ -421,7 +449,8 @@ async def patch_draft_sector(
object_ref=sector_record_id,
details={
"scheme_version_id": version.scheme_version_id,
"sector_id": payload.sector_id,
"old_sector_id": old_sector_id,
"new_sector_id": payload.sector_id,
"name": payload.name,
"cascaded_seats_count": cascaded_count,
"repair_result": repair_result,
@@ -442,9 +471,13 @@ async def patch_draft_group(
scheme_id: str,
group_record_id: str,
payload: GroupPatchRequest,
expected_scheme_version_id: str | None = Query(default=None),
role: str = Depends(require_api_key),
):
scheme, version = await get_current_draft_context(scheme_id)
scheme, version = await get_current_draft_context(
scheme_id,
expected_scheme_version_id=expected_scheme_version_id,
)
await validate_group_patch_uniqueness(
scheme_version_id=version.scheme_version_id,
@@ -474,7 +507,8 @@ async def patch_draft_group(
object_ref=group_record_id,
details={
"scheme_version_id": version.scheme_version_id,
"group_id": payload.group_id,
"old_group_id": old_group_id,
"new_group_id": payload.group_id,
"name": payload.name,
"cascaded_seats_count": cascaded_count,
"repair_result": repair_result,
@@ -493,9 +527,14 @@ async def patch_draft_group(
@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/repair-references", response_model=RepairReferencesResponse)
async def repair_draft_references(
scheme_id: str,
expected_scheme_version_id: str | None = Query(default=None),
role: str = Depends(require_api_key),
):
scheme, version = await get_current_draft_context(scheme_id)
scheme, version = await get_current_draft_context(
scheme_id,
expected_scheme_version_id=expected_scheme_version_id,
)
result = await repair_structure_references(
scheme_version_id=version.scheme_version_id,
)
@@ -511,7 +550,7 @@ async def repair_draft_references(
return RepairReferencesResponse(
scheme_id=scheme.scheme_id,
scheme_version_id=version.scheme_version_id,
repaired_sector_refs_count=result["repaired_sector_refs_count"],
repaired_group_refs_count=result["repaired_group_refs_count"],
details=result["details"],
repaired_sector_refs_count=result.get("repaired_sector_refs_count", 0),
repaired_group_refs_count=result.get("repaired_group_refs_count", 0),
details=result,
)