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_artifacts import get_latest_scheme_artifact 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 _load_default_display_artifact(scheme, version, upload) -> tuple[bytes, Path]: artifact = await get_latest_scheme_artifact( scheme_version_id=version.scheme_version_id, artifact_type="display_svg", artifact_variant=settings.svg_display_mode, ) if artifact is not None: path = Path(artifact.storage_path) if path.exists() and path.is_file(): return path.read_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_artifact = await get_latest_scheme_artifact( scheme_version_id=version.scheme_version_id, artifact_type="sanitized_svg", artifact_variant="source", ) sanitized_path = Path(sanitized_artifact.storage_path if sanitized_artifact else 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) from app.repositories.scheme_artifacts import create_scheme_artifact await create_scheme_artifact( scheme_id=scheme.scheme_id, scheme_version_id=version.scheme_version_id, artifact_type="display_svg", artifact_variant=settings.svg_display_mode, storage_path=display_path_str, status="ready", meta_json=meta, ) 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), ) 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 _load_default_display_artifact(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) artifact = await get_latest_scheme_artifact( scheme_version_id=version.scheme_version_id, artifact_type="sanitized_svg", artifact_variant="source", ) sanitized_path = Path(artifact.storage_path if artifact else 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 _load_default_display_artifact(scheme, version, upload) meta = _parse_svg_meta_from_bytes(display_bytes) generated_at = version.display_svg_generated_at else: artifact = await get_latest_scheme_artifact( scheme_version_id=version.scheme_version_id, artifact_type="sanitized_svg", artifact_variant="source", ) sanitized_path = Path(artifact.storage_path if artifact else 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, }