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

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

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
.git
.gitea
__pycache__
*.pyc
*.pyo
.pytest_cache
tmp/
*.core
cgi-bin/*.core

View File

@@ -1,64 +1,21 @@
FROM python:3.12-slim
ENV DEBIAN_FRONTEND=noninteractive \
PYTHONDONTWRITEBYTECODE=1 \
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
# OS packages: Apache + curl
RUN apt-get update \
&& apt-get install -y --no-install-recommends apache2 curl \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
# Enable CGI and disable default /usr/lib/cgi-bin alias
RUN a2enmod cgid && a2disconf serve-cgi-bin || true
WORKDIR /app
# Use /var/www/htdocs as DocumentRoot to match your layout
RUN mkdir -p /var/www/htdocs
WORKDIR /var/www/htdocs
# Copy app (including scenar package for imports)
COPY cgi-bin ./cgi-bin
COPY templates ./templates
COPY scenar ./scenar
COPY requirements.txt ./requirements.txt
# Ensure CGI scripts are executable
RUN find /var/www/htdocs/cgi-bin -type f -name "*.py" -exec chmod 0755 {} \;
# Writable tmp + kompatibilita s /scripts/tmp (skrypt nic neupravujeme)
RUN mkdir -p /var/www/htdocs/tmp \
/var/www/htdocs/scripts/tmp \
&& chown -R www-data:www-data /var/www/htdocs/tmp /var/www/htdocs/scripts \
&& chmod 0775 /var/www/htdocs/tmp /var/www/htdocs/scripts/tmp
# --- Python dependencies (from requirements.txt) ---
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Listen on 8080
RUN sed -ri 's/Listen 80/Listen 8080/g' /etc/apache2/ports.conf
# Vhost: enable CGI in DocumentRoot; index => scenar.py
RUN printf '%s\n' \
'<VirtualHost *:8080>' \
' ServerName localhost' \
' ServerAdmin webmaster@localhost' \
' DocumentRoot /var/www/htdocs' \
'' \
' <Directory /var/www/htdocs>' \
' Options +ExecCGI -Indexes' \
' AllowOverride None' \
' Require all granted' \
' AddHandler cgi-script .py' \
' DirectoryIndex /cgi-bin/scenar.py' \
' </Directory>' \
'' \
' ErrorLog ${APACHE_LOG_DIR}/error.log' \
' CustomLog ${APACHE_LOG_DIR}/access.log combined' \
'</VirtualHost>' \
> /etc/apache2/sites-available/000-default.conf
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD curl -fsS http://127.0.0.1:8080/ >/dev/null || exit 1
COPY . .
EXPOSE 8080
CMD ["apachectl", "-D", "FOREGROUND"]
HEALTHCHECK --interval=30s --timeout=5s CMD curl -fsS http://localhost:8080/api/health || exit 1
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]

145
TASK.md Normal file
View File

@@ -0,0 +1,145 @@
# Úkol: Refactor scenar-creator CGI → FastAPI
## Kontext
Aplikace `scenar-creator` je webový nástroj pro tvorbu časových harmonogramů (scénářů) z Excel souborů nebo inline formulářů. Výstupem je Excel timetable soubor ke stažení.
Aktuálně běží jako CGI/Apache app. Úkol: přepsat na FastAPI architekturu.
## Schválená architektura
### Tech Stack
- **Backend:** FastAPI (replaces CGI/Apache)
- **UI:** Vanilla JS + interact.js z CDN (drag-and-drop timeline, NO build pipeline)
- **PDF generování:** ReportLab (přidáme NOVÝ výstupní formát vedle Excel)
- **Docs:** MkDocs Material
- **Storage:** filesystem (JSON export/import), NO databáze
- **Python:** 3.12, `python:3.12-slim` base image
- **Server:** uvicorn na portu 8080
### Nová struktura souborů
```
scenar-creator/
├── app/
│ ├── __init__.py
│ ├── main.py # FastAPI app, router registrace, static files
│ ├── config.py # Konfigurace (verze, limity, fonts)
│ ├── models/
│ │ ├── __init__.py
│ │ ├── event.py # Pydantic: EventInfo, ProgramType, Block, ScenarioDocument
│ │ └── responses.py # API response modely
│ ├── api/
│ │ ├── __init__.py
│ │ ├── router.py # APIRouter
│ │ ├── scenario.py # POST /api/validate, /api/import-excel, /api/export-json
│ │ └── pdf.py # POST /api/generate-pdf
│ ├── core/
│ │ ├── __init__.py
│ │ ├── validator.py # z scenar/core.py - validate_inputs, validate_excel_template, overlap detection
│ │ ├── timetable.py # z scenar/core.py - create_timetable (Excel output)
│ │ ├── excel_reader.py # z scenar/core.py - read_excel, parse_inline_schedule
│ │ └── pdf_generator.py # NOVÝ - ReportLab PDF rendering (A4 landscape timetable)
│ └── static/
│ ├── index.html # Hlavní SPA
│ ├── css/
│ │ └── app.css
│ └── js/
│ ├── app.js # State management
│ ├── canvas.js # interact.js timeline editor
│ ├── api.js # fetch() wrapper
│ └── export.js # JSON import/export
├── docs/
│ └── mkdocs.yml
├── tests/
│ ├── test_api.py # Přizpůsobit existující testy
│ ├── test_core.py
│ └── test_pdf.py
├── Dockerfile # Nový - FastAPI + uvicorn, NO Apache
├── requirements.txt # Nový
├── pytest.ini
└── README.md
```
## API Endpointy
```
GET / → static/index.html
GET /api/health → {"status": "ok", "version": "2.0.0"}
POST /api/validate → validuje scenario JSON (Pydantic)
POST /api/import-excel → upload Excel → vrací ScenarioDocument JSON
POST /api/generate-excel → ScenarioDocument → Excel file download
POST /api/generate-pdf → ScenarioDocument → PDF file download
GET /api/template → stáhnout scenar_template.xlsx
GET /docs → Swagger UI (FastAPI built-in)
```
## Datové modely (Pydantic v2)
```python
class Block(BaseModel):
datum: date
zacatek: time
konec: time
program: str
typ: str
garant: Optional[str] = None
poznamka: Optional[str] = None
class ProgramType(BaseModel):
code: str
description: str
color: str # hex #RRGGBB
class EventInfo(BaseModel):
title: str = Field(..., max_length=200)
detail: str = Field(..., max_length=500)
class ScenarioDocument(BaseModel):
version: str = "1.0"
event: EventInfo
program_types: List[ProgramType]
blocks: List[Block]
```
## Zachovat beze změn
- Veškerá business logika z `scenar/core.py` — jen přesunout/refactorovat do `app/core/`
- Existující testy v `tests/` — přizpůsobit k FastAPI (TestClient místo CGI simulace)
- `templates/scenar_template.xlsx`
## Smazat/nahradit
- `cgi-bin/scenar.py` → nahradit FastAPI endpointy + static SPA
- `Dockerfile` → nový bez Apache/CGI
- `requirements.txt` → přidat: `fastapi>=0.115`, `uvicorn[standard]>=0.34`, `reportlab>=4.0`, `python-multipart>=0.0.20`
## Frontend SPA (index.html)
- Zachovat existující UI flow: tabs (Import Excel | Vytvořit inline)
- Import tab: upload Excel → volání `/api/import-excel` → zobrazit schedule editor
- Builder tab: inline tvorba schedule + types → volání `/api/generate-excel` nebo `/api/generate-pdf`
- **Nový prvek:** tlačítko "Stáhnout PDF" vedle "Stáhnout Excel"
- Timeline canvas s interact.js: drag bloky na časové ose (volitelné, pokud nestihne)
- API calls přes `api.js` fetch wrappers
## PDF výstup (ReportLab)
- A4 landscape
- Stejná struktura jako Excel output: název akce, detail, tabulka s časovými sloty po 15 min
- Barevné bloky podle program_types
- Legenda dole
## Dockerfile (nový)
```dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s CMD curl -fsS http://localhost:8080/api/health || exit 1
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
```
## CI/CD
- `.gitea/workflows/build-and-push.yaml` existuje — zachovat, jen zkontrolovat jestli funguje s novou strukturou
- Po dokončení: `git add -A && git commit -m "feat: refactor to FastAPI architecture v2.0" && git push`
## Výsledek
- Plně funkční FastAPI app nahrazující CGI
- Všechny testy procházejí (`pytest`)
- Dockerfile builduje bez chyb (`docker build .`)
- Git push do `main` branch

1
app/__init__.py Normal file
View File

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

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

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

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

@@ -0,0 +1,56 @@
"""PDF generation API endpoint."""
from io import BytesIO
import pandas as pd
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from app.models.event import ScenarioDocument
from app.core.validator import validate_inputs, ValidationError, ScenarsError
from app.core.pdf_generator import generate_pdf
router = APIRouter()
@router.post("/generate-pdf")
async def generate_pdf_endpoint(doc: ScenarioDocument):
"""Generate PDF timetable from ScenarioDocument."""
try:
validate_inputs(doc.event.title, doc.event.detail, 0)
except ValidationError as e:
raise HTTPException(status_code=422, detail=str(e))
# Convert to DataFrame
rows = []
for block in doc.blocks:
rows.append({
'Datum': block.datum,
'Zacatek': block.zacatek,
'Konec': block.konec,
'Program': block.program,
'Typ': block.typ,
'Garant': block.garant,
'Poznamka': block.poznamka,
})
df = pd.DataFrame(rows)
if df.empty:
raise HTTPException(status_code=422, detail="No blocks provided")
# Build program descriptions and colors
program_descriptions = {pt.code: pt.description for pt in doc.program_types}
program_colors = {pt.code: 'FF' + pt.color.lstrip('#') for pt in doc.program_types}
try:
pdf_bytes = generate_pdf(df, doc.event.title, doc.event.detail,
program_descriptions, program_colors)
except ScenarsError as e:
raise HTTPException(status_code=422, detail=str(e))
return StreamingResponse(
BytesIO(pdf_bytes),
media_type="application/pdf",
headers={"Content-Disposition": "attachment; filename=scenar_timetable.pdf"}
)

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

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

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

@@ -0,0 +1,168 @@
"""Scenario API endpoints: validate, import-excel, generate-excel, export-json, template."""
import os
from io import BytesIO
from datetime import date, time as dt_time
import pandas as pd
from fastapi import APIRouter, UploadFile, File, Form, HTTPException
from fastapi.responses import StreamingResponse, FileResponse
from app.config import VERSION, MAX_FILE_SIZE_MB
from app.models.event import ScenarioDocument, Block, ProgramType, EventInfo
from app.models.responses import HealthResponse, ValidationResponse, ImportExcelResponse
from app.core.validator import validate_inputs, ValidationError, TemplateError, ScenarsError
from app.core.excel_reader import read_excel, parse_inline_schedule, parse_inline_types
from app.core.timetable import create_timetable
router = APIRouter()
@router.get("/health", response_model=HealthResponse)
async def health():
return HealthResponse(version=VERSION)
@router.post("/validate", response_model=ValidationResponse)
async def validate_scenario(doc: ScenarioDocument):
"""Validate a ScenarioDocument."""
errors = []
if not doc.blocks:
errors.append("No blocks defined")
if not doc.program_types:
errors.append("No program types defined")
type_codes = {pt.code for pt in doc.program_types}
for i, block in enumerate(doc.blocks):
if block.typ not in type_codes:
errors.append(f"Block {i+1}: unknown type '{block.typ}'")
if block.zacatek >= block.konec:
errors.append(f"Block {i+1}: start time must be before end time")
return ValidationResponse(valid=len(errors) == 0, errors=errors)
@router.post("/import-excel")
async def import_excel(
file: UploadFile = File(...),
title: str = Form("Imported Event"),
detail: str = Form("Imported from Excel"),
):
"""Upload Excel file and return ScenarioDocument JSON."""
content = await file.read()
if len(content) > MAX_FILE_SIZE_MB * 1024 * 1024:
raise HTTPException(status_code=413, detail=f"File exceeds {MAX_FILE_SIZE_MB}MB limit")
try:
valid_data, error_rows = read_excel(content)
except TemplateError as e:
raise HTTPException(status_code=422, detail=str(e))
if valid_data.empty:
raise HTTPException(status_code=422, detail="No valid rows found in Excel file")
# Extract unique types
types_in_data = valid_data["Typ"].dropna().unique().tolist()
program_types = [
ProgramType(code=t, description=str(t), color="#0070C0")
for t in types_in_data
]
# Convert rows to blocks
blocks = []
for _, row in valid_data.iterrows():
blocks.append(Block(
datum=row["Datum"],
zacatek=row["Zacatek"],
konec=row["Konec"],
program=str(row["Program"]),
typ=str(row["Typ"]),
garant=str(row["Garant"]) if pd.notna(row.get("Garant")) else None,
poznamka=str(row["Poznamka"]) if pd.notna(row.get("Poznamka")) else None,
))
doc = ScenarioDocument(
event=EventInfo(title=title, detail=detail),
program_types=program_types,
blocks=blocks,
)
warnings = [f"Row {e['index']}: {e.get('error', e.get('Error', 'unknown'))}" for e in error_rows]
return ImportExcelResponse(
success=True,
document=doc.model_dump(mode="json"),
warnings=warnings,
)
@router.post("/generate-excel")
async def generate_excel(doc: ScenarioDocument):
"""Generate Excel timetable from ScenarioDocument."""
try:
validate_inputs(doc.event.title, doc.event.detail, 0)
except ValidationError as e:
raise HTTPException(status_code=422, detail=str(e))
# Convert to DataFrame
rows = []
for block in doc.blocks:
rows.append({
'Datum': block.datum,
'Zacatek': block.zacatek,
'Konec': block.konec,
'Program': block.program,
'Typ': block.typ,
'Garant': block.garant,
'Poznamka': block.poznamka,
})
df = pd.DataFrame(rows)
if df.empty:
raise HTTPException(status_code=422, detail="No blocks provided")
# Build program descriptions and colors
program_descriptions = {pt.code: pt.description for pt in doc.program_types}
program_colors = {pt.code: 'FF' + pt.color.lstrip('#') for pt in doc.program_types}
try:
wb = create_timetable(df, doc.event.title, doc.event.detail,
program_descriptions, program_colors)
except ScenarsError as e:
raise HTTPException(status_code=422, detail=str(e))
output = BytesIO()
wb.save(output)
output.seek(0)
return StreamingResponse(
output,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": "attachment; filename=scenar_timetable.xlsx"}
)
@router.post("/export-json")
async def export_json(doc: ScenarioDocument):
"""Export ScenarioDocument as JSON."""
return doc.model_dump(mode="json")
@router.get("/template")
async def download_template():
"""Download the Excel template file."""
template_path = os.path.join(os.path.dirname(__file__), "..", "..", "templates", "scenar_template.xlsx")
template_path = os.path.abspath(template_path)
if not os.path.exists(template_path):
raise HTTPException(status_code=404, detail="Template file not found")
return FileResponse(
template_path,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
filename="scenar_template.xlsx"
)

5
app/config.py Normal file
View File

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

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

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

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

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

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

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

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

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

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

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

26
app/main.py Normal file
View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

180
tests/test_api.py Normal file
View File

@@ -0,0 +1,180 @@
"""
API endpoint tests using FastAPI TestClient.
"""
import io
import json
import pandas as pd
import pytest
from fastapi.testclient import TestClient
from app.main import app
@pytest.fixture
def client():
return TestClient(app)
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_health(client):
r = client.get("/api/health")
assert r.status_code == 200
data = r.json()
assert data["status"] == "ok"
assert data["version"] == "2.0.0"
def test_root_returns_html(client):
r = client.get("/")
assert r.status_code == 200
assert "text/html" in r.headers["content-type"]
assert "Scenar Creator" in r.text
def test_validate_valid(client):
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)
assert r.status_code == 200
data = r.json()
assert data["valid"] is True
assert data["errors"] == []
def test_validate_unknown_type(client):
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": "UNKNOWN"
}]
}
r = client.post("/api/validate", json=doc)
assert r.status_code == 200
data = r.json()
assert data["valid"] is False
assert any("UNKNOWN" in e for e in data["errors"])
def test_validate_bad_time_order(client):
doc = {
"event": {"title": "Test", "detail": "Detail"},
"program_types": [{"code": "WS", "description": "Workshop", "color": "#FF0000"}],
"blocks": [{
"datum": "2025-11-13",
"zacatek": "10:00:00",
"konec": "09:00:00",
"program": "Bad",
"typ": "WS"
}]
}
r = client.post("/api/validate", json=doc)
assert r.status_code == 200
data = r.json()
assert data["valid"] is False
def test_import_excel(client):
df = pd.DataFrame({
'Datum': [pd.Timestamp('2025-11-13').date()],
'Zacatek': ['09:00'],
'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
data = r.json()
assert data["success"] is True
assert data["document"] is not None
assert data["document"]["event"]["title"] == "Imported Event"
assert len(data["document"]["blocks"]) == 1
def test_generate_excel(client):
doc = {
"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 "spreadsheetml" in r.headers["content-type"]
assert len(r.content) > 0
def test_generate_excel_no_blocks(client):
doc = {
"event": {"title": "Test", "detail": "Detail"},
"program_types": [{"code": "WS", "description": "Workshop", "color": "#FF0000"}],
"blocks": []
}
r = client.post("/api/generate-excel", json=doc)
assert r.status_code == 422
def test_export_json(client):
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/export-json", json=doc)
assert r.status_code == 200
data = r.json()
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):
r = client.get("/docs")
assert r.status_code == 200

532
tests/test_core.py Normal file
View File

@@ -0,0 +1,532 @@
"""
Core business logic tests — adapted from original test_read_excel.py and test_inline_builder.py.
Tests the refactored app.core modules.
"""
import io
import pandas as pd
import pytest
from datetime import date, time
from app.core import (
read_excel, create_timetable, get_program_types, ScenarsError,
parse_inline_schedule, parse_inline_types, ValidationError,
validate_inputs, normalize_time,
)
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()
# --- Validator tests ---
def test_validate_inputs_valid():
validate_inputs("Title", "Detail", 100)
def test_validate_inputs_empty_title():
with pytest.raises(ValidationError):
validate_inputs("", "Detail", 100)
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'

100
tests/test_pdf.py Normal file
View File

@@ -0,0 +1,100 @@
"""
PDF generation tests.
"""
import pandas as pd
import pytest
from datetime import time
from fastapi.testclient import TestClient
from app.core.pdf_generator import generate_pdf
from app.core.validator import ScenarsError
from app.main import app
@pytest.fixture
def client():
return TestClient(app)
def test_generate_pdf_basic():
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'],
})
descriptions = {'WORKSHOP': 'Workshop Type'}
colors = {'WORKSHOP': 'FF0070C0'}
pdf_bytes = generate_pdf(df, "Test PDF", "PDF Detail", descriptions, colors)
assert isinstance(pdf_bytes, bytes)
assert len(pdf_bytes) > 0
assert pdf_bytes[:5] == b'%PDF-'
def test_generate_pdf_multiday():
df = pd.DataFrame({
'Datum': [pd.Timestamp('2025-11-13').date(), pd.Timestamp('2025-11-14').date()],
'Zacatek': [time(9, 0), time(14, 0)],
'Konec': [time(10, 0), time(15, 0)],
'Program': ['Day 1', 'Day 2'],
'Typ': ['KEYNOTE', 'WORKSHOP'],
'Garant': ['Alice', 'Bob'],
'Poznamka': [None, 'Hands-on'],
})
descriptions = {'KEYNOTE': 'Keynote', 'WORKSHOP': 'Workshop'}
colors = {'KEYNOTE': 'FFFF0000', 'WORKSHOP': 'FF0070C0'}
pdf_bytes = generate_pdf(df, "Multi-day", "Two days", descriptions, colors)
assert isinstance(pdf_bytes, bytes)
assert pdf_bytes[:5] == b'%PDF-'
def test_generate_pdf_empty_data():
df = pd.DataFrame(columns=['Datum', 'Zacatek', 'Konec', 'Program', 'Typ', 'Garant', 'Poznamka'])
with pytest.raises(ScenarsError):
generate_pdf(df, "Empty", "Detail", {}, {})
def test_generate_pdf_missing_type():
df = pd.DataFrame({
'Datum': [pd.Timestamp('2025-11-13').date()],
'Zacatek': [time(9, 0)],
'Konec': [time(10, 0)],
'Program': ['Test'],
'Typ': ['UNKNOWN'],
'Garant': [None],
'Poznamka': [None],
})
with pytest.raises(ScenarsError):
generate_pdf(df, "Test", "Detail", {}, {})
def test_generate_pdf_api(client):
doc = {
"event": {"title": "PDF Test", "detail": "API PDF"},
"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-pdf", json=doc)
assert r.status_code == 200
assert r.headers["content-type"] == "application/pdf"
assert r.content[:5] == b'%PDF-'