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,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,
}