feat: refactor to FastAPI architecture v2.0
Some checks failed
Build & Push Docker / build (push) Has been cancelled

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 16:28:21 +01:00
parent 87f1fc2c7a
commit e2bdadd0ce
32 changed files with 2896 additions and 55 deletions

1
app/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Scenar Creator - FastAPI application for timetable generation."""

1
app/api/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""API routes for Scenar Creator."""

56
app/api/pdf.py Normal file
View File

@@ -0,0 +1,56 @@
"""PDF generation API endpoint."""
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.pdf_generator import generate_pdf
router = APIRouter()
@router.post("/generate-pdf")
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)
except ScenarsError as e:
raise HTTPException(status_code=422, detail=str(e))
return StreamingResponse(
BytesIO(pdf_bytes),
media_type="application/pdf",
headers={"Content-Disposition": "attachment; filename=scenar_timetable.pdf"}
)

10
app/api/router.py Normal file
View File

@@ -0,0 +1,10 @@
"""Main API router combining all sub-routers."""
from fastapi import APIRouter
from . import scenario, pdf
api_router = APIRouter(prefix="/api")
api_router.include_router(scenario.router)
api_router.include_router(pdf.router)

168
app/api/scenario.py Normal file
View File

@@ -0,0 +1,168 @@
"""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"
)

5
app/config.py Normal file
View File

@@ -0,0 +1,5 @@
"""Application configuration."""
VERSION = "2.0.0"
MAX_FILE_SIZE_MB = 10
DEFAULT_COLOR = "#ffffff"

28
app/core/__init__.py Normal file
View File

@@ -0,0 +1,28 @@
"""Core business logic for Scenar Creator."""
from .validator import (
ScenarsError,
ValidationError,
TemplateError,
validate_inputs,
validate_excel_template,
normalize_time,
)
from .timetable import create_timetable, calculate_row_height, calculate_column_width
from .excel_reader import read_excel, get_program_types, parse_inline_schedule, parse_inline_types
__all__ = [
"ScenarsError",
"ValidationError",
"TemplateError",
"validate_inputs",
"validate_excel_template",
"normalize_time",
"create_timetable",
"calculate_row_height",
"calculate_column_width",
"read_excel",
"get_program_types",
"parse_inline_schedule",
"parse_inline_types",
]

274
app/core/excel_reader.py Normal file
View File

@@ -0,0 +1,274 @@
"""
Excel reading and form parsing logic for Scenar Creator.
Extracted from scenar/core.py — read_excel, get_program_types, parse_inline_schedule, parse_inline_types.
"""
import pandas as pd
from io import BytesIO
import logging
from .validator import (
validate_excel_template,
normalize_time,
ValidationError,
TemplateError,
DEFAULT_COLOR,
)
logger = logging.getLogger(__name__)
def read_excel(file_content: bytes, show_debug: bool = False) -> tuple:
"""
Parse Excel file and return (valid_data, error_rows).
Handles different column naming conventions:
- Old format: Datum, Zacatek, Konec, Program, Typ, Garant, Poznamka
- New template: Datum, Zacatek bloku, Konec bloku, Nazev bloku, Typ bloku, Garant, Poznamka
Returns:
tuple: (pandas.DataFrame with valid rows, list of dicts with error details)
"""
try:
excel_data = pd.read_excel(BytesIO(file_content), skiprows=0)
except Exception as e:
raise TemplateError(f"Failed to read Excel file: {str(e)}")
# Map column names from various possible names to our standard names
column_mapping = {
'Zacatek bloku': 'Zacatek',
'Konec bloku': 'Konec',
'Nazev bloku': 'Program',
'Typ bloku': 'Typ',
}
excel_data = excel_data.rename(columns=column_mapping)
# Validate template
validate_excel_template(excel_data)
if show_debug:
logger.debug(f"Raw data:\n{excel_data.head()}")
error_rows = []
valid_data = []
for index, row in excel_data.iterrows():
try:
datum = pd.to_datetime(row["Datum"], errors='coerce').date()
zacatek = normalize_time(str(row["Zacatek"]))
konec = normalize_time(str(row["Konec"]))
if pd.isna(datum) or zacatek is None or konec is None:
raise ValueError("Invalid date or time format")
valid_data.append({
"index": index,
"Datum": datum,
"Zacatek": zacatek,
"Konec": konec,
"Program": row["Program"],
"Typ": row["Typ"],
"Garant": row["Garant"],
"Poznamka": row["Poznamka"],
"row_data": row
})
except Exception as e:
error_rows.append({"index": index, "row": row, "error": str(e)})
valid_data = pd.DataFrame(valid_data)
# Early return if no valid rows
if valid_data.empty:
logger.warning("No valid rows after parsing")
return valid_data.drop(columns='index', errors='ignore'), error_rows
if show_debug:
logger.debug(f"Cleaned data:\n{valid_data.head()}")
logger.debug(f"Error rows: {error_rows}")
# Detect overlaps
overlap_errors = []
for date, group in valid_data.groupby('Datum'):
sorted_group = group.sort_values(by='Zacatek')
previous_end_time = None
for _, r in sorted_group.iterrows():
if previous_end_time and r['Zacatek'] < previous_end_time:
overlap_errors.append({
"index": r["index"],
"Datum": r["Datum"],
"Zacatek": r["Zacatek"],
"Konec": r["Konec"],
"Program": r["Program"],
"Typ": r["Typ"],
"Garant": r["Garant"],
"Poznamka": r["Poznamka"],
"Error": f"Overlapping time block with previous block ending at {previous_end_time}",
"row_data": r["row_data"]
})
previous_end_time = r['Konec']
if overlap_errors:
if show_debug:
logger.debug(f"Overlap errors: {overlap_errors}")
valid_data = valid_data[~valid_data.index.isin([e['index'] for e in overlap_errors])]
error_rows.extend(overlap_errors)
return valid_data.drop(columns='index'), error_rows
def get_program_types(form_data: dict) -> tuple:
"""
Extract program types from form data.
Form fields: type_code_{i}, desc_{i}, color_{i}
Returns:
tuple: (program_descriptions dict, program_colors dict)
"""
program_descriptions = {}
program_colors = {}
def get_value(data, key, default=''):
# Support both dict-like and cgi.FieldStorage objects
if hasattr(data, 'getvalue'):
return data.getvalue(key, default)
return data.get(key, default)
for key in list(form_data.keys()):
if key.startswith('type_code_'):
index = key.split('_')[-1]
type_code = (get_value(form_data, f'type_code_{index}', '') or '').strip()
description = (get_value(form_data, f'desc_{index}', '') or '').strip()
raw_color = (get_value(form_data, f'color_{index}', DEFAULT_COLOR) or DEFAULT_COLOR)
if not type_code:
continue
color_hex = 'FF' + str(raw_color).lstrip('#')
program_descriptions[type_code] = description
program_colors[type_code] = color_hex
return program_descriptions, program_colors
def parse_inline_schedule(form_data) -> pd.DataFrame:
"""
Parse inline schedule form data into DataFrame.
Form fields:
datum_{i}, zacatek_{i}, konec_{i}, program_{i}, typ_{i}, garant_{i}, poznamka_{i}
Args:
form_data: dict or cgi.FieldStorage with form data
Returns:
DataFrame with parsed schedule data
Raises:
ValidationError: if required fields missing or invalid
"""
rows = []
row_indices = set()
# Helper to get value from both dict and FieldStorage
def get_value(data, key, default=''):
if hasattr(data, 'getvalue'): # cgi.FieldStorage
return data.getvalue(key, default).strip()
else: # dict
return data.get(key, default).strip()
# Find all row indices
for key in form_data.keys():
if key.startswith('datum_'):
idx = key.split('_')[-1]
row_indices.add(idx)
for idx in sorted(row_indices, key=int):
datum_str = get_value(form_data, f'datum_{idx}', '')
zacatek_str = get_value(form_data, f'zacatek_{idx}', '')
konec_str = get_value(form_data, f'konec_{idx}', '')
program = get_value(form_data, f'program_{idx}', '')
typ = get_value(form_data, f'typ_{idx}', '')
garant = get_value(form_data, f'garant_{idx}', '')
poznamka = get_value(form_data, f'poznamka_{idx}', '')
# Skip empty rows
if not any([datum_str, zacatek_str, konec_str, program, typ]):
continue
# Validate required fields
if not all([datum_str, zacatek_str, konec_str, program, typ]):
raise ValidationError(
f"Řádek {int(idx)+1}: Všechna povinná pole (Datum, Začátek, Konec, Program, Typ) musí být vyplněna"
)
try:
datum = pd.to_datetime(datum_str).date()
except Exception:
raise ValidationError(f"Řádek {int(idx)+1}: Neplatné datum")
zacatek = normalize_time(zacatek_str)
konec = normalize_time(konec_str)
if zacatek is None or konec is None:
raise ValidationError(f"Řádek {int(idx)+1}: Neplatný čas (použijte HH:MM nebo HH:MM:SS)")
rows.append({
'Datum': datum,
'Zacatek': zacatek,
'Konec': konec,
'Program': program,
'Typ': typ,
'Garant': garant if garant else None,
'Poznamka': poznamka if poznamka else None,
})
if not rows:
raise ValidationError("Žádné platné řádky ve formuláři")
return pd.DataFrame(rows)
def parse_inline_types(form_data) -> tuple:
"""
Parse inline type definitions from form data.
Form fields: type_name_{i}, type_desc_{i}, type_color_{i}
Args:
form_data: dict or cgi.FieldStorage with form data
Returns:
tuple: (program_descriptions dict, program_colors dict)
"""
descriptions = {}
colors = {}
type_indices = set()
# Helper to get value from both dict and FieldStorage
def get_value(data, key, default=''):
if hasattr(data, 'getvalue'): # cgi.FieldStorage
return data.getvalue(key, default).strip()
else: # dict
return data.get(key, default).strip()
# Find all type indices
for key in form_data.keys():
if key.startswith('type_name_'):
idx = key.split('_')[-1]
type_indices.add(idx)
for idx in sorted(type_indices, key=int):
type_name = get_value(form_data, f'type_name_{idx}', '')
type_desc = get_value(form_data, f'type_desc_{idx}', '')
type_color = get_value(form_data, f'type_color_{idx}', DEFAULT_COLOR)
# Skip empty types
if not type_name:
continue
descriptions[type_name] = type_desc
colors[type_name] = 'FF' + type_color.lstrip('#')
return descriptions, colors

194
app/core/pdf_generator.py Normal file
View File

@@ -0,0 +1,194 @@
"""
PDF generation for Scenar Creator using ReportLab.
Generates A4 landscape timetable PDF with colored blocks and legend.
"""
import pandas as pd
from io import BytesIO
from datetime import datetime
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4, landscape
from reportlab.lib.units import mm
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.enums import TA_CENTER, TA_LEFT
import logging
from .validator import ScenarsError
logger = logging.getLogger(__name__)
def hex_to_reportlab_color(hex_color: str) -> colors.Color:
"""Convert hex color (AARRGGBB or #RRGGBB) to ReportLab color."""
h = hex_color.lstrip('#')
if len(h) == 8: # AARRGGBB format
h = h[2:] # strip alpha
if len(h) == 6:
r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
return colors.Color(r / 255.0, g / 255.0, b / 255.0)
return colors.white
def generate_pdf(data: pd.DataFrame, title: str, detail: str,
program_descriptions: dict, program_colors: dict) -> bytes:
"""
Generate a PDF timetable.
Args:
data: DataFrame with validated schedule data
title: Event title
detail: Event detail/description
program_descriptions: {type: description}
program_colors: {type: color_hex in AARRGGBB format}
Returns:
bytes: PDF file content
Raises:
ScenarsError: if data is invalid
"""
if data.empty:
raise ScenarsError("Data is empty after validation")
missing_types = [typ for typ in data["Typ"].unique() if typ not in program_colors]
if missing_types:
raise ScenarsError(
f"Missing type definitions: {', '.join(missing_types)}. "
"Please define all program types."
)
buffer = BytesIO()
doc = SimpleDocTemplate(
buffer,
pagesize=landscape(A4),
leftMargin=10 * mm,
rightMargin=10 * mm,
topMargin=10 * mm,
bottomMargin=10 * mm,
)
styles = getSampleStyleSheet()
title_style = ParagraphStyle(
'TimetableTitle', parent=styles['Title'],
fontSize=18, alignment=TA_CENTER, spaceAfter=2 * mm
)
detail_style = ParagraphStyle(
'TimetableDetail', parent=styles['Normal'],
fontSize=12, alignment=TA_CENTER, spaceAfter=4 * mm,
textColor=colors.gray
)
cell_style = ParagraphStyle(
'CellStyle', parent=styles['Normal'],
fontSize=7, alignment=TA_CENTER, leading=9
)
legend_style = ParagraphStyle(
'LegendStyle', parent=styles['Normal'],
fontSize=8, alignment=TA_LEFT
)
elements = []
elements.append(Paragraph(title, title_style))
elements.append(Paragraph(detail, detail_style))
data = data.sort_values(by=["Datum", "Zacatek"])
start_times = data["Zacatek"]
end_times = data["Konec"]
min_time = min(start_times)
max_time = max(end_times)
time_slots = pd.date_range(
datetime.combine(datetime.today(), min_time),
datetime.combine(datetime.today(), max_time),
freq='15min'
).time
# Build header row
header = ["Datum"] + [t.strftime("%H:%M") for t in time_slots]
table_data = [header]
cell_colors = [] # list of (row, col, color) for styling
grouped_data = data.groupby(data['Datum'])
row_idx = 1
for date_val, group in grouped_data:
day_name = date_val.strftime("%A")
date_str = date_val.strftime(f"%d.%m {day_name}")
row = [date_str] + [""] * len(time_slots)
table_data.append(row)
row_idx += 1
# Create a sub-row for blocks
block_row = [""] * (len(time_slots) + 1)
for _, blk in group.iterrows():
try:
start_idx = list(time_slots).index(blk["Zacatek"]) + 1
end_idx = list(time_slots).index(blk["Konec"]) + 1
except ValueError:
continue
label = blk['Program']
if pd.notna(blk.get('Garant')):
label += f"\n{blk['Garant']}"
block_row[start_idx] = Paragraph(label.replace('\n', '<br/>'), cell_style)
rl_color = hex_to_reportlab_color(program_colors[blk["Typ"]])
for ci in range(start_idx, end_idx):
cell_colors.append((row_idx, ci, rl_color))
table_data.append(block_row)
row_idx += 1
# Calculate column widths
avail_width = landscape(A4)[0] - 20 * mm
date_col_width = 30 * mm
slot_width = max(12 * mm, (avail_width - date_col_width) / max(len(time_slots), 1))
col_widths = [date_col_width] + [slot_width] * len(time_slots)
table = Table(table_data, colWidths=col_widths, repeatRows=1)
style_cmds = [
('BACKGROUND', (0, 0), (-1, 0), colors.Color(0.83, 0.83, 0.83)),
('TEXTCOLOR', (0, 0), (-1, 0), colors.black),
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('FONTSIZE', (0, 0), (-1, 0), 7),
('FONTSIZE', (0, 1), (-1, -1), 6),
('GRID', (0, 0), (-1, -1), 0.5, colors.black),
('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.Color(0.97, 0.97, 0.97)]),
]
for r, c, clr in cell_colors:
style_cmds.append(('BACKGROUND', (c, r), (c, r), clr))
table.setStyle(TableStyle(style_cmds))
elements.append(table)
# Legend
elements.append(Spacer(1, 5 * mm))
elements.append(Paragraph("<b>Legenda:</b>", legend_style))
legend_data = []
legend_colors_list = []
for i, (typ, desc) in enumerate(program_descriptions.items()):
legend_data.append([Paragraph(f"{desc} ({typ})", legend_style)])
legend_colors_list.append(hex_to_reportlab_color(program_colors[typ]))
if legend_data:
legend_table = Table(legend_data, colWidths=[80 * mm])
legend_cmds = [
('GRID', (0, 0), (-1, -1), 0.5, colors.black),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
]
for i, clr in enumerate(legend_colors_list):
legend_cmds.append(('BACKGROUND', (0, i), (0, i), clr))
legend_table.setStyle(TableStyle(legend_cmds))
elements.append(legend_table)
doc.build(elements)
return buffer.getvalue()

242
app/core/timetable.py Normal file
View File

@@ -0,0 +1,242 @@
"""
Timetable generation logic for Scenar Creator.
Extracted from scenar/core.py — create_timetable (Excel output).
"""
import pandas as pd
from openpyxl import Workbook
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from openpyxl.utils import get_column_letter
from datetime import datetime
import logging
from .validator import ScenarsError
logger = logging.getLogger(__name__)
def calculate_row_height(cell_value, column_width):
"""Calculate row height based on content."""
if not cell_value:
return 15
max_line_length = column_width * 1.2
lines = str(cell_value).split('\n')
line_count = 0
for line in lines:
line_count += len(line) // max_line_length + 1
return line_count * 15
def calculate_column_width(text):
"""Calculate column width based on text length."""
max_length = max(len(line) for line in str(text).split('\n'))
return max_length * 1.2
def create_timetable(data: pd.DataFrame, title: str, detail: str,
program_descriptions: dict, program_colors: dict) -> Workbook:
"""
Create an OpenPyXL timetable workbook.
Args:
data: DataFrame with validated schedule data
title: Event title
detail: Event detail/description
program_descriptions: {type: description}
program_colors: {type: color_hex}
Returns:
openpyxl.Workbook
Raises:
ScenarsError: if data is invalid or types are missing
"""
if data.empty:
raise ScenarsError("Data is empty after validation")
missing_types = [typ for typ in data["Typ"].unique() if typ not in program_colors]
if missing_types:
raise ScenarsError(
f"Missing type definitions: {', '.join(missing_types)}. "
"Please define all program types."
)
wb = Workbook()
ws = wb.active
thick_border = Border(left=Side(style='thick', color='000000'),
right=Side(style='thick', color='000000'),
top=Side(style='thick', color='000000'),
bottom=Side(style='thick', color='000000'))
# Title and detail
ws['A1'] = title
ws['A1'].alignment = Alignment(horizontal="center", vertical="center")
ws['A1'].font = Font(size=24, bold=True)
ws['A1'].border = thick_border
ws['A2'] = detail
ws['A2'].alignment = Alignment(horizontal="center", vertical="center")
ws['A2'].font = Font(size=16, italic=True)
ws['A2'].border = thick_border
if ws.column_dimensions[get_column_letter(1)].width is None:
ws.column_dimensions[get_column_letter(1)].width = 40
title_row_height = calculate_row_height(title, ws.column_dimensions[get_column_letter(1)].width)
detail_row_height = calculate_row_height(detail, ws.column_dimensions[get_column_letter(1)].width)
ws.row_dimensions[1].height = title_row_height
ws.row_dimensions[2].height = detail_row_height
data = data.sort_values(by=["Datum", "Zacatek"])
start_times = data["Zacatek"]
end_times = data["Konec"]
if start_times.isnull().any() or end_times.isnull().any():
raise ScenarsError("Data contains invalid time values")
try:
min_time = min(start_times)
max_time = max(end_times)
except ValueError as e:
raise ScenarsError(f"Error determining time range: {e}")
time_slots = pd.date_range(
datetime.combine(datetime.today(), min_time),
datetime.combine(datetime.today(), max_time),
freq='15min'
).time
total_columns = len(time_slots) + 1
ws.merge_cells(start_row=1, start_column=1, end_row=1, end_column=total_columns)
ws.merge_cells(start_row=2, start_column=1, end_row=2, end_column=total_columns)
row_offset = 3
col_offset = 1
cell = ws.cell(row=row_offset, column=col_offset, value="Datum")
cell.fill = PatternFill(start_color="D3D3D3", end_color="D3D3D3", fill_type="solid")
cell.alignment = Alignment(horizontal="center", vertical="center")
cell.font = Font(bold=True)
cell.border = thick_border
for i, time_slot in enumerate(time_slots, start=col_offset + 1):
cell = ws.cell(row=row_offset, column=i, value=time_slot.strftime("%H:%M"))
cell.fill = PatternFill(start_color="D3D3D3", end_color="D3D3D3", fill_type="solid")
cell.alignment = Alignment(horizontal="center", vertical="center")
cell.font = Font(bold=True)
cell.border = thick_border
current_row = row_offset + 1
grouped_data = data.groupby(data['Datum'])
for date, group in grouped_data:
day_name = date.strftime("%A")
date_str = date.strftime(f"%d.%m {day_name}")
cell = ws.cell(row=current_row, column=col_offset, value=date_str)
cell.alignment = Alignment(horizontal="center", vertical="center")
cell.fill = PatternFill(start_color="D3D3D3", end_color="D3D3D3", fill_type="solid")
cell.font = Font(bold=True, size=14)
cell.border = thick_border
# Track which cells are already filled (for overlap detection)
date_row = current_row
occupied_cells = set() # (row, col) pairs already filled
for _, row in group.iterrows():
start_time = row["Zacatek"]
end_time = row["Konec"]
try:
start_index = list(time_slots).index(start_time) + col_offset + 1
end_index = list(time_slots).index(end_time) + col_offset + 1
except ValueError as e:
logger.error(f"Time slot not found: {start_time} to {end_time}")
continue
cell_value = f"{row['Program']}"
if pd.notna(row['Garant']):
cell_value += f"\n{row['Garant']}"
if pd.notna(row['Poznamka']):
cell_value += f"\n\n{row['Poznamka']}"
# Check for overlaps
working_row = date_row + 1
conflict = False
for col in range(start_index, end_index):
if (working_row, col) in occupied_cells:
conflict = True
break
# If conflict, find next available row
if conflict:
while any((working_row, col) in occupied_cells for col in range(start_index, end_index)):
working_row += 1
# Mark cells as occupied
for col in range(start_index, end_index):
occupied_cells.add((working_row, col))
try:
ws.merge_cells(start_row=working_row, start_column=start_index,
end_row=working_row, end_column=end_index - 1)
# Get the first cell of the merge (not the merged cell)
cell = ws.cell(row=working_row, column=start_index)
cell.value = cell_value
except Exception as e:
raise ScenarsError(f"Error creating timetable cell: {str(e)}")
cell.alignment = Alignment(wrap_text=True, horizontal="center", vertical="center")
lines = str(cell_value).split("\n")
for idx, _ in enumerate(lines):
if idx == 0:
cell.font = Font(bold=True)
elif idx == 1:
cell.font = Font(bold=False)
elif idx > 1 and pd.notna(row['Poznamka']):
cell.font = Font(italic=True)
cell.fill = PatternFill(start_color=program_colors[row["Typ"]],
end_color=program_colors[row["Typ"]],
fill_type="solid")
cell.border = thick_border
# Update current_row to be after all rows for this date
if occupied_cells:
max_row_for_date = max(r for r, c in occupied_cells)
current_row = max_row_for_date + 1
else:
current_row += 1
# Legend
legend_row = current_row + 2
legend_max_length = 0
ws.cell(row=legend_row, column=1, value="Legenda:").font = Font(bold=True)
legend_row += 1
for typ, desc in program_descriptions.items():
legend_text = f"{desc} ({typ})"
legend_cell = ws.cell(row=legend_row, column=1, value=legend_text)
legend_cell.fill = PatternFill(start_color=program_colors[typ], fill_type="solid")
legend_max_length = max(legend_max_length, calculate_column_width(legend_text))
legend_row += 1
ws.column_dimensions[get_column_letter(1)].width = legend_max_length
for col in range(2, total_columns + 1):
ws.column_dimensions[get_column_letter(col)].width = 15
for row in ws.iter_rows(min_row=1, max_row=current_row - 1, min_col=1, max_col=total_columns):
for cell in row:
cell.alignment = Alignment(wrap_text=True, horizontal="center", vertical="center")
cell.border = thick_border
for row in ws.iter_rows(min_row=1, max_row=current_row - 1):
max_height = 0
for cell in row:
if cell.value:
height = calculate_row_height(cell.value, ws.column_dimensions[get_column_letter(cell.column)].width)
if height > max_height:
max_height = height
ws.row_dimensions[row[0].row].height = max_height
return wb

69
app/core/validator.py Normal file
View File

@@ -0,0 +1,69 @@
"""
Validation logic for Scenar Creator.
Extracted from scenar/core.py — validate_inputs, validate_excel_template, overlap detection.
"""
import pandas as pd
from datetime import datetime
import logging
logger = logging.getLogger(__name__)
DEFAULT_COLOR = "#ffffff"
MAX_FILE_SIZE_MB = 10
REQUIRED_COLUMNS = ["Datum", "Zacatek", "Konec", "Program", "Typ", "Garant", "Poznamka"]
class ScenarsError(Exception):
"""Base exception for Scenar Creator."""
pass
class ValidationError(ScenarsError):
"""Raised when input validation fails."""
pass
class TemplateError(ScenarsError):
"""Raised when Excel template is invalid."""
pass
def validate_inputs(title: str, detail: str, file_size: int) -> None:
"""Validate user inputs for security and sanity."""
if not title or not isinstance(title, str):
raise ValidationError("Title is required and must be a string")
if len(title.strip()) == 0:
raise ValidationError("Title cannot be empty")
if len(title) > 200:
raise ValidationError("Title is too long (max 200 characters)")
if not detail or not isinstance(detail, str):
raise ValidationError("Detail is required and must be a string")
if len(detail.strip()) == 0:
raise ValidationError("Detail cannot be empty")
if len(detail) > 500:
raise ValidationError("Detail is too long (max 500 characters)")
if file_size > MAX_FILE_SIZE_MB * 1024 * 1024:
raise ValidationError(f"File size exceeds {MAX_FILE_SIZE_MB} MB limit")
def normalize_time(time_str: str):
"""Parse time string in formats %H:%M or %H:%M:%S."""
for fmt in ('%H:%M', '%H:%M:%S'):
try:
return datetime.strptime(time_str, fmt).time()
except ValueError:
continue
return None
def validate_excel_template(df: pd.DataFrame) -> None:
"""Validate that Excel has required columns."""
missing_cols = set(REQUIRED_COLUMNS) - set(df.columns)
if missing_cols:
raise TemplateError(
f"Excel template missing required columns: {', '.join(missing_cols)}. "
f"Expected: {', '.join(REQUIRED_COLUMNS)}"
)

26
app/main.py Normal file
View File

@@ -0,0 +1,26 @@
"""FastAPI application entry point."""
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
import os
from app.api.router import api_router
from app.config import VERSION
app = FastAPI(
title="Scenar Creator",
description="Web tool for creating timetable scenarios from Excel or inline forms",
version=VERSION,
)
app.include_router(api_router)
# Static files
static_dir = os.path.join(os.path.dirname(__file__), "static")
app.mount("/static", StaticFiles(directory=static_dir), name="static")
@app.get("/", include_in_schema=False)
async def root():
return FileResponse(os.path.join(static_dir, "index.html"))

3
app/models/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
from .event import Block, ProgramType, EventInfo, ScenarioDocument
__all__ = ["Block", "ProgramType", "EventInfo", "ScenarioDocument"]

34
app/models/event.py Normal file
View File

@@ -0,0 +1,34 @@
"""Pydantic v2 models for Scenar Creator."""
from datetime import date, time
from typing import List, Optional
from pydantic import BaseModel, Field
class Block(BaseModel):
datum: date
zacatek: time
konec: time
program: str
typ: str
garant: Optional[str] = None
poznamka: Optional[str] = None
class ProgramType(BaseModel):
code: str
description: str
color: str # hex #RRGGBB
class EventInfo(BaseModel):
title: str = Field(..., max_length=200)
detail: str = Field(..., max_length=500)
class ScenarioDocument(BaseModel):
version: str = "1.0"
event: EventInfo
program_types: List[ProgramType]
blocks: List[Block]

22
app/models/responses.py Normal file
View File

@@ -0,0 +1,22 @@
"""API response models."""
from typing import Any, List, Optional
from pydantic import BaseModel
class HealthResponse(BaseModel):
status: str = "ok"
version: str
class ValidationResponse(BaseModel):
valid: bool
errors: List[str] = []
class ImportExcelResponse(BaseModel):
success: bool
document: Optional[Any] = None
errors: List[str] = []
warnings: List[str] = []

293
app/static/css/app.css Normal file
View File

@@ -0,0 +1,293 @@
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f5f5f5;
color: #333;
line-height: 1.6;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
h1 {
text-align: center;
color: #2c3e50;
margin-bottom: 5px;
}
.subtitle {
text-align: center;
color: #7f8c8d;
margin-bottom: 20px;
}
/* Tabs */
.tabs {
display: flex;
gap: 0;
margin-bottom: 0;
border-bottom: 2px solid #3498db;
}
.tab {
padding: 10px 24px;
border: 1px solid #ddd;
border-bottom: none;
background: #ecf0f1;
cursor: pointer;
font-size: 14px;
border-radius: 6px 6px 0 0;
transition: background 0.2s;
}
.tab.active {
background: #fff;
border-color: #3498db;
border-bottom: 2px solid #fff;
margin-bottom: -2px;
font-weight: bold;
}
.tab:hover:not(.active) {
background: #d5dbdb;
}
.tab-content {
display: none;
background: #fff;
padding: 20px;
border: 1px solid #ddd;
border-top: none;
border-radius: 0 0 6px 6px;
}
.tab-content.active {
display: block;
}
/* Form */
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
font-weight: 600;
margin-bottom: 4px;
}
.form-group input[type="text"],
.form-group input[type="file"] {
width: 100%;
padding: 8px 12px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
}
.form-actions {
margin-top: 20px;
display: flex;
gap: 10px;
}
/* Buttons */
.btn {
display: inline-block;
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
text-decoration: none;
text-align: center;
transition: background 0.2s;
}
.btn-primary {
background: #3498db;
color: #fff;
}
.btn-primary:hover {
background: #2980b9;
}
.btn-secondary {
background: #95a5a6;
color: #fff;
}
.btn-secondary:hover {
background: #7f8c8d;
}
.btn-danger {
background: #e74c3c;
color: #fff;
}
.btn-danger:hover {
background: #c0392b;
}
.btn-sm {
padding: 4px 10px;
font-size: 12px;
}
/* Types */
.type-row {
display: flex;
gap: 8px;
margin-bottom: 8px;
align-items: center;
}
.type-row input[type="text"] {
flex: 1;
padding: 6px 10px;
border: 1px solid #ccc;
border-radius: 4px;
}
.type-row input[type="color"] {
width: 40px;
height: 32px;
border: 1px solid #ccc;
border-radius: 4px;
cursor: pointer;
}
.type-code {
max-width: 200px;
}
/* Schedule table */
#scheduleTable {
width: 100%;
border-collapse: collapse;
margin: 10px 0;
}
#scheduleTable th,
#scheduleTable td {
padding: 6px 8px;
border: 1px solid #ddd;
text-align: left;
}
#scheduleTable th {
background: #ecf0f1;
font-weight: 600;
font-size: 13px;
}
#scheduleTable input {
width: 100%;
padding: 4px 6px;
border: 1px solid #ccc;
border-radius: 3px;
font-size: 13px;
}
#scheduleTable input[type="date"] {
min-width: 130px;
}
#scheduleTable input[type="time"] {
min-width: 90px;
}
/* Editor area */
.editor-area {
background: #fff;
padding: 20px;
border: 1px solid #ddd;
border-radius: 6px;
margin-top: 20px;
}
.editor-area h2 {
margin-bottom: 10px;
color: #2c3e50;
}
.editor-area h3 {
margin: 15px 0 8px;
color: #34495e;
}
/* Imported type editor */
.imported-type-row {
display: flex;
gap: 8px;
margin-bottom: 6px;
align-items: center;
}
.imported-type-row input[type="text"] {
flex: 1;
padding: 5px 8px;
border: 1px solid #ccc;
border-radius: 3px;
}
.imported-type-row input[type="color"] {
width: 36px;
height: 28px;
border: 1px solid #ccc;
border-radius: 3px;
}
/* Block list */
.block-item {
background: #f9f9f9;
padding: 8px 12px;
margin-bottom: 4px;
border-radius: 4px;
border-left: 4px solid #3498db;
font-size: 13px;
}
/* Status message */
.status-message {
margin-top: 15px;
padding: 12px;
border-radius: 4px;
font-size: 14px;
}
.status-message.success {
background: #d5f5e3;
color: #27ae60;
border: 1px solid #27ae60;
}
.status-message.error {
background: #fadbd8;
color: #e74c3c;
border: 1px solid #e74c3c;
}
/* JSON import */
.json-import {
margin-top: 20px;
padding: 10px;
background: #fafafa;
border-radius: 4px;
font-size: 13px;
}
h3 {
margin: 20px 0 10px;
color: #2c3e50;
}

