Implement display artifacts, pricing integrity, draft base and publish preview bundle

This commit is contained in:
greebo
2026-03-19 17:58:17 +03:00
parent 85fb2f4bb9
commit c91c5abf15
35 changed files with 3283 additions and 302 deletions

View File

@@ -1,7 +1,10 @@
from fastapi import APIRouter
from app.api.routes.admin import router as admin_router
from app.api.routes.audit import router as audit_router
from app.api.routes.editor import router as editor_router
from app.api.routes.pricing import router as pricing_router
from app.api.routes.publish import router as publish_router
from app.api.routes.schemes import router as schemes_router
from app.api.routes.structure import router as structure_router
from app.api.routes.system import router as system_router
@@ -16,3 +19,6 @@ router.include_router(structure_router)
router.include_router(pricing_router)
router.include_router(test_mode_router)
router.include_router(audit_router)
router.include_router(admin_router)
router.include_router(editor_router)
router.include_router(publish_router)

View File

@@ -0,0 +1,162 @@
from fastapi import APIRouter, Depends, Query
from app.core.config import settings
from app.repositories.scheme_artifacts import artifact_exists, list_scheme_artifacts
from app.repositories.scheme_versions import get_current_scheme_version
from app.repositories.schemes import get_scheme_record_by_scheme_id, list_scheme_records
from app.repositories.uploads import get_upload_record_by_upload_id
from app.security.auth import require_api_key
from app.services.display_regenerator import regenerate_display_artifact
from app.services.scheme_validation import build_scheme_validation_report
router = APIRouter()
@router.get(f"{settings.api_v1_prefix}/admin/schemes/{{scheme_id}}/current/artifacts")
async def list_current_scheme_artifacts(
scheme_id: str,
role: str = Depends(require_api_key),
):
scheme = await get_scheme_record_by_scheme_id(scheme_id)
version = await get_current_scheme_version(
scheme_id=scheme.scheme_id,
current_version_number=scheme.current_version_number,
)
rows = await list_scheme_artifacts(scheme_version_id=version.scheme_version_id)
return {
"scheme_id": scheme.scheme_id,
"scheme_version_id": version.scheme_version_id,
"items": [
{
"artifact_id": row.artifact_id,
"artifact_type": row.artifact_type,
"artifact_variant": row.artifact_variant,
"status": row.status,
"storage_path": row.storage_path,
"meta_json": row.meta_json,
"created_at": row.created_at.isoformat(),
}
for row in rows
],
"total": len(rows),
}
@router.get(f"{settings.api_v1_prefix}/admin/schemes/{{scheme_id}}/current/validation")
async def validate_current_scheme(
scheme_id: str,
role: str = Depends(require_api_key),
):
scheme = await get_scheme_record_by_scheme_id(scheme_id)
version = await get_current_scheme_version(
scheme_id=scheme.scheme_id,
current_version_number=scheme.current_version_number,
)
report = await build_scheme_validation_report(
scheme_id=scheme.scheme_id,
scheme_version_id=version.scheme_version_id,
)
return {
"scheme_id": scheme.scheme_id,
"scheme_version_id": version.scheme_version_id,
"report": report,
}
@router.post(f"{settings.api_v1_prefix}/admin/schemes/{{scheme_id}}/current/display/regenerate")
async def regenerate_current_display(
scheme_id: str,
mode: str = Query(default="passthrough"),
role: str = Depends(require_api_key),
):
scheme = await get_scheme_record_by_scheme_id(scheme_id)
version = await get_current_scheme_version(
scheme_id=scheme.scheme_id,
current_version_number=scheme.current_version_number,
)
upload = await get_upload_record_by_upload_id(scheme.source_upload_id)
return await regenerate_display_artifact(
scheme_id=scheme.scheme_id,
scheme_version_id=version.scheme_version_id,
upload_id=upload.upload_id,
original_filename=upload.original_filename,
sanitized_storage_path=upload.sanitized_storage_path,
mode=mode,
)
@router.post(f"{settings.api_v1_prefix}/admin/display/backfill")
async def bulk_backfill_display_artifacts(
mode: str = Query(default="passthrough"),
limit: int = Query(default=100, ge=1, le=1000),
only_missing: bool = Query(default=True),
role: str = Depends(require_api_key),
):
schemes = await list_scheme_records(limit=limit, offset=0)
processed: list[dict] = []
skipped: list[dict] = []
failed: list[dict] = []
for scheme in schemes:
try:
version = await get_current_scheme_version(
scheme_id=scheme.scheme_id,
current_version_number=scheme.current_version_number,
)
if only_missing:
exists = await artifact_exists(
scheme_version_id=version.scheme_version_id,
artifact_type="display_svg",
artifact_variant=mode,
)
if exists:
skipped.append(
{
"scheme_id": scheme.scheme_id,
"scheme_version_id": version.scheme_version_id,
"reason": "artifact already exists",
}
)
continue
upload = await get_upload_record_by_upload_id(scheme.source_upload_id)
result = await regenerate_display_artifact(
scheme_id=scheme.scheme_id,
scheme_version_id=version.scheme_version_id,
upload_id=upload.upload_id,
original_filename=upload.original_filename,
sanitized_storage_path=upload.sanitized_storage_path,
mode=mode,
)
processed.append(
{
"scheme_id": scheme.scheme_id,
"scheme_version_id": version.scheme_version_id,
"artifact_variant": result["artifact_variant"],
}
)
except Exception as exc:
failed.append(
{
"scheme_id": scheme.scheme_id,
"reason": f"{exc.__class__.__name__}: {exc}",
}
)
return {
"mode": mode,
"limit": limit,
"only_missing": only_missing,
"processed_count": len(processed),
"skipped_count": len(skipped),
"failed_count": len(failed),
"processed": processed,
"skipped": skipped,
"failed": failed,
}

