fix(core): stabilize editor lifecycle, transactional versions, and runtime config

This commit is contained in:
greebo
2026-03-20 12:38:10 +03:00
parent 0f9c2a1cbd
commit 239b32a246
17 changed files with 1224 additions and 457 deletions

View File

@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, Query, Request
from app.core.config import settings
from app.repositories.audit import create_audit_event
@@ -508,6 +508,7 @@ async def delete_draft_group(
@router.patch(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/seats/records/{{seat_record_id}}", response_model=SeatPatchResponse)
async def patch_draft_seat(
request: Request,
scheme_id: str,
seat_record_id: str,
payload: SeatPatchRequest,
@@ -530,14 +531,20 @@ async def patch_draft_seat(
group_id=payload.group_id,
)
raw_json = await request.json()
update_data = {k: v for k, v in payload.model_dump(exclude_unset=True).items() if k in raw_json}
for field in ("seat_id", "sector_id", "group_id"):
if field in update_data and (update_data[field] is None or update_data[field] == ""):
from app.services.api_errors import raise_unprocessable
raise_unprocessable(
code="business_identifier_nullification_forbidden",
message=f"{field} cannot be nullified or explicitly cleared",
)
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,
**update_data,
)
await create_audit_event(
@@ -569,6 +576,7 @@ async def patch_draft_seat(
@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/seats/bulk", response_model=BulkSeatPatchResponse)
async def bulk_patch_draft_seats(
request: Request,
scheme_id: str,
payload: BulkSeatPatchRequest,
expected_scheme_version_id: str | None = Query(default=None),
@@ -579,7 +587,20 @@ async def bulk_patch_draft_seats(
expected_scheme_version_id=expected_scheme_version_id,
)
items = [item.model_dump() for item in payload.items]
raw_json = await request.json()
items = []
for i, item in enumerate(payload.items):
item_raw = raw_json.get("items", [])[i] if "items" in raw_json else {}
items.append({k: item.model_dump(exclude_unset=True).get(k) for k in item_raw})
for item in items:
for field in ("seat_id", "sector_id", "group_id"):
if field in item and (item[field] is None or item[field] == ""):
from app.services.api_errors import raise_unprocessable
raise_unprocessable(
code="business_identifier_nullification_forbidden",
message=f"{field} cannot be nullified or explicitly cleared",
)
await validate_bulk_seat_patch_uniqueness(
scheme_version_id=version.scheme_version_id,
items=items,
@@ -625,6 +646,7 @@ async def bulk_patch_draft_seats(
@router.patch(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/sectors/records/{{sector_record_id}}", response_model=SectorPatchResponse)
async def patch_draft_sector(
request: Request,
scheme_id: str,
sector_record_id: str,
payload: SectorPatchRequest,
@@ -642,20 +664,28 @@ async def patch_draft_sector(
new_sector_id=payload.sector_id,
)
raw_json = await request.json()
update_data = {k: v for k, v in payload.model_dump(exclude_unset=True).items() if k in raw_json}
for field in ("sector_id",):
if field in update_data and (update_data[field] is None or update_data[field] == ""):
from app.services.api_errors import raise_unprocessable
raise_unprocessable(
code="business_identifier_nullification_forbidden",
message=f"{field} cannot be nullified or explicitly cleared",
)
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,
**update_data,
)
cascaded_count = 0
if "sector_id" in update_data and update_data["sector_id"] and update_data["sector_id"] != old_sector_id:
cascaded_count = await cascade_update_seat_sector_reference(
scheme_version_id=version.scheme_version_id,
old_sector_id=old_sector_id,
new_sector_id=update_data["sector_id"],
)
await create_audit_event(
scheme_id=scheme.scheme_id,
@@ -668,7 +698,6 @@ async def patch_draft_sector(
"new_sector_id": payload.sector_id,
"name": payload.name,
"cascaded_seats_count": cascaded_count,
"repair_result": repair_result,
},
)
@@ -683,6 +712,7 @@ async def patch_draft_sector(
@router.patch(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/groups/records/{{group_record_id}}", response_model=GroupPatchResponse)
async def patch_draft_group(
request: Request,
scheme_id: str,
group_record_id: str,
payload: GroupPatchRequest,
@@ -700,20 +730,28 @@ async def patch_draft_group(
new_group_id=payload.group_id,
)
raw_json = await request.json()
update_data = {k: v for k, v in payload.model_dump(exclude_unset=True).items() if k in raw_json}
for field in ("group_id",):
if field in update_data and (update_data[field] is None or update_data[field] == ""):
from app.services.api_errors import raise_unprocessable
raise_unprocessable(
code="business_identifier_nullification_forbidden",
message=f"{field} cannot be nullified or explicitly cleared",
)
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,
**update_data,
)
cascaded_count = 0
if "group_id" in update_data and update_data["group_id"] and update_data["group_id"] != old_group_id:
cascaded_count = await cascade_update_seat_group_reference(
scheme_version_id=version.scheme_version_id,
old_group_id=old_group_id,
new_group_id=update_data["group_id"],
)
await create_audit_event(
scheme_id=scheme.scheme_id,
@@ -726,7 +764,6 @@ async def patch_draft_group(
"new_group_id": payload.group_id,
"name": payload.name,
"cascaded_seats_count": cascaded_count,
"repair_result": repair_result,
},
)

View File

@@ -2,12 +2,10 @@ 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 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,
create_next_scheme_version_from_current_checked,
ensure_draft_scheme_version_consistent,
get_current_scheme_version,
list_scheme_versions,
)
@@ -34,26 +32,12 @@ from app.schemas.scheme_versions import (
SchemeVersionListResponse,
)
from app.security.auth import require_api_key
from app.services.api_errors import raise_conflict
from app.services.publish_service import publish_current_draft_scheme
from app.services.scheme_validation import build_scheme_validation_report
router = APIRouter()
def _build_stale_current_version_detail(
*,
expected_scheme_version_id: str,
actual_scheme_version_id: str,
) -> dict:
return {
"code": "stale_current_version",
"message": "Current scheme version changed. Reload scheme state before creating a new version.",
"expected_scheme_version_id": expected_scheme_version_id,
"actual_scheme_version_id": actual_scheme_version_id,
}
@router.get(f"{settings.api_v1_prefix}/schemes", response_model=SchemeListResponse)
async def get_schemes(
limit: int = Query(default=50, ge=1, le=200),
@@ -155,36 +139,9 @@ async def create_next_scheme_version_endpoint(
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
and expected_current_scheme_version_id != current_version.scheme_version_id
):
raise_conflict(
_build_stale_current_version_detail(
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,
current_version, new_version = await create_next_scheme_version_from_current_checked(
scheme_id=scheme_id,
expected_current_scheme_version_id=expected_current_scheme_version_id,
)
await create_audit_event(
@@ -214,26 +171,14 @@ async def ensure_draft_scheme_version(
expected_current_scheme_version_id: str | None = Query(default=None),
role: str = Depends(require_api_key),
):
scheme = await get_scheme_record_by_scheme_id(scheme_id)
current_version = await get_current_scheme_version(
scheme_id=scheme.scheme_id,
current_version_number=scheme.current_version_number,
current_version, created, source_scheme_version_id = await ensure_draft_scheme_version_consistent(
scheme_id=scheme_id,
expected_current_scheme_version_id=expected_current_scheme_version_id,
)
if (
expected_current_scheme_version_id
and expected_current_scheme_version_id != current_version.scheme_version_id
):
raise_conflict(
_build_stale_current_version_detail(
expected_scheme_version_id=expected_current_scheme_version_id,
actual_scheme_version_id=current_version.scheme_version_id,
)
)
if scheme.status == "draft" and current_version.status == "draft":
if not created:
return EnsureDraftResponse(
scheme_id=scheme.scheme_id,
scheme_id=current_version.scheme_id,
scheme_version_id=current_version.scheme_version_id,
version_number=current_version.version_number,
status=current_version.status,
@@ -242,42 +187,27 @@ async def ensure_draft_scheme_version(
source_scheme_version_id=None,
)
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,
object_ref=current_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,
"source_scheme_version_id": source_scheme_version_id,
"version_number": current_version.version_number,
"normalized_storage_path": current_version.normalized_storage_path,
"reason": "ensure_draft",
},
)
return EnsureDraftResponse(
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,
scheme_id=current_version.scheme_id,
scheme_version_id=current_version.scheme_version_id,
version_number=current_version.version_number,
status=current_version.status,
normalized_storage_path=current_version.normalized_storage_path,
created=True,
source_scheme_version_id=current_version.scheme_version_id,
source_scheme_version_id=source_scheme_version_id,
)

View File

@@ -10,8 +10,7 @@ from app.repositories.scheme_artifacts import create_scheme_artifact
from app.repositories.scheme_groups import replace_scheme_version_groups
from app.repositories.scheme_seats import replace_scheme_version_seats
from app.repositories.scheme_sectors import replace_scheme_version_sectors
from app.repositories.scheme_versions import create_initial_scheme_version
from app.repositories.schemes import create_scheme_from_upload
from app.repositories.schemes import create_scheme_from_upload_with_initial_version
from app.repositories.uploads import (
count_upload_records,
create_upload_record,
@@ -202,17 +201,9 @@ async def upload_scheme_svg(
processing_status="completed",
)
scheme_id = await create_scheme_from_upload(
scheme_id, scheme_version_id = await create_scheme_from_upload_with_initial_version(
source_upload_id=upload_id,
name=Path(filename).stem or filename,
normalized_elements_count=summary["elements_count"],
normalized_seats_count=summary["seats_count"],
normalized_groups_count=summary["groups_count"],
normalized_sectors_count=summary["sectors_count"],
)
scheme_version_id = await create_initial_scheme_version(
scheme_id=scheme_id,
normalized_storage_path=normalized_storage_path,
normalized_elements_count=summary["elements_count"],
normalized_seats_count=summary["seats_count"],