134
app/static/index.html Normal file
View File

@@ -0,0 +1,134 @@
<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Scenar Creator</title>
<link rel="stylesheet" href="/static/css/app.css">
</head>
<body>
<div class="container">
<h1>Scenar Creator</h1>
<p class="subtitle">Tvorba časových harmonogramů</p>
<!-- Tabs -->
<div class="tabs">
<button class="tab active" onclick="switchTab(event, 'importTab')">Importovat Excel</button>
<button class="tab" onclick="switchTab(event, 'builderTab')">Vytvořit inline</button>
</div>
<!-- Import Excel Tab -->
<div id="importTab" class="tab-content active">
<form id="importForm" onsubmit="return handleImport(event)">
<div class="form-group">
<label for="importTitle">Název akce:</label>
<input type="text" id="importTitle" name="title" maxlength="200" required placeholder="Název události">
</div>
<div class="form-group">
<label for="importDetail">Detail:</label>
<input type="text" id="importDetail" name="detail" maxlength="500" required placeholder="Popis události">
</div>
<div class="form-group">
<label for="excelFile">Excel soubor:</label>
<input type="file" id="excelFile" name="file" accept=".xlsx,.xls" required>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Importovat</button>
<a href="/api/template" class="btn btn-secondary">Stáhnout šablonu</a>
</div>
</form>
</div>
<!-- Builder Tab -->
<div id="builderTab" class="tab-content">
<form id="builderForm" onsubmit="return handleBuild(event)">
<div class="form-group">
<label for="builderTitle">Název akce:</label>
<input type="text" id="builderTitle" name="title" maxlength="200" required placeholder="Název události">
</div>
<div class="form-group">
<label for="builderDetail">Detail:</label>
<input type="text" id="builderDetail" name="detail" maxlength="500" required placeholder="Popis události">
</div>
<h3>Typy programů</h3>
<div id="typesContainer">
<div class="type-row" data-index="0">
<input type="text" name="type_name_0" placeholder="Kód typu (např. WORKSHOP)" class="type-code">
<input type="text" name="type_desc_0" placeholder="Popis">
<input type="color" name="type_color_0" value="#0070C0">
<button type="button" class="btn btn-danger btn-sm" onclick="removeTypeRow(0)">X</button>
</div>
</div>
<button type="button" class="btn btn-secondary btn-sm" onclick="addTypeRow()">+ Přidat typ</button>
<h3>Časový harmonogram</h3>
<datalist id="availableTypes"></datalist>
<div id="scheduleContainer">
<table id="scheduleTable">
<thead>
<tr>
<th>Datum</th>
<th>Začátek</th>
<th>Konec</th>
<th>Program</th>
<th>Typ</th>
<th>Garant</th>
<th>Poznámka</th>
<th></th>
</tr>
</thead>
<tbody id="scheduleBody">
<tr data-index="0">
<td><input type="date" name="datum_0" required></td>
<td><input type="time" name="zacatek_0" required></td>
<td><input type="time" name="konec_0" required></td>
<td><input type="text" name="program_0" required placeholder="Název bloku"></td>
<td><input type="text" name="typ_0" list="availableTypes" required placeholder="Typ"></td>
<td><input type="text" name="garant_0" placeholder="Garant"></td>
<td><input type="text" name="poznamka_0" placeholder="Poznámka"></td>
<td><button type="button" class="btn btn-danger btn-sm" onclick="removeScheduleRow(0)">X</button></td>
</tr>
</tbody>
</table>
</div>
<button type="button" class="btn btn-secondary btn-sm" onclick="addScheduleRow()">+ Přidat řádek</button>
<div class="form-actions">
<button type="submit" class="btn btn-primary" name="format" value="excel">Stáhnout Excel</button>
<button type="button" class="btn btn-primary" onclick="handleBuildPdf()">Stáhnout PDF</button>
</div>
</form>
</div>
<!-- Import results / editor area -->
<div id="editorArea" class="editor-area" style="display:none;">
<h2>Importovaná data</h2>
<div id="importedInfo"></div>
<h3>Typy programů</h3>
<div id="importedTypesContainer"></div>
<h3>Bloky</h3>
<div id="importedBlocksContainer"></div>
<div class="form-actions">
<button class="btn btn-primary" onclick="generateExcelFromImport()">Stáhnout Excel</button>
<button class="btn btn-primary" onclick="generatePdfFromImport()">Stáhnout PDF</button>
<button class="btn btn-secondary" onclick="exportJson()">Exportovat JSON</button>
</div>
</div>
<!-- JSON import -->
<div class="json-import">
<label>Importovat JSON: <input type="file" id="jsonFile" accept=".json" onchange="handleJsonImport(event)"></label>
</div>
<div id="statusMessage" class="status-message" style="display:none;"></div>
</div>
<script src="/static/js/api.js"></script>
<script src="/static/js/export.js"></script>
<script src="/static/js/app.js"></script>
</body>
</html>

