Files
scenar-creator/cgi-bin/scenar.py
Martin Sukany b7b56fe15f
Some checks failed
Build & Push Docker / build (push) Has been cancelled
Refactor: Oddělení business logiky + inline editor
- Nový modul scenar/core.py (491 řádků čisté logiky)
- Refactored cgi-bin/scenar.py (450 řádků CGI wrapper)
- Inline editor s JavaScript row managementem
- Custom exceptions (ScenarsError, ValidationError, TemplateError)
- Kompletní test coverage (10 testů, všechny )
- Fixed Dockerfile (COPY scenar/, requirements.txt)
- Fixed requirements.txt (openpyxl==3.1.5)
- Fixed pytest.ini (pythonpath = .)
- Nové testy: test_http_inline.py, test_inline_builder.py
- HTTP testy označeny jako @pytest.mark.integration
- Build script: scripts/build_image.sh
- Dokumentace: COMPLETION.md
2025-11-13 16:06:32 +01:00

923 lines
42 KiB
Python
Executable File
Raw Blame History

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Scenar Creator — CGI web application for creating timetables from Excel data.
Main UI logic and HTTP handling.
"""
import sys
import os
# Fallback sys.path for CGI context (add both parent and current directory)
DOCROOT = "/var/www/htdocs"
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
PARENT_DIR = os.path.dirname(SCRIPT_DIR)
# Try multiple paths for package resolution
for path in [DOCROOT, PARENT_DIR, SCRIPT_DIR]:
if path not in sys.path:
sys.path.insert(0, path)
import html
import base64
import logging
import tempfile
import urllib.parse
from io import BytesIO
import pandas as pd
from scenar.core import (
read_excel, create_timetable, get_program_types,
validate_inputs, ScenarsError, ValidationError, TemplateError,
parse_inline_schedule, parse_inline_types
)
# ===== Config =====
# Determine DOCROOT based on environment
_default_docroot = "/var/www/htdocs"
DOCROOT = _default_docroot
# Try to use default, but fall back if permissions fail
try:
# Try to use /var/www/htdocs if in production
if os.path.exists("/var/www"):
os.makedirs(_default_docroot, exist_ok=True)
DOCROOT = _default_docroot
else:
# Local dev: use current directory
DOCROOT = os.getcwd()
if "pytest" in sys.modules:
# In tests: use temp directory
DOCROOT = tempfile.gettempdir()
except (OSError, PermissionError):
# If can't use /var/www, use current directory or temp
DOCROOT = os.getcwd()
if "pytest" in sys.modules or not os.access(DOCROOT, os.W_OK):
DOCROOT = tempfile.gettempdir()
TMP_DIR = os.path.join(DOCROOT, "tmp")
DEFAULT_COLOR = "#ffffff"
MAX_FILE_SIZE_MB = 10
# ===================
# Setup logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
print("Content-Type: text/html; charset=utf-8")
print()
# Simple CGI form parser (replaces deprecated cgi.FieldStorage for Python 3.13+)
class FileItem:
"""Mimics cgi.FieldStorage file item for multipart uploads."""
def __init__(self, filename, content):
self.filename = filename
self.file = BytesIO(content)
self.value = content
class SimpleFieldStorage(dict):
"""Simple dictionary-based form parser for GET/POST data and file uploads."""
def __init__(self):
super().__init__()
self._parse_form()
def _parse_form(self):
"""Parse GET/POST parameters into dict, including multipart file uploads."""
# Parse GET parameters
query_string = os.environ.get('QUERY_STRING', '')
if query_string:
for key, value in urllib.parse.parse_qsl(query_string):
self[key] = value
# Parse POST parameters
content_type = os.environ.get('CONTENT_TYPE', '')
content_length_str = os.environ.get('CONTENT_LENGTH', '0').strip()
content_length = int(content_length_str) if content_length_str else 0
if content_length == 0:
return
# Read body
try:
body = sys.stdin.buffer.read(content_length)
except (AttributeError, TypeError):
body_str = sys.stdin.read(content_length)
body = body_str.encode('utf-8') if isinstance(body_str, str) else body_str
if content_type.startswith('application/x-www-form-urlencoded'):
# URL-encoded form
for key, value in urllib.parse.parse_qsl(body.decode('utf-8')):
self[key] = value
elif content_type.startswith('multipart/form-data'):
# Multipart form (file upload)
boundary_match = content_type.split('boundary=')
if len(boundary_match) > 1:
boundary = boundary_match[1].split(';')[0].strip('"')
self._parse_multipart(body, boundary)
def _parse_multipart(self, body: bytes, boundary: str):
"""Parse multipart/form-data body."""
boundary_bytes = f'--{boundary}'.encode('utf-8')
end_boundary = f'--{boundary}--'.encode('utf-8')
parts = body.split(boundary_bytes)
for part in parts:
if part.startswith(b'--') or not part.strip():
continue
# Split headers from content
try:
header_end = part.find(b'\r\n\r\n')
if header_end == -1:
header_end = part.find(b'\n\n')
if header_end == -1:
continue
headers = part[:header_end].decode('utf-8', errors='ignore')
content = part[header_end + 2:]
else:
headers = part[:header_end].decode('utf-8', errors='ignore')
content = part[header_end + 4:]
except:
continue
# Remove trailing boundary marker and whitespace
if content.endswith(b'\r\n'):
content = content[:-2]
elif content.endswith(b'\n'):
content = content[:-1]
# Parse Content-Disposition header
name = None
filename = None
for line in headers.split('\n'):
if 'Content-Disposition:' in line:
# Extract name and filename
import re as regex
name_match = regex.search(r'name="([^"]*)"', line)
if name_match:
name = name_match.group(1)
filename_match = regex.search(r'filename="([^"]*)"', line)
if filename_match:
filename = filename_match.group(1)
if name:
if filename:
# File field
self[name] = FileItem(filename, content)
else:
# Regular form field
self[name] = content.decode('utf-8', errors='ignore').strip()
def __contains__(self, key):
"""Check if key exists (for 'in' operator)."""
return super().__contains__(key)
def getvalue(self, key, default=''):
"""Get value from form, mimicking cgi.FieldStorage API."""
val = self.get(key, default)
if isinstance(val, FileItem):
return val.value
return val
form = SimpleFieldStorage()
title = form.getvalue('title', '').strip()
detail = form.getvalue('detail', '').strip()
show_debug = form.getvalue('debug') == 'on'
step = form.getvalue('step', '1')
file_item = form.get('file', None) # Get file upload if present
def render_error(message: str) -> None:
"""Render error page."""
print(f'''<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<title>Chyba - Scenar Creator</title>
<style>
body {{ font-family: Arial, sans-serif; margin: 20px; background-color: #f0f4f8; color: #333; }}
.error-container {{ max-width: 600px; margin: auto; padding: 20px; background-color: #fff;
box-shadow: 0 0 10px rgba(0,0,0,0.1); border-radius: 8px; border-left: 4px solid #dc3545; }}
.error-message {{ color: #dc3545; font-weight: bold; margin-bottom: 10px; }}
a {{ color: #007BFF; text-decoration: none; }}
a:hover {{ text-decoration: underline; }}
</style>
</head>
<body>
<div class="error-container">
<div class="error-message">⚠️ Chyba</div>
<p>{html.escape(message)}</p>
<p><a href="/cgi-bin/scenar.py">← Zpět na úvodní formulář</a></p>
</div>
</body>
</html>''')
def render_home() -> None:
"""Render home page with import and builder tabs."""
print('''<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<title>Scenar Creator - Nový scénář</title>
<style>
* { box-sizing: border-box; }
body { font-family: Arial, sans-serif; margin: 0; padding: 20px; background: #f5f5f5; }
.container { max-width: 1000px; margin: 0 auto; }
.card { background: white; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); padding: 20px; margin-bottom: 20px; }
.tabs { display: flex; gap: 10px; margin-bottom: 20px; border-bottom: 2px solid #ddd; }
.tab-btn { padding: 10px 20px; background: none; border: none; cursor: pointer;
font-size: 16px; color: #666; border-bottom: 3px solid transparent; }
.tab-btn.active { color: #007BFF; border-bottom-color: #007BFF; }
.tab-content { display: none; }
.tab-content.active { display: block; }
.form-group { margin-bottom: 15px; }
label { display: block; font-weight: bold; margin-bottom: 5px; }
input[type="text"], input[type="file"], textarea { width: 100%; padding: 10px;
border: 1px solid #ccc; border-radius: 4px; }
textarea { resize: vertical; min-height: 100px; }
button { padding: 10px 20px; background: #007BFF; color: white; border: none;
border-radius: 4px; cursor: pointer; font-size: 14px; }
button:hover { background: #0056b3; }
.info-box { background: #e7f3ff; padding: 10px; border-left: 4px solid #2196F3; margin-bottom: 15px; }
</style>
</head>
<body>
<div class="container">
<h1><3E><> Scenar Creator</h1>
<div class="tabs">
<button class="tab-btn active" onclick="switchTab(event, 'tab-import')">Importovat Excel</button>
<button class="tab-btn" onclick="switchTab(event, 'tab-builder')">Vytvořit inline</button>
</div>
<div id="tab-import" class="tab-content active">
<div class="card">
<h2>Importovat scénář z Excelu</h2>
<p><a href="/templates/scenar_template.xlsx">📥 Stáhnout šablonu</a></p>
<form action="/cgi-bin/scenar.py" method="post" enctype="multipart/form-data">
<div class="form-group">
<label for="title">Název akce:</label>
<input type="text" id="title" name="title" required maxlength="200">
</div>
<div class="form-group">
<label for="detail">Detail akce:</label>
<input type="text" id="detail" name="detail" required maxlength="500">
</div>
<div class="form-group">
<label for="file">Excel soubor (max 10 MB):</label>
<input type="file" id="file" name="file" accept=".xlsx" required>
</div>
<div class="form-group">
<input type="checkbox" id="debug" name="debug">
<label for="debug" style="display: inline;">Zobrazit debug info</label>
</div>
<input type="hidden" name="step" value="2">
<button type="submit">Pokračovat »</button>
</form>
</div>
</div>
<div id="tab-builder" class="tab-content">
<div class="card">
<h2>Vytvořit scénář přímo</h2>
<form action="/cgi-bin/scenar.py" method="post" id="builderForm">
<div class="form-group">
<label for="builder-title">Název akce:</label>
<input type="text" id="builder-title" name="title" required maxlength="200">
</div>
<div class="form-group">
<label for="builder-detail">Detail akce:</label>
<textarea id="builder-detail" name="detail" required maxlength="500"></textarea>
</div>
<h3>Program (řádky)</h3>
<div style="overflow-x: auto;">
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="background: #f0f0f0;">
<th style="border: 1px solid #ddd; padding: 8px;">Datum</th>
<th style="border: 1px solid #ddd; padding: 8px;">Začátek</th>
<th style="border: 1px solid #ddd; padding: 8px;">Konec</th>
<th style="border: 1px solid #ddd; padding: 8px;">Program</th>
<th style="border: 1px solid #ddd; padding: 8px;">Typ</th>
<th style="border: 1px solid #ddd; padding: 8px;">Garant</th>
<th style="border: 1px solid #ddd; padding: 8px;">Poznámka</th>
<th style="border: 1px solid #ddd; padding: 8px;">Akce</th>
</tr>
</thead>
<tbody id="scheduleTable">
<tr class="schedule-row" data-row-id="0">
<td style="border: 1px solid #ddd; padding: 4px;"><input type="date" name="datum_0" class="datum-input" style="width: 100%; padding: 4px; border: 1px solid #ccc;"></td>
<td style="border: 1px solid #ddd; padding: 4px;"><input type="time" name="zacatek_0" class="zacatek-input" style="width: 100%; padding: 4px; border: 1px solid #ccc;"></td>
<td style="border: 1px solid #ddd; padding: 4px;"><input type="time" name="konec_0" class="konec-input" style="width: 100%; padding: 4px; border: 1px solid #ccc;"></td>
<td style="border: 1px solid #ddd; padding: 4px;"><input type="text" name="program_0" class="program-input" placeholder="Název programu" style="width: 100%; padding: 4px; border: 1px solid #ccc;"></td>
<td style="border: 1px solid #ddd; padding: 4px;">
<select name="typ_0" class="typ-select" style="width: 100%; padding: 4px; border: 1px solid #ccc;">
<option value="">-- Zvolte typ --</option>
</select>
</td>
<td style="border: 1px solid #ddd; padding: 4px;"><input type="text" name="garant_0" class="garant-input" placeholder="Garante" style="width: 100%; padding: 4px; border: 1px solid #ccc;"></td>
<td style="border: 1px solid #ddd; padding: 4px;"><input type="text" name="poznamka_0" class="poznamka-input" placeholder="Poznámka" style="width: 100%; padding: 4px; border: 1px solid #ccc;"></td>
<td style="border: 1px solid #ddd; padding: 4px; text-align: center;">
<button type="button" class="remove-row-btn" onclick="removeScheduleRow(0)" style="background: #dc3545; color: white; padding: 4px 8px; border: none; border-radius: 3px; cursor: pointer;">Smazat</button>
</td>
</tr>
</tbody>
</table>
</div>
<button type="button" onclick="addScheduleRow()" style="margin-top: 10px; background: #28a745; color: white; padding: 8px 15px; border: none; border-radius: 4px; cursor: pointer;">+ Přidat řádek</button>
<h3 style="margin-top: 20px;">Typy programu</h3>
<div id="typesContainer">
<div class="type-def" data-type-id="0" style="margin-bottom: 15px; padding: 10px; background: #f9f9f9; border-left: 3px solid #007BFF;">
<label style="display: block; font-weight: bold; margin-bottom: 5px;">Typ: <input type="text" name="type_name_0" placeholder="Název typu (např. WORKSHOP)" style="width: 200px; padding: 4px; border: 1px solid #ccc; margin-left: 5px;" onchange="updateTypeDatalist()"></label>
<label style="display: block; margin-bottom: 5px;">Popis: <input type="text" name="type_desc_0" placeholder="Popis" style="width: 100%; padding: 4px; border: 1px solid #ccc; margin-top: 3px;"></label>
<label style="display: block;">Barva: <input type="color" name="type_color_0" value="#3498db" style="margin-left: 5px; width: 60px;"></label>
<button type="button" onclick="removeTypeRow(0)" style="margin-top: 8px; background: #dc3545; color: white; padding: 4px 8px; border: none; border-radius: 3px; cursor: pointer;">Smazat</button>
</div>
</div>
<button type="button" onclick="addTypeRow()" style="margin-top: 10px; background: #28a745; color: white; padding: 8px 15px; border: none; border-radius: 4px; cursor: pointer;">+ Přidat typ</button>
<input type="hidden" name="step" value="builder">
<button type="submit" style="margin-top: 20px; padding: 12px 30px; background: #007BFF; color: white; font-size: 16px;">Generovat scénář »</button>
</form>
<datalist id="availableTypes">
</datalist>
</div>
</div>
</div>
<script>
let scheduleRowCounter = 1;
let typeRowCounter = 1;
function switchTab(e, tabId) {
e.preventDefault();
document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
document.querySelectorAll('.tab-btn').forEach(el => el.classList.remove('active'));
document.getElementById(tabId).classList.add('active');
e.target.classList.add('active');
}
function addScheduleRow() {
const table = document.getElementById('scheduleTable');
const rowId = scheduleRowCounter++;
const newRow = document.createElement('tr');
newRow.className = 'schedule-row';
newRow.dataset.rowId = rowId;
// Create type select with current available types
const typeSelect = createTypeSelect(rowId);
newRow.innerHTML = `
<td style="border: 1px solid #ddd; padding: 4px;"><input type="date" name="datum_${rowId}" class="datum-input" style="width: 100%; padding: 4px; border: 1px solid #ccc;"></td>
<td style="border: 1px solid #ddd; padding: 4px;"><input type="time" name="zacatek_${rowId}" class="zacatek-input" style="width: 100%; padding: 4px; border: 1px solid #ccc;"></td>
<td style="border: 1px solid #ddd; padding: 4px;"><input type="time" name="konec_${rowId}" class="konec-input" style="width: 100%; padding: 4px; border: 1px solid #ccc;"></td>
<td style="border: 1px solid #ddd; padding: 4px;"><input type="text" name="program_${rowId}" class="program-input" placeholder="Název programu" style="width: 100%; padding: 4px; border: 1px solid #ccc;"></td>
<td style="border: 1px solid #ddd; padding: 4px;"></td>
<td style="border: 1px solid #ddd; padding: 4px;"><input type="text" name="garant_${rowId}" class="garant-input" placeholder="Garant" style="width: 100%; padding: 4px; border: 1px solid #ccc;"></td>
<td style="border: 1px solid #ddd; padding: 4px;"><input type="text" name="poznamka_${rowId}" class="poznamka-input" placeholder="Poznámka" style="width: 100%; padding: 4px; border: 1px solid #ccc;"></td>
<td style="border: 1px solid #ddd; padding: 4px; text-align: center;">
<button type="button" class="remove-row-btn" onclick="removeScheduleRow(${rowId})" style="background: #dc3545; color: white; padding: 4px 8px; border: none; border-radius: 3px; cursor: pointer;">Smazat</button>
</td>
`;
table.appendChild(newRow);
// Insert select into the appropriate cell
const typeCell = newRow.querySelectorAll('td')[4];
typeCell.innerHTML = '';
typeCell.appendChild(typeSelect);
}
function createTypeSelect(rowId) {
const select = document.createElement('select');
select.name = `typ_${rowId}`;
select.className = 'typ-select';
select.style.cssText = 'width: 100%; padding: 4px; border: 1px solid #ccc;';
const defaultOption = document.createElement('option');
defaultOption.value = '';
defaultOption.textContent = '-- Zvolte typ --';
select.appendChild(defaultOption);
// Add all defined types
const typeInputs = document.querySelectorAll('input[name^="type_name_"]');
typeInputs.forEach(input => {
const typeName = input.value.trim();
if (typeName) {
const option = document.createElement('option');
option.value = typeName;
option.textContent = typeName;
select.appendChild(option);
}
});
return select;
}
function removeScheduleRow(rowId) {
const row = document.querySelector(`tr[data-row-id="${rowId}"]`);
if (row) row.remove();
}
function updateTypeDatalist() {
const typeInputs = document.querySelectorAll('input[name^="type_name_"]');
const datalist = document.getElementById('availableTypes');
datalist.innerHTML = '';
// Also update all type selects
const typeSelects = document.querySelectorAll('select[name^="typ_"]');
const uniqueTypes = new Set();
typeInputs.forEach(input => {
const typeName = input.value.trim();
if (typeName) uniqueTypes.add(typeName);
});
// Update datalist
uniqueTypes.forEach(typeName => {
const option = document.createElement('option');
option.value = typeName;
datalist.appendChild(option);
});
// Update all type selects
typeSelects.forEach(select => {
const currentValue = select.value;
const currentOptions = Array.from(select.options).slice(1); // Skip default option
currentOptions.forEach(opt => opt.remove());
uniqueTypes.forEach(typeName => {
const option = document.createElement('option');
option.value = typeName;
option.textContent = typeName;
if (typeName === currentValue) option.selected = true;
select.appendChild(option);
});
});
}
function addTypeRow() {
const container = document.getElementById('typesContainer');
const typeId = typeRowCounter++;
const newTypeDiv = document.createElement('div');
newTypeDiv.className = 'type-def';
newTypeDiv.dataset.typeId = typeId;
newTypeDiv.style.marginBottom = '15px';
newTypeDiv.style.padding = '10px';
newTypeDiv.style.background = '#f9f9f9';
newTypeDiv.style.borderLeft = '3px solid #007BFF';
newTypeDiv.innerHTML = `
<label style="display: block; font-weight: bold; margin-bottom: 5px;">Typ: <input type="text" name="type_name_${typeId}" placeholder="Název typu (např. WORKSHOP)" style="width: 200px; padding: 4px; border: 1px solid #ccc; margin-left: 5px;" onchange="updateTypeDatalist()"></label>
<label style="display: block; margin-bottom: 5px;">Popis: <input type="text" name="type_desc_${typeId}" placeholder="Popis" style="width: 100%; padding: 4px; border: 1px solid #ccc; margin-top: 3px;"></label>
<label style="display: block;">Barva: <input type="color" name="type_color_${typeId}" value="#3498db" style="margin-left: 5px; width: 60px;"></label>
<button type="button" onclick="removeTypeRow(${typeId}); updateTypeDatalist();" style="margin-top: 8px; background: #dc3545; color: white; padding: 4px 8px; border: none; border-radius: 3px; cursor: pointer;">Smazat</button>
`;
container.appendChild(newTypeDiv);
updateTypeDatalist();
}
function removeTypeRow(typeId) {
const typeDiv = document.querySelector(`div[data-type-id="${typeId}"]`);
if (typeDiv) typeDiv.remove();
updateTypeDatalist();
}
</script>
</body>
</html>''')
# ====== Main flow ======
if step == '1':
render_home()
elif step == '2' and file_item is not None and file_item.filename:
try:
file_content = file_item.file.read()
file_size = len(file_content)
# Validate inputs
validate_inputs(title, detail, file_size)
# Read Excel
data, error_rows = read_excel(file_content, show_debug)
if data.empty:
render_error("Načtená data jsou prázdná nebo obsahují neplatné hodnoty. Zkontrolujte vstupní soubor.")
else:
# Extract program types
program_types = sorted([str(t).strip() for t in data["Typ"].dropna().unique()])
file_content_base64 = base64.b64encode(file_content).decode('utf-8')
print('''<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<title>Scenar Creator - Typy programu</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; background-color: #f0f4f8; color: #333; }
.form-container { max-width: 600px; margin: auto; padding: 20px; background-color: #fff;
box-shadow: 0 0 10px rgba(0,0,0,0.1); border-radius: 8px; }
.form-group { margin-bottom: 15px; }
label { display: block; font-weight: bold; margin-bottom: 5px; }
input[type="text"], input[type="color"] { padding: 8px; border: 1px solid #ccc; border-radius: 4px; }
input[type="text"] { width: 100%; }
input[type="color"] { width: 60px; }
button { padding: 10px 20px; background: #007BFF; color: white; border: none; border-radius: 4px; cursor: pointer; }
button:hover { background: #0056b3; }
</style>
</head>
<body>
<div class="form-container">
<h1>Typy programu</h1>
<p>Vyplň popis a barvu pro každý typ programu:</p>
<form action="/cgi-bin/scenar.py" method="post">
<input type="hidden" name="title" value="''' + html.escape(title) + '''">
<input type="hidden" name="detail" value="''' + html.escape(detail) + '''">
<input type="hidden" name="file_content_base64" value="''' + html.escape(file_content_base64) + '''">
<input type="hidden" name="step" value="3">
''')
for i, typ in enumerate(program_types, start=0):
print(f''' <div class="form-group">
<label>Typ: {html.escape(typ)}</label>
<input type="text" name="desc_{i}" placeholder="Popis" required value="{html.escape(typ)}">
<input type="color" name="color_{i}" value="#3498db" required>
<input type="hidden" name="type_code_{i}" value="{html.escape(typ)}">
</div>''')
print(''' <div style="display: flex; gap: 10px;">
<button type="submit" formaction="/cgi-bin/scenar.py" formmethod="post">Generovat scénář »</button>
<button type="button" onclick="goToInlineEditor()">Upravit v inline editoru »</button>
</div>
<script>
function goToInlineEditor() {
const form = document.querySelector('form');
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'step';
input.value = '2b';
form.appendChild(input);
// Change form's step value
document.querySelector('input[name="step"]').value = '2b';
form.submit();
}
</script>
</form>
</div>
</body>
</html>''')
except ValidationError as e:
render_error(f"Chyba validace: {str(e)}")
except (TemplateError, ScenarsError) as e:
render_error(f"Chyba: {str(e)}")
except Exception as e:
logger.error(f"Unexpected error: {str(e)}")
render_error(f"Neočekávaná chyba: {str(e)}")
elif step == '2b' and file_item is not None and file_item.filename:
"""Load Excel data into inline editor for editing."""
try:
file_content = file_item.file.read()
file_size = len(file_content)
# Validate inputs
validate_inputs(title, detail, file_size)
# Read Excel
data, error_rows = read_excel(file_content, show_debug)
if data.empty:
render_error("Načtená data jsou prázdná nebo obsahují neplatné hodnoty. Zkontrolujte vstupní soubor.")
else:
# Extract program types and prepare for inline editor
program_types = sorted([str(t).strip() for t in data["Typ"].dropna().unique()])
# Render inline editor with loaded data
print('''<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<title>Scenar Creator - Upravit scénář</title>
<style>
* { box-sizing: border-box; }
body { font-family: Arial, sans-serif; margin: 0; padding: 20px; background: #f5f5f5; }
.container { max-width: 1200px; margin: 0 auto; }
.card { background: white; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); padding: 20px; margin-bottom: 20px; }
.form-group { margin-bottom: 15px; }
label { display: block; font-weight: bold; margin-bottom: 5px; }
input[type="text"], input[type="date"], input[type="time"], input[type="color"], select, textarea {
padding: 8px; border: 1px solid #ccc; border-radius: 4px; }
input[type="text"], input[type="date"], input[type="time"], select, textarea { width: 100%; }
textarea { resize: vertical; min-height: 80px; }
button { padding: 10px 20px; background: #007BFF; color: white; border: none;
border-radius: 4px; cursor: pointer; font-size: 14px; }
button:hover { background: #0056b3; }
table { width: 100%; border-collapse: collapse; margin-bottom: 20px; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background: #f0f0f0; }
</style>
</head>
<body>
<div class="container">
<h1>Upravit scénář</h1>
<form action="/cgi-bin/scenar.py" method="post" id="importedEditorForm">
<div class="card">
<div class="form-group">
<label for="import-title">Název akce:</label>
<input type="text" id="import-title" name="title" value="''' + html.escape(title) + '''" required maxlength="200">
</div>
<div class="form-group">
<label for="import-detail">Detail akce:</label>
<textarea id="import-detail" name="detail" required maxlength="500">''' + html.escape(detail) + '''</textarea>
</div>
</div>
<div class="card">
<h2>Program (řádky)</h2>
<div style="overflow-x: auto;">
<table>
<thead>
<tr>
<th>Datum</th>
<th>Začátek</th>
<th>Konec</th>
<th>Program</th>
<th>Typ</th>
<th>Garant</th>
<th>Poznámka</th>
<th>Akce</th>
</tr>
</thead>
<tbody id="importedScheduleTable">
''')
# Load data from Excel into inline editor
row_counter = 0
for _, row in data.iterrows():
datum = row['Datum'].strftime('%Y-%m-%d') if pd.notna(row['Datum']) else ''
zacatek = str(row['Zacatek']).strip() if pd.notna(row['Zacatek']) else ''
konec = str(row['Konec']).strip() if pd.notna(row['Konec']) else ''
program = str(row['Program']).strip() if pd.notna(row['Program']) else ''
typ = str(row['Typ']).strip() if pd.notna(row['Typ']) else ''
garant = str(row.get('Garant', '')).strip() if pd.notna(row.get('Garant')) else ''
poznamka = str(row.get('Poznamka', '')).strip() if pd.notna(row.get('Poznamka')) else ''
print(f''' <tr class="schedule-row" data-row-id="{row_counter}">
<td><input type="date" name="datum_{row_counter}" value="{html.escape(datum)}" style="width: 100%; padding: 4px; border: 1px solid #ccc;"></td>
<td><input type="time" name="zacatek_{row_counter}" value="{html.escape(zacatek)}" style="width: 100%; padding: 4px; border: 1px solid #ccc;"></td>
<td><input type="time" name="konec_{row_counter}" value="{html.escape(konec)}" style="width: 100%; padding: 4px; border: 1px solid #ccc;"></td>
<td><input type="text" name="program_{row_counter}" value="{html.escape(program)}" style="width: 100%; padding: 4px; border: 1px solid #ccc;"></td>
<td>
<select name="typ_{row_counter}" class="typ-select" style="width: 100%; padding: 4px; border: 1px solid #ccc;">
<option value="">-- Zvolte typ --</option>
''')
for type_option in program_types:
selected = 'selected' if type_option == typ else ''
print(f' <option value="{html.escape(type_option)}" {selected}>{html.escape(type_option)}</option>')
print(f''' </select>
</td>
<td><input type="text" name="garant_{row_counter}" value="{html.escape(garant)}" style="width: 100%; padding: 4px; border: 1px solid #ccc;"></td>
<td><input type="text" name="poznamka_{row_counter}" value="{html.escape(poznamka)}" style="width: 100%; padding: 4px; border: 1px solid #ccc;"></td>
<td style="text-align: center;">
<button type="button" onclick="removeScheduleRow({row_counter})" style="background: #dc3545; color: white; padding: 4px 8px; border: none; border-radius: 3px; cursor: pointer;">Smazat</button>
</td>
</tr>
''')
row_counter += 1
print(''' </tbody>
</table>
</div>
<button type="button" onclick="addScheduleRow()">+ Přidat řádek</button>
</div>
<div class="card">
<h2>Typy programu</h2>
<div id="typesContainer">
''')
# Load type definitions
type_counter = 0
for type_name in program_types:
print(f''' <div class="type-def" data-type-id="{type_counter}" style="margin-bottom: 15px; padding: 10px; background: #f9f9f9; border-left: 3px solid #007BFF;">
<label style="display: block; font-weight: bold; margin-bottom: 5px;">Typ: <input type="text" name="type_name_{type_counter}" value="{html.escape(type_name)}" style="width: 200px; padding: 4px; border: 1px solid #ccc; margin-left: 5px;"></label>
<label style="display: block; margin-bottom: 5px;">Popis: <input type="text" name="type_desc_{type_counter}" placeholder="Popis" style="width: 100%; padding: 4px; border: 1px solid #ccc; margin-top: 3px;"></label>
<label style="display: block;">Barva: <input type="color" name="type_color_{type_counter}" value="#3498db" style="margin-left: 5px; width: 60px;"></label>
<button type="button" onclick="removeTypeRow({type_counter})" style="margin-top: 8px; background: #dc3545; color: white; padding: 4px 8px; border: none; border-radius: 3px; cursor: pointer;">Smazat</button>
</div>
''')
type_counter += 1
print(''' </div>
<button type="button" onclick="addTypeRow()">+ Přidat typ</button>
</div>
<div class="card" style="text-align: center;">
<input type="hidden" name="step" value="builder">
<button type="submit" style="padding: 12px 30px; font-size: 16px;">Generovat scénář »</button>
</div>
</form>
</div>
<script>
let scheduleRowCounter = ''' + str(row_counter) + ''';
let typeRowCounter = ''' + str(type_counter) + ''';
function addScheduleRow() {
const table = document.getElementById('importedScheduleTable');
const rowId = scheduleRowCounter++;
const newRow = document.createElement('tr');
newRow.className = 'schedule-row';
newRow.dataset.rowId = rowId;
newRow.innerHTML = `
<td><input type="date" name="datum_${rowId}" style="width: 100%; padding: 4px; border: 1px solid #ccc;"></td>
<td><input type="time" name="zacatek_${rowId}" style="width: 100%; padding: 4px; border: 1px solid #ccc;"></td>
<td><input type="time" name="konec_${rowId}" style="width: 100%; padding: 4px; border: 1px solid #ccc;"></td>
<td><input type="text" name="program_${rowId}" style="width: 100%; padding: 4px; border: 1px solid #ccc;"></td>
<td>
<select name="typ_${rowId}" class="typ-select" style="width: 100%; padding: 4px; border: 1px solid #ccc;">
<option value="">-- Zvolte typ --</option>
''')
for type_option in program_types:
print(f' <option value="{html.escape(type_option)}">{html.escape(type_option)}</option>')
print(''' </select>
</td>
<td><input type="text" name="garant_${rowId}" style="width: 100%; padding: 4px; border: 1px solid #ccc;"></td>
<td><input type="text" name="poznamka_${rowId}" style="width: 100%; padding: 4px; border: 1px solid #ccc;"></td>
<td style="text-align: center;">
<button type="button" onclick="removeScheduleRow(${rowId})" style="background: #dc3545; color: white; padding: 4px 8px; border: none; border-radius: 3px; cursor: pointer;">Smazat</button>
</td>
`;
table.appendChild(newRow);
}
function removeScheduleRow(rowId) {
const row = document.querySelector(`tr[data-row-id="${rowId}"]`);
if (row) row.remove();
}
function addTypeRow() {
const container = document.getElementById('typesContainer');
const typeId = typeRowCounter++;
const newTypeDiv = document.createElement('div');
newTypeDiv.className = 'type-def';
newTypeDiv.dataset.typeId = typeId;
newTypeDiv.style.marginBottom = '15px';
newTypeDiv.style.padding = '10px';
newTypeDiv.style.background = '#f9f9f9';
newTypeDiv.style.borderLeft = '3px solid #007BFF';
newTypeDiv.innerHTML = `
<label style="display: block; font-weight: bold; margin-bottom: 5px;">Typ: <input type="text" name="type_name_${typeId}" style="width: 200px; padding: 4px; border: 1px solid #ccc; margin-left: 5px;"></label>
<label style="display: block; margin-bottom: 5px;">Popis: <input type="text" name="type_desc_${typeId}" placeholder="Popis" style="width: 100%; padding: 4px; border: 1px solid #ccc; margin-top: 3px;"></label>
<label style="display: block;">Barva: <input type="color" name="type_color_${typeId}" value="#3498db" style="margin-left: 5px; width: 60px;"></label>
<button type="button" onclick="removeTypeRow(${typeId})" style="margin-top: 8px; background: #dc3545; color: white; padding: 4px 8px; border: none; border-radius: 3px; cursor: pointer;">Smazat</button>
`;
container.appendChild(newTypeDiv);
}
function removeTypeRow(typeId) {
const typeDiv = document.querySelector(`div[data-type-id="${typeId}"]`);
if (typeDiv) typeDiv.remove();
}
</script>
</body>
</html>''')
except ValidationError as e:
render_error(f"Chyba validace: {str(e)}")
except (TemplateError, ScenarsError) as e:
render_error(f"Chyba: {str(e)}")
except Exception as e:
logger.error(f"Unexpected error in 2b: {str(e)}")
render_error(f"Neočekávaná chyba: {str(e)}")
elif step == '3' and title and detail:
try:
file_content_base64 = form.getvalue('file_content_base64', '')
if not file_content_base64:
render_error("Chyba: Soubor nebyl nalezen.")
else:
file_content = base64.b64decode(file_content_base64)
data, error_rows = read_excel(file_content, show_debug)
if data.empty:
render_error("Načtená data jsou prázdná.")
else:
program_descriptions, program_colors = get_program_types(form)
wb = create_timetable(data, title, detail, program_descriptions, program_colors)
os.makedirs(TMP_DIR, exist_ok=True)
filename = f"{title}.xlsx"
safe_name = "".join(ch if ch.isalnum() or ch in "._- " else "_" for ch in filename)
file_path = os.path.join(TMP_DIR, safe_name)
wb.save(file_path)
print(f'''<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<title>Scenar Creator - Výsledek</title>
<style>
body {{ font-family: Arial, sans-serif; margin: 20px; background: #f5f5f5; }}
.container {{ max-width: 800px; margin: 0 auto; }}
.card {{ background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }}
.success {{ color: #28a745; font-weight: bold; }}
.download-btn {{ display: inline-block; margin-top: 10px; padding: 10px 20px; background: #28a745;
color: white; text-decoration: none; border-radius: 4px; }}
.download-btn:hover {{ background: #218838; }}
</style>
</head>
<body>
<div class="container">
<div class="card">
<h1>✅ Scénář úspěšně vygenerován!</h1>
<p class="success">{html.escape(title)}</p>
<p>{html.escape(detail)}</p>
<a href="/tmp/{html.escape(safe_name)}" download class="download-btn">📥 Stáhnout scénář</a>
<p style="margin-top: 20px; font-size: 12px; color: #666;">
<a href="/cgi-bin/scenar.py">← Zpět na úvodní formulář</a>
</p>
</div>
</div>
</body>
</html>''')
except ScenarsError as e:
render_error(f"Chyba: {str(e)}")
except Exception as e:
logger.error(f"Unexpected error: {str(e)}")
render_error(f"Neočekávaná chyba: {str(e)}")
elif step == 'builder':
"""Handle inline builder form submission."""
try:
validate_inputs(title, detail, 0) # 0 = no file size check
# Parse inline schedule and types
data = parse_inline_schedule(form)
program_descriptions, program_colors = parse_inline_types(form)
# Generate timetable
wb = create_timetable(data, title, detail, program_descriptions, program_colors)
# Save to tmp
os.makedirs(TMP_DIR, exist_ok=True)
filename = f"{title}.xlsx"
safe_name = "".join(ch if ch.isalnum() or ch in "._- " else "_" for ch in filename)
file_path = os.path.join(TMP_DIR, safe_name)
wb.save(file_path)
print(f'''<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<title>Scenar Creator - Výsledek</title>
<style>
body {{ font-family: Arial, sans-serif; margin: 20px; background: #f5f5f5; }}
.container {{ max-width: 800px; margin: 0 auto; }}
.card {{ background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }}
.success {{ color: #28a745; font-weight: bold; }}
.download-btn {{ display: inline-block; margin-top: 10px; padding: 10px 20px; background: #28a745;
color: white; text-decoration: none; border-radius: 4px; }}
.download-btn:hover {{ background: #218838; }}
</style>
</head>
<body>
<div class="container">
<div class="card">
<h1>✅ Scénář úspěšně vygenerován!</h1>
<p class="success">{html.escape(title)}</p>
<p>{html.escape(detail)}</p>
<a href="/tmp/{html.escape(safe_name)}" download class="download-btn">📥 Stáhnout scénář</a>
<p style="margin-top: 20px; font-size: 12px; color: #666;">
<a href="/cgi-bin/scenar.py">← Zpět na úvodní formulář</a>
</p>
</div>
</div>
</body>
</html>''')
except ValidationError as e:
render_error(f"Chyba validace: {str(e)}")
except ScenarsError as e:
render_error(f"Chyba: {str(e)}")
except Exception as e:
logger.error(f"Unexpected error in builder: {str(e)}")
render_error(f"Neočekávaná chyba: {str(e)}")
else:
render_error("Chyba: Neplatný krok nebo chybějící data.")