add stale draft protection for mutation flows validate referenced entities before applying draft changes reduce invalid draft writes caused by stale state and broken references keep mutation behavior explicit and version-aware
568 lines
18 KiB
Python
568 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_references,
|
|
validate_bulk_seat_patch_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,
|
|
)
|
|
|
|
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,
|
|
)
|
|
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,
|
|
)
|