- add editor entry flow with editor context and ensure-draft bootstrap - add draft summary read model and single-record draft read endpoints - add typed draft, edit and publish conflicts with validation errors - add pricing diagnostics and publish readiness endpoints - fix Decimal serialization in seat price and test preview flows - harden draft lifecycle guards for published vs draft current version - update API map and smoke regression checklist - add backend README and smoke regression script
361 lines
14 KiB
Python
361 lines
14 KiB
Python
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=str(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,
|
||
}
|