Some checks failed
Build & Push Docker / build (push) Has been cancelled
- 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
923 lines
42 KiB
Python
Executable File
923 lines
42 KiB
Python
Executable File
#!/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.")
|