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 from io import BytesIO
import pandas as pd
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from app.models.event import ScenarioDocument 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 from app.core.pdf_generator import generate_pdf
router = APIRouter() router = APIRouter()
@@ -17,35 +16,7 @@ router = APIRouter()
async def generate_pdf_endpoint(doc: ScenarioDocument): async def generate_pdf_endpoint(doc: ScenarioDocument):
"""Generate PDF timetable from ScenarioDocument.""" """Generate PDF timetable from ScenarioDocument."""
try: try:
validate_inputs(doc.event.title, doc.event.detail, 0) pdf_bytes = generate_pdf(doc)
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: except ScenarsError as e:
raise HTTPException(status_code=422, detail=str(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 import os
from io import BytesIO
from datetime import date, time as dt_time
import pandas as pd from fastapi import APIRouter
from fastapi import APIRouter, UploadFile, File, Form, HTTPException from fastapi.responses import JSONResponse
from fastapi.responses import StreamingResponse, FileResponse
from app.config import VERSION, MAX_FILE_SIZE_MB from app.config import VERSION
from app.models.event import ScenarioDocument, Block, ProgramType, EventInfo from app.models.event import ScenarioDocument
from app.models.responses import HealthResponse, ValidationResponse, ImportExcelResponse from app.models.responses import HealthResponse, ValidationResponse
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 = APIRouter()
@@ -34,135 +29,26 @@ async def validate_scenario(doc: ScenarioDocument):
if not doc.program_types: if not doc.program_types:
errors.append("No program types defined") 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): for i, block in enumerate(doc.blocks):
if block.typ not in type_codes: if block.type_id not in type_ids:
errors.append(f"Block {i+1}: unknown type '{block.typ}'") errors.append(f"Block {i+1}: unknown type '{block.type_id}'")
if block.zacatek >= block.konec: if block.start >= block.end:
errors.append(f"Block {i+1}: start time must be before end time") errors.append(f"Block {i+1}: start time must be before end time")
return ValidationResponse(valid=len(errors) == 0, errors=errors) return ValidationResponse(valid=len(errors) == 0, errors=errors)
@router.post("/import-excel") @router.get("/sample")
async def import_excel( async def get_sample():
file: UploadFile = File(...), """Return sample ScenarioDocument JSON."""
title: str = Form("Imported Event"), sample_path = os.path.join(os.path.dirname(__file__), "..", "static", "sample.json")
detail: str = Form("Imported from Excel"), sample_path = os.path.abspath(sample_path)
):
"""Upload Excel file and return ScenarioDocument JSON."""
content = await file.read()
if len(content) > MAX_FILE_SIZE_MB * 1024 * 1024: with open(sample_path, "r", encoding="utf-8") as f:
raise HTTPException(status_code=413, detail=f"File exceeds {MAX_FILE_SIZE_MB}MB limit") data = json.load(f)
try: return JSONResponse(
valid_data, error_rows = read_excel(content) content=data,
except TemplateError as e: headers={"Content-Disposition": "attachment; filename=sample.json"}
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"
) )

View File

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

View File

