feat: v3.0 - canvas editor, JSON-only, no Excel, new UI
Some checks failed
Build & Push Docker / build (push) Has been cancelled
Some checks failed
Build & Push Docker / build (push) Has been cancelled
- Remove all Excel code (import, export, template, pandas, openpyxl) - New canvas-based schedule editor with drag & drop (interact.js) - Modern 3-panel UI: sidebar, canvas, documentation tab - New data model: Block with id/date/start/end, ProgramType with id/name/color - Clean API: GET /api/health, POST /api/validate, GET /api/sample, POST /api/generate-pdf - Rewritten PDF generator using ScenarioDocument directly (no DataFrame) - Professional PDF output: dark header, colored blocks, merged cells, legend, footer - Sample JSON: "Zimní výjezd oddílu" with 11 blocks, 3 program types - 30 tests passing (API, core models, PDF generation) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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))
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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:
|
||||||
|
if block.type_id not in type_map:
|
||||||
raise ScenarsError(
|
raise ScenarsError(
|
||||||
f"Missing type definitions: {', '.join(missing_types)}. "
|
f"Missing type definition: '{block.type_id}'. "
|
||||||
"Please define all program types."
|
"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))
|
||||||
|
legend_items = []
|
||||||
|
for pt in doc.program_types:
|
||||||
|
legend_items.append([Paragraph(f" {pt.name}", legend_style)])
|
||||||
|
|
||||||
|
if legend_items:
|
||||||
elements.append(Paragraph("<b>Legenda:</b>", legend_style))
|
elements.append(Paragraph("<b>Legenda:</b>", legend_style))
|
||||||
|
elements.append(Spacer(1, 2 * mm))
|
||||||
legend_data = []
|
legend_table = Table(legend_items, colWidths=[60 * mm])
|
||||||
legend_colors_list = []
|
|
||||||
for i, (typ, desc) in enumerate(program_descriptions.items()):
|
|
||||||
legend_data.append([Paragraph(f"{desc} ({typ})", legend_style)])
|
|
||||||
legend_colors_list.append(hex_to_reportlab_color(program_colors[typ]))
|
|
||||||
|
|
||||||
if legend_data:
|
|
||||||
legend_table = Table(legend_data, colWidths=[80 * mm])
|
|
||||||
legend_cmds = [
|
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()
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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)}"
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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] = []
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<label class="btn btn-secondary btn-sm" id="importJsonBtn">
|
||||||
|
<input type="file" accept=".json" id="importJsonInput" hidden>
|
||||||
|
Import JSON
|
||||||
|
</label>
|
||||||
|
<button class="btn btn-secondary btn-sm" id="newScenarioBtn">Nový scénář</button>
|
||||||
|
<button class="btn btn-secondary btn-sm" id="exportJsonBtn">Export JSON</button>
|
||||||
|
<button class="btn btn-primary btn-sm" id="generatePdfBtn">Generovat PDF</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<!-- Tabs -->
|
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
<button class="tab active" onclick="switchTab(event, 'importTab')">Importovat Excel</button>
|
<button class="tab active" data-tab="editor">Editor</button>
|
||||||
<button class="tab" onclick="switchTab(event, 'builderTab')">Vytvořit inline</button>
|
<button class="tab" data-tab="docs">Dokumentace</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Import Excel Tab -->
|
<div class="tab-content" id="tab-editor">
|
||||||
<div id="importTab" class="tab-content active">
|
<div class="app-layout">
|
||||||
<form id="importForm" onsubmit="return handleImport(event)">
|
<aside class="sidebar">
|
||||||
|
<section class="sidebar-section">
|
||||||
|
<h3 class="sidebar-heading">Informace o akci</h3>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="importTitle">Název akce:</label>
|
<label>Název</label>
|
||||||
<input type="text" id="importTitle" name="title" maxlength="200" required placeholder="Název události">
|
<input type="text" id="eventTitle" placeholder="Název akce">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="importDetail">Detail:</label>
|
<label>Podtitul</label>
|
||||||
<input type="text" id="importDetail" name="detail" maxlength="500" required placeholder="Popis události">
|
<input type="text" id="eventSubtitle" placeholder="Podtitul">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="excelFile">Excel soubor:</label>
|
<label>Datum</label>
|
||||||
<input type="file" id="excelFile" name="file" accept=".xlsx,.xls" required>
|
<input type="date" id="eventDate">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-actions">
|
<div class="form-group">
|
||||||
<button type="submit" class="btn btn-primary">Importovat</button>
|
<label>Místo</label>
|
||||||
<a href="/api/template" class="btn btn-secondary">Stáhnout šablonu</a>
|
<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>
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Builder Tab -->
|
<div class="tab-content hidden" id="tab-docs">
|
||||||
<div id="builderTab" class="tab-content">
|
<div class="docs-container">
|
||||||
<form id="builderForm" onsubmit="return handleBuild(event)">
|
<h2>Dokumentace — Scenár Creator v3</h2>
|
||||||
<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>
|
<h3>Jak začít</h3>
|
||||||
<div id="typesContainer">
|
<p>Máte dvě možnosti:</p>
|
||||||
<div class="type-row" data-index="0">
|
<ol>
|
||||||
<input type="text" name="type_name_0" placeholder="Kód typu (např. WORKSHOP)" class="type-code">
|
<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>
|
||||||
<input type="text" name="type_desc_0" placeholder="Popis">
|
<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>
|
||||||
<input type="color" name="type_color_0" value="#0070C0">
|
</ol>
|
||||||
<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>
|
<h3>Práce s bloky</h3>
|
||||||
<datalist id="availableTypes"></datalist>
|
<ul>
|
||||||
<div id="scheduleContainer">
|
<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>
|
||||||
<table id="scheduleTable">
|
<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>
|
<thead>
|
||||||
<tr>
|
<tr><th>Pole</th><th>Typ</th><th>Popis</th></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>
|
</thead>
|
||||||
<tbody id="scheduleBody">
|
<tbody>
|
||||||
<tr data-index="0">
|
<tr><td>version</td><td>string</td><td>Verze formátu (1.0)</td></tr>
|
||||||
<td><input type="date" name="datum_0" required></td>
|
<tr><td>event.title</td><td>string</td><td>Název akce</td></tr>
|
||||||
<td><input type="time" name="zacatek_0" required></td>
|
<tr><td>event.subtitle</td><td>string?</td><td>Podtitul</td></tr>
|
||||||
<td><input type="time" name="konec_0" required></td>
|
<tr><td>event.date</td><td>string?</td><td>Datum (YYYY-MM-DD)</td></tr>
|
||||||
<td><input type="text" name="program_0" required placeholder="Název bloku"></td>
|
<tr><td>event.location</td><td>string?</td><td>Místo</td></tr>
|
||||||
<td><input type="text" name="typ_0" list="availableTypes" required placeholder="Typ"></td>
|
<tr><td>program_types[].id</td><td>string</td><td>Unikátní ID typu</td></tr>
|
||||||
<td><input type="text" name="garant_0" placeholder="Garant"></td>
|
<tr><td>program_types[].name</td><td>string</td><td>Název typu</td></tr>
|
||||||
<td><input type="text" name="poznamka_0" placeholder="Poznámka"></td>
|
<tr><td>program_types[].color</td><td>string</td><td>Barva (#RRGGBB)</td></tr>
|
||||||
<td><button type="button" class="btn btn-danger btn-sm" onclick="removeScheduleRow(0)">X</button></td>
|
<tr><td>blocks[].id</td><td>string</td><td>Unikátní ID bloku</td></tr>
|
||||||
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
|
||||||
<button type="button" class="btn btn-secondary btn-sm" onclick="addScheduleRow()">+ Přidat řádek</button>
|
|
||||||
|
|
||||||
<div class="form-actions">
|
<h3>Vzorový JSON</h3>
|
||||||
<button type="submit" class="btn btn-primary" name="format" value="excel">Stáhnout Excel</button>
|
<p><a href="/api/sample" class="btn btn-secondary btn-sm">Stáhnout sample.json</a></p>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- JSON import -->
|
<!-- Block edit modal -->
|
||||||
<div class="json-import">
|
<div class="modal-overlay hidden" id="blockModal">
|
||||||
<label>Importovat JSON: <input type="file" id="jsonFile" accept=".json" onchange="handleJsonImport(event)"></label>
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="modalTitle">Upravit blok</h3>
|
||||||
|
<button class="modal-close" id="modalClose">×</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>
|
</div>
|
||||||
|
|
||||||
<div id="statusMessage" class="status-message" style="display:none;"></div>
|
<!-- Status toast -->
|
||||||
</div>
|
<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>
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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 --- */
|
|
||||||
function addTypeRow() {
|
|
||||||
const container = document.getElementById('typesContainer');
|
|
||||||
const idx = typeCounter++;
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.className = 'type-row';
|
|
||||||
div.setAttribute('data-index', idx);
|
|
||||||
div.innerHTML = `
|
|
||||||
<input type="text" name="type_name_${idx}" placeholder="Kód typu" class="type-code">
|
|
||||||
<input type="text" name="type_desc_${idx}" placeholder="Popis">
|
|
||||||
<input type="color" name="type_color_${idx}" value="#0070C0">
|
|
||||||
<button type="button" class="btn btn-danger btn-sm" onclick="removeTypeRow(${idx})">X</button>
|
|
||||||
`;
|
|
||||||
container.appendChild(div);
|
|
||||||
updateTypeDatalist();
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeTypeRow(idx) {
|
|
||||||
const row = document.querySelector(`.type-row[data-index="${idx}"]`);
|
|
||||||
if (row) row.remove();
|
|
||||||
updateTypeDatalist();
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateTypeDatalist() {
|
|
||||||
const datalist = document.getElementById('availableTypes');
|
|
||||||
datalist.innerHTML = '';
|
|
||||||
document.querySelectorAll('#typesContainer .type-row').forEach(row => {
|
|
||||||
const nameInput = row.querySelector('input[name^="type_name_"]');
|
|
||||||
if (nameInput && nameInput.value.trim()) {
|
|
||||||
const opt = document.createElement('option');
|
|
||||||
opt.value = nameInput.value.trim();
|
|
||||||
datalist.appendChild(opt);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update datalist on type name changes
|
|
||||||
document.getElementById('typesContainer').addEventListener('input', function (e) {
|
|
||||||
if (e.target.name && e.target.name.startsWith('type_name_')) {
|
|
||||||
updateTypeDatalist();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/* --- Schedule management --- */
|
|
||||||
function addScheduleRow() {
|
|
||||||
const tbody = document.getElementById('scheduleBody');
|
|
||||||
const idx = scheduleCounter++;
|
|
||||||
const tr = document.createElement('tr');
|
|
||||||
tr.setAttribute('data-index', idx);
|
|
||||||
tr.innerHTML = `
|
|
||||||
<td><input type="date" name="datum_${idx}" required></td>
|
|
||||||
<td><input type="time" name="zacatek_${idx}" required></td>
|
|
||||||
<td><input type="time" name="konec_${idx}" required></td>
|
|
||||||
<td><input type="text" name="program_${idx}" required placeholder="Název bloku"></td>
|
|
||||||
<td><input type="text" name="typ_${idx}" list="availableTypes" required placeholder="Typ"></td>
|
|
||||||
<td><input type="text" name="garant_${idx}" placeholder="Garant"></td>
|
|
||||||
<td><input type="text" name="poznamka_${idx}" placeholder="Poznámka"></td>
|
|
||||||
<td><button type="button" class="btn btn-danger btn-sm" onclick="removeScheduleRow(${idx})">X</button></td>
|
|
||||||
`;
|
|
||||||
tbody.appendChild(tr);
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeScheduleRow(idx) {
|
|
||||||
const row = document.querySelector(`#scheduleBody tr[data-index="${idx}"]`);
|
|
||||||
if (row) row.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Build ScenarioDocument from builder form --- */
|
|
||||||
function buildDocumentFromForm() {
|
|
||||||
const title = document.getElementById('builderTitle').value.trim();
|
|
||||||
const detail = document.getElementById('builderDetail').value.trim();
|
|
||||||
|
|
||||||
if (!title || !detail) {
|
|
||||||
throw new Error('Název akce a detail jsou povinné');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect types
|
|
||||||
const programTypes = [];
|
|
||||||
document.querySelectorAll('#typesContainer .type-row').forEach(row => {
|
|
||||||
const code = row.querySelector('input[name^="type_name_"]').value.trim();
|
|
||||||
const desc = row.querySelector('input[name^="type_desc_"]').value.trim();
|
|
||||||
const color = row.querySelector('input[name^="type_color_"]').value;
|
|
||||||
if (code) {
|
|
||||||
programTypes.push({ code, description: desc, color });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Collect blocks
|
|
||||||
const blocks = [];
|
|
||||||
document.querySelectorAll('#scheduleBody tr').forEach(tr => {
|
|
||||||
const inputs = tr.querySelectorAll('input');
|
|
||||||
const datum = inputs[0].value;
|
|
||||||
const zacatek = inputs[1].value;
|
|
||||||
const konec = inputs[2].value;
|
|
||||||
const program = inputs[3].value.trim();
|
|
||||||
const typ = inputs[4].value.trim();
|
|
||||||
const garant = inputs[5].value.trim() || null;
|
|
||||||
const poznamka = inputs[6].value.trim() || null;
|
|
||||||
|
|
||||||
if (datum && zacatek && konec && program && typ) {
|
|
||||||
blocks.push({ datum, zacatek, konec, program, typ, garant, poznamka });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (blocks.length === 0) {
|
|
||||||
throw new Error('Přidejte alespoň jeden blok');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
getDocument() {
|
||||||
|
this.syncEventFromUI();
|
||||||
return {
|
return {
|
||||||
version: "1.0",
|
version: '1.0',
|
||||||
event: { title, detail },
|
event: { ...this.state.event },
|
||||||
program_types: programTypes,
|
program_types: this.state.program_types.map(pt => ({ ...pt })),
|
||||||
blocks
|
blocks: this.state.blocks.map(b => ({ ...b }))
|
||||||
};
|
};
|
||||||
}
|
},
|
||||||
|
|
||||||
/* --- Handle builder form submit (Excel) --- */
|
loadDocument(doc) {
|
||||||
async function handleBuild(event) {
|
this.state.event = { ...doc.event };
|
||||||
event.preventDefault();
|
this.state.program_types = (doc.program_types || []).map(pt => ({ ...pt }));
|
||||||
try {
|
this.state.blocks = (doc.blocks || []).map(b => ({
|
||||||
const doc = buildDocumentFromForm();
|
...b,
|
||||||
const blob = await API.postBlob('/api/generate-excel', doc);
|
id: b.id || this.uid()
|
||||||
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() {
|
|
||||||
|
syncEventFromUI() {
|
||||||
|
this.state.event.title = document.getElementById('eventTitle').value.trim() || 'Nová akce';
|
||||||
|
this.state.event.subtitle = document.getElementById('eventSubtitle').value.trim() || null;
|
||||||
|
this.state.event.date = document.getElementById('eventDate').value || null;
|
||||||
|
this.state.event.location = document.getElementById('eventLocation').value.trim() || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
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}">×</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 {
|
try {
|
||||||
const doc = getCurrentDocument();
|
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);
|
const blob = await API.postBlob('/api/generate-pdf', doc);
|
||||||
API.downloadBlob(blob, 'scenar_timetable.pdf');
|
API.downloadBlob(blob, 'scenar_timetable.pdf');
|
||||||
showStatus('PDF vygenerován', 'success');
|
this.toast('PDF vygenerován', 'success');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showStatus('Chyba: ' + err.message, 'error');
|
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
267
app/static/js/canvas.js
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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
138
app/static/sample.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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.
@@ -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):
|
||||||
|
|||||||
@@ -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'
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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'])
|
|
||||||
@@ -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'
|
|
||||||
@@ -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-'
|
||||||
|
|||||||
@@ -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'}
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user