Initial commit: svg backend
This commit is contained in:
18
backend/app/api/routes/__init__.py
Normal file
18
backend/app/api/routes/__init__.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.routes.audit import router as audit_router
|
||||
from app.api.routes.pricing import router as pricing_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
|
||||
from app.api.routes.test_mode import router as test_mode_router
|
||||
from app.api.routes.uploads import router as uploads_router
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(system_router)
|
||||
router.include_router(uploads_router)
|
||||
router.include_router(schemes_router)
|
||||
router.include_router(structure_router)
|
||||
router.include_router(pricing_router)
|
||||
router.include_router(test_mode_router)
|
||||
router.include_router(audit_router)
|
||||
31
backend/app/api/routes/audit.py
Normal file
31
backend/app/api/routes/audit.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.core.config import settings
|
||||
from app.repositories.audit import list_audit_events
|
||||
from app.repositories.schemes import get_scheme_record_by_scheme_id
|
||||
from app.schemas.audit import AuditEventItem, SchemeAuditResponse
|
||||
from app.security.auth import require_api_key
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@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),
|
||||
)
|
||||
233
backend/app/api/routes/pricing.py
Normal file
233
backend/app/api/routes/pricing.py
Normal file
@@ -0,0 +1,233 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.core.config import settings
|
||||
from app.repositories.audit import create_audit_event
|
||||
from app.repositories.pricing import (
|
||||
create_price_rule,
|
||||
create_pricing_category,
|
||||
delete_price_rule,
|
||||
delete_pricing_category,
|
||||
list_price_rules,
|
||||
list_pricing_categories,
|
||||
update_price_rule,
|
||||
update_pricing_category,
|
||||
)
|
||||
from app.repositories.schemes import get_scheme_record_by_scheme_id
|
||||
from app.schemas.pricing import (
|
||||
DeleteResponse,
|
||||
PriceRuleCreateRequest,
|
||||
PriceRuleCreateResponse,
|
||||
PriceRuleItem,
|
||||
PriceRuleUpdateRequest,
|
||||
PriceRuleUpdateResponse,
|
||||
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)
|
||||
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/{{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")
|
||||
241
backend/app/api/routes/schemes.py
Normal file
241
backend/app/api/routes/schemes.py
Normal file
@@ -0,0 +1,241 @@
|
||||
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,
|
||||
get_current_scheme_version,
|
||||
list_scheme_versions,
|
||||
)
|
||||
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,
|
||||
)
|
||||
from app.schemas.scheme_registry import (
|
||||
SchemeCurrentResponse,
|
||||
SchemeDetailResponse,
|
||||
SchemeListItem,
|
||||
SchemeListResponse,
|
||||
SchemePublishResponse,
|
||||
SchemeRollbackRequest,
|
||||
SchemeRollbackResponse,
|
||||
)
|
||||
from app.schemas.scheme_versions import (
|
||||
SchemeVersionCreateResponse,
|
||||
SchemeVersionListItem,
|
||||
SchemeVersionListResponse,
|
||||
)
|
||||
from app.security.auth import require_api_key
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@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", 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.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.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,
|
||||
)
|
||||
331
backend/app/api/routes/structure.py
Normal file
331
backend/app/api/routes/structure.py
Normal file
@@ -0,0 +1,331 @@
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from fastapi.responses import FileResponse
|
||||
from lxml import etree
|
||||
|
||||
from app.core.config import settings
|
||||
from app.repositories.pricing import find_effective_price_rule
|
||||
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
|
||||
from app.repositories.scheme_versions import (
|
||||
get_current_scheme_version,
|
||||
update_scheme_version_display_artifact,
|
||||
)
|
||||
from app.repositories.schemes import get_scheme_record_by_scheme_id
|
||||
from app.repositories.uploads import get_upload_record_by_upload_id
|
||||
from app.schemas.pricing import EffectiveSeatPriceResponse
|
||||
from app.schemas.scheme_groups import SchemeGroupItem, SchemeGroupListResponse
|
||||
from app.schemas.scheme_seats import SchemeSeatItem, SchemeSeatListResponse
|
||||
from app.schemas.scheme_sectors import SchemeSectorItem, SchemeSectorListResponse
|
||||
from app.security.auth import require_api_key
|
||||
from app.services.storage import save_display_svg
|
||||
from app.services.svg_display_processor import ALLOWED_MODES, generate_display_svg
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _parse_svg_meta_from_bytes(content: bytes) -> dict:
|
||||
parser = etree.XMLParser(
|
||||
resolve_entities=False,
|
||||
remove_blank_text=False,
|
||||
remove_comments=False,
|
||||
no_network=True,
|
||||
recover=False,
|
||||
huge_tree=True,
|
||||
)
|
||||
root = etree.fromstring(content, parser=parser)
|
||||
return {
|
||||
"view_box": root.attrib.get("viewBox"),
|
||||
"width": root.attrib.get("width"),
|
||||
"height": root.attrib.get("height"),
|
||||
}
|
||||
|
||||
|
||||
def _resolve_mode(mode: str | None) -> str:
|
||||
resolved = mode or settings.svg_display_mode
|
||||
if resolved not in ALLOWED_MODES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Unsupported display mode: {resolved}",
|
||||
)
|
||||
return resolved
|
||||
|
||||
|
||||
async def _load_current_context(scheme_id: str):
|
||||
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 scheme, version, upload
|
||||
|
||||
|
||||
async def _generate_default_display_artifact_if_needed(scheme, version, upload) -> tuple[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)
|
||||
if not sanitized_path.exists() or not sanitized_path.is_file():
|
||||
if version.display_svg_status == "pending":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Current scheme version is not ready for display rendering",
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Display SVG not found for current scheme version",
|
||||
)
|
||||
|
||||
sanitized_bytes = sanitized_path.read_bytes()
|
||||
try:
|
||||
display_bytes, meta = generate_display_svg(sanitized_bytes, settings.svg_display_mode)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"display_svg.lazy_generate failed scheme_id=%s scheme_version_id=%s mode=%s",
|
||||
scheme.scheme_id,
|
||||
version.scheme_version_id,
|
||||
settings.svg_display_mode,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Current scheme version is not ready for display rendering",
|
||||
)
|
||||
|
||||
display_path_str = save_display_svg(
|
||||
upload_id=upload.upload_id,
|
||||
filename=upload.original_filename,
|
||||
content=display_bytes,
|
||||
)
|
||||
display_path = Path(display_path_str)
|
||||
|
||||
await update_scheme_version_display_artifact(
|
||||
scheme_version_id=version.scheme_version_id,
|
||||
display_svg_storage_path=display_path_str,
|
||||
display_svg_status="ready",
|
||||
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
|
||||
|
||||
|
||||
@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}}/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}}/current/svg/display")
|
||||
async def get_scheme_current_display_svg(
|
||||
scheme_id: str,
|
||||
mode: str | None = Query(default=None),
|
||||
role: str = Depends(require_api_key),
|
||||
):
|
||||
resolved_mode = _resolve_mode(mode)
|
||||
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)
|
||||
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)
|
||||
if not sanitized_path.exists() or not sanitized_path.is_file():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Display SVG not found for current scheme version",
|
||||
)
|
||||
|
||||
sanitized_bytes = sanitized_path.read_bytes()
|
||||
try:
|
||||
display_bytes, _meta = generate_display_svg(sanitized_bytes, resolved_mode)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"display_svg.on_demand failed scheme_id=%s scheme_version_id=%s mode=%s",
|
||||
scheme.scheme_id,
|
||||
version.scheme_version_id,
|
||||
resolved_mode,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Current scheme version is not ready for display rendering",
|
||||
)
|
||||
|
||||
return Response(content=display_bytes, media_type="image/svg+xml")
|
||||
|
||||
|
||||
@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/current/svg/display/meta")
|
||||
async def get_scheme_current_display_svg_meta(
|
||||
scheme_id: str,
|
||||
mode: str | None = Query(default=None),
|
||||
role: str = Depends(require_api_key),
|
||||
):
|
||||
resolved_mode = _resolve_mode(mode)
|
||||
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)
|
||||
meta = _parse_svg_meta_from_bytes(display_bytes)
|
||||
generated_at = version.display_svg_generated_at
|
||||
else:
|
||||
sanitized_path = Path(upload.sanitized_storage_path)
|
||||
if not sanitized_path.exists() or not sanitized_path.is_file():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Display SVG not found for current scheme version",
|
||||
)
|
||||
|
||||
sanitized_bytes = sanitized_path.read_bytes()
|
||||
try:
|
||||
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",
|
||||
scheme.scheme_id,
|
||||
version.scheme_version_id,
|
||||
resolved_mode,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Current scheme version is not ready for display rendering",
|
||||
)
|
||||
meta = _parse_svg_meta_from_bytes(display_bytes)
|
||||
generated_at = datetime.now(timezone.utc)
|
||||
|
||||
return {
|
||||
"scheme_id": scheme.scheme_id,
|
||||
"scheme_version_id": version.scheme_version_id,
|
||||
"display_svg_available": True,
|
||||
"view_box": meta["view_box"],
|
||||
"width": meta["width"],
|
||||
"height": meta["height"],
|
||||
"generated_at": generated_at.isoformat() if generated_at else None,
|
||||
}
|
||||
76
backend/app/api/routes/system.py
Normal file
76
backend/app/api/routes/system.py
Normal file
@@ -0,0 +1,76 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.session import db_ping
|
||||
from app.schemas.manifest import ServiceManifestResponse
|
||||
from app.security.auth import require_api_key
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@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",
|
||||
],
|
||||
},
|
||||
)
|
||||
88
backend/app/api/routes/test_mode.py
Normal file
88
backend/app/api/routes/test_mode.py
Normal file
@@ -0,0 +1,88 @@
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from app.core.config import settings
|
||||
from app.repositories.pricing import find_effective_price_rule
|
||||
from app.repositories.scheme_seats import get_scheme_version_seat_by_seat_id
|
||||
from app.repositories.scheme_versions import get_current_scheme_version
|
||||
from app.repositories.schemes import get_scheme_record_by_scheme_id
|
||||
from app.schemas.test_mode import TestSeatPreviewResponse
|
||||
from app.security.auth import require_api_key
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@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,
|
||||
)
|
||||
261
backend/app/api/routes/uploads.py
Normal file
261
backend/app/api/routes/uploads.py
Normal file
@@ -0,0 +1,261 @@
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
|
||||
|
||||
from app.core.config import settings
|
||||
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.uploads import (
|
||||
count_upload_records,
|
||||
create_upload_record,
|
||||
get_upload_record_by_upload_id,
|
||||
list_upload_records,
|
||||
)
|
||||
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_display_svg,
|
||||
save_normalized_json,
|
||||
save_original_svg,
|
||||
save_sanitized_svg,
|
||||
)
|
||||
from app.services.svg_display_processor import generate_display_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(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.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)
|
||||
|
||||
display_svg_storage_path = None
|
||||
display_svg_status = "pending"
|
||||
display_svg_generated_at = None
|
||||
|
||||
if settings.svg_display_enabled:
|
||||
try:
|
||||
display_content, display_meta = generate_display_svg(
|
||||
sanitized_content,
|
||||
settings.svg_display_mode,
|
||||
)
|
||||
logger.info(
|
||||
"display_svg.upload generated mode=%s view_box=%s width=%s height=%s",
|
||||
settings.svg_display_mode,
|
||||
display_meta.get("view_box"),
|
||||
display_meta.get("width"),
|
||||
display_meta.get("height"),
|
||||
)
|
||||
display_svg_status = "ready"
|
||||
except Exception:
|
||||
logger.exception("display_svg.upload failed filename=%s", filename)
|
||||
display_content = None
|
||||
display_svg_status = "failed"
|
||||
else:
|
||||
display_content = None
|
||||
display_svg_status = "failed"
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
if display_content is not None:
|
||||
display_svg_storage_path = save_display_svg(
|
||||
upload_id=upload_id,
|
||||
filename=filename,
|
||||
content=display_content,
|
||||
)
|
||||
display_svg_generated_at = datetime.now(timezone.utc)
|
||||
|
||||
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"],
|
||||
display_svg_storage_path=display_svg_storage_path,
|
||||
display_svg_status=display_svg_status,
|
||||
display_svg_generated_at=display_svg_generated_at,
|
||||
)
|
||||
|
||||
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", []),
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
Reference in New Issue
Block a user