Initial commit: svg backend
This commit is contained in:
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,
|
||||
}
|
||||
Reference in New Issue
Block a user