import json import logging from pathlib import Path from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status from fastapi.responses import FileResponse from app.core.config import settings from app.db.session import db_ping from app.repositories.audit import create_audit_event, list_audit_events from app.repositories.pricing import ( create_price_rule, create_pricing_category, delete_price_rule, delete_pricing_category, find_effective_price_rule, list_price_rules, list_pricing_categories, update_price_rule, update_pricing_category, ) from app.repositories.scheme_groups import ( clone_scheme_version_groups, list_scheme_version_groups, replace_scheme_version_groups, ) from app.repositories.scheme_seats import ( clone_scheme_version_seats, get_scheme_version_seat_by_seat_id, list_scheme_version_seats, replace_scheme_version_seats, ) from app.repositories.scheme_sectors import ( clone_scheme_version_sectors, list_scheme_version_sectors, replace_scheme_version_sectors, ) from app.repositories.scheme_versions import ( count_scheme_versions, create_initial_scheme_version, create_next_scheme_version_from_current, get_current_scheme_version, list_scheme_versions, ) from app.repositories.schemes import ( count_scheme_records, create_scheme_from_upload, get_scheme_record_by_scheme_id, list_scheme_records, publish_scheme, rollback_scheme_to_version, unpublish_scheme, ) from app.repositories.uploads import ( count_upload_records, create_upload_record, get_upload_record_by_upload_id, list_upload_records, ) from app.schemas.audit import AuditEventItem, SchemeAuditResponse from app.schemas.manifest import ServiceManifestResponse from app.schemas.pricing import ( DeleteResponse, EffectiveSeatPriceResponse, PriceRuleCreateRequest, PriceRuleCreateResponse, PriceRuleItem, PriceRuleUpdateRequest, PriceRuleUpdateResponse, PricingCategoryCreateRequest, PricingCategoryCreateResponse, PricingCategoryItem, PricingCategoryUpdateRequest, PricingCategoryUpdateResponse, SchemePricingResponse, ) from app.schemas.scheme_groups import SchemeGroupItem, SchemeGroupListResponse from app.schemas.scheme_registry import ( SchemeCurrentResponse, SchemeDetailResponse, SchemeListItem, SchemeListResponse, SchemePublishResponse, SchemeRollbackRequest, SchemeRollbackResponse, ) from app.schemas.scheme_seats import SchemeSeatItem, SchemeSeatListResponse from app.schemas.scheme_sectors import SchemeSectorItem, SchemeSectorListResponse from app.schemas.scheme_versions import ( SchemeVersionCreateResponse, SchemeVersionListItem, SchemeVersionListResponse, ) from app.schemas.test_mode import TestSeatPreviewResponse from app.schemas.upload import UploadResponse from app.schemas.upload_registry import UploadDetailResponse, UploadListItem, UploadListResponse from app.security.auth import require_api_key from app.services.normalized_reader import read_normalized_payload_from_path from app.services.storage import ( load_normalized_json, save_normalized_json, save_original_svg, save_sanitized_svg, ) from app.services.svg_inspector import inspect_svg_bytes from app.services.svg_normalizer import normalize_svg_bytes_to_json from app.services.svg_sanitizer import sanitize_svg_bytes router = APIRouter() logger = logging.getLogger(__name__) @router.get("/healthz") async def healthz(): return {"status": "ok"} @router.get(f"{settings.api_v1_prefix}/db/ping") async def ping_db(role: str = Depends(require_api_key)): result = await db_ping() return { "database": "ok", "result": result, "host": settings.postgres_host, "port": settings.postgres_port, "db": settings.postgres_db, } @router.get(f"{settings.api_v1_prefix}/ping") async def ping(role: str = Depends(require_api_key)): return { "message": "pong", "prefix": settings.api_v1_prefix, "role": role, } @router.get(f"{settings.api_v1_prefix}/auth/me") async def auth_me(role: str = Depends(require_api_key)): return { "role": role, "auth_header": settings.auth_header_name, } @router.get(f"{settings.api_v1_prefix}/manifest", response_model=ServiceManifestResponse) async def get_manifest(role: str = Depends(require_api_key)): return ServiceManifestResponse( service=settings.app_name, api_prefix=settings.api_v1_prefix, auth_header_name=settings.auth_header_name, svg_limits={ "max_file_size_bytes": settings.svg_max_file_size_bytes, "max_elements": settings.svg_max_elements, }, sanitization={ "allow_internal_use_references_only": settings.svg_allow_internal_use_references_only, "forbid_foreign_object_v1": settings.svg_forbid_foreign_object_v1, "forbid_style_v1": settings.svg_forbid_style_v1, "forbid_image_v1": settings.svg_forbid_image_v1, "allowed_data_attributes": [ "data-seat-id", "data-sector-id", "data-group-id", "data-row", "data-seat-number", ], }, extraction_contract={ "seat_fields": ["seat_id", "sector_id", "group_id", "row", "seat_number"], "priority": [ "data-* attributes", "inherited parent sector/group", "fallback to element id", ], }, ) @router.get(f"{settings.api_v1_prefix}/uploads", response_model=UploadListResponse) async def get_uploads( limit: int = Query(default=50, ge=1, le=200), offset: int = Query(default=0, ge=0), role: str = Depends(require_api_key), ): rows = await list_upload_records(limit=limit, offset=offset) total = await count_upload_records() items = [ UploadListItem( upload_id=row.upload_id, original_filename=row.original_filename, content_type=row.content_type, size_bytes=row.size_bytes, element_count=row.element_count, removed_elements_count=row.removed_elements_count, removed_attributes_count=row.removed_attributes_count, normalized_elements_count=row.normalized_elements_count, normalized_seats_count=row.normalized_seats_count, normalized_groups_count=row.normalized_groups_count, normalized_sectors_count=row.normalized_sectors_count, original_storage_path=row.original_storage_path, sanitized_storage_path=row.sanitized_storage_path, normalized_storage_path=row.normalized_storage_path, processing_status=row.processing_status, created_at=row.created_at.isoformat(), ) for row in rows ] return UploadListResponse(items=items, total=total) @router.get(f"{settings.api_v1_prefix}/uploads/{{upload_id}}", response_model=UploadDetailResponse) async def get_upload(upload_id: str, role: str = Depends(require_api_key)): row = await get_upload_record_by_upload_id(upload_id) return UploadDetailResponse( upload_id=row.upload_id, original_filename=row.original_filename, content_type=row.content_type, size_bytes=row.size_bytes, element_count=row.element_count, removed_elements_count=row.removed_elements_count, removed_attributes_count=row.removed_attributes_count, normalized_elements_count=row.normalized_elements_count, normalized_seats_count=row.normalized_seats_count, normalized_groups_count=row.normalized_groups_count, normalized_sectors_count=row.normalized_sectors_count, original_storage_path=row.original_storage_path, sanitized_storage_path=row.sanitized_storage_path, normalized_storage_path=row.normalized_storage_path, processing_status=row.processing_status, created_at=row.created_at.isoformat(), ) @router.get(f"{settings.api_v1_prefix}/uploads/{{upload_id}}/normalized") async def get_normalized(upload_id: str, role: str = Depends(require_api_key)): payload = load_normalized_json(upload_id) return json.loads(payload) @router.get(f"{settings.api_v1_prefix}/schemes", response_model=SchemeListResponse) async def get_schemes( limit: int = Query(default=50, ge=1, le=200), offset: int = Query(default=0, ge=0), role: str = Depends(require_api_key), ): rows = await list_scheme_records(limit=limit, offset=offset) total = await count_scheme_records() items = [ SchemeListItem( scheme_id=row.scheme_id, source_upload_id=row.source_upload_id, name=row.name, status=row.status, current_version_number=row.current_version_number, published_at=row.published_at.isoformat() if row.published_at else None, normalized_elements_count=row.normalized_elements_count, normalized_seats_count=row.normalized_seats_count, normalized_groups_count=row.normalized_groups_count, normalized_sectors_count=row.normalized_sectors_count, created_at=row.created_at.isoformat(), ) for row in rows ] return SchemeListResponse(items=items, total=total) @router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}", response_model=SchemeDetailResponse) async def get_scheme(scheme_id: str, role: str = Depends(require_api_key)): row = await get_scheme_record_by_scheme_id(scheme_id) return SchemeDetailResponse( scheme_id=row.scheme_id, source_upload_id=row.source_upload_id, name=row.name, status=row.status, current_version_number=row.current_version_number, published_at=row.published_at.isoformat() if row.published_at else None, normalized_elements_count=row.normalized_elements_count, normalized_seats_count=row.normalized_seats_count, normalized_groups_count=row.normalized_groups_count, normalized_sectors_count=row.normalized_sectors_count, created_at=row.created_at.isoformat(), ) @router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/current/svg") async def get_scheme_current_svg(scheme_id: str, role: str = Depends(require_api_key)): scheme = await get_scheme_record_by_scheme_id(scheme_id) upload = await get_upload_record_by_upload_id(scheme.source_upload_id) svg_path = Path(upload.sanitized_storage_path) if not svg_path.exists() or not svg_path.is_file(): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Current sanitized SVG not found", ) filename = f"{scheme.name or scheme.scheme_id}.svg" return FileResponse( path=svg_path, media_type="image/svg+xml", filename=filename, ) @router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/audit", response_model=SchemeAuditResponse) async def get_scheme_audit(scheme_id: str, role: str = Depends(require_api_key)): await get_scheme_record_by_scheme_id(scheme_id) rows = await list_audit_events(scheme_id) return SchemeAuditResponse( items=[ AuditEventItem( audit_event_id=row.audit_event_id, scheme_id=row.scheme_id, event_type=row.event_type, object_type=row.object_type, object_ref=row.object_ref, details_json=row.details_json, created_at=row.created_at.isoformat(), ) for row in rows ], total=len(rows), ) @router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/publish", response_model=SchemePublishResponse) 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, ) @router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/unpublish", response_model=SchemePublishResponse) async def unpublish_scheme_endpoint(scheme_id: str, role: str = Depends(require_api_key)): row = await unpublish_scheme(scheme_id) await create_audit_event( scheme_id=row.scheme_id, event_type="scheme.unpublished", 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, ) @router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/rollback", response_model=SchemeRollbackResponse) async def rollback_scheme_endpoint( scheme_id: str, payload: SchemeRollbackRequest, role: str = Depends(require_api_key), ): row = await rollback_scheme_to_version( scheme_id=scheme_id, target_version_number=payload.target_version_number, ) await create_audit_event( scheme_id=row.scheme_id, event_type="scheme.rolled_back", object_type="scheme_version", object_ref=str(payload.target_version_number), details={ "current_version_number": row.current_version_number, "status": row.status, }, ) return SchemeRollbackResponse( 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, ) @router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/versions", response_model=SchemeVersionCreateResponse) async def create_next_scheme_version_endpoint( scheme_id: str, 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, ) 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, details={ "source_scheme_version_id": current_version.scheme_version_id, "version_number": new_version.version_number, "normalized_storage_path": new_version.normalized_storage_path, }, ) return SchemeVersionCreateResponse( 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, ) @router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/current", response_model=SchemeCurrentResponse) async def get_scheme_current(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, ) return SchemeCurrentResponse( scheme_id=version.scheme_id, scheme_version_id=version.scheme_version_id, version_number=version.version_number, status=version.status, normalized_storage_path=version.normalized_storage_path, normalized_elements_count=version.normalized_elements_count, normalized_seats_count=version.normalized_seats_count, normalized_groups_count=version.normalized_groups_count, normalized_sectors_count=version.normalized_sectors_count, created_at=version.created_at.isoformat(), ) @router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/versions", response_model=SchemeVersionListResponse) async def get_scheme_versions( scheme_id: str, limit: int = Query(default=100, ge=1, le=200), offset: int = Query(default=0, ge=0), role: str = Depends(require_api_key), ): rows = await list_scheme_versions(scheme_id=scheme_id, limit=limit, offset=offset) total = await count_scheme_versions(scheme_id=scheme_id) items = [ SchemeVersionListItem( scheme_version_id=row.scheme_version_id, scheme_id=row.scheme_id, version_number=row.version_number, status=row.status, normalized_storage_path=row.normalized_storage_path, normalized_elements_count=row.normalized_elements_count, normalized_seats_count=row.normalized_seats_count, normalized_groups_count=row.normalized_groups_count, normalized_sectors_count=row.normalized_sectors_count, created_at=row.created_at.isoformat(), ) for row in rows ] return SchemeVersionListResponse(items=items, total=total) @router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/current/sectors", response_model=SchemeSectorListResponse) async def get_scheme_current_sectors(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_version_sectors(version.scheme_version_id) items = [ SchemeSectorItem( 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 rows ] return SchemeSectorListResponse(items=items, total=len(items)) @router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/current/groups", response_model=SchemeGroupListResponse) async def get_scheme_current_groups(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_version_groups(version.scheme_version_id) items = [ SchemeGroupItem( 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 rows ] return SchemeGroupListResponse(items=items, total=len(items)) @router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/current/seats", response_model=SchemeSeatListResponse) async def get_scheme_current_seats(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_version_seats(version.scheme_version_id) items = [ SchemeSeatItem( 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 rows ] return SchemeSeatListResponse(items=items, total=len(items)) @router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/current/seats/{{seat_id}}/price", response_model=EffectiveSeatPriceResponse) async def get_effective_seat_price( scheme_id: str, seat_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, ) seat = await get_scheme_version_seat_by_seat_id( scheme_version_id=version.scheme_version_id, seat_id=seat_id, ) if not seat.seat_id: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Невозможно рассчитать цену: у места отсутствует seat_id", ) matched_rule_level, rule = await find_effective_price_rule( scheme_id=scheme.scheme_id, seat_id=seat.seat_id, group_id=seat.group_id, sector_id=seat.sector_id, ) return EffectiveSeatPriceResponse( scheme_id=scheme.scheme_id, scheme_version_id=version.scheme_version_id, seat_id=seat.seat_id, sector_id=seat.sector_id, group_id=seat.group_id, matched_rule_level=matched_rule_level, matched_target_ref=rule["target_ref"], pricing_category_id=rule["pricing_category_id"], amount=rule["amount"], currency=rule["currency"], ) @router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/test/seats/{{seat_id}}", response_model=TestSeatPreviewResponse) async def preview_test_seat( scheme_id: str, seat_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, ) seat = await get_scheme_version_seat_by_seat_id( scheme_version_id=version.scheme_version_id, seat_id=seat_id, ) if not seat.seat_id: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Невозможно построить preview: у места отсутствует seat_id", ) matched_rule_level = None matched_target_ref = None pricing_category_id = None amount = None currency = None has_price = False try: matched_rule_level, rule = await find_effective_price_rule( scheme_id=scheme.scheme_id, seat_id=seat.seat_id, group_id=seat.group_id, sector_id=seat.sector_id, ) matched_target_ref = rule["target_ref"] pricing_category_id = rule["pricing_category_id"] amount = rule["amount"] currency = rule["currency"] has_price = True except HTTPException as exc: if exc.status_code != status.HTTP_404_NOT_FOUND: raise except Exception as exc: logger.exception( "preview_test_seat failed for scheme_id=%s seat_id=%s", scheme_id, seat_id, ) raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=f"Не удалось построить preview: {exc.__class__.__name__}: {exc}", ) return TestSeatPreviewResponse( scheme_id=scheme.scheme_id, scheme_version_id=version.scheme_version_id, seat_id=seat.seat_id, element_id=seat.element_id, sector_id=seat.sector_id, group_id=seat.group_id, row_label=seat.row_label, seat_number=seat.seat_number, selectable=has_price, has_price=has_price, matched_rule_level=matched_rule_level, matched_target_ref=matched_target_ref, pricing_category_id=pricing_category_id, amount=amount, currency=currency, ) @router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/categories", response_model=PricingCategoryCreateResponse) 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, ) 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, ) @router.put(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/categories/{{pricing_category_id}}", response_model=PricingCategoryUpdateResponse) async def update_pricing_category_endpoint( scheme_id: str, pricing_category_id: str, payload: PricingCategoryUpdateRequest, role: str = Depends(require_api_key), ): row = await update_pricing_category( scheme_id=scheme_id, pricing_category_id=pricing_category_id, name=payload.name, code=payload.code, ) 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, ) @router.delete(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/categories/{{pricing_category_id}}", response_model=DeleteResponse) 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 create_audit_event( scheme_id=scheme_id, event_type="pricing.category.deleted", object_type="pricing_category", object_ref=pricing_category_id, details=None, ) return DeleteResponse(status="deleted") @router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/rules", response_model=PriceRuleCreateResponse) 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) 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, currency=payload.currency, ) await create_audit_event( scheme_id=scheme_id, event_type="pricing.rule.created", object_type="price_rule", object_ref=price_rule_id, details={ "pricing_category_id": payload.pricing_category_id, "target_type": payload.target_type, "target_ref": payload.target_ref, "amount": str(payload.amount), "currency": payload.currency, }, ) 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, ) @router.put(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/rules/{{price_rule_id}}", response_model=PriceRuleUpdateResponse) async def update_price_rule_endpoint( scheme_id: str, price_rule_id: str, payload: PriceRuleUpdateRequest, role: str = Depends(require_api_key), ): 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, currency=payload.currency, ) await create_audit_event( scheme_id=scheme_id, event_type="pricing.rule.updated", object_type="price_rule", object_ref=price_rule_id, details={ "pricing_category_id": payload.pricing_category_id, "target_type": payload.target_type, "target_ref": payload.target_ref, "amount": str(payload.amount), "currency": payload.currency, }, ) 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, ) @router.delete(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/rules/{{price_rule_id}}", response_model=DeleteResponse) 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 create_audit_event( scheme_id=scheme_id, event_type="pricing.rule.deleted", object_type="price_rule", object_ref=price_rule_id, details=None, ) return DeleteResponse(status="deleted") @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) categories = await list_pricing_categories(scheme_id) rules = await list_price_rules(scheme_id) return SchemePricingResponse( categories=[ PricingCategoryItem( pricing_category_id=row.pricing_category_id, scheme_id=row.scheme_id, name=row.name, code=row.code, created_at=row.created_at.isoformat(), ) for row in categories ], rules=[ PriceRuleItem( 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, created_at=row.created_at.isoformat(), ) for row in rules ], ) @router.post(f"{settings.api_v1_prefix}/schemes/upload", response_model=UploadResponse) async def upload_scheme_svg( file: UploadFile = File(...), role: str = Depends(require_api_key), ): filename = file.filename or "" suffix = Path(filename).suffix.lower() content_type = (file.content_type or "").lower() if suffix != ".svg" and content_type != "image/svg+xml": raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Only SVG files are allowed", ) content = await file.read() size_bytes = len(content) if size_bytes == 0: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Uploaded file is empty", ) if size_bytes > settings.svg_max_file_size_bytes: raise HTTPException( status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="SVG file exceeds configured size limit", ) element_count = inspect_svg_bytes(content) sanitized_content, removed_elements_count, removed_attributes_count = sanitize_svg_bytes(content) normalized_json, normalized_payload = normalize_svg_bytes_to_json(sanitized_content) upload_id, original_storage_path = save_original_svg(filename=filename, content=content) sanitized_storage_path = save_sanitized_svg( upload_id=upload_id, filename=filename, content=sanitized_content, ) normalized_storage_path = save_normalized_json( upload_id=upload_id, filename=filename, content=normalized_json, ) summary = normalized_payload["summary"] await create_upload_record( upload_id=upload_id, original_filename=filename, content_type=content_type, size_bytes=size_bytes, element_count=element_count, removed_elements_count=removed_elements_count, removed_attributes_count=removed_attributes_count, normalized_elements_count=summary["elements_count"], normalized_seats_count=summary["seats_count"], normalized_groups_count=summary["groups_count"], normalized_sectors_count=summary["sectors_count"], original_storage_path=original_storage_path, sanitized_storage_path=sanitized_storage_path, normalized_storage_path=normalized_storage_path, processing_status="completed", ) scheme_id = await create_scheme_from_upload( 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"], normalized_groups_count=summary["groups_count"], normalized_sectors_count=summary["sectors_count"], ) normalized_payload_from_file = read_normalized_payload_from_path(normalized_storage_path) await replace_scheme_version_sectors( scheme_id=scheme_id, scheme_version_id=scheme_version_id, sectors=normalized_payload_from_file.get("sectors", []), ) await replace_scheme_version_groups( scheme_id=scheme_id, scheme_version_id=scheme_version_id, groups=normalized_payload_from_file.get("groups", []), ) await replace_scheme_version_seats( scheme_id=scheme_id, scheme_version_id=scheme_version_id, seats=normalized_payload_from_file.get("seats", []), ) await create_audit_event( scheme_id=scheme_id, event_type="scheme.created_from_upload", object_type="upload", object_ref=upload_id, details={ "scheme_version_id": scheme_version_id, "filename": 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"], }, ) return UploadResponse( upload_id=upload_id, filename=filename, content_type=content_type, size_bytes=size_bytes, element_count=element_count, removed_elements_count=removed_elements_count, removed_attributes_count=removed_attributes_count, normalized_elements_count=summary["elements_count"], normalized_seats_count=summary["seats_count"], normalized_groups_count=summary["groups_count"], normalized_sectors_count=summary["sectors_count"], svg_max_file_size_bytes=settings.svg_max_file_size_bytes, svg_max_elements=settings.svg_max_elements, original_storage_path=original_storage_path, sanitized_storage_path=sanitized_storage_path, normalized_storage_path=normalized_storage_path, accepted=True, )