feat: v3.0 - canvas editor, JSON-only, no Excel, new UI
Some checks failed
Build & Push Docker / build (push) Has been cancelled
Some checks failed
Build & Push Docker / build (push) Has been cancelled
- Remove all Excel code (import, export, template, pandas, openpyxl) - New canvas-based schedule editor with drag & drop (interact.js) - Modern 3-panel UI: sidebar, canvas, documentation tab - New data model: Block with id/date/start/end, ProgramType with id/name/color - Clean API: GET /api/health, POST /api/validate, GET /api/sample, POST /api/generate-pdf - Rewritten PDF generator using ScenarioDocument directly (no DataFrame) - Professional PDF output: dark header, colored blocks, merged cells, legend, footer - Sample JSON: "Zimní výjezd oddílu" with 11 blocks, 3 program types - 30 tests passing (API, core models, PDF generation) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,19 +1,14 @@
|
||||
"""Scenario API endpoints: validate, import-excel, generate-excel, export-json, template."""
|
||||
"""Scenario API endpoints: health, validate, sample."""
|
||||
|
||||
import json
|
||||
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 fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
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
|
||||
from app.config import VERSION
|
||||
from app.models.event import ScenarioDocument
|
||||
from app.models.responses import HealthResponse, ValidationResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -34,135 +29,26 @@ async def validate_scenario(doc: ScenarioDocument):
|
||||
if not doc.program_types:
|
||||
errors.append("No program types defined")
|
||||
|
||||
type_codes = {pt.code for pt in doc.program_types}
|
||||
type_ids = {pt.id 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:
|
||||
if block.type_id not in type_ids:
|
||||
errors.append(f"Block {i+1}: unknown type '{block.type_id}'")
|
||||
if block.start >= block.end:
|
||||
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()
|
||||
@router.get("/sample")
|
||||
async def get_sample():
|
||||
"""Return sample ScenarioDocument JSON."""
|
||||
sample_path = os.path.join(os.path.dirname(__file__), "..", "static", "sample.json")
|
||||
sample_path = os.path.abspath(sample_path)
|
||||
|
||||
if len(content) > MAX_FILE_SIZE_MB * 1024 * 1024:
|
||||
raise HTTPException(status_code=413, detail=f"File exceeds {MAX_FILE_SIZE_MB}MB limit")
|
||||
with open(sample_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
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"
|
||||
return JSONResponse(
|
||||
content=data,
|
||||
headers={"Content-Disposition": "attachment; filename=sample.json"}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user