View File

@@ -0,0 +1,517 @@
from fastapi import APIRouter, Depends
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,
role: str = Depends(require_api_key),
):
scheme, version = await get_current_draft_context(scheme_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,
role: str = Depends(require_api_key),
):
scheme, version = await get_current_draft_context(scheme_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,
role: str = Depends(require_api_key),
):
scheme, version = await get_current_draft_context(scheme_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,
role: str = Depends(require_api_key),
):
scheme, version = await get_current_draft_context(scheme_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,
role: str = Depends(require_api_key),
):
scheme, version = await get_current_draft_context(scheme_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,
role: str = Depends(require_api_key),
):
scheme, version = await get_current_draft_context(scheme_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,
role: str = Depends(require_api_key),
):
scheme, version = await get_current_draft_context(scheme_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,
"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,
role: str = Depends(require_api_key),
):
scheme, version = await get_current_draft_context(scheme_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,
"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,
role: str = Depends(require_api_key),
):
scheme, version = await get_current_draft_context(scheme_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["repaired_sector_refs_count"],
repaired_group_refs_count=result["repaired_group_refs_count"],
details=result["details"],
)

View File

@@ -1,4 +1,6 @@
from fastapi import APIRouter, Depends
from decimal import Decimal
from fastapi import APIRouter, Depends, HTTPException, status
from app.core.config import settings
from app.repositories.audit import create_audit_event
@@ -12,33 +14,44 @@ from app.repositories.pricing import (
update_price_rule,
update_pricing_category,
)
from app.repositories.scheme_version_pricing import replace_scheme_version_pricing_snapshot
from app.repositories.schemes import get_scheme_record_by_scheme_id
from app.repositories.scheme_versions import get_current_scheme_version
from app.schemas.pricing import (
DeleteResponse,
PriceRuleCreateRequest,
PriceRuleCreateResponse,
PriceRuleItem,
PriceRuleUpdateRequest,
PriceRuleUpdateResponse,
PricingBundleResponse,
PricingCategoryCreateRequest,
PricingCategoryCreateResponse,
PricingCategoryItem,
PricingCategoryUpdateRequest,
PricingCategoryUpdateResponse,
SchemePricingResponse,
)
from app.security.auth import require_api_key
router = APIRouter()
@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing", response_model=SchemePricingResponse)
async def get_scheme_pricing(scheme_id: str, role: str = Depends(require_api_key)):
await get_scheme_record_by_scheme_id(scheme_id)
async def _refresh_current_draft_snapshot_if_possible(scheme_id: str) -> dict | None:
scheme = await get_scheme_record_by_scheme_id(scheme_id)
version = await get_current_scheme_version(
scheme_id=scheme.scheme_id,
current_version_number=scheme.current_version_number,
)
if scheme.status != "draft" or version.status != "draft":
return None
return await replace_scheme_version_pricing_snapshot(
scheme_id=scheme.scheme_id,
scheme_version_id=version.scheme_version_id,
)
@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing", response_model=PricingBundleResponse)
async def get_pricing_bundle(scheme_id: str, role: str = Depends(require_api_key)):
categories = await list_pricing_categories(scheme_id)
rules = await list_price_rules(scheme_id)
return SchemePricingResponse(
return PricingBundleResponse(
categories=[
PricingCategoryItem(
pricing_category_id=row.pricing_category_id,
@@ -56,7 +69,7 @@ async def get_scheme_pricing(scheme_id: str, role: str = Depends(require_api_key
pricing_category_id=row.pricing_category_id,
target_type=row.target_type,
target_ref=row.target_ref,
amount=row.amount,
amount=str(row.amount),
currency=row.currency,
created_at=row.created_at.isoformat(),
)
@@ -65,34 +78,36 @@ async def get_scheme_pricing(scheme_id: str, role: str = Depends(require_api_key
)
@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/categories", response_model=PricingCategoryCreateResponse)
@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/categories")
async def create_pricing_category_endpoint(
scheme_id: str,
payload: PricingCategoryCreateRequest,
role: str = Depends(require_api_key),
):
await get_scheme_record_by_scheme_id(scheme_id)
pricing_category_id = await create_pricing_category(
scheme_id=scheme_id,
name=payload.name,
code=payload.code,
)
snapshot = await _refresh_current_draft_snapshot_if_possible(scheme_id)
await create_audit_event(
scheme_id=scheme_id,
event_type="pricing.category.created",
object_type="pricing_category",
object_ref=pricing_category_id,
details={"name": payload.name, "code": payload.code},
)
return PricingCategoryCreateResponse(
pricing_category_id=pricing_category_id,
scheme_id=scheme_id,
name=payload.name,
code=payload.code,
details={"name": payload.name, "code": payload.code, "snapshot": snapshot},
)
return {
"pricing_category_id": pricing_category_id,
"scheme_id": scheme_id,
"name": payload.name,
"code": payload.code,
}
@router.put(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/categories/{{pricing_category_id}}", response_model=PricingCategoryUpdateResponse)
@router.put(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/categories/{{pricing_category_id}}")
async def update_pricing_category_endpoint(
scheme_id: str,
pricing_category_id: str,
@@ -105,53 +120,71 @@ async def update_pricing_category_endpoint(
name=payload.name,
code=payload.code,
)
snapshot = await _refresh_current_draft_snapshot_if_possible(scheme_id)
await create_audit_event(
scheme_id=scheme_id,
event_type="pricing.category.updated",
object_type="pricing_category",
object_ref=pricing_category_id,
details={"name": payload.name, "code": payload.code},
)
return PricingCategoryUpdateResponse(
pricing_category_id=row.pricing_category_id,
scheme_id=row.scheme_id,
name=row.name,
code=row.code,
details={"name": payload.name, "code": payload.code, "snapshot": snapshot},
)
return {
"pricing_category_id": row.pricing_category_id,
"scheme_id": row.scheme_id,
"name": row.name,
"code": row.code,
}
@router.delete(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/categories/{{pricing_category_id}}", response_model=DeleteResponse)
@router.delete(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/categories/{{pricing_category_id}}")
async def delete_pricing_category_endpoint(
scheme_id: str,
pricing_category_id: str,
role: str = Depends(require_api_key),
):
await delete_pricing_category(scheme_id=scheme_id, pricing_category_id=pricing_category_id)
await delete_pricing_category(
scheme_id=scheme_id,
pricing_category_id=pricing_category_id,
)
snapshot = await _refresh_current_draft_snapshot_if_possible(scheme_id)
await create_audit_event(
scheme_id=scheme_id,
event_type="pricing.category.deleted",
object_type="pricing_category",
object_ref=pricing_category_id,
details=None,
details={"snapshot": snapshot},
)
return DeleteResponse(status="deleted")
return {"deleted": True, "pricing_category_id": pricing_category_id}
@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/rules", response_model=PriceRuleCreateResponse)
@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/rules")
async def create_price_rule_endpoint(
scheme_id: str,
payload: PriceRuleCreateRequest,
role: str = Depends(require_api_key),
):
await get_scheme_record_by_scheme_id(scheme_id)
try:
amount = Decimal(payload.amount)
except Exception:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Некорректная сумма",
)
price_rule_id = await create_price_rule(
scheme_id=scheme_id,
pricing_category_id=payload.pricing_category_id,
target_type=payload.target_type,
target_ref=payload.target_ref,
amount=payload.amount,
amount=amount,
currency=payload.currency,
)
snapshot = await _refresh_current_draft_snapshot_if_possible(scheme_id)
await create_audit_event(
scheme_id=scheme_id,
event_type="pricing.rule.created",
@@ -161,37 +194,49 @@ async def create_price_rule_endpoint(
"pricing_category_id": payload.pricing_category_id,
"target_type": payload.target_type,
"target_ref": payload.target_ref,
"amount": str(payload.amount),
"amount": payload.amount,
"currency": payload.currency,
"snapshot": snapshot,
},
)
return PriceRuleCreateResponse(
price_rule_id=price_rule_id,
scheme_id=scheme_id,
pricing_category_id=payload.pricing_category_id,
target_type=payload.target_type,
target_ref=payload.target_ref,
amount=payload.amount,
currency=payload.currency,
)
return {
"price_rule_id": price_rule_id,
"scheme_id": scheme_id,
"pricing_category_id": payload.pricing_category_id,
"target_type": payload.target_type,
"target_ref": payload.target_ref,
"amount": payload.amount,
"currency": payload.currency,
}
@router.put(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/rules/{{price_rule_id}}", response_model=PriceRuleUpdateResponse)
@router.put(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/rules/{{price_rule_id}}")
async def update_price_rule_endpoint(
scheme_id: str,
price_rule_id: str,
payload: PriceRuleUpdateRequest,
role: str = Depends(require_api_key),
):
try:
amount = Decimal(payload.amount)
except Exception:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Некорректная сумма",
)
row = await update_price_rule(
scheme_id=scheme_id,
price_rule_id=price_rule_id,
pricing_category_id=payload.pricing_category_id,
target_type=payload.target_type,
target_ref=payload.target_ref,
amount=payload.amount,
amount=amount,
currency=payload.currency,
)
snapshot = await _refresh_current_draft_snapshot_if_possible(scheme_id)
await create_audit_event(
scheme_id=scheme_id,
event_type="pricing.rule.updated",
@@ -201,33 +246,41 @@ async def update_price_rule_endpoint(
"pricing_category_id": payload.pricing_category_id,
"target_type": payload.target_type,
"target_ref": payload.target_ref,
"amount": str(payload.amount),
"amount": payload.amount,
"currency": payload.currency,
"snapshot": snapshot,
},
)
return PriceRuleUpdateResponse(
price_rule_id=row.price_rule_id,
scheme_id=row.scheme_id,
pricing_category_id=row.pricing_category_id,
target_type=row.target_type,
target_ref=row.target_ref,
amount=row.amount,
currency=row.currency,
)
return {
"price_rule_id": row.price_rule_id,
"scheme_id": row.scheme_id,
"pricing_category_id": row.pricing_category_id,
"target_type": row.target_type,
"target_ref": row.target_ref,
"amount": str(row.amount),
"currency": row.currency,
}
@router.delete(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/rules/{{price_rule_id}}", response_model=DeleteResponse)
@router.delete(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/rules/{{price_rule_id}}")
async def delete_price_rule_endpoint(
scheme_id: str,
price_rule_id: str,
role: str = Depends(require_api_key),
):
await delete_price_rule(scheme_id=scheme_id, price_rule_id=price_rule_id)
await delete_price_rule(
scheme_id=scheme_id,
price_rule_id=price_rule_id,
)
snapshot = await _refresh_current_draft_snapshot_if_possible(scheme_id)
await create_audit_event(
scheme_id=scheme_id,
event_type="pricing.rule.deleted",
object_type="price_rule",
object_ref=price_rule_id,
details=None,
details={"snapshot": snapshot},
)
return DeleteResponse(status="deleted")
return {"deleted": True, "price_rule_id": price_rule_id}

View File

@@ -0,0 +1,132 @@
from fastapi import APIRouter, Depends, Query
from app.core.config import settings
from app.repositories.audit import create_audit_event
from app.repositories.scheme_version_pricing import replace_scheme_version_pricing_snapshot
from app.schemas.publish_preview import (
PublishPreviewResponse,
RemapApplyRequest,
RemapApplyResponse,
RemapPreviewRequest,
RemapPreviewResponse,
RemapPreviewSeatItem,
)
from app.security.auth import require_api_key
from app.services.draft_guard import get_current_draft_context
from app.services.publish_preview import get_or_build_publish_preview_bundle
from app.services.remap_service import apply_remap, preview_remap
router = APIRouter()
@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/pricing/snapshot")
async def create_draft_pricing_snapshot(
scheme_id: str,
role: str = Depends(require_api_key),
):
scheme, version = await get_current_draft_context(scheme_id)
result = await replace_scheme_version_pricing_snapshot(
scheme_id=scheme.scheme_id,
scheme_version_id=version.scheme_version_id,
)
await create_audit_event(
scheme_id=scheme.scheme_id,
event_type="draft.pricing.snapshot.created",
object_type="pricing_snapshot",
object_ref=version.scheme_version_id,
details=result,
)
return {
"scheme_id": scheme.scheme_id,
"scheme_version_id": version.scheme_version_id,
**result,
}
@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/publish-preview", response_model=PublishPreviewResponse)
async def get_publish_preview(
scheme_id: str,
baseline_scheme_version_id: str | None = Query(default=None),
refresh: bool = Query(default=False),
role: str = Depends(require_api_key),
):
scheme, version = await get_current_draft_context(scheme_id)
bundle = await get_or_build_publish_preview_bundle(
scheme_id=scheme.scheme_id,
scheme_version_id=version.scheme_version_id,
baseline_override_scheme_version_id=baseline_scheme_version_id,
refresh=refresh,
)
return PublishPreviewResponse(
scheme_id=scheme.scheme_id,
scheme_version_id=version.scheme_version_id,
artifacts=bundle["artifacts"],
validation=bundle["validation"],
structure_diff=bundle["structure_diff"],
pricing_coverage=bundle["pricing_coverage"],
summary=bundle["summary"],
)
@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/remap/preview", response_model=RemapPreviewResponse)
async def preview_draft_remap(
scheme_id: str,
payload: RemapPreviewRequest,
role: str = Depends(require_api_key),
):
scheme, version = await get_current_draft_context(scheme_id)
items = await preview_remap(
scheme_version_id=version.scheme_version_id,
seat_record_ids=payload.seat_record_ids,
from_sector_id=payload.from_sector_id,
to_sector_id=payload.to_sector_id,
from_group_id=payload.from_group_id,
to_group_id=payload.to_group_id,
)
return RemapPreviewResponse(
scheme_id=scheme.scheme_id,
scheme_version_id=version.scheme_version_id,
matched_count=len(items),
items=[RemapPreviewSeatItem(**item) for item in items],
)
@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/remap/apply", response_model=RemapApplyResponse)
async def apply_draft_remap(
scheme_id: str,
payload: RemapApplyRequest,
role: str = Depends(require_api_key),
):
scheme, version = await get_current_draft_context(scheme_id)
items = await apply_remap(
scheme_version_id=version.scheme_version_id,
seat_record_ids=payload.seat_record_ids,
from_sector_id=payload.from_sector_id,
to_sector_id=payload.to_sector_id,
from_group_id=payload.from_group_id,
to_group_id=payload.to_group_id,
)
await create_audit_event(
scheme_id=scheme.scheme_id,
event_type="draft.remap.applied",
object_type="draft_structure",
object_ref=version.scheme_version_id,
details={
"matched_count": len(items),
"from_sector_id": payload.from_sector_id,
"to_sector_id": payload.to_sector_id,
"from_group_id": payload.from_group_id,
"to_group_id": payload.to_group_id,
},
)
return RemapApplyResponse(
scheme_id=scheme.scheme_id,
scheme_version_id=version.scheme_version_id,
updated_count=len(items),
items=[RemapPreviewSeatItem(**item) for item in items],
)

View File

@@ -15,7 +15,6 @@ from app.repositories.schemes import (
count_scheme_records,
get_scheme_record_by_scheme_id,
list_scheme_records,
publish_scheme,
rollback_scheme_to_version,
unpublish_scheme,
)
@@ -34,6 +33,8 @@ from app.schemas.scheme_versions import (
SchemeVersionListResponse,
)
from app.security.auth import require_api_key
from app.services.publish_service import publish_current_draft_scheme
from app.services.scheme_validation import build_scheme_validation_report
router = APIRouter()
@@ -180,22 +181,27 @@ async def create_next_scheme_version_endpoint(
)
@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/publish", response_model=SchemePublishResponse)
@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/publish/validation")
async def get_publish_validation(scheme_id: str, role: str = Depends(require_api_key)):
scheme = await get_scheme_record_by_scheme_id(scheme_id)
version = await get_current_scheme_version(
scheme_id=scheme.scheme_id,
current_version_number=scheme.current_version_number,
)
report = await build_scheme_validation_report(
scheme_id=scheme.scheme_id,
scheme_version_id=version.scheme_version_id,
)
return {
"scheme_id": scheme.scheme_id,
"scheme_version_id": version.scheme_version_id,
"report": report,
}
@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/publish")
async def publish_scheme_endpoint(scheme_id: str, role: str = Depends(require_api_key)):
row = await publish_scheme(scheme_id)
await create_audit_event(
scheme_id=row.scheme_id,
event_type="scheme.published",
object_type="scheme",
object_ref=row.scheme_id,
details={"current_version_number": row.current_version_number, "status": row.status},
)
return SchemePublishResponse(
scheme_id=row.scheme_id,
status=row.status,
current_version_number=row.current_version_number,
published_at=row.published_at.isoformat() if row.published_at else None,
)
return await publish_current_draft_scheme(scheme_id=scheme_id)
@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/unpublish", response_model=SchemePublishResponse)

View File

@@ -8,6 +8,7 @@ from lxml import etree
from app.core.config import settings
from app.repositories.pricing import find_effective_price_rule
from app.repositories.scheme_artifacts import get_latest_scheme_artifact
from app.repositories.scheme_groups import list_scheme_version_groups
from app.repositories.scheme_seats import get_scheme_version_seat_by_seat_id, list_scheme_version_seats
from app.repositories.scheme_sectors import list_scheme_version_sectors
@@ -66,13 +67,29 @@ async def _load_current_context(scheme_id: str):
return scheme, version, upload
async def _generate_default_display_artifact_if_needed(scheme, version, upload) -> tuple[bytes, Path]:
async def _load_default_display_artifact(scheme, version, upload) -> tuple[bytes, Path]:
artifact = await get_latest_scheme_artifact(
scheme_version_id=version.scheme_version_id,
artifact_type="display_svg",
artifact_variant=settings.svg_display_mode,
)
if artifact is not None:
path = Path(artifact.storage_path)
if path.exists() and path.is_file():
return path.read_bytes(), path
if version.display_svg_status == "ready" and version.display_svg_storage_path:
path = Path(version.display_svg_storage_path)
if path.exists() and path.is_file():
return path.read_bytes(), path
sanitized_path = Path(upload.sanitized_storage_path)
sanitized_artifact = await get_latest_scheme_artifact(
scheme_version_id=version.scheme_version_id,
artifact_type="sanitized_svg",
artifact_variant="source",
)
sanitized_path = Path(sanitized_artifact.storage_path if sanitized_artifact else upload.sanitized_storage_path)
if not sanitized_path.exists() or not sanitized_path.is_file():
if version.display_svg_status == "pending":
raise HTTPException(
@@ -106,6 +123,16 @@ async def _generate_default_display_artifact_if_needed(scheme, version, upload)
)
display_path = Path(display_path_str)
from app.repositories.scheme_artifacts import create_scheme_artifact
await create_scheme_artifact(
scheme_id=scheme.scheme_id,
scheme_version_id=version.scheme_version_id,
artifact_type="display_svg",
artifact_variant=settings.svg_display_mode,
storage_path=display_path_str,
status="ready",
meta_json=meta,
)
await update_scheme_version_display_artifact(
scheme_version_id=version.scheme_version_id,
display_svg_storage_path=display_path_str,
@@ -113,14 +140,6 @@ async def _generate_default_display_artifact_if_needed(scheme, version, upload)
display_svg_generated_at=datetime.now(timezone.utc),
)
logger.info(
"display_svg.lazy_generate scheme_id=%s scheme_version_id=%s mode=%s view_box=%s",
scheme.scheme_id,
version.scheme_version_id,
settings.svg_display_mode,
meta.get("view_box"),
)
return display_bytes, display_path
@@ -253,11 +272,16 @@ async def get_scheme_current_display_svg(
scheme, version, upload = await _load_current_context(scheme_id)
if resolved_mode == settings.svg_display_mode:
display_bytes, display_path = await _generate_default_display_artifact_if_needed(scheme, version, upload)
_display_bytes, display_path = await _load_default_display_artifact(scheme, version, upload)
filename = f"{scheme.name or scheme.scheme_id}.{resolved_mode}.svg"
return FileResponse(path=display_path, media_type="image/svg+xml", filename=filename)
sanitized_path = Path(upload.sanitized_storage_path)
artifact = await get_latest_scheme_artifact(
scheme_version_id=version.scheme_version_id,
artifact_type="sanitized_svg",
artifact_variant="source",
)
sanitized_path = Path(artifact.storage_path if artifact else upload.sanitized_storage_path)
if not sanitized_path.exists() or not sanitized_path.is_file():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
@@ -292,11 +316,16 @@ async def get_scheme_current_display_svg_meta(
scheme, version, upload = await _load_current_context(scheme_id)
if resolved_mode == settings.svg_display_mode:
display_bytes, _display_path = await _generate_default_display_artifact_if_needed(scheme, version, upload)
display_bytes, _display_path = await _load_default_display_artifact(scheme, version, upload)
meta = _parse_svg_meta_from_bytes(display_bytes)
generated_at = version.display_svg_generated_at
else:
sanitized_path = Path(upload.sanitized_storage_path)
artifact = await get_latest_scheme_artifact(
scheme_version_id=version.scheme_version_id,
artifact_type="sanitized_svg",
artifact_variant="source",
)
sanitized_path = Path(artifact.storage_path if artifact else upload.sanitized_storage_path)
if not sanitized_path.exists() or not sanitized_path.is_file():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
@@ -305,7 +334,7 @@ async def get_scheme_current_display_svg_meta(
sanitized_bytes = sanitized_path.read_bytes()
try:
display_bytes, meta = generate_display_svg(sanitized_bytes, resolved_mode)
display_bytes, _meta = generate_display_svg(sanitized_bytes, resolved_mode)
except Exception:
logger.exception(
"display_svg.meta_on_demand failed scheme_id=%s scheme_version_id=%s mode=%s",

View File

@@ -6,6 +6,7 @@ from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
from app.core.config import settings
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
@@ -222,6 +223,33 @@ async def upload_scheme_svg(
display_svg_generated_at=display_svg_generated_at,
)
await create_scheme_artifact(
scheme_id=scheme_id,
scheme_version_id=scheme_version_id,
artifact_type="sanitized_svg",
artifact_variant="source",
storage_path=sanitized_storage_path,
status="ready",
)
await create_scheme_artifact(
scheme_id=scheme_id,
scheme_version_id=scheme_version_id,
artifact_type="normalized_json",
artifact_variant="default",
storage_path=normalized_storage_path,
status="ready",
)
if display_svg_storage_path:
await create_scheme_artifact(
scheme_id=scheme_id,
scheme_version_id=scheme_version_id,
artifact_type="display_svg",
artifact_variant=settings.svg_display_mode,
storage_path=display_svg_storage_path,
status="ready",
meta_json=display_meta,
)
normalized_payload_from_file = read_normalized_payload_from_path(normalized_storage_path)
await replace_scheme_version_sectors(