@@ -1,28 +1,10 @@
"""Core business logic for Scenar Creator.""" """Core business logic for Scenar Creator v3."""
from .validator import ( from .validator import ScenarsError, ValidationError
ScenarsError, from .pdf_generator import generate_pdf
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__ = [ __all__ = [
"ScenarsError", "ScenarsError",
"ValidationError", "ValidationError",
"TemplateError", "generate_pdf",
"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",
] ]

View File

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

View File

@@ -1,17 +1,17 @@
""" """
PDF generation for Scenar Creator using ReportLab. PDF generation for Scenar Creator v3 using ReportLab.
Generates A4 landscape timetable PDF with colored blocks and legend. Generates A4 landscape timetable PDF with colored blocks and legend.
""" """
import pandas as pd
from io import BytesIO from io import BytesIO
from datetime import datetime from datetime import datetime
from collections import defaultdict
from reportlab.lib import colors from reportlab.lib import colors
from reportlab.lib.pagesizes import A4, landscape from reportlab.lib.pagesizes import A4, landscape
from reportlab.lib.units import mm from reportlab.lib.units import mm
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.enums import TA_CENTER, TA_LEFT from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT
import logging import logging
from .validator import ScenarsError from .validator import ScenarsError
@@ -20,175 +20,270 @@ logger = logging.getLogger(__name__)
def hex_to_reportlab_color(hex_color: str) -> colors.Color: def hex_to_reportlab_color(hex_color: str) -> colors.Color:
"""Convert hex color (AARRGGBB or #RRGGBB) to ReportLab color.""" """Convert #RRGGBB hex color to ReportLab color."""
h = hex_color.lstrip('#') h = hex_color.lstrip('#')
if len(h) == 8: # AARRGGBB format if len(h) == 8:
h = h[2:] # strip alpha h = h[2:]
if len(h) == 6: if len(h) == 6:
r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16) 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.Color(r / 255.0, g / 255.0, b / 255.0)
return colors.white return colors.white
def generate_pdf(data: pd.DataFrame, title: str, detail: str, def is_light_color(hex_color: str) -> bool:
program_descriptions: dict, program_colors: dict) -> bytes: """Check if a color is light (needs dark text)."""
h = hex_color.lstrip('#')
if len(h) == 8:
h = h[2:]
if len(h) == 6:
r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
return luminance > 0.6
return False
def time_to_minutes(time_str: str) -> int:
"""Convert HH:MM to minutes since midnight."""
parts = time_str.split(":")
return int(parts[0]) * 60 + int(parts[1])
def generate_pdf(doc) -> bytes:
""" """
Generate a PDF timetable. Generate a PDF timetable from a ScenarioDocument.
Args: Args:
data: DataFrame with validated schedule data doc: ScenarioDocument instance
title: Event title
detail: Event detail/description
program_descriptions: {type: description}
program_colors: {type: color_hex in AARRGGBB format}
Returns: Returns:
bytes: PDF file content bytes: PDF file content
Raises:
ScenarsError: if data is invalid
""" """
if data.empty: if not doc.blocks:
raise ScenarsError("Data is empty after validation") raise ScenarsError("No blocks provided")
missing_types = [typ for typ in data["Typ"].unique() if typ not in program_colors] type_map = {pt.id: pt for pt in doc.program_types}
if missing_types: for block in doc.blocks:
raise ScenarsError( if block.type_id not in type_map:
f"Missing type definitions: {', '.join(missing_types)}. " raise ScenarsError(
"Please define all program types." f"Missing type definition: '{block.type_id}'. "
) "Please define all program types."
)
buffer = BytesIO() buffer = BytesIO()
doc = SimpleDocTemplate( page_w, page_h = landscape(A4)
doc_pdf = SimpleDocTemplate(
buffer, buffer,
pagesize=landscape(A4), pagesize=landscape(A4),
leftMargin=10 * mm, leftMargin=12 * mm,
rightMargin=10 * mm, rightMargin=12 * mm,
topMargin=10 * mm, topMargin=12 * mm,
bottomMargin=10 * mm, bottomMargin=12 * mm,
) )
styles = getSampleStyleSheet() styles = getSampleStyleSheet()
title_style = ParagraphStyle( title_style = ParagraphStyle(
'TimetableTitle', parent=styles['Title'], 'TimetableTitle', parent=styles['Title'],
fontSize=18, alignment=TA_CENTER, spaceAfter=2 * mm fontSize=20, alignment=TA_LEFT, spaceAfter=1 * mm,
textColor=colors.Color(0.118, 0.161, 0.231),
fontName='Helvetica-Bold'
) )
detail_style = ParagraphStyle( subtitle_style = ParagraphStyle(
'TimetableDetail', parent=styles['Normal'], 'TimetableSubtitle', parent=styles['Normal'],
fontSize=12, alignment=TA_CENTER, spaceAfter=4 * mm, fontSize=12, alignment=TA_LEFT, spaceAfter=1 * mm,
textColor=colors.gray textColor=colors.Color(0.4, 0.4, 0.4),
fontName='Helvetica'
) )
cell_style = ParagraphStyle( info_style = ParagraphStyle(
'CellStyle', parent=styles['Normal'], 'InfoStyle', parent=styles['Normal'],
fontSize=7, alignment=TA_CENTER, leading=9 fontSize=10, alignment=TA_LEFT, spaceAfter=4 * mm,
textColor=colors.Color(0.5, 0.5, 0.5),
fontName='Helvetica'
)
cell_style_white = ParagraphStyle(
'CellStyleWhite', parent=styles['Normal'],
fontSize=8, alignment=TA_CENTER, leading=10,
textColor=colors.white, fontName='Helvetica-Bold'
)
cell_style_dark = ParagraphStyle(
'CellStyleDark', parent=styles['Normal'],
fontSize=8, alignment=TA_CENTER, leading=10,
textColor=colors.Color(0.1, 0.1, 0.1), fontName='Helvetica-Bold'
)
time_style = ParagraphStyle(
'TimeStyle', parent=styles['Normal'],
fontSize=7, alignment=TA_RIGHT, leading=9,
textColor=colors.Color(0.5, 0.5, 0.5), fontName='Helvetica'
) )
legend_style = ParagraphStyle( legend_style = ParagraphStyle(
'LegendStyle', parent=styles['Normal'], 'LegendStyle', parent=styles['Normal'],
fontSize=8, alignment=TA_LEFT fontSize=9, alignment=TA_LEFT, fontName='Helvetica'
)
footer_style = ParagraphStyle(
'FooterStyle', parent=styles['Normal'],
fontSize=8, alignment=TA_CENTER,
textColor=colors.Color(0.6, 0.6, 0.6), fontName='Helvetica-Oblique'
) )
elements = [] elements = []
elements.append(Paragraph(title, title_style))
elements.append(Paragraph(detail, detail_style))
data = data.sort_values(by=["Datum", "Zacatek"]) # Header
elements.append(Paragraph(doc.event.title, title_style))
if doc.event.subtitle:
elements.append(Paragraph(doc.event.subtitle, subtitle_style))
start_times = data["Zacatek"] info_parts = []
end_times = data["Konec"] if doc.event.date:
info_parts.append(f"Datum: {doc.event.date}")
if doc.event.location:
info_parts.append(f"M\u00edsto: {doc.event.location}")
if info_parts:
elements.append(Paragraph(" | ".join(info_parts), info_style))
min_time = min(start_times) elements.append(Spacer(1, 3 * mm))
max_time = max(end_times)
time_slots = pd.date_range( # Group blocks by date
datetime.combine(datetime.today(), min_time), blocks_by_date = defaultdict(list)
datetime.combine(datetime.today(), max_time), for block in doc.blocks:
freq='15min' blocks_by_date[block.date].append(block)
).time
# Build header row sorted_dates = sorted(blocks_by_date.keys())
header = ["Datum"] + [t.strftime("%H:%M") for t in time_slots]
# Find global time range
all_starts = [time_to_minutes(b.start) for b in doc.blocks]
all_ends = [time_to_minutes(b.end) for b in doc.blocks]
global_start = (min(all_starts) // 30) * 30
global_end = ((max(all_ends) + 29) // 30) * 30
# Generate 30-min time slots
time_slots = []
t = global_start
while t <= global_end:
h, m = divmod(t, 60)
time_slots.append(f"{h:02d}:{m:02d}")
t += 30
# Build table: time column + one column per day
header = [""] + [d for d in sorted_dates]
table_data = [header] table_data = [header]
cell_colors = [] # list of (row, col, color) for styling slot_count = len(time_slots) - 1
grouped_data = data.groupby(data['Datum']) # Build grid and track colored cells
row_idx = 1 cell_colors_list = []
for date_val, group in grouped_data: for slot_idx in range(slot_count):
day_name = date_val.strftime("%A") slot_start = time_slots[slot_idx]
date_str = date_val.strftime(f"%d.%m {day_name}") slot_end = time_slots[slot_idx + 1]
row = [Paragraph(slot_start, time_style)]
for date_key in sorted_dates:
cell_content = ""
for block in blocks_by_date[date_key]:
block_start_min = time_to_minutes(block.start)
block_end_min = time_to_minutes(block.end)
slot_start_min = time_to_minutes(slot_start)
slot_end_min = time_to_minutes(slot_end)
if block_start_min <= slot_start_min and block_end_min >= slot_end_min:
pt = type_map[block.type_id]
light = is_light_color(pt.color)
cs = cell_style_dark if light else cell_style_white
if block_start_min == slot_start_min:
label = block.title
if block.responsible:
label += f"<br/><font size='6'>{block.responsible}</font>"
cell_content = Paragraph(label, cs)
else:
cell_content = ""
cell_colors_list.append((len(table_data), len(row), pt.color))
break
row.append(cell_content if cell_content else "")
row = [date_str] + [""] * len(time_slots)
table_data.append(row) table_data.append(row)
row_idx += 1
# Create a sub-row for blocks # Column widths
block_row = [""] * (len(time_slots) + 1) avail_width = page_w - 24 * mm
for _, blk in group.iterrows(): time_col_width = 18 * mm
try: day_col_width = (avail_width - time_col_width) / max(len(sorted_dates), 1)
start_idx = list(time_slots).index(blk["Zacatek"]) + 1 col_widths = [time_col_width] + [day_col_width] * len(sorted_dates)
end_idx = list(time_slots).index(blk["Konec"]) + 1
except ValueError:
continue
label = blk['Program'] row_height = 20
if pd.notna(blk.get('Garant')): table = Table(table_data, colWidths=col_widths, rowHeights=[24] + [row_height] * slot_count)
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 = [ style_cmds = [
('BACKGROUND', (0, 0), (-1, 0), colors.Color(0.83, 0.83, 0.83)), ('BACKGROUND', (0, 0), (-1, 0), colors.Color(0.118, 0.161, 0.231)),
('TEXTCOLOR', (0, 0), (-1, 0), colors.black), ('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
('ALIGN', (0, 0), (-1, -1), 'CENTER'), ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, 0), 10),
('ALIGN', (0, 0), (-1, 0), 'CENTER'),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('FONTSIZE', (0, 0), (-1, 0), 7), ('ALIGN', (0, 1), (0, -1), 'RIGHT'),
('FONTSIZE', (0, 1), (-1, -1), 6), ('FONTSIZE', (0, 1), (-1, -1), 7),
('GRID', (0, 0), (-1, -1), 0.5, colors.black), ('GRID', (0, 0), (-1, -1), 0.5, colors.Color(0.85, 0.85, 0.85)),
('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.Color(0.97, 0.97, 0.97)]), ('LINEBELOW', (0, 0), (-1, 0), 1.5, colors.Color(0.118, 0.161, 0.231)),
('ROWBACKGROUNDS', (1, 1), (-1, -1), [colors.white, colors.Color(0.98, 0.98, 0.98)]),
] ]
for r, c, clr in cell_colors: for r, c, hex_clr in cell_colors_list:
style_cmds.append(('BACKGROUND', (c, r), (c, r), clr)) rl_color = hex_to_reportlab_color(hex_clr)
style_cmds.append(('BACKGROUND', (c, r), (c, r), rl_color))
# Merge cells for blocks spanning multiple time slots
for date_idx, date_key in enumerate(sorted_dates):
col = date_idx + 1
for block in blocks_by_date[date_key]:
block_start_min = time_to_minutes(block.start)
block_end_min = time_to_minutes(block.end)
start_row = None
end_row = None
for slot_idx in range(slot_count):
slot_min = time_to_minutes(time_slots[slot_idx])
if slot_min == block_start_min:
start_row = slot_idx + 1
if slot_idx + 1 < len(time_slots):
next_slot_min = time_to_minutes(time_slots[slot_idx + 1])
if next_slot_min == block_end_min:
end_row = slot_idx + 1
if start_row is not None and end_row is not None and end_row > start_row:
style_cmds.append(('SPAN', (col, start_row), (col, end_row)))
table.setStyle(TableStyle(style_cmds)) table.setStyle(TableStyle(style_cmds))
elements.append(table) elements.append(table)
# Legend # Legend
elements.append(Spacer(1, 5 * mm)) elements.append(Spacer(1, 5 * mm))
elements.append(Paragraph("<b>Legenda:</b>", legend_style)) legend_items = []
for pt in doc.program_types:
legend_items.append([Paragraph(f" {pt.name}", legend_style)])
legend_data = [] if legend_items:
legend_colors_list = [] elements.append(Paragraph("<b>Legenda:</b>", legend_style))
for i, (typ, desc) in enumerate(program_descriptions.items()): elements.append(Spacer(1, 2 * mm))
legend_data.append([Paragraph(f"{desc} ({typ})", legend_style)]) legend_table = Table(legend_items, colWidths=[60 * mm])
legend_colors_list.append(hex_to_reportlab_color(program_colors[typ]))
if legend_data:
legend_table = Table(legend_data, colWidths=[80 * mm])
legend_cmds = [ legend_cmds = [
('GRID', (0, 0), (-1, -1), 0.5, colors.black),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('BOX', (0, 0), (-1, -1), 0.5, colors.Color(0.85, 0.85, 0.85)),
('INNERGRID', (0, 0), (-1, -1), 0.5, colors.Color(0.85, 0.85, 0.85)),
] ]
for i, clr in enumerate(legend_colors_list): for i, pt in enumerate(doc.program_types):
legend_cmds.append(('BACKGROUND', (0, i), (0, i), clr)) rl_color = hex_to_reportlab_color(pt.color)
legend_cmds.append(('BACKGROUND', (0, i), (0, i), rl_color))
if not is_light_color(pt.color):
legend_cmds.append(('TEXTCOLOR', (0, i), (0, i), colors.white))
legend_table.setStyle(TableStyle(legend_cmds)) legend_table.setStyle(TableStyle(legend_cmds))
elements.append(legend_table) elements.append(legend_table)
doc.build(elements) # Footer
elements.append(Spacer(1, 5 * mm))
gen_date = datetime.now().strftime("%d.%m.%Y %H:%M")
elements.append(Paragraph(
f"Vygenerov\u00e1no Scen\u00e1r Creatorem | {gen_date}",
footer_style
))
doc_pdf.build(elements)
return buffer.getvalue() return buffer.getvalue()

View File

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

View File

@@ -1,18 +1,9 @@
""" """Validation logic for Scenar Creator v3."""
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 import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
DEFAULT_COLOR = "#ffffff"
MAX_FILE_SIZE_MB = 10
REQUIRED_COLUMNS = ["Datum", "Zacatek", "Konec", "Program", "Typ", "Garant", "Poznamka"]
class ScenarsError(Exception): class ScenarsError(Exception):
"""Base exception for Scenar Creator.""" """Base exception for Scenar Creator."""
@@ -22,48 +13,3 @@ class ScenarsError(Exception):
class ValidationError(ScenarsError): class ValidationError(ScenarsError):
"""Raised when input validation fails.""" """Raised when input validation fails."""
pass 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)}"
)

View File

@@ -10,7 +10,7 @@ from app.config import VERSION
app = FastAPI( app = FastAPI(
title="Scenar Creator", title="Scenar Creator",
description="Web tool for creating timetable scenarios from Excel or inline forms", description="Web tool for creating experience course scenarios with canvas editor",
version=VERSION, version=VERSION,
) )

View File

@@ -1,30 +1,33 @@
"""Pydantic v2 models for Scenar Creator.""" """Pydantic v2 models for Scenar Creator v3."""
from datetime import date, time import uuid
from typing import List, Optional from typing import List, Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
class Block(BaseModel): class Block(BaseModel):
datum: date id: str = Field(default_factory=lambda: str(uuid.uuid4()))
zacatek: time date: str # "YYYY-MM-DD"
konec: time start: str # "HH:MM"
program: str end: str # "HH:MM"
typ: str title: str
garant: Optional[str] = None type_id: str
poznamka: Optional[str] = None responsible: Optional[str] = None
notes: Optional[str] = None
class ProgramType(BaseModel): class ProgramType(BaseModel):
code: str id: str
description: str name: str
color: str # hex #RRGGBB color: str # "#RRGGBB"
class EventInfo(BaseModel): class EventInfo(BaseModel):
title: str = Field(..., max_length=200) title: str
detail: str = Field(..., max_length=500) subtitle: Optional[str] = None
date: Optional[str] = None
location: Optional[str] = None
class ScenarioDocument(BaseModel): class ScenarioDocument(BaseModel):

View File

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

View File

@@ -1,293 +1,711 @@
/* Scenar Creator v3 — Main Stylesheet */
:root {
--header-bg: #1e293b;
--accent: #3b82f6;
--accent-hover: #2563eb;
--danger: #ef4444;
--danger-hover: #dc2626;
--success: #22c55e;
--text: #1e293b;
--text-light: #64748b;
--border: #e2e8f0;
--bg: #f8fafc;
--white: #ffffff;
--sidebar-width: 280px;
--header-height: 56px;
--tab-height: 40px;
--grid-row-height: 30px;
--radius: 8px;
--radius-sm: 4px;
--shadow: 0 1px 3px rgba(0,0,0,0.1);
--shadow-lg: 0 4px 12px rgba(0,0,0,0.15);
}
* { * {
box-sizing: border-box;
margin: 0; margin: 0;
padding: 0; padding: 0;
box-sizing: border-box;
} }
body { body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f5; background: var(--bg);
color: #333; color: var(--text);
line-height: 1.6; font-size: 14px;
line-height: 1.5;
overflow: hidden;
height: 100vh;
} }
.container { /* Header */
max-width: 1200px; .header {
margin: 0 auto; height: var(--header-height);
padding: 20px; background: var(--header-bg);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
color: white;
z-index: 100;
} }
h1 { .header-left {
text-align: center; display: flex;
color: #2c3e50; align-items: center;
margin-bottom: 5px; gap: 10px;
} }
.subtitle { .header-title {
text-align: center; font-size: 18px;
color: #7f8c8d; font-weight: 600;
margin-bottom: 20px; letter-spacing: -0.3px;
}
.header-version {
font-size: 11px;
background: rgba(255,255,255,0.15);
padding: 2px 8px;
border-radius: 10px;
font-weight: 500;
}
.header-actions {
display: flex;
gap: 8px;
align-items: center;
} }
/* Tabs */ /* Tabs */
.tabs { .tabs {
height: var(--tab-height);
background: var(--white);
border-bottom: 1px solid var(--border);
display: flex; display: flex;
padding: 0 20px;
gap: 0; gap: 0;
margin-bottom: 0;
border-bottom: 2px solid #3498db;
} }
.tab { .tab {
padding: 10px 24px; padding: 0 16px;
border: 1px solid #ddd; height: 100%;
border-bottom: none; border: none;
background: #ecf0f1; background: none;
cursor: pointer; cursor: pointer;
font-size: 14px; font-size: 13px;
border-radius: 6px 6px 0 0; font-weight: 500;
transition: background 0.2s; color: var(--text-light);
border-bottom: 2px solid transparent;
transition: all 0.15s;
font-family: inherit;
}
.tab:hover {
color: var(--text);
} }
.tab.active { .tab.active {
background: #fff; color: var(--accent);
border-color: #3498db; border-bottom-color: var(--accent);
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 */ /* Buttons */
.btn { .btn {
display: inline-block; display: inline-flex;
padding: 10px 20px; align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 16px;
border: none; border: none;
border-radius: 4px; border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 500;
cursor: pointer; cursor: pointer;
font-size: 14px; transition: all 0.15s;
text-decoration: none; font-family: inherit;
text-align: center; white-space: nowrap;
transition: background 0.2s;
} }
.btn-primary { .btn-primary {
background: #3498db; background: var(--accent);
color: #fff; color: white;
} }
.btn-primary:hover { .btn-primary:hover {
background: #2980b9; background: var(--accent-hover);
} }
.btn-secondary { .btn-secondary {
background: #95a5a6; background: rgba(255,255,255,0.12);
color: #fff; color: white;
border: 1px solid rgba(255,255,255,0.2);
} }
.btn-secondary:hover { .btn-secondary:hover {
background: #7f8c8d; background: rgba(255,255,255,0.2);
} }
.btn-danger { .btn-danger {
background: #e74c3c; background: var(--danger);
color: #fff; color: white;
} }
.btn-danger:hover { .btn-danger:hover {
background: #c0392b; background: var(--danger-hover);
} }
.btn-sm { .btn-sm {
padding: 4px 10px; padding: 6px 12px;
font-size: 12px; font-size: 12px;
} }
/* Types */ .btn-xs {
padding: 4px 10px;
font-size: 11px;
background: var(--bg);
color: var(--text-light);
border: 1px solid var(--border);
}
.btn-xs:hover {
background: var(--border);
color: var(--text);
}
.btn-block {
width: 100%;
}
/* Layout */
.tab-content {
height: calc(100vh - var(--header-height) - var(--tab-height));
overflow: hidden;
}
.tab-content.hidden {
display: none;
}
.app-layout {
display: flex;
height: 100%;
}
/* Sidebar */
.sidebar {
width: var(--sidebar-width);
min-width: var(--sidebar-width);
background: var(--white);
border-right: 1px solid var(--border);
overflow-y: auto;
padding: 16px;
}
.sidebar-section {
margin-bottom: 20px;
}
.sidebar-heading {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-light);
margin-bottom: 10px;
font-weight: 600;
}
.form-group {
margin-bottom: 10px;
}
.form-group label {
display: block;
font-size: 12px;
font-weight: 500;
color: var(--text-light);
margin-bottom: 4px;
}
.form-group input[type="text"],
.form-group input[type="date"],
.form-group input[type="time"],
.form-group select,
.form-group textarea {
width: 100%;
padding: 7px 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: 13px;
font-family: inherit;
background: var(--white);
color: var(--text);
transition: border-color 0.15s;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(59,130,246,0.1);
}
.form-row {
display: flex;
gap: 10px;
}
.form-row .form-group {
flex: 1;
}
/* Program type rows */
.type-row { .type-row {
display: flex; display: flex;
gap: 8px;
margin-bottom: 8px;
align-items: center; align-items: center;
gap: 6px;
margin-bottom: 8px;
padding: 6px 8px;
background: var(--bg);
border-radius: var(--radius-sm);
}
.type-row input[type="color"] {
width: 28px;
height: 28px;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
padding: 0;
background: none;
} }
.type-row input[type="text"] { .type-row input[type="text"] {
flex: 1; flex: 1;
padding: 6px 10px; padding: 5px 8px;
border: 1px solid #ccc; border: 1px solid var(--border);
border-radius: 4px; border-radius: var(--radius-sm);
font-size: 12px;
font-family: inherit;
} }
.type-row input[type="color"] { .type-row input[type="text"]:focus {
width: 40px; outline: none;
height: 32px; border-color: var(--accent);
border: 1px solid #ccc; }
border-radius: 4px;
.type-remove {
width: 22px;
height: 22px;
border: none;
background: none;
color: var(--text-light);
cursor: pointer; cursor: pointer;
font-size: 16px;
line-height: 1;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
} }
.type-code { .type-remove:hover {
max-width: 200px; background: var(--danger);
color: white;
} }
/* Schedule table */ /* Canvas */
#scheduleTable { .canvas-wrapper {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--bg);
}
.canvas-header {
display: flex;
padding: 0 0 0 60px;
background: var(--white);
border-bottom: 1px solid var(--border);
min-height: 36px;
align-items: stretch;
}
.canvas-header .day-header {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
color: var(--text);
border-left: 1px solid var(--border);
padding: 8px;
}
.canvas-header .day-header:first-child {
border-left: none;
}
.canvas-scroll {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
position: relative;
}
.canvas {
display: flex;
position: relative;
min-height: 600px;
}
.time-axis {
width: 60px;
min-width: 60px;
position: relative;
background: var(--white);
border-right: 1px solid var(--border);
}
.time-label {
position: absolute;
right: 8px;
font-size: 11px;
color: var(--text-light);
transform: translateY(-50%);
font-variant-numeric: tabular-nums;
}
.day-columns {
flex: 1;
display: flex;
position: relative;
}
.day-column {
flex: 1;
position: relative;
border-left: 1px solid var(--border);
min-width: 200px;
}
.day-column:first-child {
border-left: none;
}
/* Grid lines */
.grid-line {
position: absolute;
left: 0;
right: 0;
height: 1px;
background: var(--border);
pointer-events: none;
}
.grid-line.hour {
background: var(--border);
}
.grid-line.half {
background: #f1f5f9;
}
/* Schedule blocks */
.schedule-block {
position: absolute;
left: 4px;
right: 4px;
border-radius: 6px;
padding: 6px 8px;
cursor: grab;
overflow: hidden;
transition: box-shadow 0.15s;
z-index: 10;
display: flex;
flex-direction: column;
min-height: 20px;
user-select: none;
touch-action: none;
}
.schedule-block:hover {
box-shadow: var(--shadow-lg);
z-index: 20;
}
.schedule-block:active {
cursor: grabbing;
}
.schedule-block .block-color-bar {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
border-radius: 6px 0 0 6px;
}
.schedule-block .block-title {
font-size: 12px;
font-weight: 600;
color: white;
line-height: 1.2;
padding-left: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.schedule-block .block-time {
font-size: 10px;
color: rgba(255,255,255,0.8);
padding-left: 4px;
}
.schedule-block .block-responsible {
font-size: 10px;
color: rgba(255,255,255,0.7);
padding-left: 4px;
margin-top: auto;
}
.schedule-block.light-bg .block-title {
color: var(--text);
}
.schedule-block.light-bg .block-time {
color: var(--text-light);
}
.schedule-block.light-bg .block-responsible {
color: var(--text-light);
}
/* Resize handle */
.schedule-block .resize-handle {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 8px;
cursor: s-resize;
background: transparent;
}
.schedule-block:hover .resize-handle {
background: rgba(0,0,0,0.1);
border-radius: 0 0 6px 6px;
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-overlay.hidden {
display: none;
}
.modal {
background: var(--white);
border-radius: var(--radius);
box-shadow: var(--shadow-lg);
width: 420px;
max-width: 90vw;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
}
.modal-header h3 {
font-size: 16px;
font-weight: 600;
}
.modal-close {
width: 30px;
height: 30px;
border: none;
background: none;
font-size: 20px;
cursor: pointer;
color: var(--text-light);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.modal-close:hover {
background: var(--bg);
}
.modal-body {
padding: 20px;
}
.modal-footer {
display: flex;
justify-content: space-between;
padding: 16px 20px;
border-top: 1px solid var(--border);
}
/* Toast notification */
.toast {
position: fixed;
bottom: 24px;
right: 24px;
padding: 12px 20px;
border-radius: var(--radius);
font-size: 13px;
font-weight: 500;
z-index: 2000;
transition: opacity 0.3s, transform 0.3s;
box-shadow: var(--shadow-lg);
}
.toast.hidden {
opacity: 0;
transform: translateY(10px);
pointer-events: none;
}
.toast.success {
background: var(--success);
color: white;
}
.toast.error {
background: var(--danger);
color: white;
}
.toast.info {
background: var(--accent);
color: white;
}
/* Documentation */
.docs-container {
max-width: 800px;
margin: 0 auto;
padding: 32px 24px;
overflow-y: auto;
height: 100%;
}
.docs-container h2 {
font-size: 24px;
font-weight: 700;
margin-bottom: 24px;
color: var(--text);
}
.docs-container h3 {
font-size: 16px;
font-weight: 600;
margin-top: 24px;
margin-bottom: 12px;
color: var(--text);
}
.docs-container p {
margin-bottom: 12px;
color: var(--text-light);
line-height: 1.7;
}
.docs-container ul,
.docs-container ol {
margin-bottom: 12px;
padding-left: 24px;
color: var(--text-light);
}
.docs-container li {
margin-bottom: 6px;
line-height: 1.6;
}
.docs-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
margin: 10px 0; margin: 16px 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; font-size: 13px;
} }
#scheduleTable input { .docs-table th,
width: 100%; .docs-table td {
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; padding: 8px 12px;
margin-bottom: 4px; text-align: left;
border-radius: 4px; border: 1px solid var(--border);
border-left: 4px solid #3498db;
font-size: 13px;
} }
/* Status message */ .docs-table th {
.status-message { background: var(--bg);
margin-top: 15px; font-weight: 600;
padding: 12px; color: var(--text);
border-radius: 4px;
font-size: 14px;
} }
.status-message.success { .docs-table td {
background: #d5f5e3; color: var(--text-light);
color: #27ae60;
border: 1px solid #27ae60;
} }
.status-message.error { /* Canvas click area for creating blocks */
background: #fadbd8; .day-column-click-area {
color: #e74c3c; position: absolute;
border: 1px solid #e74c3c; inset: 0;
z-index: 1;
} }
/* JSON import */ /* Scrollbar */
.json-import { .canvas-scroll::-webkit-scrollbar,
margin-top: 20px; .sidebar::-webkit-scrollbar {
padding: 10px; width: 6px;
background: #fafafa;
border-radius: 4px;
font-size: 13px;
} }
h3 { .canvas-scroll::-webkit-scrollbar-track,
margin: 20px 0 10px; .sidebar::-webkit-scrollbar-track {
color: #2c3e50; background: transparent;
}
.canvas-scroll::-webkit-scrollbar-thumb,
.sidebar::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
}
.canvas-scroll::-webkit-scrollbar-thumb:hover,
.sidebar::-webkit-scrollbar-thumb:hover {
background: var(--text-light);
}
/* Sidebar buttons in non-header context */
.sidebar .btn-secondary {
background: var(--bg);
color: var(--text-light);
border: 1px solid var(--border);
}
.sidebar .btn-secondary:hover {
background: var(--border);
color: var(--text);
} }

View File

@@ -3,131 +3,193 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Scenar Creator</title> <title>Scenár Creator</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/app.css"> <link rel="stylesheet" href="/static/css/app.css">
</head> </head>
<body> <body>
<div class="container"> <header class="header">
<h1>Scenar Creator</h1> <div class="header-left">
<p class="subtitle">Tvorba časových harmonogramů</p> <h1 class="header-title">Scenár Creator</h1>
<span class="header-version">v3.0</span>
<!-- 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> </div>
<div class="header-actions">
<!-- Import Excel Tab --> <label class="btn btn-secondary btn-sm" id="importJsonBtn">
<div id="importTab" class="tab-content active"> <input type="file" accept=".json" id="importJsonInput" hidden>
<form id="importForm" onsubmit="return handleImport(event)"> Import JSON
<div class="form-group"> </label>
<label for="importTitle">Název akce:</label> <button class="btn btn-secondary btn-sm" id="newScenarioBtn">Nový scénář</button>
<input type="text" id="importTitle" name="title" maxlength="200" required placeholder="Název události"> <button class="btn btn-secondary btn-sm" id="exportJsonBtn">Export JSON</button>
</div> <button class="btn btn-primary btn-sm" id="generatePdfBtn">Generovat PDF</button>
<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> </div>
</header>
<!-- Builder Tab --> <div class="tabs">
<div id="builderTab" class="tab-content"> <button class="tab active" data-tab="editor">Editor</button>
<form id="builderForm" onsubmit="return handleBuild(event)"> <button class="tab" data-tab="docs">Dokumentace</button>
<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> </div>
<div class="tab-content" id="tab-editor">
<div class="app-layout">
<aside class="sidebar">
<section class="sidebar-section">
<h3 class="sidebar-heading">Informace o akci</h3>
<div class="form-group">
<label>Název</label>
<input type="text" id="eventTitle" placeholder="Název akce">
</div>
<div class="form-group">
<label>Podtitul</label>
<input type="text" id="eventSubtitle" placeholder="Podtitul">
</div>
<div class="form-group">
<label>Datum</label>
<input type="date" id="eventDate">
</div>
<div class="form-group">
<label>Místo</label>
<input type="text" id="eventLocation" placeholder="Místo konání">
</div>
</section>
<section class="sidebar-section">
<h3 class="sidebar-heading">Typy programů</h3>
<div id="programTypesContainer"></div>
<button class="btn btn-secondary btn-xs" id="addTypeBtn">+ Přidat typ</button>
</section>
<section class="sidebar-section">
<button class="btn btn-primary btn-block" id="addBlockBtn">+ Přidat blok</button>
</section>
</aside>
<main class="canvas-wrapper">
<div class="canvas-header" id="canvasHeader"></div>
<div class="canvas-scroll">
<div class="canvas" id="canvas">
<div class="time-axis" id="timeAxis"></div>
<div class="day-columns" id="dayColumns"></div>
</div>
</div>
</main>
</div>
</div>
<div class="tab-content hidden" id="tab-docs">
<div class="docs-container">
<h2>Dokumentace — Scenár Creator v3</h2>
<h3>Jak začít</h3>
<p>Máte dvě možnosti:</p>
<ol>
<li><strong>Nový scénář</strong> — klikněte na tlačítko "Nový scénář" v záhlaví. Vytvoří se prázdný scénář s jedním dnem.</li>
<li><strong>Import JSON</strong> — klikněte na "Import JSON" a vyberte dříve uložený .json soubor. Můžete také přetáhnout JSON soubor přímo na plochu editoru.</li>
</ol>
<h3>Práce s bloky</h3>
<ul>
<li><strong>Přidání bloku:</strong> Klikněte na "+ Přidat blok" v postranním panelu, nebo klikněte na prázdné místo na časové ose.</li>
<li><strong>Přesun bloku:</strong> Chytněte blok myší a přetáhněte ho na jiný čas. Bloky se přichytávají na 15minutovou mřížku.</li>
<li><strong>Změna délky:</strong> Chytněte dolní okraj bloku a tažením změňte dobu trvání.</li>
<li><strong>Úprava bloku:</strong> Klikněte na blok pro otevření editačního popup okna, kde můžete upravit název, typ, garanta a poznámku.</li>
<li><strong>Smazání bloku:</strong> V editačním popup okně klikněte na tlačítko "Smazat blok".</li>
</ul>
<h3>Typy programů a barvy</h3>
<p>V postranním panelu v sekci "Typy programů" můžete:</p>
<ul>
<li>Přidat nový typ kliknutím na "+ Přidat typ"</li>
<li>Pojmenovat typ a vybrat barvu pomocí barevného výběru</li>
<li>Odebrat typ kliknutím na tlačítko ×</li>
</ul>
<h3>Export JSON</h3>
<p>Kliknutím na "Export JSON" stáhnete aktuální stav scénáře jako .json soubor. Tento soubor můžete později znovu importovat a pokračovat v úpravách.</p>
<h3>Generování PDF</h3>
<p>Kliknutím na "Generovat PDF" se scénář odešle na server a vygeneruje se přehledný PDF dokument ve formátu A4 na šířku s barevnými bloky a legendou.</p>
<h3>Formát JSON</h3>
<table class="docs-table">
<thead>
<tr><th>Pole</th><th>Typ</th><th>Popis</th></tr>
</thead>
<tbody>
<tr><td>version</td><td>string</td><td>Verze formátu (1.0)</td></tr>
<tr><td>event.title</td><td>string</td><td>Název akce</td></tr>
<tr><td>event.subtitle</td><td>string?</td><td>Podtitul</td></tr>
<tr><td>event.date</td><td>string?</td><td>Datum (YYYY-MM-DD)</td></tr>
<tr><td>event.location</td><td>string?</td><td>Místo</td></tr>
<tr><td>program_types[].id</td><td>string</td><td>Unikátní ID typu</td></tr>
<tr><td>program_types[].name</td><td>string</td><td>Název typu</td></tr>
<tr><td>program_types[].color</td><td>string</td><td>Barva (#RRGGBB)</td></tr>
<tr><td>blocks[].id</td><td>string</td><td>Unikátní ID bloku</td></tr>
<tr><td>blocks[].date</td><td>string</td><td>Datum (YYYY-MM-DD)</td></tr>
<tr><td>blocks[].start</td><td>string</td><td>Začátek (HH:MM)</td></tr>
<tr><td>blocks[].end</td><td>string</td><td>Konec (HH:MM)</td></tr>
<tr><td>blocks[].title</td><td>string</td><td>Název bloku</td></tr>
<tr><td>blocks[].type_id</td><td>string</td><td>ID typu programu</td></tr>
<tr><td>blocks[].responsible</td><td>string?</td><td>Garant</td></tr>
<tr><td>blocks[].notes</td><td>string?</td><td>Poznámka</td></tr>
</tbody>
</table>
<h3>Vzorový JSON</h3>
<p><a href="/api/sample" class="btn btn-secondary btn-sm">Stáhnout sample.json</a></p>
</div>
</div>
<!-- Block edit modal -->
<div class="modal-overlay hidden" id="blockModal">
<div class="modal">
<div class="modal-header">
<h3 id="modalTitle">Upravit blok</h3>
<button class="modal-close" id="modalClose">&times;</button>
</div>
<div class="modal-body">
<input type="hidden" id="modalBlockId">
<div class="form-group">
<label>Název bloku</label>
<input type="text" id="modalBlockTitle" placeholder="Název">
</div>
<div class="form-group">
<label>Typ programu</label>
<select id="modalBlockType"></select>
</div>
<div class="form-row">
<div class="form-group">
<label>Začátek</label>
<input type="time" id="modalBlockStart">
</div>
<div class="form-group">
<label>Konec</label>
<input type="time" id="modalBlockEnd">
</div>
</div>
<div class="form-group">
<label>Garant</label>
<input type="text" id="modalBlockResponsible" placeholder="Garant">
</div>
<div class="form-group">
<label>Poznámka</label>
<textarea id="modalBlockNotes" rows="3" placeholder="Poznámka"></textarea>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-danger btn-sm" id="modalDeleteBtn">Smazat blok</button>
<button class="btn btn-primary btn-sm" id="modalSaveBtn">Uložit</button>
</div>
</div>
</div>
<!-- Status toast -->
<div class="toast hidden" id="toast"></div>
<script src="https://cdn.jsdelivr.net/npm/interactjs@1.10.27/dist/interact.min.js"></script>
<script src="/static/js/api.js"></script> <script src="/static/js/api.js"></script>
<script src="/static/js/canvas.js"></script>
<script src="/static/js/export.js"></script> <script src="/static/js/export.js"></script>
<script src="/static/js/app.js"></script> <script src="/static/js/app.js"></script>
</body> </body>

View File

@@ -1,5 +1,5 @@
/** /**
* API fetch wrapper for Scenar Creator. * API fetch wrapper for Scenar Creator v3.
*/ */
const API = { const API = {
@@ -9,10 +9,9 @@ const API = {
opts.headers = { 'Content-Type': 'application/json' }; opts.headers = { 'Content-Type': 'application/json' };
opts.body = JSON.stringify(body); opts.body = JSON.stringify(body);
} else { } else {
opts.body = body; // FormData opts.body = body;
} }
const res = await fetch(url, opts); return fetch(url, opts);
return res;
}, },
async postJson(url, body) { async postJson(url, body) {
@@ -33,21 +32,18 @@ const API = {
return res.blob(); 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) { async get(url) {
const res = await fetch(url); const res = await fetch(url);
if (!res.ok) throw new Error(res.statusText); if (!res.ok) throw new Error(res.statusText);
return res.json(); return res.json();
}, },
async getBlob(url) {
const res = await fetch(url);
if (!res.ok) throw new Error(res.statusText);
return res.blob();
},
downloadBlob(blob, filename) { downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');

View File

@@ -1,258 +1,304 @@
/** /**
* Main application logic for Scenar Creator SPA. * Main application logic for Scenar Creator v3.
* State management, UI wiring, modal handling.
*/ */
window.currentDocument = null; const App = {
let typeCounter = 1; state: {
let scheduleCounter = 1; event: { title: '', subtitle: '', date: '', location: '' },
program_types: [],
blocks: []
},
/* --- Tab switching --- */ init() {
function switchTab(event, tabId) { this.bindEvents();
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); this.newScenario();
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); },
event.target.classList.add('active');
document.getElementById(tabId).classList.add('active');
}
/* --- Status messages --- */ // --- State ---
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 --- */ getDocument() {
function addTypeRow() { this.syncEventFromUI();
const container = document.getElementById('typesContainer'); return {
const idx = typeCounter++; version: '1.0',
const div = document.createElement('div'); event: { ...this.state.event },
div.className = 'type-row'; program_types: this.state.program_types.map(pt => ({ ...pt })),
div.setAttribute('data-index', idx); blocks: this.state.blocks.map(b => ({ ...b }))
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) { loadDocument(doc) {
const row = document.querySelector(`.type-row[data-index="${idx}"]`); this.state.event = { ...doc.event };
if (row) row.remove(); this.state.program_types = (doc.program_types || []).map(pt => ({ ...pt }));
updateTypeDatalist(); this.state.blocks = (doc.blocks || []).map(b => ({
} ...b,
id: b.id || this.uid()
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,
})); }));
} this.syncEventToUI();
return window.currentDocument; this.renderTypes();
} this.renderCanvas();
},
/* --- Generate Excel from imported data --- */ newScenario() {
async function generateExcelFromImport() { const today = new Date().toISOString().split('T')[0];
try { this.state = {
const doc = getCurrentDocument(); event: { title: 'Nová akce', subtitle: '', date: today, location: '' },
const blob = await API.postBlob('/api/generate-excel', doc); program_types: [
API.downloadBlob(blob, 'scenar_timetable.xlsx'); { id: 'main', name: 'Hlavní program', color: '#3B82F6' },
showStatus('Excel vygenerován', 'success'); { id: 'rest', name: 'Odpočinek', color: '#22C55E' }
} catch (err) { ],
showStatus('Chyba: ' + err.message, 'error'); blocks: []
} };
} this.syncEventToUI();
this.renderTypes();
this.renderCanvas();
},
/* --- Generate PDF from imported data --- */ // --- Sync sidebar <-> state ---
async function generatePdfFromImport() {
try { syncEventFromUI() {
const doc = getCurrentDocument(); this.state.event.title = document.getElementById('eventTitle').value.trim() || 'Nová akce';
const blob = await API.postBlob('/api/generate-pdf', doc); this.state.event.subtitle = document.getElementById('eventSubtitle').value.trim() || null;
API.downloadBlob(blob, 'scenar_timetable.pdf'); this.state.event.date = document.getElementById('eventDate').value || null;
showStatus('PDF vygenerován', 'success'); this.state.event.location = document.getElementById('eventLocation').value.trim() || null;
} catch (err) { },
showStatus('Chyba: ' + err.message, 'error');
syncEventToUI() {
document.getElementById('eventTitle').value = this.state.event.title || '';
document.getElementById('eventSubtitle').value = this.state.event.subtitle || '';
document.getElementById('eventDate').value = this.state.event.date || '';
document.getElementById('eventLocation').value = this.state.event.location || '';
},
// --- Program types ---
renderTypes() {
const container = document.getElementById('programTypesContainer');
container.innerHTML = '';
this.state.program_types.forEach((pt, i) => {
const row = document.createElement('div');
row.className = 'type-row';
row.innerHTML = `
<input type="color" value="${pt.color}" data-idx="${i}">
<input type="text" value="${pt.name}" placeholder="Název typu" data-idx="${i}">
<button class="type-remove" data-idx="${i}">&times;</button>
`;
// Color change
row.querySelector('input[type="color"]').addEventListener('change', (e) => {
this.state.program_types[i].color = e.target.value;
this.renderCanvas();
});
// Name change
row.querySelector('input[type="text"]').addEventListener('change', (e) => {
this.state.program_types[i].name = e.target.value.trim();
});
// Remove
row.querySelector('.type-remove').addEventListener('click', () => {
this.state.program_types.splice(i, 1);
this.renderTypes();
this.renderCanvas();
});
container.appendChild(row);
});
},
addType() {
const id = 'type_' + this.uid().substring(0, 6);
this.state.program_types.push({
id,
name: 'Nový typ',
color: '#' + Math.floor(Math.random() * 16777215).toString(16).padStart(6, '0')
});
this.renderTypes();
},
// --- Blocks ---
createBlock(date, start, end) {
const typeId = this.state.program_types.length > 0 ? this.state.program_types[0].id : 'main';
const block = {
id: this.uid(),
date: date || this.state.event.date || new Date().toISOString().split('T')[0],
start: start || '09:00',
end: end || '10:00',
title: 'Nový blok',
type_id: typeId,
responsible: null,
notes: null
};
this.state.blocks.push(block);
this.renderCanvas();
this.editBlock(block.id);
},
updateBlockTime(blockId, start, end) {
const block = this.state.blocks.find(b => b.id === blockId);
if (block) {
block.start = start;
block.end = end;
this.renderCanvas();
}
},
deleteBlock(blockId) {
this.state.blocks = this.state.blocks.filter(b => b.id !== blockId);
this.renderCanvas();
},
// --- Canvas rendering ---
renderCanvas() {
const dates = this.getUniqueDates();
Canvas.renderTimeAxis();
Canvas.renderDayColumns(dates);
Canvas.renderBlocks(this.state.blocks, this.state.program_types);
},
getUniqueDates() {
const dateSet = new Set();
this.state.blocks.forEach(b => dateSet.add(b.date));
if (this.state.event.date) dateSet.add(this.state.event.date);
const dates = Array.from(dateSet).sort();
return dates.length > 0 ? dates : [new Date().toISOString().split('T')[0]];
},
// --- Modal ---
editBlock(blockId) {
const block = this.state.blocks.find(b => b.id === blockId);
if (!block) return;
document.getElementById('modalBlockId').value = block.id;
document.getElementById('modalBlockTitle').value = block.title;
document.getElementById('modalBlockStart').value = block.start;
document.getElementById('modalBlockEnd').value = block.end;
document.getElementById('modalBlockResponsible').value = block.responsible || '';
document.getElementById('modalBlockNotes').value = block.notes || '';
// Populate type select
const select = document.getElementById('modalBlockType');
select.innerHTML = '';
this.state.program_types.forEach(pt => {
const opt = document.createElement('option');
opt.value = pt.id;
opt.textContent = pt.name;
if (pt.id === block.type_id) opt.selected = true;
select.appendChild(opt);
});
document.getElementById('blockModal').classList.remove('hidden');
},
saveBlockFromModal() {
const blockId = document.getElementById('modalBlockId').value;
const block = this.state.blocks.find(b => b.id === blockId);
if (!block) return;
block.title = document.getElementById('modalBlockTitle').value.trim() || 'Blok';
block.type_id = document.getElementById('modalBlockType').value;
block.start = document.getElementById('modalBlockStart').value;
block.end = document.getElementById('modalBlockEnd').value;
block.responsible = document.getElementById('modalBlockResponsible').value.trim() || null;
block.notes = document.getElementById('modalBlockNotes').value.trim() || null;
this.closeModal();
this.renderCanvas();
},
closeModal() {
document.getElementById('blockModal').classList.add('hidden');
},
// --- Toast ---
toast(message, type) {
const el = document.getElementById('toast');
el.textContent = message;
el.className = 'toast ' + (type || 'info');
setTimeout(() => el.classList.add('hidden'), 3000);
},
// --- PDF ---
async generatePdf() {
try {
const doc = this.getDocument();
if (doc.blocks.length === 0) {
this.toast('Přidejte alespoň jeden blok', 'error');
return;
}
const blob = await API.postBlob('/api/generate-pdf', doc);
API.downloadBlob(blob, 'scenar_timetable.pdf');
this.toast('PDF vygenerován', 'success');
} catch (err) {
this.toast('Chyba: ' + err.message, 'error');
}
},
// --- Utility ---
uid() {
return 'xxxx-xxxx-xxxx'.replace(/x/g, () =>
Math.floor(Math.random() * 16).toString(16)
);
},
// --- Event binding ---
bindEvents() {
// Tabs
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.add('hidden'));
tab.classList.add('active');
document.getElementById('tab-' + tab.dataset.tab).classList.remove('hidden');
});
});
// Header buttons
document.getElementById('newScenarioBtn').addEventListener('click', () => this.newScenario());
document.getElementById('exportJsonBtn').addEventListener('click', () => exportJson());
document.getElementById('generatePdfBtn').addEventListener('click', () => this.generatePdf());
// Import JSON
document.getElementById('importJsonInput').addEventListener('change', (e) => {
if (e.target.files[0]) importJson(e.target.files[0]);
e.target.value = '';
});
// Sidebar
document.getElementById('addTypeBtn').addEventListener('click', () => this.addType());
document.getElementById('addBlockBtn').addEventListener('click', () => this.createBlock());
// Modal
document.getElementById('modalSaveBtn').addEventListener('click', () => this.saveBlockFromModal());
document.getElementById('modalClose').addEventListener('click', () => this.closeModal());
document.getElementById('modalDeleteBtn').addEventListener('click', () => {
const blockId = document.getElementById('modalBlockId').value;
this.closeModal();
this.deleteBlock(blockId);
});
// Close modal on overlay click
document.getElementById('blockModal').addEventListener('click', (e) => {
if (e.target.classList.contains('modal-overlay')) this.closeModal();
});
// Drag & drop JSON on canvas
const canvasWrapper = document.querySelector('.canvas-wrapper');
if (canvasWrapper) {
canvasWrapper.addEventListener('dragover', (e) => e.preventDefault());
canvasWrapper.addEventListener('drop', (e) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (file && file.name.endsWith('.json')) {
importJson(file);
}
});
}
} }
} };
// Boot
document.addEventListener('DOMContentLoaded', () => App.init());

267
app/static/js/canvas.js Normal file
View File

@@ -0,0 +1,267 @@
/**
* Canvas editor for Scenar Creator v3.
* Renders schedule blocks on a time-grid canvas with drag & drop via interact.js.
*/
const Canvas = {
GRID_MINUTES: 15,
PX_PER_SLOT: 30, // 30px per 15 min
START_HOUR: 7,
END_HOUR: 23,
get pxPerMinute() {
return this.PX_PER_SLOT / this.GRID_MINUTES;
},
get totalSlots() {
return ((this.END_HOUR - this.START_HOUR) * 60) / this.GRID_MINUTES;
},
get totalHeight() {
return this.totalSlots * this.PX_PER_SLOT;
},
minutesToPx(minutes) {
return (minutes - this.START_HOUR * 60) * this.pxPerMinute;
},
pxToMinutes(px) {
return Math.round(px / this.pxPerMinute + this.START_HOUR * 60);
},
snapMinutes(minutes) {
return Math.round(minutes / this.GRID_MINUTES) * this.GRID_MINUTES;
},
formatTime(totalMinutes) {
const h = Math.floor(totalMinutes / 60);
const m = totalMinutes % 60;
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
},
parseTime(str) {
const [h, m] = str.split(':').map(Number);
return h * 60 + m;
},
isLightColor(hex) {
const h = hex.replace('#', '');
const r = parseInt(h.substring(0, 2), 16);
const g = parseInt(h.substring(2, 4), 16);
const b = parseInt(h.substring(4, 6), 16);
return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.6;
},
renderTimeAxis() {
const axis = document.getElementById('timeAxis');
axis.innerHTML = '';
axis.style.height = this.totalHeight + 'px';
for (let slot = 0; slot <= this.totalSlots; slot++) {
const minutes = this.START_HOUR * 60 + slot * this.GRID_MINUTES;
const isHour = minutes % 60 === 0;
const isHalf = minutes % 30 === 0;
if (isHour || isHalf) {
const label = document.createElement('div');
label.className = 'time-label';
label.style.top = (slot * this.PX_PER_SLOT) + 'px';
label.textContent = this.formatTime(minutes);
if (!isHour) {
label.style.fontSize = '9px';
label.style.opacity = '0.6';
}
axis.appendChild(label);
}
}
},
renderDayColumns(dates) {
const header = document.getElementById('canvasHeader');
const columns = document.getElementById('dayColumns');
header.innerHTML = '';
columns.innerHTML = '';
if (dates.length === 0) dates = [new Date().toISOString().split('T')[0]];
dates.forEach(dateStr => {
// Header
const dh = document.createElement('div');
dh.className = 'day-header';
dh.textContent = dateStr;
header.appendChild(dh);
// Column
const col = document.createElement('div');
col.className = 'day-column';
col.dataset.date = dateStr;
col.style.height = this.totalHeight + 'px';
// Grid lines
for (let slot = 0; slot <= this.totalSlots; slot++) {
const minutes = this.START_HOUR * 60 + slot * this.GRID_MINUTES;
const line = document.createElement('div');
line.className = 'grid-line ' + (minutes % 60 === 0 ? 'hour' : 'half');
line.style.top = (slot * this.PX_PER_SLOT) + 'px';
col.appendChild(line);
}
// Click area for creating blocks
const clickArea = document.createElement('div');
clickArea.className = 'day-column-click-area';
clickArea.addEventListener('click', (e) => {
if (e.target !== clickArea) return;
const rect = col.getBoundingClientRect();
const y = e.clientY - rect.top;
const minutes = this.snapMinutes(this.pxToMinutes(y));
const endMinutes = Math.min(minutes + 60, this.END_HOUR * 60);
App.createBlock(dateStr, this.formatTime(minutes), this.formatTime(endMinutes));
});
col.appendChild(clickArea);
columns.appendChild(col);
});
},
renderBlocks(blocks, programTypes) {
// Remove existing blocks
document.querySelectorAll('.schedule-block').forEach(el => el.remove());
const typeMap = {};
programTypes.forEach(pt => { typeMap[pt.id] = pt; });
blocks.forEach(block => {
const col = document.querySelector(`.day-column[data-date="${block.date}"]`);
if (!col) return;
const pt = typeMap[block.type_id] || { color: '#94a3b8', name: '?' };
const startMin = this.parseTime(block.start);
const endMin = this.parseTime(block.end);
const top = this.minutesToPx(startMin);
const height = (endMin - startMin) * this.pxPerMinute;
const isLight = this.isLightColor(pt.color);
const el = document.createElement('div');
el.className = 'schedule-block' + (isLight ? ' light-bg' : '');
el.dataset.blockId = block.id;
el.style.top = top + 'px';
el.style.height = Math.max(height, 20) + 'px';
el.style.backgroundColor = pt.color;
el.innerHTML = `
<div class="block-color-bar" style="background:${this.darkenColor(pt.color)}"></div>
<div class="block-title">${this.escapeHtml(block.title)}</div>
<div class="block-time">${block.start} ${block.end}</div>
${block.responsible ? `<div class="block-responsible">${this.escapeHtml(block.responsible)}</div>` : ''}
<div class="resize-handle"></div>
`;
// Click to edit
el.addEventListener('click', (e) => {
if (e.target.classList.contains('resize-handle')) return;
App.editBlock(block.id);
});
col.appendChild(el);
});
this.initInteract();
},
initInteract() {
if (typeof interact === 'undefined') return;
interact('.schedule-block').draggable({
inertia: false,
modifiers: [
interact.modifiers.snap({
targets: [interact.snappers.grid({
x: 1, y: this.PX_PER_SLOT
})],
range: Infinity,
relativePoint: { x: 0, y: 0 }
}),
interact.modifiers.restrict({
restriction: 'parent',
elementRect: { top: 0, left: 0, bottom: 1, right: 1 }
})
],
listeners: {
start: (event) => {
event.target.style.zIndex = 50;
event.target.style.opacity = '0.9';
},
move: (event) => {
const target = event.target;
const y = (parseFloat(target.dataset.dragY) || 0) + event.dy;
target.dataset.dragY = y;
const currentTop = parseFloat(target.style.top) || 0;
const newTop = currentTop + event.dy;
target.style.top = newTop + 'px';
},
end: (event) => {
const target = event.target;
target.style.zIndex = '';
target.style.opacity = '';
target.dataset.dragY = 0;
const blockId = target.dataset.blockId;
const newTop = parseFloat(target.style.top);
const height = parseFloat(target.style.height);
const startMin = Canvas.snapMinutes(Canvas.pxToMinutes(newTop));
const endMin = Canvas.snapMinutes(Canvas.pxToMinutes(newTop + height));
App.updateBlockTime(blockId, Canvas.formatTime(startMin), Canvas.formatTime(endMin));
}
}
}).resizable({
edges: { bottom: '.resize-handle' },
modifiers: [
interact.modifiers.snap({
targets: [interact.snappers.grid({
x: 1, y: this.PX_PER_SLOT
})],
range: Infinity,
relativePoint: { x: 0, y: 0 },
offset: 'self'
}),
interact.modifiers.restrictSize({
min: { width: 0, height: this.PX_PER_SLOT }
})
],
listeners: {
move: (event) => {
const target = event.target;
target.style.height = event.rect.height + 'px';
},
end: (event) => {
const target = event.target;
const blockId = target.dataset.blockId;
const top = parseFloat(target.style.top);
const height = parseFloat(target.style.height);
const startMin = Canvas.snapMinutes(Canvas.pxToMinutes(top));
const endMin = Canvas.snapMinutes(Canvas.pxToMinutes(top + height));
App.updateBlockTime(blockId, Canvas.formatTime(startMin), Canvas.formatTime(endMin));
}
}
});
},
darkenColor(hex) {
const h = hex.replace('#', '');
const r = Math.max(0, parseInt(h.substring(0, 2), 16) - 30);
const g = Math.max(0, parseInt(h.substring(2, 4), 16) - 30);
const b = Math.max(0, parseInt(h.substring(4, 6), 16) - 30);
return `rgb(${r},${g},${b})`;
},
escapeHtml(str) {
if (!str) return '';
const d = document.createElement('div');
d.textContent = str;
return d.innerHTML;
}
};

View File

@@ -1,33 +1,31 @@
/** /**
* JSON import/export for Scenar Creator. * JSON import/export for Scenar Creator v3.
*/ */
function exportJson() { function exportJson() {
if (!window.currentDocument) { const doc = App.getDocument();
showStatus('No document to export', 'error'); if (!doc) {
App.toast('Žádný scénář k exportu', 'error');
return; return;
} }
const json = JSON.stringify(window.currentDocument, null, 2); const json = JSON.stringify(doc, null, 2);
const blob = new Blob([json], { type: 'application/json' }); const blob = new Blob([json], { type: 'application/json' });
API.downloadBlob(blob, 'scenar_export.json'); API.downloadBlob(blob, 'scenar_export.json');
App.toast('JSON exportován', 'success');
} }
function handleJsonImport(event) { function importJson(file) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader(); const reader = new FileReader();
reader.onload = function (e) { reader.onload = function (e) {
try { try {
const doc = JSON.parse(e.target.result); const doc = JSON.parse(e.target.result);
if (!doc.event || !doc.blocks || !doc.program_types) { if (!doc.event || !doc.blocks || !doc.program_types) {
throw new Error('Invalid ScenarioDocument format'); throw new Error('Neplatný formát ScenarioDocument');
} }
window.currentDocument = doc; App.loadDocument(doc);
showImportedDocument(doc); App.toast('JSON importován', 'success');
showStatus('JSON imported successfully', 'success');
} catch (err) { } catch (err) {
showStatus('JSON import error: ' + err.message, 'error'); App.toast('Chyba importu: ' + err.message, 'error');
} }
}; };
reader.readAsText(file); reader.readAsText(file);

138
app/static/sample.json Normal file
View File

@@ -0,0 +1,138 @@
{
"version": "1.0",
"event": {
"title": "Zimní výjezd oddílu",
"subtitle": "Víkendový zážitkový kurz pro mladé",
"date": "2026-03-01",
"location": "Chata Horní Lhota"
},
"program_types": [
{
"id": "morning",
"name": "Ranní program",
"color": "#F97316"
},
{
"id": "main",
"name": "Hlavní program",
"color": "#3B82F6"
},
{
"id": "rest",
"name": "Odpočinek",
"color": "#22C55E"
}
],
"blocks": [
{
"id": "b1",
"date": "2026-03-01",
"start": "08:00",
"end": "08:30",
"title": "Budíček a rozcvička",
"type_id": "morning",
"responsible": "Kuba",
"notes": "Venkovní rozcvička, pokud počasí dovolí"
},
{
"id": "b2",
"date": "2026-03-01",
"start": "08:30",
"end": "09:00",
"title": "Snídaně",
"type_id": "rest",
"responsible": "Kuchyň",
"notes": null
},
{
"id": "b3",
"date": "2026-03-01",
"start": "09:00",
"end": "10:30",
"title": "Stopovací hra v lese",
"type_id": "main",
"responsible": "Lucka",
"notes": "Rozdělení do 4 skupin, mapky připraveny"
},
{
"id": "b4",
"date": "2026-03-01",
"start": "10:30",
"end": "11:00",
"title": "Svačina a pauza",
"type_id": "rest",
"responsible": null,
"notes": null
},
{
"id": "b5",
"date": "2026-03-01",
"start": "11:00",
"end": "12:30",
"title": "Stavba iglú / přístřešků",
"type_id": "main",
"responsible": "Petr",
"notes": "Soutěž o nejlepší stavbu, potřeba lopaty a plachty"
},
{
"id": "b6",
"date": "2026-03-01",
"start": "12:30",
"end": "14:00",
"title": "Oběd a polední klid",
"type_id": "rest",
"responsible": "Kuchyň",
"notes": "Guláš + čaj"
},
{
"id": "b7",
"date": "2026-03-01",
"start": "14:00",
"end": "16:00",
"title": "Orientační běh",
"type_id": "main",
"responsible": "Honza",
"notes": "Trasa 3 km, kontroly rozmístěny od rána"
},
{
"id": "b8",
"date": "2026-03-01",
"start": "16:00",
"end": "16:30",
"title": "Svačina",
"type_id": "rest",
"responsible": null,
"notes": null
},
{
"id": "b9",
"date": "2026-03-01",
"start": "16:30",
"end": "18:00",
"title": "Workshopy dle výběru",
"type_id": "morning",
"responsible": "Lucka + Petr",
"notes": "Uzlování / Orientace s buzolou / Kresba mapy"
},
{
"id": "b10",
"date": "2026-03-01",
"start": "18:00",
"end": "19:00",
"title": "Večeře",
"type_id": "rest",
"responsible": "Kuchyň",
"notes": null
},
{
"id": "b11",
"date": "2026-03-01",
"start": "19:00",
"end": "21:00",
"title": "Noční hra Světlušky",
"type_id": "main",
"responsible": "Honza + Kuba",
"notes": "Potřeba: čelovky, reflexní pásky, vysílačky"
}
]
}

View File

@@ -2,7 +2,5 @@ fastapi>=0.115
uvicorn[standard]>=0.34 uvicorn[standard]>=0.34
python-multipart>=0.0.20 python-multipart>=0.0.20
reportlab>=4.0 reportlab>=4.0
pandas>=2.1.3
openpyxl>=3.1.5
pytest>=7.4.3 pytest>=7.4.3
httpx>=0.27 httpx>=0.27

Binary file not shown.

View File

@@ -1,10 +1,7 @@
""" """
API endpoint tests using FastAPI TestClient. API endpoint tests for Scenar Creator v3.
""" """
import io
import json
import pandas as pd
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@@ -16,11 +13,20 @@ def client():
return TestClient(app) return TestClient(app)
def make_excel_bytes(df: pd.DataFrame) -> bytes: def make_valid_doc():
bio = io.BytesIO() return {
with pd.ExcelWriter(bio, engine='openpyxl') as writer: "version": "1.0",
df.to_excel(writer, index=False) "event": {"title": "Test Event"},
return bio.getvalue() "program_types": [{"id": "ws", "name": "Workshop", "color": "#FF0000"}],
"blocks": [{
"id": "b1",
"date": "2026-03-01",
"start": "09:00",
"end": "10:00",
"title": "Opening",
"type_id": "ws"
}]
}
def test_health(client): def test_health(client):
@@ -28,28 +34,18 @@ def test_health(client):
assert r.status_code == 200 assert r.status_code == 200
data = r.json() data = r.json()
assert data["status"] == "ok" assert data["status"] == "ok"
assert data["version"] == "2.0.0" assert data["version"] == "3.0.0"
def test_root_returns_html(client): def test_root_returns_html(client):
r = client.get("/") r = client.get("/")
assert r.status_code == 200 assert r.status_code == 200
assert "text/html" in r.headers["content-type"] assert "text/html" in r.headers["content-type"]
assert "Scenar Creator" in r.text assert "Scen" in r.text and "Creator" in r.text
def test_validate_valid(client): def test_validate_valid(client):
doc = { doc = make_valid_doc()
"event": {"title": "Test", "detail": "Detail"},
"program_types": [{"code": "WS", "description": "Workshop", "color": "#FF0000"}],
"blocks": [{
"datum": "2025-11-13",
"zacatek": "09:00:00",
"konec": "10:00:00",
"program": "Opening",
"typ": "WS"
}]
}
r = client.post("/api/validate", json=doc) r = client.post("/api/validate", json=doc)
assert r.status_code == 200 assert r.status_code == 200
data = r.json() data = r.json()
@@ -58,17 +54,8 @@ def test_validate_valid(client):
def test_validate_unknown_type(client): def test_validate_unknown_type(client):
doc = { doc = make_valid_doc()
"event": {"title": "Test", "detail": "Detail"}, doc["blocks"][0]["type_id"] = "UNKNOWN"
"program_types": [{"code": "WS", "description": "Workshop", "color": "#FF0000"}],
"blocks": [{
"datum": "2025-11-13",
"zacatek": "09:00:00",
"konec": "10:00:00",
"program": "Opening",
"typ": "UNKNOWN"
}]
}
r = client.post("/api/validate", json=doc) r = client.post("/api/validate", json=doc)
assert r.status_code == 200 assert r.status_code == 200
data = r.json() data = r.json()
@@ -77,102 +64,81 @@ def test_validate_unknown_type(client):
def test_validate_bad_time_order(client): def test_validate_bad_time_order(client):
doc = { doc = make_valid_doc()
"event": {"title": "Test", "detail": "Detail"}, doc["blocks"][0]["start"] = "10:00"
"program_types": [{"code": "WS", "description": "Workshop", "color": "#FF0000"}], doc["blocks"][0]["end"] = "09:00"
"blocks": [{ r = client.post("/api/validate", json=doc)
"datum": "2025-11-13", assert r.status_code == 200
"zacatek": "10:00:00", data = r.json()
"konec": "09:00:00", assert data["valid"] is False
"program": "Bad", assert any("start time" in e for e in data["errors"])
"typ": "WS"
}]
} def test_validate_no_blocks(client):
doc = make_valid_doc()
doc["blocks"] = []
r = client.post("/api/validate", json=doc) r = client.post("/api/validate", json=doc)
assert r.status_code == 200 assert r.status_code == 200
data = r.json() data = r.json()
assert data["valid"] is False assert data["valid"] is False
def test_import_excel(client): def test_validate_no_types(client):
df = pd.DataFrame({ doc = make_valid_doc()
'Datum': [pd.Timestamp('2025-11-13').date()], doc["program_types"] = []
'Zacatek': ['09:00'], r = client.post("/api/validate", json=doc)
'Konec': ['10:00'],
'Program': ['Test Program'],
'Typ': ['WORKSHOP'],
'Garant': ['John'],
'Poznamka': ['Note']
})
content = make_excel_bytes(df)
r = client.post(
"/api/import-excel",
files={"file": ("test.xlsx", content, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")},
data={"title": "Imported Event", "detail": "From Excel"}
)
assert r.status_code == 200 assert r.status_code == 200
data = r.json() data = r.json()
assert data["success"] is True assert data["valid"] is False
assert data["document"] is not None
assert data["document"]["event"]["title"] == "Imported Event"
assert len(data["document"]["blocks"]) == 1
def test_generate_excel(client): def test_sample_endpoint(client):
doc = { r = client.get("/api/sample")
"event": {"title": "Test Event", "detail": "Test Detail"},
"program_types": [{"code": "WS", "description": "Workshop", "color": "#0070C0"}],
"blocks": [{
"datum": "2025-11-13",
"zacatek": "09:00:00",
"konec": "10:00:00",
"program": "Opening",
"typ": "WS",
"garant": "John",
"poznamka": "Note"
}]
}
r = client.post("/api/generate-excel", json=doc)
assert r.status_code == 200 assert r.status_code == 200
assert "spreadsheetml" in r.headers["content-type"] data = r.json()
assert len(r.content) > 0 assert data["version"] == "1.0"
assert data["event"]["title"] == "Zimní výjezd oddílu"
assert len(data["program_types"]) == 3
assert len(data["blocks"]) >= 8
def test_generate_excel_no_blocks(client): def test_sample_blocks_valid(client):
doc = { r = client.get("/api/sample")
"event": {"title": "Test", "detail": "Detail"}, data = r.json()
"program_types": [{"code": "WS", "description": "Workshop", "color": "#FF0000"}], type_ids = {pt["id"] for pt in data["program_types"]}
"blocks": [] for block in data["blocks"]:
} assert block["type_id"] in type_ids
r = client.post("/api/generate-excel", json=doc) assert block["start"] < block["end"]
def test_generate_pdf(client):
doc = make_valid_doc()
r = client.post("/api/generate-pdf", json=doc)
assert r.status_code == 200
assert r.headers["content-type"] == "application/pdf"
assert r.content[:5] == b'%PDF-'
def test_generate_pdf_no_blocks(client):
doc = make_valid_doc()
doc["blocks"] = []
r = client.post("/api/generate-pdf", json=doc)
assert r.status_code == 422 assert r.status_code == 422
def test_export_json(client): def test_generate_pdf_multiday(client):
doc = { doc = make_valid_doc()
"event": {"title": "Test", "detail": "Detail"}, doc["blocks"].append({
"program_types": [{"code": "WS", "description": "Workshop", "color": "#FF0000"}], "id": "b2",
"blocks": [{ "date": "2026-03-02",
"datum": "2025-11-13", "start": "14:00",
"zacatek": "09:00:00", "end": "15:00",
"konec": "10:00:00", "title": "Day 2 Session",
"program": "Opening", "type_id": "ws"
"typ": "WS" })
}] r = client.post("/api/generate-pdf", json=doc)
}
r = client.post("/api/export-json", json=doc)
assert r.status_code == 200 assert r.status_code == 200
data = r.json() assert r.content[:5] == b'%PDF-'
assert data["event"]["title"] == "Test"
assert len(data["blocks"]) == 1
def test_template_download(client):
r = client.get("/api/template")
# Template might not exist in test env, but endpoint should work
assert r.status_code in [200, 404]
def test_swagger_docs(client): def test_swagger_docs(client):

View File

@@ -1,532 +1,97 @@
""" """
Core business logic tests — adapted from original test_read_excel.py and test_inline_builder.py. Core logic tests for Scenar Creator v3.
Tests the refactored app.core modules. Tests models, validation, and document structure.
""" """
import io
import pandas as pd
import pytest import pytest
from datetime import date, time from pydantic import ValidationError as PydanticValidationError
from app.core import ( from app.models.event import Block, ProgramType, EventInfo, ScenarioDocument
read_excel, create_timetable, get_program_types, ScenarsError, from app.core.validator import ScenarsError, ValidationError
parse_inline_schedule, parse_inline_types, ValidationError,
validate_inputs, normalize_time,
)
def make_excel_bytes(df: pd.DataFrame) -> bytes: # --- Model tests ---
bio = io.BytesIO()
with pd.ExcelWriter(bio, engine='openpyxl') as writer: def test_block_default_id():
df.to_excel(writer, index=False) b = Block(date="2026-03-01", start="09:00", end="10:00", title="Test", type_id="ws")
return bio.getvalue() assert b.id is not None
assert len(b.id) > 0
def test_block_optional_fields():
b = Block(date="2026-03-01", start="09:00", end="10:00", title="Test", type_id="ws")
assert b.responsible is None
assert b.notes is None
def test_block_with_all_fields():
b = Block(
id="custom-id", date="2026-03-01", start="09:00", end="10:00",
title="Full Block", type_id="ws", responsible="John", notes="A note"
)
assert b.id == "custom-id"
assert b.responsible == "John"
assert b.notes == "A note"
def test_program_type():
pt = ProgramType(id="main", name="Main Program", color="#3B82F6")
assert pt.id == "main"
assert pt.name == "Main Program"
assert pt.color == "#3B82F6"
def test_event_info_minimal():
e = EventInfo(title="Test")
assert e.title == "Test"
assert e.subtitle is None
assert e.date is None
assert e.location is None
def test_event_info_full():
e = EventInfo(title="Event", subtitle="Sub", date="2026-03-01", location="Prague")
assert e.location == "Prague"
def test_scenario_document():
doc = ScenarioDocument(
event=EventInfo(title="Test"),
program_types=[ProgramType(id="ws", name="Workshop", color="#FF0000")],
blocks=[Block(date="2026-03-01", start="09:00", end="10:00", title="B1", type_id="ws")]
)
assert doc.version == "1.0"
assert len(doc.blocks) == 1
assert len(doc.program_types) == 1
def test_scenario_document_serialization():
doc = ScenarioDocument(
event=EventInfo(title="Test"),
program_types=[ProgramType(id="ws", name="Workshop", color="#FF0000")],
blocks=[Block(id="b1", date="2026-03-01", start="09:00", end="10:00", title="B1", type_id="ws")]
)
data = doc.model_dump(mode="json")
assert data["event"]["title"] == "Test"
assert data["blocks"][0]["type_id"] == "ws"
assert data["blocks"][0]["id"] == "b1"
def test_scenario_document_missing_title():
with pytest.raises(PydanticValidationError):
ScenarioDocument(
event=EventInfo(),
program_types=[],
blocks=[]
)
# --- Validator tests --- # --- Validator tests ---
def test_validate_inputs_valid(): def test_scenars_error_hierarchy():
validate_inputs("Title", "Detail", 100) assert issubclass(ValidationError, ScenarsError)
def test_validate_inputs_empty_title(): def test_validation_error_message():
with pytest.raises(ValidationError): err = ValidationError("test error")
validate_inputs("", "Detail", 100) assert str(err) == "test error"
def test_validate_inputs_long_title():
with pytest.raises(ValidationError):
validate_inputs("x" * 201, "Detail", 100)
def test_validate_inputs_file_too_large():
with pytest.raises(ValidationError):
validate_inputs("Title", "Detail", 11 * 1024 * 1024)
def test_normalize_time_hhmm():
t = normalize_time("09:00")
assert t == time(9, 0)
def test_normalize_time_hhmmss():
t = normalize_time("09:00:00")
assert t == time(9, 0)
def test_normalize_time_invalid():
assert normalize_time("invalid") is None
# --- Excel reader tests ---
def test_read_excel_happy_path():
df = pd.DataFrame({
'Datum': [pd.Timestamp('2025-11-13').date()],
'Zacatek': ['09:00'],
'Konec': ['10:00'],
'Program': ['Test program'],
'Typ': ['WORKSHOP'],
'Garant': ['Garant Name'],
'Poznamka': ['Pozn']
})
content = make_excel_bytes(df)
valid, errors = read_excel(content)
assert isinstance(valid, pd.DataFrame)
assert len(errors) == 0
assert len(valid) == 1
assert valid.iloc[0]['Program'] == 'Test program'
def test_read_excel_invalid_time():
df = pd.DataFrame({
'Datum': [pd.Timestamp('2025-11-13').date()],
'Zacatek': ['not-a-time'],
'Konec': ['10:00'],
'Program': ['Bad Time'],
'Typ': ['LECTURE'],
'Garant': [None],
'Poznamka': [None]
})
content = make_excel_bytes(df)
valid, errors = read_excel(content)
assert isinstance(errors, list)
assert len(errors) >= 1
def test_get_program_types():
form_data = {
'type_code_0': 'WORKSHOP',
'type_code_1': 'LECTURE',
'desc_0': 'Workshop description',
'desc_1': 'Lecture description',
'color_0': '#FF0000',
'color_1': '#00FF00',
}
descriptions, colors = get_program_types(form_data)
assert len(descriptions) == 2
assert descriptions['WORKSHOP'] == 'Workshop description'
assert descriptions['LECTURE'] == 'Lecture description'
assert colors['WORKSHOP'] == 'FFFF0000'
assert colors['LECTURE'] == 'FF00FF00'
# --- Timetable tests ---
def test_create_timetable():
df = pd.DataFrame({
'Datum': [pd.Timestamp('2025-11-13').date()],
'Zacatek': [time(9, 0)],
'Konec': [time(10, 0)],
'Program': ['Test Program'],
'Typ': ['WORKSHOP'],
'Garant': ['John Doe'],
'Poznamka': ['Test note'],
})
program_descriptions = {'WORKSHOP': 'Workshop Type'}
program_colors = {'WORKSHOP': 'FF0070C0'}
wb = create_timetable(
df, title="Test Timetable", detail="Test Detail",
program_descriptions=program_descriptions,
program_colors=program_colors
)
assert wb is not None
assert len(wb.sheetnames) > 0
ws = wb.active
assert ws['A1'].value == "Test Timetable"
assert ws['A2'].value == "Test Detail"
def test_create_timetable_with_color_dict():
df = pd.DataFrame({
'Datum': [pd.Timestamp('2025-11-13').date()],
'Zacatek': [time(9, 0)],
'Konec': [time(10, 0)],
'Program': ['Lecture 101'],
'Typ': ['LECTURE'],
'Garant': ['Dr. Smith'],
'Poznamka': [None],
})
program_descriptions = {'LECTURE': 'Standard Lecture'}
program_colors = {'LECTURE': 'FFFF6600'}
wb = create_timetable(
df, title="Advanced Timetable", detail="With color dict",
program_descriptions=program_descriptions,
program_colors=program_colors
)
assert wb is not None
ws = wb.active
assert ws['A1'].value == "Advanced Timetable"
# --- Inline schedule tests ---
def test_parse_inline_schedule():
form_data = {
'datum_0': '2025-11-13',
'zacatek_0': '09:00',
'konec_0': '10:00',
'program_0': 'Test Program',
'typ_0': 'WORKSHOP',
'garant_0': 'John Doe',
'poznamka_0': 'Test note',
'datum_1': '2025-11-13',
'zacatek_1': '10:30',
'konec_1': '11:30',
'program_1': 'Another Program',
'typ_1': 'LECTURE',
'garant_1': 'Jane Smith',
'poznamka_1': '',
}
df = parse_inline_schedule(form_data)
assert isinstance(df, pd.DataFrame)
assert len(df) == 2
assert df.iloc[0]['Program'] == 'Test Program'
assert df.iloc[0]['Typ'] == 'WORKSHOP'
assert df.iloc[1]['Program'] == 'Another Program'
assert df.iloc[1]['Typ'] == 'LECTURE'
def test_parse_inline_schedule_missing_required():
form_data = {
'datum_0': '2025-11-13',
'zacatek_0': '09:00',
}
with pytest.raises(ValidationError):
parse_inline_schedule(form_data)
def test_parse_inline_types():
form_data = {
'type_name_0': 'WORKSHOP',
'type_desc_0': 'Workshop Type',
'type_color_0': '#0070C0',
'type_name_1': 'LECTURE',
'type_desc_1': 'Lecture Type',
'type_color_1': '#FF6600',
}
descriptions, colors = parse_inline_types(form_data)
assert len(descriptions) == 2
assert descriptions['WORKSHOP'] == 'Workshop Type'
assert descriptions['LECTURE'] == 'Lecture Type'
assert colors['WORKSHOP'] == 'FF0070C0'
assert colors['LECTURE'] == 'FFFF6600'
# --- Integration tests ---
def test_inline_workflow_integration():
schedule_form = {
'datum_0': '2025-11-13',
'zacatek_0': '09:00',
'konec_0': '10:00',
'program_0': 'Opening',
'typ_0': 'KEYNOTE',
'garant_0': 'Dr. Smith',
'poznamka_0': 'Start of event',
}
types_form = {
'type_name_0': 'KEYNOTE',
'type_desc_0': 'Keynote Speech',
'type_color_0': '#FF0000',
}
form_data = {**schedule_form, **types_form}
df = parse_inline_schedule(form_data)
descriptions, colors = parse_inline_types(form_data)
wb = create_timetable(
df, title="Integration Test Event", detail="Testing inline workflow",
program_descriptions=descriptions, program_colors=colors
)
assert wb is not None
ws = wb.active
assert ws['A1'].value == "Integration Test Event"
assert ws['A2'].value == "Testing inline workflow"
def test_excel_import_to_step2_workflow():
import base64
df = pd.DataFrame({
'Datum': [pd.Timestamp('2025-11-13').date()],
'Zacatek': ['09:00'],
'Konec': ['10:00'],
'Program': ['Opening Keynote'],
'Typ': ['KEYNOTE'],
'Garant': ['John Smith'],
'Poznamka': ['Welcome speech']
})
file_content = make_excel_bytes(df)
valid_data, errors = read_excel(file_content)
assert len(errors) == 0
assert len(valid_data) == 1
program_types = sorted([str(t).strip() for t in valid_data["Typ"].dropna().unique()])
assert program_types == ['KEYNOTE']
file_content_base64 = base64.b64encode(file_content).decode('utf-8')
form_data = {
'title': 'Test Event',
'detail': 'Test Detail',
'file_content_base64': file_content_base64,
'type_code_0': 'KEYNOTE',
'desc_0': 'Main keynote presentation',
'color_0': '#FF0000',
'step': '3'
}
descriptions, colors = get_program_types(form_data)
assert descriptions['KEYNOTE'] == 'Main keynote presentation'
assert colors['KEYNOTE'] == 'FFFF0000'
file_content_decoded = base64.b64decode(form_data['file_content_base64'])
data, _ = read_excel(file_content_decoded)
wb = create_timetable(
data, title=form_data['title'], detail=form_data['detail'],
program_descriptions=descriptions, program_colors=colors
)
assert wb is not None
ws = wb.active
assert ws['A1'].value == 'Test Event'
assert ws['A2'].value == 'Test Detail'
def test_excel_import_to_inline_editor_workflow():
import base64
df = pd.DataFrame({
'Datum': [pd.Timestamp('2025-11-13').date(), pd.Timestamp('2025-11-14').date()],
'Zacatek': ['09:00', '14:00'],
'Konec': ['10:00', '15:30'],
'Program': ['Morning Session', 'Afternoon Workshop'],
'Typ': ['KEYNOTE', 'WORKSHOP'],
'Garant': ['Alice', 'Bob'],
'Poznamka': ['', 'Hands-on']
})
file_content = make_excel_bytes(df)
valid_data, errors = read_excel(file_content)
assert len(errors) == 0
assert len(valid_data) == 2
file_content_base64 = base64.b64encode(file_content).decode('utf-8')
file_content_decoded = base64.b64decode(file_content_base64)
data_in_editor, _ = read_excel(file_content_decoded)
assert len(data_in_editor) == 2
assert data_in_editor.iloc[0]['Program'] == 'Morning Session'
assert data_in_editor.iloc[1]['Program'] == 'Afternoon Workshop'
program_types = sorted([str(t).strip() for t in data_in_editor["Typ"].dropna().unique()])
assert set(program_types) == {'KEYNOTE', 'WORKSHOP'}
# --- Inline builder tests (from test_inline_builder.py) ---
def test_inline_builder_valid_form():
form_data = {
'title': 'Test Conference',
'detail': 'Testing inline builder',
'step': 'builder',
'datum_0': '2025-11-13',
'zacatek_0': '09:00',
'konec_0': '10:00',
'program_0': 'Opening Keynote',
'typ_0': 'KEYNOTE',
'garant_0': 'Dr. Smith',
'poznamka_0': 'Welcome speech',
'datum_1': '2025-11-13',
'zacatek_1': '10:30',
'konec_1': '11:30',
'program_1': 'Workshop: Python',
'typ_1': 'WORKSHOP',
'garant_1': 'Jane Doe',
'poznamka_1': 'Hands-on coding',
'type_name_0': 'KEYNOTE',
'type_desc_0': 'Main Address',
'type_color_0': '#FF0000',
'type_name_1': 'WORKSHOP',
'type_desc_1': 'Interactive Session',
'type_color_1': '#0070C0',
}
schedule = parse_inline_schedule(form_data)
assert len(schedule) == 2
assert schedule.iloc[0]['Program'] == 'Opening Keynote'
descriptions, colors = parse_inline_types(form_data)
assert len(descriptions) == 2
assert descriptions['KEYNOTE'] == 'Main Address'
assert colors['KEYNOTE'] == 'FFFF0000'
wb = create_timetable(
schedule, title=form_data['title'], detail=form_data['detail'],
program_descriptions=descriptions, program_colors=colors
)
assert wb is not None
ws = wb.active
assert ws['A1'].value == 'Test Conference'
def test_inline_builder_missing_type_definition():
form_data = {
'datum_0': '2025-11-13',
'zacatek_0': '09:00',
'konec_0': '10:00',
'program_0': 'Unknown Program',
'typ_0': 'UNKNOWN_TYPE',
'garant_0': 'Someone',
'poznamka_0': '',
'type_name_0': 'LECTURE',
'type_desc_0': 'Standard Lecture',
'type_color_0': '#3498db',
}
schedule = parse_inline_schedule(form_data)
descriptions, colors = parse_inline_types(form_data)
with pytest.raises(Exception):
create_timetable(schedule, 'Bad', 'Bad', descriptions, colors)
def test_inline_builder_empty_type_definition():
form_data = {
'type_name_0': '',
'type_desc_0': 'Empty type',
'type_color_0': '#FF0000',
'type_name_1': 'WORKSHOP',
'type_desc_1': 'Workshop Type',
'type_color_1': '#0070C0',
}
descriptions, colors = parse_inline_types(form_data)
assert 'WORKSHOP' in descriptions
assert '' not in descriptions
def test_inline_builder_overlapping_times():
form_data = {
'datum_0': '2025-11-13',
'zacatek_0': '09:00',
'konec_0': '10:00',
'program_0': 'Program A',
'typ_0': 'WORKSHOP',
'garant_0': 'John',
'poznamka_0': '',
'datum_1': '2025-11-13',
'zacatek_1': '09:30',
'konec_1': '10:30',
'program_1': 'Program B',
'typ_1': 'WORKSHOP',
'garant_1': 'Jane',
'poznamka_1': '',
'type_name_0': 'WORKSHOP',
'type_desc_0': 'Workshop',
'type_color_0': '#0070C0',
}
schedule = parse_inline_schedule(form_data)
descriptions, colors = parse_inline_types(form_data)
assert len(schedule) == 2
wb = create_timetable(schedule, 'Conf', 'Test', descriptions, colors)
assert wb is not None
def test_inline_builder_multiday():
form_data = {
'datum_0': '2025-11-13',
'zacatek_0': '09:00',
'konec_0': '10:00',
'program_0': 'Day 1 Opening',
'typ_0': 'KEYNOTE',
'garant_0': 'Dr. A',
'poznamka_0': '',
'datum_1': '2025-11-14',
'zacatek_1': '09:00',
'konec_1': '10:00',
'program_1': 'Day 2 Opening',
'typ_1': 'KEYNOTE',
'garant_1': 'Dr. B',
'poznamka_1': '',
'type_name_0': 'KEYNOTE',
'type_desc_0': 'Keynote Speech',
'type_color_0': '#FF6600',
}
schedule = parse_inline_schedule(form_data)
descriptions, colors = parse_inline_types(form_data)
wb = create_timetable(schedule, 'Multi-day', 'Test', descriptions, colors)
assert wb is not None
ws = wb.active
assert ws['A1'].value == 'Multi-day'
def test_inline_builder_validation_errors():
form_data_missing = {
'datum_0': '2025-11-13',
'garant_0': 'John',
}
with pytest.raises(ValidationError):
parse_inline_schedule(form_data_missing)
def test_inline_builder_with_empty_rows():
form_data = {
'datum_0': '2025-11-13',
'zacatek_0': '09:00',
'konec_0': '10:00',
'program_0': 'Program 1',
'typ_0': 'WORKSHOP',
'garant_0': 'John',
'poznamka_0': '',
'datum_1': '',
'zacatek_1': '',
'konec_1': '',
'program_1': '',
'typ_1': '',
'garant_1': '',
'poznamka_1': '',
'type_name_0': 'WORKSHOP',
'type_desc_0': 'Workshop',
'type_color_0': '#0070C0',
}
schedule = parse_inline_schedule(form_data)
assert len(schedule) == 1
assert schedule.iloc[0]['Program'] == 'Program 1'

View File

@@ -1,61 +0,0 @@
import os
import shutil
import subprocess
import time
import urllib.request
import pytest
def podman_available():
return shutil.which("podman") is not None
def wait_for_http(url, timeout=30):
end = time.time() + timeout
last_exc = None
while time.time() < end:
try:
with urllib.request.urlopen(url, timeout=3) as r:
body = r.read().decode('utf-8', errors='ignore')
return r.getcode(), body
except Exception as e:
last_exc = e
time.sleep(0.5)
raise RuntimeError(f"HTTP check failed after {timeout}s: {last_exc}")
@pytest.mark.skipif(not podman_available(), reason="Podman is not available on this runner")
@pytest.mark.integration
def test_build_run_and_cleanup_podman():
image_tag = "scenar-creator:latest"
container_name = "scenar-creator-test"
port = int(os.environ.get('SCENAR_TEST_PORT', '8080'))
# Ensure podman machine is running (macOS/Windows)
if not subprocess.run(["podman", "machine", "info"], capture_output=True).returncode == 0:
print("Starting podman machine...")
subprocess.run(["podman", "machine", "start"], check=True)
time.sleep(2)
# Build image
subprocess.run(["podman", "build", "-t", image_tag, "."], check=True)
# Ensure no leftover container
subprocess.run(["podman", "rm", "-f", container_name], check=False)
try:
# Run container
subprocess.run([
"podman", "run", "-d", "--name", container_name, "-p", f"{port}:8080", image_tag
], check=True)
# Wait for HTTP and verify content
code, body = wait_for_http(f"http://127.0.0.1:{port}/")
assert code == 200
assert "Vytvoření Scénáře" in body or "Scenar" in body or "Vytvoření" in body
finally:
# Cleanup container and image
subprocess.run(["podman", "rm", "-f", container_name], check=False)
subprocess.run(["podman", "rmi", image_tag], check=False)

View File

@@ -1,138 +0,0 @@
"""
HTTP/CGI integration tests for inline builder workflow.
Tests the real form submission through the CGI endpoint.
"""
import pytest
import urllib.parse
import subprocess
import json
from io import BytesIO
def send_cgi_request(post_data=None, get_params=None):
"""
Send HTTP request to local CGI server and return response.
Assumes server is running on localhost:8000
"""
import urllib.request
import urllib.error
url = "http://localhost:8000/cgi-bin/scenar.py"
if get_params:
url += "?" + urllib.parse.urlencode(get_params)
if post_data:
data = urllib.parse.urlencode(post_data).encode('utf-8')
else:
data = None
try:
req = urllib.request.Request(url, data=data, method='POST' if data else 'GET')
with urllib.request.urlopen(req, timeout=10) as response:
return response.read().decode('utf-8'), response.status
except Exception as e:
return str(e), None
@pytest.mark.integration
def test_inline_builder_form_submission_valid():
"""Test inline builder with valid form data."""
form_data = {
'title': 'Test Event',
'detail': 'Test Detail',
'step': 'builder',
# Schedule row 0
'datum_0': '2025-11-13',
'zacatek_0': '09:00',
'konec_0': '10:00',
'program_0': 'Opening',
'typ_0': 'KEYNOTE',
'garant_0': 'John Smith',
'poznamka_0': 'Welcome',
# Type definition 0
'type_name_0': 'KEYNOTE',
'type_desc_0': 'Main keynote',
'type_color_0': '#FF0000',
}
response, status = send_cgi_request(post_data=form_data)
# Should get 200 OK
assert status == 200, f"Expected 200, got {status}"
# Response should contain success message
assert '✅ Scénář úspěšně vygenerován' in response or 'Stáhnout scénář' in response, \
f"Expected success message in response, got: {response[:500]}"
# Should have download link
assert '/tmp/' in response, "Expected download link in response"
@pytest.mark.integration
def test_inline_builder_missing_type():
"""Test that missing type definition is rejected."""
form_data = {
'title': 'Test Event',
'detail': 'Test Detail',
'step': 'builder',
# Schedule with type WORKSHOP
'datum_0': '2025-11-13',
'zacatek_0': '09:00',
'konec_0': '10:00',
'program_0': 'Workshop',
'typ_0': 'WORKSHOP', # This type is not defined!
'garant_0': 'John',
'poznamka_0': '',
# Only KEYNOTE defined
'type_name_0': 'KEYNOTE',
'type_desc_0': 'Keynote',
'type_color_0': '#FF0000',
}
response, status = send_cgi_request(post_data=form_data)
# Should get error response
assert status == 200 # CGI returns 200 with error content
assert 'Chyba' in response or 'Missing type' in response, \
f"Expected error message, got: {response[:500]}"
@pytest.mark.integration
def test_home_page_load():
"""Test that home page loads without errors."""
response, status = send_cgi_request()
assert status == 200
assert '<!DOCTYPE html>' in response
assert 'Scenar Creator' in response
assert 'Importovat Excel' in response # Tab 1
assert 'Vytvořit inline' in response # Tab 2
@pytest.mark.integration
def test_inline_editor_tabs_present():
"""Test that inline editor form elements are present."""
response, status = send_cgi_request()
assert status == 200
# Check for key form elements
assert 'id="builderForm"' in response
assert 'id="scheduleTable"' in response
assert 'id="typesContainer"' in response
assert 'id="availableTypes"' in response # datalist
# Check for JavaScript functions
assert 'function addScheduleRow' in response
assert 'function removeScheduleRow' in response
assert 'function addTypeRow' in response
assert 'function removeTypeRow' in response
if __name__ == '__main__':
pytest.main([__file__, '-v'])

View File

@@ -1,261 +0,0 @@
"""
End-to-end tests for inline builder (without Excel upload).
Tests the full form submission flow from HTML form to timetable generation.
"""
import pytest
from datetime import date, time
import pandas as pd
from scenar.core import parse_inline_schedule, parse_inline_types, create_timetable, ValidationError
def test_inline_builder_valid_form():
"""Test inline builder with valid schedule and types."""
# Simulate form data from HTML
form_data = {
# Metadata
'title': 'Test Conference',
'detail': 'Testing inline builder',
'step': 'builder',
# Schedule rows (2 rows)
'datum_0': '2025-11-13',
'zacatek_0': '09:00',
'konec_0': '10:00',
'program_0': 'Opening Keynote',
'typ_0': 'KEYNOTE',
'garant_0': 'Dr. Smith',
'poznamka_0': 'Welcome speech',
'datum_1': '2025-11-13',
'zacatek_1': '10:30',
'konec_1': '11:30',
'program_1': 'Workshop: Python',
'typ_1': 'WORKSHOP',
'garant_1': 'Jane Doe',
'poznamka_1': 'Hands-on coding',
# Type definitions
'type_name_0': 'KEYNOTE',
'type_desc_0': 'Main Address',
'type_color_0': '#FF0000',
'type_name_1': 'WORKSHOP',
'type_desc_1': 'Interactive Session',
'type_color_1': '#0070C0',
}
# Parse schedule
schedule = parse_inline_schedule(form_data)
assert len(schedule) == 2
assert schedule.iloc[0]['Program'] == 'Opening Keynote'
assert schedule.iloc[0]['Typ'] == 'KEYNOTE'
# Parse types
descriptions, colors = parse_inline_types(form_data)
assert len(descriptions) == 2
assert descriptions['KEYNOTE'] == 'Main Address'
assert colors['KEYNOTE'] == 'FFFF0000'
# Generate timetable
wb = create_timetable(
schedule,
title=form_data['title'],
detail=form_data['detail'],
program_descriptions=descriptions,
program_colors=colors
)
assert wb is not None
ws = wb.active
assert ws['A1'].value == 'Test Conference'
assert ws['A2'].value == 'Testing inline builder'
def test_inline_builder_missing_type_definition():
"""Test that undefined type in schedule raises error."""
form_data = {
'title': 'Bad Conference',
'detail': 'Missing type definition',
# Schedule with UNKNOWN type
'datum_0': '2025-11-13',
'zacatek_0': '09:00',
'konec_0': '10:00',
'program_0': 'Unknown Program',
'typ_0': 'UNKNOWN_TYPE', # This type is not defined below!
'garant_0': 'Someone',
'poznamka_0': '',
# Only define LECTURE, not UNKNOWN_TYPE
'type_name_0': 'LECTURE',
'type_desc_0': 'Standard Lecture',
'type_color_0': '#3498db',
}
schedule = parse_inline_schedule(form_data)
descriptions, colors = parse_inline_types(form_data)
# Should fail because UNKNOWN_TYPE is not in colors dict
with pytest.raises(Exception): # ScenarsError
create_timetable(schedule, 'Bad', 'Bad', descriptions, colors)
def test_inline_builder_empty_type_definition():
"""Test that empty type definitions are skipped."""
form_data = {
'title': 'Conference',
'detail': 'With empty types',
'datum_0': '2025-11-13',
'zacatek_0': '09:00',
'konec_0': '10:00',
'program_0': 'Program 1',
'typ_0': 'WORKSHOP',
'garant_0': 'John',
'poznamka_0': '',
# One empty type definition (should be skipped)
'type_name_0': '',
'type_desc_0': 'Empty type',
'type_color_0': '#FF0000',
# One valid type
'type_name_1': 'WORKSHOP',
'type_desc_1': 'Workshop Type',
'type_color_1': '#0070C0',
}
descriptions, colors = parse_inline_types(form_data)
# Empty type should be skipped
assert 'WORKSHOP' in descriptions
assert '' not in descriptions
def test_inline_builder_overlapping_times():
"""Test schedule with overlapping time slots."""
form_data = {
'title': 'Conference',
'detail': 'Overlapping schedule',
# Two programs at same time
'datum_0': '2025-11-13',
'zacatek_0': '09:00',
'konec_0': '10:00',
'program_0': 'Program A',
'typ_0': 'WORKSHOP',
'garant_0': 'John',
'poznamka_0': '',
'datum_1': '2025-11-13',
'zacatek_1': '09:30', # Overlaps with Program A
'konec_1': '10:30',
'program_1': 'Program B',
'typ_1': 'WORKSHOP',
'garant_1': 'Jane',
'poznamka_1': '',
'type_name_0': 'WORKSHOP',
'type_desc_0': 'Workshop',
'type_color_0': '#0070C0',
}
schedule = parse_inline_schedule(form_data)
descriptions, colors = parse_inline_types(form_data)
# Should allow overlapping times — timetable will show them
assert len(schedule) == 2
# Generate timetable (may have rendering issues but should complete)
wb = create_timetable(schedule, 'Conf', 'Test', descriptions, colors)
assert wb is not None
def test_inline_builder_multiday():
"""Test schedule spanning multiple days."""
form_data = {
'title': 'Multi-day Conference',
'detail': 'Two-day event',
# Day 1
'datum_0': '2025-11-13',
'zacatek_0': '09:00',
'konec_0': '10:00',
'program_0': 'Day 1 Opening',
'typ_0': 'KEYNOTE',
'garant_0': 'Dr. A',
'poznamka_0': '',
# Day 2
'datum_1': '2025-11-14',
'zacatek_1': '09:00',
'konec_1': '10:00',
'program_1': 'Day 2 Opening',
'typ_1': 'KEYNOTE',
'garant_1': 'Dr. B',
'poznamka_1': '',
'type_name_0': 'KEYNOTE',
'type_desc_0': 'Keynote Speech',
'type_color_0': '#FF6600',
}
schedule = parse_inline_schedule(form_data)
descriptions, colors = parse_inline_types(form_data)
wb = create_timetable(schedule, 'Multi-day', 'Test', descriptions, colors)
assert wb is not None
ws = wb.active
# Should have multiple date rows
assert ws['A1'].value == 'Multi-day'
def test_inline_builder_validation_errors():
"""Test validation of inline form data."""
# Missing required field
form_data_missing = {
'datum_0': '2025-11-13',
# Missing zacatek_0, konec_0, program_0, typ_0
'garant_0': 'John',
}
with pytest.raises(ValidationError):
parse_inline_schedule(form_data_missing)
def test_inline_builder_with_empty_rows():
"""Test that empty schedule rows are skipped."""
form_data = {
'title': 'Test',
'detail': 'With empty rows',
# Valid row
'datum_0': '2025-11-13',
'zacatek_0': '09:00',
'konec_0': '10:00',
'program_0': 'Program 1',
'typ_0': 'WORKSHOP',
'garant_0': 'John',
'poznamka_0': '',
# Empty row (all fields missing)
'datum_1': '',
'zacatek_1': '',
'konec_1': '',
'program_1': '',
'typ_1': '',
'garant_1': '',
'poznamka_1': '',
'type_name_0': 'WORKSHOP',
'type_desc_0': 'Workshop',
'type_color_0': '#0070C0',
}
schedule = parse_inline_schedule(form_data)
# Should only have 1 row (empty row skipped)
assert len(schedule) == 1
assert schedule.iloc[0]['Program'] == 'Program 1'

View File

@@ -1,100 +1,95 @@
""" """
PDF generation tests. PDF generation tests for Scenar Creator v3.
""" """
import pandas as pd
import pytest import pytest
from datetime import time
from fastapi.testclient import TestClient
from app.core.pdf_generator import generate_pdf from app.core.pdf_generator import generate_pdf
from app.core.validator import ScenarsError from app.core.validator import ScenarsError
from app.main import app from app.models.event import ScenarioDocument, Block, ProgramType, EventInfo
@pytest.fixture def make_doc(**kwargs):
def client(): defaults = {
return TestClient(app) "version": "1.0",
"event": EventInfo(title="Test PDF", subtitle="Subtitle"),
"program_types": [
ProgramType(id="ws", name="Workshop", color="#0070C0"),
],
"blocks": [
Block(id="b1", date="2026-03-01", start="09:00", end="10:00",
title="Test Program", type_id="ws", responsible="John"),
]
}
defaults.update(kwargs)
return ScenarioDocument(**defaults)
def test_generate_pdf_basic(): def test_generate_pdf_basic():
df = pd.DataFrame({ doc = make_doc()
'Datum': [pd.Timestamp('2025-11-13').date()], pdf_bytes = generate_pdf(doc)
'Zacatek': [time(9, 0)],
'Konec': [time(10, 0)],
'Program': ['Test Program'],
'Typ': ['WORKSHOP'],
'Garant': ['John Doe'],
'Poznamka': ['Test note'],
})
descriptions = {'WORKSHOP': 'Workshop Type'}
colors = {'WORKSHOP': 'FF0070C0'}
pdf_bytes = generate_pdf(df, "Test PDF", "PDF Detail", descriptions, colors)
assert isinstance(pdf_bytes, bytes) assert isinstance(pdf_bytes, bytes)
assert len(pdf_bytes) > 0 assert len(pdf_bytes) > 0
assert pdf_bytes[:5] == b'%PDF-' assert pdf_bytes[:5] == b'%PDF-'
def test_generate_pdf_multiday(): def test_generate_pdf_multiday():
df = pd.DataFrame({ doc = make_doc(
'Datum': [pd.Timestamp('2025-11-13').date(), pd.Timestamp('2025-11-14').date()], program_types=[
'Zacatek': [time(9, 0), time(14, 0)], ProgramType(id="key", name="Keynote", color="#FF0000"),
'Konec': [time(10, 0), time(15, 0)], ProgramType(id="ws", name="Workshop", color="#0070C0"),
'Program': ['Day 1', 'Day 2'], ],
'Typ': ['KEYNOTE', 'WORKSHOP'], blocks=[
'Garant': ['Alice', 'Bob'], Block(id="b1", date="2026-03-01", start="09:00", end="10:00",
'Poznamka': [None, 'Hands-on'], title="Day 1", type_id="key", responsible="Alice"),
}) Block(id="b2", date="2026-03-02", start="14:00", end="15:00",
title="Day 2", type_id="ws", responsible="Bob"),
descriptions = {'KEYNOTE': 'Keynote', 'WORKSHOP': 'Workshop'} ]
colors = {'KEYNOTE': 'FFFF0000', 'WORKSHOP': 'FF0070C0'} )
pdf_bytes = generate_pdf(doc)
pdf_bytes = generate_pdf(df, "Multi-day", "Two days", descriptions, colors)
assert isinstance(pdf_bytes, bytes) assert isinstance(pdf_bytes, bytes)
assert pdf_bytes[:5] == b'%PDF-' assert pdf_bytes[:5] == b'%PDF-'
def test_generate_pdf_empty_data(): def test_generate_pdf_empty_blocks():
df = pd.DataFrame(columns=['Datum', 'Zacatek', 'Konec', 'Program', 'Typ', 'Garant', 'Poznamka']) doc = make_doc(blocks=[])
with pytest.raises(ScenarsError): with pytest.raises(ScenarsError):
generate_pdf(df, "Empty", "Detail", {}, {}) generate_pdf(doc)
def test_generate_pdf_missing_type(): def test_generate_pdf_missing_type():
df = pd.DataFrame({ doc = make_doc(
'Datum': [pd.Timestamp('2025-11-13').date()], blocks=[
'Zacatek': [time(9, 0)], Block(id="b1", date="2026-03-01", start="09:00", end="10:00",
'Konec': [time(10, 0)], title="Test", type_id="UNKNOWN"),
'Program': ['Test'], ]
'Typ': ['UNKNOWN'], )
'Garant': [None],
'Poznamka': [None],
})
with pytest.raises(ScenarsError): with pytest.raises(ScenarsError):
generate_pdf(df, "Test", "Detail", {}, {}) generate_pdf(doc)
def test_generate_pdf_api(client): def test_generate_pdf_with_event_info():
doc = { doc = make_doc(
"event": {"title": "PDF Test", "detail": "API PDF"}, event=EventInfo(
"program_types": [{"code": "WS", "description": "Workshop", "color": "#0070C0"}], title="Full Event",
"blocks": [{ subtitle="With all fields",
"datum": "2025-11-13", date="2026-03-01",
"zacatek": "09:00:00", location="Prague"
"konec": "10:00:00", )
"program": "Opening", )
"typ": "WS", pdf_bytes = generate_pdf(doc)
"garant": "John", assert pdf_bytes[:5] == b'%PDF-'
"poznamka": "Note"
}]
} def test_generate_pdf_multiple_blocks_same_day():
r = client.post("/api/generate-pdf", json=doc) doc = make_doc(
assert r.status_code == 200 blocks=[
assert r.headers["content-type"] == "application/pdf" Block(id="b1", date="2026-03-01", start="09:00", end="10:00",
assert r.content[:5] == b'%PDF-' title="Morning", type_id="ws"),
Block(id="b2", date="2026-03-01", start="10:00", end="11:30",
title="Midday", type_id="ws"),
Block(id="b3", date="2026-03-01", start="14:00", end="16:00",
title="Afternoon", type_id="ws", responsible="Team"),
]
)
pdf_bytes = generate_pdf(doc)
assert pdf_bytes[:5] == b'%PDF-'

View File

@@ -1,361 +0,0 @@
import io
import pandas as pd
import pytest
from datetime import date, time
from scenar.core import (
read_excel, create_timetable, get_program_types, ScenarsError,
parse_inline_schedule, parse_inline_types, ValidationError
)
def make_excel_bytes(df: pd.DataFrame) -> bytes:
bio = io.BytesIO()
with pd.ExcelWriter(bio, engine='openpyxl') as writer:
df.to_excel(writer, index=False)
return bio.getvalue()
def test_read_excel_happy_path():
df = pd.DataFrame({
'Datum': [pd.Timestamp('2025-11-13').date()],
'Zacatek': ['09:00'],
'Konec': ['10:00'],
'Program': ['Test program'],
'Typ': ['WORKSHOP'],
'Garant': ['Garant Name'],
'Poznamka': ['Pozn']
})
content = make_excel_bytes(df)
valid, errors = read_excel(content)
assert isinstance(valid, pd.DataFrame)
assert len(errors) == 0
assert len(valid) == 1
assert valid.iloc[0]['Program'] == 'Test program'
def test_read_excel_invalid_time():
df = pd.DataFrame({
'Datum': [pd.Timestamp('2025-11-13').date()],
'Zacatek': ['not-a-time'],
'Konec': ['10:00'],
'Program': ['Bad Time'],
'Typ': ['LECTURE'],
'Garant': [None],
'Poznamka': [None]
})
content = make_excel_bytes(df)
valid, errors = read_excel(content)
assert isinstance(errors, list)
assert len(errors) >= 1
def test_get_program_types():
"""Test form field parsing for program type/color/description."""
form_data = {
'type_code_0': 'WORKSHOP',
'type_code_1': 'LECTURE',
'desc_0': 'Workshop description',
'desc_1': 'Lecture description',
'color_0': '#FF0000',
'color_1': '#00FF00',
}
# get_program_types returns (descriptions_dict, colors_dict) tuple
descriptions, colors = get_program_types(form_data)
assert len(descriptions) == 2
assert descriptions['WORKSHOP'] == 'Workshop description'
assert descriptions['LECTURE'] == 'Lecture description'
assert colors['WORKSHOP'] == 'FFFF0000' # FF prefix added
assert colors['LECTURE'] == 'FF00FF00'
def test_create_timetable():
"""Test Excel timetable generation with properly parsed times."""
from datetime import time
# Create test data with time objects (as returned by read_excel)
df = pd.DataFrame({
'Datum': [pd.Timestamp('2025-11-13').date()],
'Zacatek': [time(9, 0)], # Use time object, not string
'Konec': [time(10, 0)],
'Program': ['Test Program'],
'Typ': ['WORKSHOP'],
'Garant': ['John Doe'],
'Poznamka': ['Test note'],
})
program_descriptions = {
'WORKSHOP': 'Workshop Type',
}
program_colors = {
'WORKSHOP': 'FF0070C0', # AARRGGBB format
}
# create_timetable returns openpyxl.Workbook
wb = create_timetable(
df,
title="Test Timetable",
detail="Test Detail",
program_descriptions=program_descriptions,
program_colors=program_colors
)
assert wb is not None
assert len(wb.sheetnames) > 0
ws = wb.active
assert ws['A1'].value == "Test Timetable"
assert ws['A2'].value == "Test Detail"
def test_create_timetable_with_color_dict():
"""Test timetable generation with separate color dict."""
from datetime import time
df = pd.DataFrame({
'Datum': [pd.Timestamp('2025-11-13').date()],
'Zacatek': [time(9, 0)], # Use time object
'Konec': [time(10, 0)],
'Program': ['Lecture 101'],
'Typ': ['LECTURE'],
'Garant': ['Dr. Smith'],
'Poznamka': [None],
})
program_descriptions = {
'LECTURE': 'Standard Lecture',
}
program_colors = {
'LECTURE': 'FFFF6600',
}
wb = create_timetable(
df,
title="Advanced Timetable",
detail="With color dict",
program_descriptions=program_descriptions,
program_colors=program_colors
)
assert wb is not None
ws = wb.active
assert ws['A1'].value == "Advanced Timetable"
def test_parse_inline_schedule():
"""Test parsing inline schedule form data."""
form_data = {
'datum_0': '2025-11-13',
'zacatek_0': '09:00',
'konec_0': '10:00',
'program_0': 'Test Program',
'typ_0': 'WORKSHOP',
'garant_0': 'John Doe',
'poznamka_0': 'Test note',
'datum_1': '2025-11-13',
'zacatek_1': '10:30',
'konec_1': '11:30',
'program_1': 'Another Program',
'typ_1': 'LECTURE',
'garant_1': 'Jane Smith',
'poznamka_1': '',
}
df = parse_inline_schedule(form_data)
assert isinstance(df, pd.DataFrame)
assert len(df) == 2
assert df.iloc[0]['Program'] == 'Test Program'
assert df.iloc[0]['Typ'] == 'WORKSHOP'
assert df.iloc[1]['Program'] == 'Another Program'
assert df.iloc[1]['Typ'] == 'LECTURE'
def test_parse_inline_schedule_missing_required():
"""Test that missing required fields raise ValidationError."""
form_data = {
'datum_0': '2025-11-13',
'zacatek_0': '09:00',
# Missing konec_0, program_0, typ_0
}
with pytest.raises(ValidationError):
parse_inline_schedule(form_data)
def test_parse_inline_types():
"""Test parsing inline type definitions."""
form_data = {
'type_name_0': 'WORKSHOP',
'type_desc_0': 'Workshop Type',
'type_color_0': '#0070C0',
'type_name_1': 'LECTURE',
'type_desc_1': 'Lecture Type',
'type_color_1': '#FF6600',
}
descriptions, colors = parse_inline_types(form_data)
assert len(descriptions) == 2
assert descriptions['WORKSHOP'] == 'Workshop Type'
assert descriptions['LECTURE'] == 'Lecture Type'
assert colors['WORKSHOP'] == 'FF0070C0'
assert colors['LECTURE'] == 'FFFF6600'
def test_inline_workflow_integration():
"""Test end-to-end inline workflow: parse form → create timetable."""
# Schedule form data
schedule_form = {
'datum_0': '2025-11-13',
'zacatek_0': '09:00',
'konec_0': '10:00',
'program_0': 'Opening',
'typ_0': 'KEYNOTE',
'garant_0': 'Dr. Smith',
'poznamka_0': 'Start of event',
}
# Type form data
types_form = {
'type_name_0': 'KEYNOTE',
'type_desc_0': 'Keynote Speech',
'type_color_0': '#FF0000',
}
# Merge forms
form_data = {**schedule_form, **types_form}
# Parse
df = parse_inline_schedule(form_data)
descriptions, colors = parse_inline_types(form_data)
# Generate timetable
wb = create_timetable(
df,
title="Integration Test Event",
detail="Testing inline workflow",
program_descriptions=descriptions,
program_colors=colors
)
assert wb is not None
ws = wb.active
assert ws['A1'].value == "Integration Test Event"
assert ws['A2'].value == "Testing inline workflow"
def test_excel_import_to_step2_workflow():
"""Test workflow: Excel import -> step 2 (type definition) -> step 3 (generate).
This simulates the user uploading Excel, then filling type definitions.
"""
import base64
# Step 1: Create Excel file
df = pd.DataFrame({
'Datum': [pd.Timestamp('2025-11-13').date()],
'Zacatek': ['09:00'],
'Konec': ['10:00'],
'Program': ['Opening Keynote'],
'Typ': ['KEYNOTE'],
'Garant': ['John Smith'],
'Poznamka': ['Welcome speech']
})
file_content = make_excel_bytes(df)
# Step 2: Read Excel (simulating step=2 processing)
valid_data, errors = read_excel(file_content)
assert len(errors) == 0
assert len(valid_data) == 1
# Extract types
program_types = sorted([str(t).strip() for t in valid_data["Typ"].dropna().unique()])
assert program_types == ['KEYNOTE']
# Step 3: User fills type definitions (simulating form submission in step=2)
# This would be base64 encoded in the hidden field
file_content_base64 = base64.b64encode(file_content).decode('utf-8')
form_data = {
'title': 'Test Event',
'detail': 'Test Detail',
'file_content_base64': file_content_base64,
'type_code_0': 'KEYNOTE',
'desc_0': 'Main keynote presentation',
'color_0': '#FF0000',
'step': '3'
}
# Parse types (simulating step=3 processing)
descriptions, colors = get_program_types(form_data)
assert descriptions['KEYNOTE'] == 'Main keynote presentation'
assert colors['KEYNOTE'] == 'FFFF0000'
# Step 4: Generate timetable
# Re-read Excel from base64
file_content_decoded = base64.b64decode(form_data['file_content_base64'])
data, _ = read_excel(file_content_decoded)
wb = create_timetable(
data,
title=form_data['title'],
detail=form_data['detail'],
program_descriptions=descriptions,
program_colors=colors
)
assert wb is not None
ws = wb.active
assert ws['A1'].value == 'Test Event'
assert ws['A2'].value == 'Test Detail'
def test_excel_import_to_inline_editor_workflow():
"""Test workflow: Excel import -> step 2 -> step 2b (inline editor).
This simulates clicking 'Upravit v inline editoru' button.
"""
import base64
# Create Excel file
df = pd.DataFrame({
'Datum': [pd.Timestamp('2025-11-13').date(), pd.Timestamp('2025-11-14').date()],
'Zacatek': ['09:00', '14:00'],
'Konec': ['10:00', '15:30'],
'Program': ['Morning Session', 'Afternoon Workshop'],
'Typ': ['KEYNOTE', 'WORKSHOP'],
'Garant': ['Alice', 'Bob'],
'Poznamka': ['', 'Hands-on']
})
file_content = make_excel_bytes(df)
# Step 2: Read Excel
valid_data, errors = read_excel(file_content)
assert len(errors) == 0
assert len(valid_data) == 2
# Step 2b: User clicks "Upravit v inline editoru"
# The form would pass file_content_base64 to step=2b
file_content_base64 = base64.b64encode(file_content).decode('utf-8')
# Simulate step 2b: decode and re-read Excel
file_content_decoded = base64.b64decode(file_content_base64)
data_in_editor, _ = read_excel(file_content_decoded)
# Verify data loaded correctly
assert len(data_in_editor) == 2
assert data_in_editor.iloc[0]['Program'] == 'Morning Session'
assert data_in_editor.iloc[1]['Program'] == 'Afternoon Workshop'
# Extract types for editor
program_types = sorted([str(t).strip() for t in data_in_editor["Typ"].dropna().unique()])
assert set(program_types) == {'KEYNOTE', 'WORKSHOP'}