Files
svg-backend/backend/app/api/routes/editor.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

557 lines
18 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 (
create_scheme_version_group,
delete_scheme_version_group_by_record_id,
list_scheme_version_groups,
update_scheme_version_group_by_record_id,
)
from app.repositories.scheme_seats import (
bulk_update_scheme_version_seats_by_record_id,
cascade_update_seat_group_reference,
cascade_update_seat_sector_reference,
list_scheme_version_seats,
update_scheme_version_seat_by_record_id,
)
from app.repositories.scheme_sectors import (
create_scheme_version_sector,
delete_scheme_version_sector_by_record_id,
list_scheme_version_sectors,
update_scheme_version_sector_by_record_id,
)
from app.schemas.editor import (
BulkSeatPatchRequest,
BulkSeatPatchResponse,
BulkSeatPatchResultItem,
CreateGroupRequest,
CreateGroupResponse,
CreateSectorRequest,
CreateSectorResponse,
DeleteEntityResponse,
DraftGroupItem,
DraftSeatItem,
DraftSectorItem,
DraftStructureResponse,
GroupPatchRequest,
GroupPatchResponse,
RepairReferencesResponse,
SeatPatchRequest,
SeatPatchResponse,
SectorPatchRequest,
SectorPatchResponse,
StructureDiffEntityItem,
StructureDiffResponse,
)
from app.security.auth import require_api_key
from app.services.draft_guard import get_current_draft_context
from app.services.editor_validation import (
validate_bulk_seat_patch_uniqueness,
validate_group_patch_uniqueness,
validate_sector_patch_uniqueness,
validate_single_seat_patch_uniqueness,
)
from app.services.structure_diff import build_structure_diff
from app.services.structure_sync import repair_structure_references
router = APIRouter()
@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/structure", response_model=DraftStructureResponse)
async def get_draft_structure(
scheme_id: str,
role: str = Depends(require_api_key),
):
scheme, version = await get_current_draft_context(scheme_id)
seats = await list_scheme_version_seats(version.scheme_version_id)
sectors = await list_scheme_version_sectors(version.scheme_version_id)
groups = await list_scheme_version_groups(version.scheme_version_id)
return DraftStructureResponse(
scheme_id=scheme.scheme_id,
scheme_version_id=version.scheme_version_id,
status=version.status,
seats=[
DraftSeatItem(
seat_record_id=row.seat_record_id,
scheme_id=row.scheme_id,
scheme_version_id=row.scheme_version_id,
element_id=row.element_id,
seat_id=row.seat_id,
sector_id=row.sector_id,
group_id=row.group_id,
row_label=row.row_label,
seat_number=row.seat_number,
tag=row.tag,
classes_raw=row.classes_raw,
x=row.x,
y=row.y,
cx=row.cx,
cy=row.cy,
width=row.width,
height=row.height,
created_at=row.created_at.isoformat(),
)
for row in seats
],
sectors=[
DraftSectorItem(
sector_record_id=row.sector_record_id,
scheme_id=row.scheme_id,
scheme_version_id=row.scheme_version_id,
element_id=row.element_id,
sector_id=row.sector_id,
name=row.name,
classes_raw=row.classes_raw,
created_at=row.created_at.isoformat(),
)
for row in sectors
],
groups=[
DraftGroupItem(
group_record_id=row.group_record_id,
scheme_id=row.scheme_id,
scheme_version_id=row.scheme_version_id,
element_id=row.element_id,
group_id=row.group_id,
name=row.name,
classes_raw=row.classes_raw,
created_at=row.created_at.isoformat(),
)
for row in groups
],
total_seats=len(seats),
total_sectors=len(sectors),
total_groups=len(groups),
)
@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/compare-preview", response_model=StructureDiffResponse)
async def get_draft_compare_preview(
scheme_id: str,
role: str = Depends(require_api_key),
):
scheme, version = await get_current_draft_context(scheme_id)
diff = await build_structure_diff(
scheme_id=scheme.scheme_id,
draft_scheme_version_id=version.scheme_version_id,
)
return StructureDiffResponse(
scheme_id=scheme.scheme_id,
draft_scheme_version_id=version.scheme_version_id,
baseline_scheme_version_id=diff["baseline_scheme_version_id"],
summary=diff["summary"],
sectors=[StructureDiffEntityItem(**item) for item in diff["sectors"]],
groups=[StructureDiffEntityItem(**item) for item in diff["groups"]],
seats=[StructureDiffEntityItem(**item) for item in diff["seats"]],
)
@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/sectors", response_model=CreateSectorResponse)
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,
expected_scheme_version_id=expected_scheme_version_id,
)
row = await create_scheme_version_sector(
scheme_id=scheme.scheme_id,
scheme_version_id=version.scheme_version_id,
element_id=payload.element_id,
sector_id=payload.sector_id,
name=payload.name,
classes_raw=payload.classes_raw,
)
await create_audit_event(
scheme_id=scheme.scheme_id,
event_type="draft.sector.created",
object_type="sector",
object_ref=row.sector_record_id,
details={
"scheme_version_id": version.scheme_version_id,
"sector_id": row.sector_id,
"name": row.name,
},
)
return CreateSectorResponse(
scheme_id=scheme.scheme_id,
scheme_version_id=version.scheme_version_id,
sector_record_id=row.sector_record_id,
element_id=row.element_id,
sector_id=row.sector_id,
name=row.name,
)
@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/groups", response_model=CreateGroupResponse)
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,
expected_scheme_version_id=expected_scheme_version_id,
)
row = await create_scheme_version_group(
scheme_id=scheme.scheme_id,
scheme_version_id=version.scheme_version_id,
element_id=payload.element_id,
group_id=payload.group_id,
name=payload.name,
classes_raw=payload.classes_raw,
)
await create_audit_event(
scheme_id=scheme.scheme_id,
event_type="draft.group.created",
object_type="group",
object_ref=row.group_record_id,
details={
"scheme_version_id": version.scheme_version_id,
"group_id": row.group_id,
"name": row.name,
},
)
return CreateGroupResponse(
scheme_id=scheme.scheme_id,
scheme_version_id=version.scheme_version_id,
group_record_id=row.group_record_id,
element_id=row.element_id,
group_id=row.group_id,
name=row.name,
)
@router.delete(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/sectors/records/{{sector_record_id}}", response_model=DeleteEntityResponse)
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,
expected_scheme_version_id=expected_scheme_version_id,
)
await delete_scheme_version_sector_by_record_id(
scheme_version_id=version.scheme_version_id,
sector_record_id=sector_record_id,
)
await create_audit_event(
scheme_id=scheme.scheme_id,
event_type="draft.sector.deleted",
object_type="sector",
object_ref=sector_record_id,
details={"scheme_version_id": version.scheme_version_id},
)
return DeleteEntityResponse(
scheme_id=scheme.scheme_id,
scheme_version_id=version.scheme_version_id,
deleted=True,
record_id=sector_record_id,
)
@router.delete(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/groups/records/{{group_record_id}}", response_model=DeleteEntityResponse)
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,
expected_scheme_version_id=expected_scheme_version_id,
)
await delete_scheme_version_group_by_record_id(
scheme_version_id=version.scheme_version_id,
group_record_id=group_record_id,
)
await create_audit_event(
scheme_id=scheme.scheme_id,
event_type="draft.group.deleted",
object_type="group",
object_ref=group_record_id,
details={"scheme_version_id": version.scheme_version_id},
)
return DeleteEntityResponse(
scheme_id=scheme.scheme_id,
scheme_version_id=version.scheme_version_id,
deleted=True,
record_id=group_record_id,
)
@router.patch(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/seats/records/{{seat_record_id}}", response_model=SeatPatchResponse)
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,
expected_scheme_version_id=expected_scheme_version_id,
)
await validate_single_seat_patch_uniqueness(
scheme_version_id=version.scheme_version_id,
seat_record_id=seat_record_id,
new_seat_id=payload.seat_id,
)
row = await update_scheme_version_seat_by_record_id(
scheme_version_id=version.scheme_version_id,
seat_record_id=seat_record_id,
seat_id=payload.seat_id,
sector_id=payload.sector_id,
group_id=payload.group_id,
row_label=payload.row_label,
seat_number=payload.seat_number,
)
await create_audit_event(
scheme_id=scheme.scheme_id,
event_type="draft.seat.updated",
object_type="seat",
object_ref=seat_record_id,
details={
"scheme_version_id": version.scheme_version_id,
"seat_id": payload.seat_id,
"sector_id": payload.sector_id,
"group_id": payload.group_id,
"row_label": payload.row_label,
"seat_number": payload.seat_number,
},
)
return SeatPatchResponse(
scheme_id=scheme.scheme_id,
scheme_version_id=version.scheme_version_id,
element_id=row.element_id,
seat_id=row.seat_id,
sector_id=row.sector_id,
group_id=row.group_id,
row_label=row.row_label,
seat_number=row.seat_number,
)
@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/seats/bulk", response_model=BulkSeatPatchResponse)
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,
expected_scheme_version_id=expected_scheme_version_id,
)
items = [item.model_dump() for item in payload.items]
await validate_bulk_seat_patch_uniqueness(
scheme_version_id=version.scheme_version_id,
items=items,
)
rows = await bulk_update_scheme_version_seats_by_record_id(
scheme_version_id=version.scheme_version_id,
items=items,
)
await create_audit_event(
scheme_id=scheme.scheme_id,
event_type="draft.seats.bulk_updated",
object_type="seat_bulk",
object_ref=version.scheme_version_id,
details={
"scheme_version_id": version.scheme_version_id,
"updated_count": len(rows),
},
)
return BulkSeatPatchResponse(
scheme_id=scheme.scheme_id,
scheme_version_id=version.scheme_version_id,
updated_count=len(rows),
items=[
BulkSeatPatchResultItem(
seat_record_id=payload.items[idx].seat_record_id,
updated_seat_id=row.seat_id,
sector_id=row.sector_id,
group_id=row.group_id,
row_label=row.row_label,
seat_number=row.seat_number,
)
for idx, row in enumerate(rows)
],
)
@router.patch(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/sectors/records/{{sector_record_id}}", response_model=SectorPatchResponse)
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,
expected_scheme_version_id=expected_scheme_version_id,
)
await validate_sector_patch_uniqueness(
scheme_version_id=version.scheme_version_id,
sector_record_id=sector_record_id,
new_sector_id=payload.sector_id,
)
row, old_sector_id = await update_scheme_version_sector_by_record_id(
scheme_version_id=version.scheme_version_id,
sector_record_id=sector_record_id,
sector_id=payload.sector_id,
name=payload.name,
)
cascaded_count = await cascade_update_seat_sector_reference(
scheme_version_id=version.scheme_version_id,
old_sector_id=old_sector_id,
new_sector_id=payload.sector_id,
)
repair_result = await repair_structure_references(
scheme_version_id=version.scheme_version_id,
)
await create_audit_event(
scheme_id=scheme.scheme_id,
event_type="draft.sector.updated",
object_type="sector",
object_ref=sector_record_id,
details={
"scheme_version_id": version.scheme_version_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,
},
)
return SectorPatchResponse(
scheme_id=scheme.scheme_id,
scheme_version_id=version.scheme_version_id,
element_id=row.element_id,
sector_id=row.sector_id,
name=row.name,
)
@router.patch(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/groups/records/{{group_record_id}}", response_model=GroupPatchResponse)
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,
expected_scheme_version_id=expected_scheme_version_id,
)
await validate_group_patch_uniqueness(
scheme_version_id=version.scheme_version_id,
group_record_id=group_record_id,
new_group_id=payload.group_id,
)
row, old_group_id = await update_scheme_version_group_by_record_id(
scheme_version_id=version.scheme_version_id,
group_record_id=group_record_id,
group_id=payload.group_id,
name=payload.name,
)
cascaded_count = await cascade_update_seat_group_reference(
scheme_version_id=version.scheme_version_id,
old_group_id=old_group_id,
new_group_id=payload.group_id,
)
repair_result = await repair_structure_references(
scheme_version_id=version.scheme_version_id,
)
await create_audit_event(
scheme_id=scheme.scheme_id,
event_type="draft.group.updated",
object_type="group",
object_ref=group_record_id,
details={
"scheme_version_id": version.scheme_version_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,
},
)
return GroupPatchResponse(
scheme_id=scheme.scheme_id,
scheme_version_id=version.scheme_version_id,
element_id=row.element_id,
group_id=row.group_id,
name=row.name,
)
@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,
expected_scheme_version_id=expected_scheme_version_id,
)
result = await repair_structure_references(
scheme_version_id=version.scheme_version_id,
)
await create_audit_event(
scheme_id=scheme.scheme_id,
event_type="draft.references.repaired",
object_type="draft_structure",
object_ref=version.scheme_version_id,
details=result,
)
return RepairReferencesResponse(
scheme_id=scheme.scheme_id,
scheme_version_id=version.scheme_version_id,
repaired_sector_refs_count=result.get("repaired_sector_refs_count", 0),
repaired_group_refs_count=result.get("repaired_group_refs_count", 0),
details=result,
)