Files
svg-backend/backend/app/api/routes/editor.py
greebo d060828256 feat(backend): prevent duplicate draft sector and group bindings
reject duplicate sector and group bindings within draft mutations

harden draft structure consistency at the backend layer
prevent conflicting bindings before they reach publish flow
2026-03-19 19:29:00 +03:00

582 lines
19 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_references,
validate_bulk_seat_patch_uniqueness,
validate_create_group_uniqueness,
validate_create_sector_uniqueness,
validate_group_patch_uniqueness,
validate_sector_patch_uniqueness,
validate_single_seat_patch_references,
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,
)
await validate_create_sector_uniqueness(
scheme_version_id=version.scheme_version_id,
sector_id=payload.sector_id,
element_id=payload.element_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,
)
await validate_create_group_uniqueness(
scheme_version_id=version.scheme_version_id,
group_id=payload.group_id,
element_id=payload.element_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,
)
await validate_single_seat_patch_references(
scheme_version_id=version.scheme_version_id,
sector_id=payload.sector_id,
group_id=payload.group_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,
)
await validate_bulk_seat_patch_references(
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,
)