feat: v3.0 - canvas editor, JSON-only, no Excel, new UI
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:
2026-02-20 17:02:51 +01:00
parent e2bdadd0ce
commit 25fd578543
27 changed files with 2004 additions and 3016 deletions

View File

@@ -2,12 +2,11 @@
from io import BytesIO
import pandas as pd
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from app.models.event import ScenarioDocument
from app.core.validator import validate_inputs, ValidationError, ScenarsError
from app.core.validator import ScenarsError
from app.core.pdf_generator import generate_pdf
router = APIRouter()
@@ -17,35 +16,7 @@ router = APIRouter()
async def generate_pdf_endpoint(doc: ScenarioDocument):
"""Generate PDF 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:
pdf_bytes = generate_pdf(df, doc.event.title, doc.event.detail,
program_descriptions, program_colors)
pdf_bytes = generate_pdf(doc)
except ScenarsError as e:
raise HTTPException(status_code=422, detail=str(e))

View File

@@ -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"}
)