Initial commit: svg backend

This commit is contained in:
adminko
2026-03-19 13:39:32 +03:00
commit 85fb2f4bb9
78 changed files with 6161 additions and 0 deletions

View 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,
)