diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..93235b1
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,9 @@
+.git
+.gitea
+__pycache__
+*.pyc
+*.pyo
+.pytest_cache
+tmp/
+*.core
+cgi-bin/*.core
diff --git a/Dockerfile b/Dockerfile
index 963a8df..762ef12 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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' \
-'' \
-' ServerName localhost' \
-' ServerAdmin webmaster@localhost' \
-' DocumentRoot /var/www/htdocs' \
-'' \
-' ' \
-' Options +ExecCGI -Indexes' \
-' AllowOverride None' \
-' Require all granted' \
-' AddHandler cgi-script .py' \
-' DirectoryIndex /cgi-bin/scenar.py' \
-' ' \
-'' \
-' ErrorLog ${APACHE_LOG_DIR}/error.log' \
-' CustomLog ${APACHE_LOG_DIR}/access.log combined' \
-'' \
-> /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"]
diff --git a/TASK.md b/TASK.md
new file mode 100644
index 0000000..2a98563
--- /dev/null
+++ b/TASK.md
@@ -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
diff --git a/app/__init__.py b/app/__init__.py
new file mode 100644
index 0000000..e92c9f1
--- /dev/null
+++ b/app/__init__.py
@@ -0,0 +1 @@
+"""Scenar Creator - FastAPI application for timetable generation."""
diff --git a/app/api/__init__.py b/app/api/__init__.py
new file mode 100644
index 0000000..d819caa
--- /dev/null
+++ b/app/api/__init__.py
@@ -0,0 +1 @@
+"""API routes for Scenar Creator."""
diff --git a/app/api/pdf.py b/app/api/pdf.py
new file mode 100644
index 0000000..5193564
--- /dev/null
+++ b/app/api/pdf.py
@@ -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"}
+ )
diff --git a/app/api/router.py b/app/api/router.py
new file mode 100644
index 0000000..4c9b227
--- /dev/null
+++ b/app/api/router.py
@@ -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)
diff --git a/app/api/scenario.py b/app/api/scenario.py
new file mode 100644
index 0000000..3b21ce7
--- /dev/null
+++ b/app/api/scenario.py
@@ -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"
+ )
diff --git a/app/config.py b/app/config.py
new file mode 100644
index 0000000..1651943
--- /dev/null
+++ b/app/config.py
@@ -0,0 +1,5 @@
+"""Application configuration."""
+
+VERSION = "2.0.0"
+MAX_FILE_SIZE_MB = 10
+DEFAULT_COLOR = "#ffffff"
diff --git a/app/core/__init__.py b/app/core/__init__.py
new file mode 100644
index 0000000..ca49866
--- /dev/null
+++ b/app/core/__init__.py
@@ -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",
+]
diff --git a/app/core/excel_reader.py b/app/core/excel_reader.py
new file mode 100644
index 0000000..dc7ed61
--- /dev/null
+++ b/app/core/excel_reader.py
@@ -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
diff --git a/app/core/pdf_generator.py b/app/core/pdf_generator.py
new file mode 100644
index 0000000..b1458f5
--- /dev/null
+++ b/app/core/pdf_generator.py
@@ -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', '
'), 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("Legenda:", 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()
diff --git a/app/core/timetable.py b/app/core/timetable.py
new file mode 100644
index 0000000..af97851
--- /dev/null
+++ b/app/core/timetable.py
@@ -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
diff --git a/app/core/validator.py b/app/core/validator.py
new file mode 100644
index 0000000..94b9dc5
--- /dev/null
+++ b/app/core/validator.py
@@ -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)}"
+ )
diff --git a/app/main.py b/app/main.py
new file mode 100644
index 0000000..451384d
--- /dev/null
+++ b/app/main.py
@@ -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"))
diff --git a/app/models/__init__.py b/app/models/__init__.py
new file mode 100644
index 0000000..bcd4556
--- /dev/null
+++ b/app/models/__init__.py
@@ -0,0 +1,3 @@
+from .event import Block, ProgramType, EventInfo, ScenarioDocument
+
+__all__ = ["Block", "ProgramType", "EventInfo", "ScenarioDocument"]
diff --git a/app/models/event.py b/app/models/event.py
new file mode 100644
index 0000000..a87b72c
--- /dev/null
+++ b/app/models/event.py
@@ -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]
diff --git a/app/models/responses.py b/app/models/responses.py
new file mode 100644
index 0000000..d642e0d
--- /dev/null
+++ b/app/models/responses.py
@@ -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] = []
diff --git a/app/static/css/app.css b/app/static/css/app.css
new file mode 100644
index 0000000..6409944
--- /dev/null
+++ b/app/static/css/app.css
@@ -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;
+}
diff --git a/app/static/index.html b/app/static/index.html
new file mode 100644
index 0000000..6093bbe
--- /dev/null
+++ b/app/static/index.html
@@ -0,0 +1,134 @@
+
+
+
+
+
+ Scenar Creator
+
+
+
+
+
Scenar Creator
+
Tvorba časových harmonogramů
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Importovaná data
+
+
+
Typy programů
+
+
+
Bloky
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/static/js/api.js b/app/static/js/api.js
new file mode 100644
index 0000000..910cbff
--- /dev/null
+++ b/app/static/js/api.js
@@ -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);
+ }
+};
diff --git a/app/static/js/app.js b/app/static/js/app.js
new file mode 100644
index 0000000..4265731
--- /dev/null
+++ b/app/static/js/app.js
@@ -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 = `
+
+
+
+
+ `;
+ 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 = `
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+ `;
+ 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 =
+ `${doc.event.title} — ${doc.event.detail}`;
+
+ // Types
+ const typesHtml = doc.program_types.map((pt, i) => `
+
+
+
+
+
+ `).join('');
+ document.getElementById('importedTypesContainer').innerHTML = typesHtml;
+
+ // Blocks
+ const blocksHtml = doc.blocks.map(b =>
+ `${b.datum} ${b.zacatek}–${b.konec} | ${b.program} [${b.typ}] ${b.garant || ''}
`
+ ).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');
+ }
+}
diff --git a/app/static/js/export.js b/app/static/js/export.js
new file mode 100644
index 0000000..cddaaa6
--- /dev/null
+++ b/app/static/js/export.js
@@ -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);
+}
diff --git a/cgi-bin/gio-querymodules.core b/cgi-bin/gio-querymodules.core
deleted file mode 100644
index a79e943..0000000
Binary files a/cgi-bin/gio-querymodules.core and /dev/null differ
diff --git a/cgi-bin/glib-compile-schemas.core b/cgi-bin/glib-compile-schemas.core
deleted file mode 100644
index 6d0bf42..0000000
Binary files a/cgi-bin/glib-compile-schemas.core and /dev/null differ
diff --git a/cgi-bin/python3.10.core b/cgi-bin/python3.10.core
deleted file mode 100644
index 421fe7a..0000000
Binary files a/cgi-bin/python3.10.core and /dev/null differ
diff --git a/cgi-bin/python3.9.core b/cgi-bin/python3.9.core
deleted file mode 100644
index 3997175..0000000
Binary files a/cgi-bin/python3.9.core and /dev/null differ
diff --git a/cgi-bin/update-desktop-database.core b/cgi-bin/update-desktop-database.core
deleted file mode 100644
index 90a7925..0000000
Binary files a/cgi-bin/update-desktop-database.core and /dev/null differ
diff --git a/requirements.txt b/requirements.txt
index b1ce9ae..0a5a1d9 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -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
diff --git a/tests/test_api.py b/tests/test_api.py
new file mode 100644
index 0000000..da9f4c2
--- /dev/null
+++ b/tests/test_api.py
@@ -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
diff --git a/tests/test_core.py b/tests/test_core.py
new file mode 100644
index 0000000..a1de2f8
--- /dev/null
+++ b/tests/test_core.py
@@ -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'
diff --git a/tests/test_pdf.py b/tests/test_pdf.py
new file mode 100644
index 0000000..193669a
--- /dev/null
+++ b/tests/test_pdf.py
@@ -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-'