61
app/static/js/api.js Normal file
View File

@@ -0,0 +1,61 @@
/**
* API fetch wrapper for Scenar Creator.
*/
const API = {
async post(url, body, isJson = true) {
const opts = { method: 'POST' };
if (isJson) {
opts.headers = { 'Content-Type': 'application/json' };
opts.body = JSON.stringify(body);
} else {
opts.body = body; // FormData
}
const res = await fetch(url, opts);
return res;
},
async postJson(url, body) {
const res = await this.post(url, body, true);
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || 'API error');
}
return res.json();
},
async postBlob(url, body) {
const res = await this.post(url, body, true);
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || 'API error');
}
return res.blob();
},
async postFormData(url, formData) {
const res = await this.post(url, formData, false);
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || 'API error');
}
return res.json();
},
async get(url) {
const res = await fetch(url);
if (!res.ok) throw new Error(res.statusText);
return res.json();
},
downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
};

258
app/static/js/app.js Normal file
View File

@@ -0,0 +1,258 @@
/**
* Main application logic for Scenar Creator SPA.
*/
window.currentDocument = null;
let typeCounter = 1;
let scheduleCounter = 1;
/* --- Tab switching --- */
function switchTab(event, tabId) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
event.target.classList.add('active');
document.getElementById(tabId).classList.add('active');
}
/* --- Status messages --- */
function showStatus(message, type) {
const el = document.getElementById('statusMessage');
el.textContent = message;
el.className = 'status-message ' + type;
el.style.display = 'block';
setTimeout(() => { el.style.display = 'none'; }, 5000);
}
/* --- Type management --- */
function addTypeRow() {
const container = document.getElementById('typesContainer');
const idx = typeCounter++;
const div = document.createElement('div');
div.className = 'type-row';
div.setAttribute('data-index', idx);
div.innerHTML = `
<input type="text" name="type_name_${idx}" placeholder="Kód typu" class="type-code">
<input type="text" name="type_desc_${idx}" placeholder="Popis">
<input type="color" name="type_color_${idx}" value="#0070C0">
<button type="button" class="btn btn-danger btn-sm" onclick="removeTypeRow(${idx})">X</button>
`;
container.appendChild(div);
updateTypeDatalist();
}
function removeTypeRow(idx) {
const row = document.querySelector(`.type-row[data-index="${idx}"]`);
if (row) row.remove();
updateTypeDatalist();
}
function updateTypeDatalist() {
const datalist = document.getElementById('availableTypes');
datalist.innerHTML = '';
document.querySelectorAll('#typesContainer .type-row').forEach(row => {
const nameInput = row.querySelector('input[name^="type_name_"]');
if (nameInput && nameInput.value.trim()) {
const opt = document.createElement('option');
opt.value = nameInput.value.trim();
datalist.appendChild(opt);
}
});
}
// Update datalist on type name changes
document.getElementById('typesContainer').addEventListener('input', function (e) {
if (e.target.name && e.target.name.startsWith('type_name_')) {
updateTypeDatalist();
}
});
/* --- Schedule management --- */
function addScheduleRow() {
const tbody = document.getElementById('scheduleBody');
const idx = scheduleCounter++;
const tr = document.createElement('tr');
tr.setAttribute('data-index', idx);
tr.innerHTML = `
<td><input type="date" name="datum_${idx}" required></td>
<td><input type="time" name="zacatek_${idx}" required></td>
<td><input type="time" name="konec_${idx}" required></td>
<td><input type="text" name="program_${idx}" required placeholder="Název bloku"></td>
<td><input type="text" name="typ_${idx}" list="availableTypes" required placeholder="Typ"></td>
<td><input type="text" name="garant_${idx}" placeholder="Garant"></td>
<td><input type="text" name="poznamka_${idx}" placeholder="Poznámka"></td>
<td><button type="button" class="btn btn-danger btn-sm" onclick="removeScheduleRow(${idx})">X</button></td>
`;
tbody.appendChild(tr);
}
function removeScheduleRow(idx) {
const row = document.querySelector(`#scheduleBody tr[data-index="${idx}"]`);
if (row) row.remove();
}
/* --- Build ScenarioDocument from builder form --- */
function buildDocumentFromForm() {
const title = document.getElementById('builderTitle').value.trim();
const detail = document.getElementById('builderDetail').value.trim();
if (!title || !detail) {
throw new Error('Název akce a detail jsou povinné');
}
// Collect types
const programTypes = [];
document.querySelectorAll('#typesContainer .type-row').forEach(row => {
const code = row.querySelector('input[name^="type_name_"]').value.trim();
const desc = row.querySelector('input[name^="type_desc_"]').value.trim();
const color = row.querySelector('input[name^="type_color_"]').value;
if (code) {
programTypes.push({ code, description: desc, color });
}
});
// Collect blocks
const blocks = [];
document.querySelectorAll('#scheduleBody tr').forEach(tr => {
const inputs = tr.querySelectorAll('input');
const datum = inputs[0].value;
const zacatek = inputs[1].value;
const konec = inputs[2].value;
const program = inputs[3].value.trim();
const typ = inputs[4].value.trim();
const garant = inputs[5].value.trim() || null;
const poznamka = inputs[6].value.trim() || null;
if (datum && zacatek && konec && program && typ) {
blocks.push({ datum, zacatek, konec, program, typ, garant, poznamka });
}
});
if (blocks.length === 0) {
throw new Error('Přidejte alespoň jeden blok');
}
return {
version: "1.0",
event: { title, detail },
program_types: programTypes,
blocks
};
}
/* --- Handle builder form submit (Excel) --- */
async function handleBuild(event) {
event.preventDefault();
try {
const doc = buildDocumentFromForm();
const blob = await API.postBlob('/api/generate-excel', doc);
API.downloadBlob(blob, 'scenar_timetable.xlsx');
showStatus('Excel vygenerován', 'success');
} catch (err) {
showStatus('Chyba: ' + err.message, 'error');
}
return false;
}
/* --- Handle builder PDF --- */
async function handleBuildPdf() {
try {
const doc = buildDocumentFromForm();
const blob = await API.postBlob('/api/generate-pdf', doc);
API.downloadBlob(blob, 'scenar_timetable.pdf');
showStatus('PDF vygenerován', 'success');
} catch (err) {
showStatus('Chyba: ' + err.message, 'error');
}
}
/* --- Handle Excel import --- */
async function handleImport(event) {
event.preventDefault();
const form = document.getElementById('importForm');
const formData = new FormData(form);
try {
const result = await API.postFormData('/api/import-excel', formData);
if (result.success && result.document) {
window.currentDocument = result.document;
showImportedDocument(result.document);
if (result.warnings && result.warnings.length > 0) {
showStatus('Import OK, warnings: ' + result.warnings.join('; '), 'success');
} else {
showStatus('Excel importován', 'success');
}
} else {
showStatus('Import failed: ' + (result.errors || []).join('; '), 'error');
}
} catch (err) {
showStatus('Chyba importu: ' + err.message, 'error');
}
return false;
}
/* --- Show imported document in editor --- */
function showImportedDocument(doc) {
const area = document.getElementById('editorArea');
area.style.display = 'block';
// Info
document.getElementById('importedInfo').innerHTML =
`<strong>${doc.event.title}</strong> — ${doc.event.detail}`;
// Types
const typesHtml = doc.program_types.map((pt, i) => `
<div class="imported-type-row">
<input type="text" value="${pt.code}" data-field="code" data-idx="${i}">
<input type="text" value="${pt.description}" data-field="description" data-idx="${i}">
<input type="color" value="${pt.color}" data-field="color" data-idx="${i}">
</div>
`).join('');
document.getElementById('importedTypesContainer').innerHTML = typesHtml;
// Blocks
const blocksHtml = doc.blocks.map(b =>
`<div class="block-item">${b.datum} ${b.zacatek}${b.konec} | <strong>${b.program}</strong> [${b.typ}] ${b.garant || ''}</div>`
).join('');
document.getElementById('importedBlocksContainer').innerHTML = blocksHtml;
}
/* --- Get current document (with any edits from import editor) --- */
function getCurrentDocument() {
if (!window.currentDocument) {
throw new Error('No document loaded');
}
// Update types from editor
const typeRows = document.querySelectorAll('#importedTypesContainer .imported-type-row');
if (typeRows.length > 0) {
window.currentDocument.program_types = Array.from(typeRows).map(row => ({
code: row.querySelector('[data-field="code"]').value.trim(),
description: row.querySelector('[data-field="description"]').value.trim(),
color: row.querySelector('[data-field="color"]').value,
}));
}
return window.currentDocument;
}
/* --- Generate Excel from imported data --- */
async function generateExcelFromImport() {
try {
const doc = getCurrentDocument();
const blob = await API.postBlob('/api/generate-excel', doc);
API.downloadBlob(blob, 'scenar_timetable.xlsx');
showStatus('Excel vygenerován', 'success');
} catch (err) {
showStatus('Chyba: ' + err.message, 'error');
}
}
/* --- Generate PDF from imported data --- */
async function generatePdfFromImport() {
try {
const doc = getCurrentDocument();
const blob = await API.postBlob('/api/generate-pdf', doc);
API.downloadBlob(blob, 'scenar_timetable.pdf');
showStatus('PDF vygenerován', 'success');
} catch (err) {
showStatus('Chyba: ' + err.message, 'error');
}
}

34
app/static/js/export.js Normal file
View File

@@ -0,0 +1,34 @@
/**
* JSON import/export for Scenar Creator.
*/
function exportJson() {
if (!window.currentDocument) {
showStatus('No document to export', 'error');
return;
}
const json = JSON.stringify(window.currentDocument, null, 2);
const blob = new Blob([json], { type: 'application/json' });
API.downloadBlob(blob, 'scenar_export.json');
}
function handleJsonImport(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function (e) {
try {
const doc = JSON.parse(e.target.result);
if (!doc.event || !doc.blocks || !doc.program_types) {
throw new Error('Invalid ScenarioDocument format');
}
window.currentDocument = doc;
showImportedDocument(doc);
showStatus('JSON imported successfully', 'success');
} catch (err) {
showStatus('JSON import error: ' + err.message, 'error');
}
};
reader.readAsText(file);
}