Some checks failed
Build & Push Docker / build (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
169 lines
5.5 KiB
Python
169 lines
5.5 KiB
Python
"""Scenario API endpoints: validate, import-excel, generate-excel, export-json, template."""
|
|
|
|
import os
|
|
from io import BytesIO
|
|
from datetime import date, time as dt_time
|
|
|
|
import pandas as pd
|
|
from fastapi import APIRouter, UploadFile, File, Form, HTTPException
|
|
from fastapi.responses import StreamingResponse, FileResponse
|
|
|
|
from app.config import VERSION, MAX_FILE_SIZE_MB
|
|
from app.models.event import ScenarioDocument, Block, ProgramType, EventInfo
|
|
from app.models.responses import HealthResponse, ValidationResponse, ImportExcelResponse
|
|
from app.core.validator import validate_inputs, ValidationError, TemplateError, ScenarsError
|
|
from app.core.excel_reader import read_excel, parse_inline_schedule, parse_inline_types
|
|
from app.core.timetable import create_timetable
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
@router.get("/health", response_model=HealthResponse)
|
|
async def health():
|
|
return HealthResponse(version=VERSION)
|
|
|
|
|
|
@router.post("/validate", response_model=ValidationResponse)
|
|
async def validate_scenario(doc: ScenarioDocument):
|
|
"""Validate a ScenarioDocument."""
|
|
errors = []
|
|
|
|
if not doc.blocks:
|
|
errors.append("No blocks defined")
|
|
|
|
if not doc.program_types:
|
|
errors.append("No program types defined")
|
|
|
|
type_codes = {pt.code for pt in doc.program_types}
|
|
for i, block in enumerate(doc.blocks):
|
|
if block.typ not in type_codes:
|
|
errors.append(f"Block {i+1}: unknown type '{block.typ}'")
|
|
if block.zacatek >= block.konec:
|
|
errors.append(f"Block {i+1}: start time must be before end time")
|
|
|
|
return ValidationResponse(valid=len(errors) == 0, errors=errors)
|
|
|
|
|
|
@router.post("/import-excel")
|
|
async def import_excel(
|
|
file: UploadFile = File(...),
|
|
title: str = Form("Imported Event"),
|
|
detail: str = Form("Imported from Excel"),
|
|
):
|
|
"""Upload Excel file and return ScenarioDocument JSON."""
|
|
content = await file.read()
|
|
|
|
if len(content) > MAX_FILE_SIZE_MB * 1024 * 1024:
|
|
raise HTTPException(status_code=413, detail=f"File exceeds {MAX_FILE_SIZE_MB}MB limit")
|
|
|
|
try:
|
|
valid_data, error_rows = read_excel(content)
|
|
except TemplateError as e:
|
|
raise HTTPException(status_code=422, detail=str(e))
|
|
|
|
if valid_data.empty:
|
|
raise HTTPException(status_code=422, detail="No valid rows found in Excel file")
|
|
|
|
# Extract unique types
|
|
types_in_data = valid_data["Typ"].dropna().unique().tolist()
|
|
program_types = [
|
|
ProgramType(code=t, description=str(t), color="#0070C0")
|
|
for t in types_in_data
|
|
]
|
|
|
|
# Convert rows to blocks
|
|
blocks = []
|
|
for _, row in valid_data.iterrows():
|
|
blocks.append(Block(
|
|
datum=row["Datum"],
|
|
zacatek=row["Zacatek"],
|
|
konec=row["Konec"],
|
|
program=str(row["Program"]),
|
|
typ=str(row["Typ"]),
|
|
garant=str(row["Garant"]) if pd.notna(row.get("Garant")) else None,
|
|
poznamka=str(row["Poznamka"]) if pd.notna(row.get("Poznamka")) else None,
|
|
))
|
|
|
|
doc = ScenarioDocument(
|
|
event=EventInfo(title=title, detail=detail),
|
|
program_types=program_types,
|
|
blocks=blocks,
|
|
)
|
|
|
|
warnings = [f"Row {e['index']}: {e.get('error', e.get('Error', 'unknown'))}" for e in error_rows]
|
|
|
|
return ImportExcelResponse(
|
|
success=True,
|
|
document=doc.model_dump(mode="json"),
|
|
warnings=warnings,
|
|
)
|
|
|
|
|
|
@router.post("/generate-excel")
|
|
async def generate_excel(doc: ScenarioDocument):
|
|
"""Generate Excel timetable from ScenarioDocument."""
|
|
try:
|
|
validate_inputs(doc.event.title, doc.event.detail, 0)
|
|
except ValidationError as e:
|
|
raise HTTPException(status_code=422, detail=str(e))
|
|
|
|
# Convert to DataFrame
|
|
rows = []
|
|
for block in doc.blocks:
|
|
rows.append({
|
|
'Datum': block.datum,
|
|
'Zacatek': block.zacatek,
|
|
'Konec': block.konec,
|
|
'Program': block.program,
|
|
'Typ': block.typ,
|
|
'Garant': block.garant,
|
|
'Poznamka': block.poznamka,
|
|
})
|
|
|
|
df = pd.DataFrame(rows)
|
|
|
|
if df.empty:
|
|
raise HTTPException(status_code=422, detail="No blocks provided")
|
|
|
|
# Build program descriptions and colors
|
|
program_descriptions = {pt.code: pt.description for pt in doc.program_types}
|
|
program_colors = {pt.code: 'FF' + pt.color.lstrip('#') for pt in doc.program_types}
|
|
|
|
try:
|
|
wb = create_timetable(df, doc.event.title, doc.event.detail,
|
|
program_descriptions, program_colors)
|
|
except ScenarsError as e:
|
|
raise HTTPException(status_code=422, detail=str(e))
|
|
|
|
output = BytesIO()
|
|
wb.save(output)
|
|
output.seek(0)
|
|
|
|
return StreamingResponse(
|
|
output,
|
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
headers={"Content-Disposition": "attachment; filename=scenar_timetable.xlsx"}
|
|
)
|
|
|
|
|
|
@router.post("/export-json")
|
|
async def export_json(doc: ScenarioDocument):
|
|
"""Export ScenarioDocument as JSON."""
|
|
return doc.model_dump(mode="json")
|
|
|
|
|
|
@router.get("/template")
|
|
async def download_template():
|
|
"""Download the Excel template file."""
|
|
template_path = os.path.join(os.path.dirname(__file__), "..", "..", "templates", "scenar_template.xlsx")
|
|
template_path = os.path.abspath(template_path)
|
|
|
|
if not os.path.exists(template_path):
|
|
raise HTTPException(status_code=404, detail="Template file not found")
|
|
|
|
return FileResponse(
|
|
template_path,
|
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
filename="scenar_template.xlsx"
|
|
)
|