chore: cleanup - remove CGI/old scripts/TASK/copilot files; update README + in-app docs
Some checks failed
Build & Push Docker / build (push) Has been cancelled

This commit is contained in:
2026-02-20 17:48:29 +01:00
parent feb75219a7
commit b91f336c12
13 changed files with 202 additions and 1933 deletions

View File

@@ -1,79 +0,0 @@
## Účel
Krátké, konkrétní instrukce pro AI agenta pracujícího v tomto repozitáři. Cílem je rychle pochopit architekturu, běžné konvence a kde dělat bezpečné úpravy.
## Velký obraz (architektura)
- Jedna hlavní aplikace: `cgi-bin/scenar.py` — jednosouborová CGI aplikace, která vykresluje HTML formuláře a generuje výsledný Excel soubor.
- Statické soubory a šablona: `templates/` (obsahuje `scenar_template.xlsx`).
- Webroot a temp: `DOCROOT` a `TMP_DIR` jsou v `cgi-bin/scenar.py` (výchozí `/var/www/htdocs` a `/var/www/htdocs/tmp`). Dockerfile také nastavuje DocumentRoot na `/var/www/htdocs` a vytváří `/var/www/htdocs/tmp`.
## Důležité soubory
- `cgi-bin/scenar.py` — hlavní logika (upload, parsování Excelu pomocí pandas, tvorba Excelu pomocí openpyxl, HTML formuláře). Hledejte: funkce `read_excel`, `create_timetable`, `get_program_types`, konstanty `DOCROOT`, `TMP_DIR`.
- `templates/scenar_template.xlsx` — excelová šablona, kterou UI nabízí ke stažení.
- `Dockerfile` — ukazuje produkční runtime (Python 3.12-slim, instalace `pandas` a `openpyxl`, Apache s CGI, DocumentRoot `/var/www/htdocs`, port 8080).
## Datový a HTTP tok (konkrétně z kódu)
- Kroková sekvence přes POST parameter `step` (1 → 2 → 3):
- `step=1`: zobrazí formulář s nahráním Excelu (pole `file`).
- `step=2`: načte Excel (pandas), zjistí unikátní typy (`Typ`) a zobrazí formulář pro zadání `type_code_{i}`, `desc_{i}`, `color_{i}`.
- `step=3`: vytvoří výsledný sešit, uloží ho do `TMP_DIR` a odkaz vrátí jako `/tmp/<safe_name>`.
## Pole formuláře a pojmenování
- Upload: pole `file` (v `step=1`).
- Základní metadatová pole: `title`, `detail`, `debug` (checkbox dává ladicí výstup), `step`.
- Dynamické pole pro typy: `type_code_{i}`, `desc_{i}`, `color_{i}` — exportované z dat a pak poskytnuté v `step=2`.
- Interně pro krok 3 se používá `file_content_base64` k bezpečnému přenosu obsahu mezi kroky.
## Konvence projektu
- HTML šablony nejsou separátní (JS/templating) — jsou inline v `scenar.py`. Upravujte opatrně: změny stylu/inl. HTML jsou v blocích `if step == '1'` / `step == '2'`.
- Soubor s výsledkem se ukládá s „safe“ jménem: povolena jsou alfanumerická znaky, `. _ -` a mezera. Pokud měníte pojmenování, ověřte odkazy `/tmp/<name>`.
- Barvy pro OpenPyXL: kód se převádí na formát AARRGGBB (`'FF' + raw_color.lstrip('#')`).
## Chyby, logika validací a okrajové případy
- Parsování času: funkce `normalize_time` podporuje formáty `%H:%M` a `%H:%M:%S`.
- `read_excel` validuje datum/časy a sbírá `error_rows`. Překryvy časů detekuje a tyto řádky vyřadí (viz `overlap_errors`).
- Pokud chybí definice typu (Typ v Excelu není přiřazen v UI), skript formou HTML vrací chybu.
## Závislosti a runtime
- Python: Dockerfile používá `python:3.12-slim` (projekt tedy cílí na moderní 3.12). Lokálně bude fungovat i Python 3.10+ (exports v repozitáři naznačují 3.10/3.9 přítomnost), ale pro přesnost použijte 3.12.
- Klíčové Python balíčky: `pandas`, `openpyxl`. Jsou instalovány v `Dockerfile`.
## Vývojové workflow (rychlé ověření lokálně)
- Rychlý způsob pro lokální testování bez Dockeru (repo má `cgi-bin/`):
1) Spusť z kořene repozitáře Python simple CGI server (vytvoří `cgi-bin/` endpointy):
python -m http.server --cgi 8000
2) Otevři v prohlížeči: http://localhost:8000/cgi-bin/scenar.py
- Pro produkční podobu použij `Dockerfile` (Apache + CGI) — Dockerfile vystavuje port 8080.
## Co upravovat a kde (rychlé reference)
- Změny chování Excelu / parsování: uprav `read_excel` a `normalize_time` v `cgi-bin/scenar.py`.
- Změny vzhledu formulářů: uprav HTML stringy v `if step == '1'` a `if step == '2'`.
- Cesty a přístupová práva: pokud měníš DocumentRoot/TMP, uprav i `DOCROOT`/`TMP_DIR` v `scenar.py` a odpovídající část v `Dockerfile`.
## Příklady (konkrétní ukázky z kódu)
- Detekce typů: `for key in form.keys(): if key.startswith('type_code_'):` — při rozšiřování formulářů zachovat tuto konvenci.
- Barva pro openpyxl: `color_hex = 'FF' + raw_color.lstrip('#')`.
- Safe filename: `safe_name = "".join(ch if ch.isalnum() or ch in "._- " else "_" for ch in filename)`.
## Poznámky pro agenta
- Buď konkrétní: ukaž přesné linie/názvy funkcí, pokud navrhuješ změnu. Např. "Uprav `calculate_row_height` v `create_timetable` když měníš rozměry řádků".
- Nevkládej náhodné závislosti — kontroluj `import` v `cgi-bin/scenar.py` a `Dockerfile`.
- Pokud budeš navrhovat CLI nebo refaktor na framework (Flask apod.), nejprve vyznač migraci: oddělit HTML do šablon a nasadit routy místo CGI.
---
Pokud chceš, upravím instrukci na míru (více/ méně detailů) nebo přidám krátký seznam rychlých úkolů (např. přidat `requirements.txt`, unit testy pro `read_excel`).

View File

@@ -1,37 +0,0 @@
# ✅ Scenar Creator — v3.0 Complete
## Co je v3.0
Kompletní přepis aplikace. Žádný Excel, žádný CGI/Apache.
### Stack
- **Backend:** FastAPI + Uvicorn + ReportLab
- **Frontend:** Vanilla JS + interact.js (drag-and-drop canvas)
- **Data:** JSON import/export (bez Excelu)
- **Output:** PDF timetable (A4 landscape, barvy, legenda)
### Features
1. **Canvas editor** — bloky na časové ose, přetahování myší, snap na 15 min, resize
2. **JSON import/export** — uložte a načtěte scénář jako .json soubor
3. **Vzorový JSON** — GET /api/sample pro šablonu
4. **PDF generátor** — ReportLab, barevné bloky dle typů, legenda, datum
5. **Dokumentace** — záložka "Dokumentace" přímo v aplikaci
6. **API docs** — GET /docs (Swagger UI)
### Endpoints
- `GET /` — hlavní UI
- `GET /api/health` — health check (vrací verzi)
- `GET /api/sample` — vzorový JSON ke stažení
- `POST /api/validate` — validace ScenarioDocument
- `POST /api/generate-pdf` — vygeneruje PDF
### JSON formát
```json
{
"version": "1.0",
"event": { "title": "...", "subtitle": "...", "date": "YYYY-MM-DD", "location": "..." },
"program_types": [{ "id": "...", "name": "...", "color": "#RRGGBB" }],
"blocks": [{ "id": "...", "date": "YYYY-MM-DD", "start": "HH:MM", "end": "HH:MM",
"title": "...", "type_id": "...", "responsible": "...", "notes": "..." }]
}
```

350
README.md
View File

@@ -1,258 +1,162 @@
# Scenar Creator
# Scenár Creator
> Moderní CGI aplikace pro vytváření časových plánů (timetablů) z Excelu nebo přímé editace v prohlížeči.
Webový nástroj pro tvorbu časových scénářů zážitkových kurzů a výjezdů.
## Co to dělá
**Live:** https://scenar.apps.sukany.cz
**Scenar Creator** je webová aplikace, která vám pomáhá vytvářet a spravovat timetably (časové plány) pro konference, školení a další akce. Aplikace podporuje:
---
-**Import z Excelu** — načtení seznamu programů/přednášek a automatické vytvoření timetablu
-**Inline editor** — přímá editace programu v prohlížeči bez Excelu (JavaScript row management)
-**Validace dat** — kontrola překryvů, chybějících polí, neplatných časů
-**Export do Excelu** — vygenerovaný timetable se stáhne v profesionálním formátu
-**Barevné rozlišení** — jednotlivé typy programu s vlastními barvami
-**Plná test coverage** — 10 testů jednotek + integrace s Docker/Podman
## Funkce
## Instalace a spuštění
- **Grafický editor** — bloky na časové ose, přetahování myší, změna délky tažením pravého okraje, snap na 15 minut
- **Vícedenní scénář** — nastavíš rozsah Od/Do, každý den = jeden řádek
- **JSON import/export** — uložíš scénář, kdykoli ho znovu načteš
- **Vzorový JSON** — `GET /api/sample`
- **PDF výstup** — A4 na šířku, vždy 1 stránka, barevné bloky dle typů, legenda
- Garant viditelný přímo v bloku
- Bloky s poznámkou mají horní index (¹ ² ³...)
- Stránka 2 (pokud jsou poznámky): výpis všech poznámek ke scénáři
- **Dokumentace na webu** — záložka "Dokumentace" přímo v aplikaci
- **Swagger UI** — `GET /docs`
### Lokálně (bez Dockeru)
---
1) **Klonuj a připrav prostředí:**
## Tech stack
| Vrstva | Technologie |
|---|---|
| Backend | FastAPI + Uvicorn (Python 3.12) |
| Frontend | Vanilla JS + [interact.js](https://interactjs.io/) (drag & drop) |
| PDF | ReportLab Canvas API + LiberationSans (česká diakritika) |
| Data | JSON (bez databáze, bez Excelu) |
| Container | Docker / Podman, python:3.12-slim |
| Deployment | Kubernetes (RKE2), namespace `scenar` |
---
## Rychlý start (lokální vývoj)
```bash
git clone <repo>
# Klonování
git clone https://git.apps.sukany.cz/martin/scenar-creator.git
cd scenar-creator
python -m venv .venv
source .venv/bin/activate # na Windows: .venv\Scripts\activate
# Virtuální prostředí
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
# Spuštění
uvicorn app.main:app --reload --port 8080
# Otevři v prohlížeči
open http://localhost:8080
```
2) **Spusť s jednoduchým CGI serverem:**
---
## Testy
```bash
python -m http.server --cgi 8000
# Otevři v prohlížeči: http://localhost:8000/cgi-bin/scenar.py
python3 -m pytest tests/ -v
```
3) **Spusť testy:**
35 testů pokrývá API endpointy, PDF generátor a validaci dat.
---
## Build a deploy
### Manuální postup
```bash
pytest -q
# Bez integračních testů (Docker):
pytest -q -m "not integration"
# 1. Build image
podman build --format docker -t git.apps.sukany.cz/martin/scenar-creator:latest .
# 2. Push do Gitea registry
podman login git.apps.sukany.cz -u <user>
podman push git.apps.sukany.cz/martin/scenar-creator:latest
# 3. Restart deploymentu na clusteru
ssh root@infra01.sukany.cz \
"kubectl -n scenar rollout restart deployment/scenar && \
kubectl -n scenar rollout status deployment/scenar"
# 4. Ověření
curl https://scenar.apps.sukany.cz/api/health
```
### Podman/Docker (produkční)
### Automatický build (Gitea CI/CD)
1) **Postav image:**
Push na `main` spustí `.gitea/workflows/build-and-push.yaml`, který automaticky builduje a pushuje image do Gitea registry.
Deployment na cluster je stále manuální (rollout restart).
```bash
./scripts/build_image.sh
# nebo ručně:
podman build -t scenar-creator:latest .
Kubernetes manifest: `sukany-org/rke2-deployments``scenar/scenar.yaml`
---
## Formát JSON
```json
{
"version": "1.0",
"event": {
"title": "Název akce",
"subtitle": "Volitelný podtitul",
"date_from": "YYYY-MM-DD",
"date_to": "YYYY-MM-DD",
"location": "Místo konání"
},
"program_types": [
{ "id": "main", "name": "Hlavní program", "color": "#3B82F6" }
],
"blocks": [
{
"id": "b1",
"date": "YYYY-MM-DD",
"start": "HH:MM",
"end": "HH:MM",
"title": "Název bloku",
"type_id": "main",
"responsible": "Garant (volitelné)",
"notes": "Poznámka → horní index v PDF (volitelné)"
}
]
}
```
2) **Spusť kontejner:**
**Overnight bloky:** `end < start` → blok přechází přes půlnoc (validní).
```bash
./scripts/start_scenar.sh
# Aplikace bude dostupná na http://127.0.0.1:8080/
```
**Zpětná kompatibilita:** pole `date` (jednodnevní starý formát) je stále akceptováno.
3) **Zastavit kontejner:**
---
```bash
./scripts/stop_scenar.sh
```
## API endpointy
## Git Hooks (pre-commit)
| Metoda | URL | Popis |
|---|---|---|
| GET | `/` | Hlavní UI |
| GET | `/api/health` | Health check (verze) |
| GET | `/api/sample` | Vzorový JSON ke stažení |
| POST | `/api/validate` | Validace ScenarioDocument |
| POST | `/api/generate-pdf` | Generování PDF |
| GET | `/docs` | Swagger UI |
Aplikace obsahuje git hooks, které spouští testy před commitem.
**Jednorazová instalace hooků:**
```bash
chmod +x scripts/install_hooks.sh
./scripts/install_hooks.sh
# nebo ručně:
git config core.hooksPath .githooks
```
**Použití:**
- Běžný commit — spustí se rychlé testy (bez Dockeru):
```bash
git commit -m "..." # Spustí: pytest -q -m "not integration"
```
- Commit s integračními testy (Podman):
```bash
RUN_INTEGRATION=1 git commit -m "..."
```
## Workflow — Jak používat aplikaci
### 1⃣ Import z Excelu (klasicky)
```
1. Otevři http://localhost:8000/cgi-bin/scenar.py
2. Klikni na tab "Importovat Excel"
3. Stáhni šablonu (scenar_template.xlsx)
4. Vyplň tabulku:
- Datum (YYYY-MM-DD)
- Začátek (HH:MM)
- Konec (HH:MM)
- Program (název přednášky)
- Typ (kategorie: WORKSHOP, LECTURE, atd.)
- Garant (vedoucí, autor)
- Poznámka (dodatečné info)
5. Uploaduj soubor
6. Zadej popis typů a barvy
7. Stáhni Excel timetable
```
### 2⃣ Inline Editor (bez Excelu)
```
1. Otevři http://localhost:8000/cgi-bin/scenar.py
2. Klikni na tab "Vytvořit inline"
3. Vyplň název a detail akce
4. V tabulce "Program (řádky)":
- Přidej řádky tlačítkem "+ Přidat řádek"
- Vyplň Datum, Začátek, Konec, Program, Typ, Garant, Poznámka
- Smažování řádku: klikni "Smazat"
5. V sekci "Typy programu":
- Přidej typy tlačítkem "+ Přidat typ"
- Vyplň název typu, popis a zvolíkona barvu
- Barvy se aplikují na timetable
6. Generuj scénář a stáhni Excel
```
## Workflow — Jak používat aplikaci
### Import z Excelu (nejčastěji)
```
1. Otevři http://localhost:8000/cgi-bin/scenar.py
2. Stáhni šablonu (scenar_template.xlsx)
3. Vyplň tabulku:
- Datum: 2025-11-14
- Začátek: 09:00
- Konec: 10:00
- Program: Úvodní přednáška
- Typ: PŘEDNÁŠKA
- Garant: Jméno lektora
- Poznámka: Volitelně
4. Nahraj upravený Excel
5. Vyplň název a detail akce
6. Přiřaď barvy jednotlivým typům programu
7. Stáhni vygenerovaný timetable (Excel)
```
---
## Struktura projektu
```
.
├── .github/copilot-instructions.md # AI instrukce pro agenty
├── .githooks/
│ └── pre-commit # Git hook se spuštěním testů
├── cgi-bin/
└── scenar.py # Hlavní CGI aplikace (UI)
├── scenar/
├── __init__.py
└── core.py # Jádro logiky (bez CGI)
├── scripts/
│ ├── build_image.sh # Postav Podman image
│ ├── start_scenar.sh # Spusť kontejner
│ ├── stop_scenar.sh # Zastavit kontejner
│ └── install_hooks.sh # Instaluj git hooks
├── templates/
│ └── scenar_template.xlsx # Excel šablona
├── tests/
│ ├── test_read_excel.py # Testy parsování
│ └── test_docker_integration.py # Docker build test
├── tmp/ # Výstupní soubory (gitignored)
├── Dockerfile # Runtime pro produkci
├── requirements.txt # Python balíčky (verze)
└── pytest.ini # Pytest konfigurace
app/
api/ REST endpointy (scenario.py, pdf.py, router.py)
core/ Business logika (pdf_generator.py, validator.py)
models/ Pydantic modely (event.py, responses.py)
static/ Frontend (index.html, css/, js/)
js/
app.js State management, modal logika
canvas.js Horizontální canvas editor (interact.js)
api.js Fetch wrapper
export.js JSON import/export
tests/ 35 pytest testů
Dockerfile python:3.12-slim + fonts-liberation
requirements.txt
```
## Architektura
### Core logika (`scenar/core.py`)
Obsahuje importovatelné funkce bez CGI závislostí:
- `read_excel(file_content)` — Parsování Excelu (pandas), detekce překryvů, validace
- `create_timetable(...)` — Tvorba OpenPyXL sešitu s timetablem
- `validate_inputs(title, detail, file_size)` — Bezpečnostní validace vstupů
Chybové typy:
- `ScenarsError` — obecná chyba
- `ValidationError` — vstupní validace
- `TemplateError` — problém s Excel šablonou
### CGI wrapper (`cgi-bin/scenar.py`)
Komunikace s webem:
- `step=1`: Domovská stránka s formulářem
- `step=2`: Načtení Excelu, extrakce typů
- `step=3`: Generování timetablu a stažení
### Podman/Docker
- **Obraz:** Python 3.12-slim + Apache2 + CGI
- **Port:** 8080
- **DocumentRoot:** `/var/www/htdocs`
## Vývoj
### Editace
- UI formuláře (HTML): `cgi-bin/scenar.py`
- Logika (bez CGI): `scenar/core.py`
- Šablona: `templates/scenar_template.xlsx`
### Konvence
- Časy: `%H:%M` nebo `%H:%M:%S`
- Excel sloupce: `Datum, Zacatek, Konec, Program, Typ, Garant, Poznamka`
- Barvy: CSS hex → OpenPyXL AARRGGBB
- Bezpečnost: všechny vstupy validovány a escaped
## Testování
```bash
# Unit testy
pytest tests/test_read_excel.py -v
# Integrační (Podman build):
pytest tests/test_docker_integration.py -v
# Všechno:
pytest -v
```
## Troubleshooting
**Podman machine (macOS):**
```bash
podman machine start
```
**ImportError scenar.core:**
```bash
export PYTHONPATH="/path/to/repo:$PYTHONPATH"
```
**Testy selhávají:**
```bash
source .venv/bin/activate
pip install -r requirements.txt
pytest -q
```
## Kontakt
Autor: **Martin Sukaný** — martin@sukany.cz

145
TASK.md
View File

@@ -1,145 +0,0 @@
# Ú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

View File

@@ -923,3 +923,26 @@ body {
line-height: 1.1;
font-style: italic;
}
/* Docs version line */
.docs-version {
color: var(--text-light);
font-size: 12px;
margin-bottom: 16px;
}
.docs-container h3 {
margin-top: 20px;
margin-bottom: 8px;
font-size: 14px;
font-weight: 600;
color: var(--text);
border-bottom: 1px solid var(--border);
padding-bottom: 4px;
}
.docs-container h2 {
font-size: 20px;
margin-bottom: 4px;
color: var(--header-bg);
}

View File

@@ -83,73 +83,84 @@
<!-- Documentation tab -->
<div class="tab-content hidden" id="tab-docs">
<div class="docs-container">
<h2>Dokumentace — Scenár Creator v4</h2>
<h2>Scenár Creator — Dokumentace</h2>
<p class="docs-version">Verze 4.2 &nbsp;|&nbsp; <a href="/docs" target="_blank">Swagger API</a> &nbsp;|&nbsp; <a href="/api/sample">Vzorový JSON</a></p>
<h3>Jak začít</h3>
<ol>
<li><strong>Nový scénář</strong> — klikněte na "Nový scénář" v záhlaví.</li>
<li><strong>Import JSON</strong> — klikněte na "Import JSON" a vyberte uložený .json soubor.</li>
<li><strong>Nový scénář</strong> — klikněte na Nový scénář" v záhlaví. Vytvoří se prázdný scénář pro dnešní den.</li>
<li><strong>Import JSON</strong> — klikněte na Import JSON" a vyberte dříve uložený .json soubor.</li>
<li><strong>Vzorový JSON</strong> — stáhněte <a href="/api/sample">sample.json</a> jako šablonu a importujte ho.</li>
</ol>
<h3>Nastavení akce</h3>
<p>V postranním panelu nastavte:</p>
<h3>Nastavení akce (postranní panel)</h3>
<ul>
<li><strong>Od / Do</strong> — rozsah dat akce. Každý den se zobrazí jako jeden řádek v editoru.</li>
<li>Jedno datum = jednodnenní akce, více datumů = vícedenní kurz.</li>
<li><strong>Název / Podtitul / Místo</strong> — zobrazí se v záhlaví PDF.</li>
<li><strong>Od / Do</strong> — rozsah dat akce. Každý den = jeden řádek v editoru. Jednodnevní kurz = stejné datum v obou polích.</li>
<li><strong>Typy programů</strong> — přidejte typ kliknutím na „+ Přidat typ", nastavte název a barvu. Každý blok patří k jednomu typu.</li>
</ul>
<h3>Práce s bloky</h3>
<ul>
<li><strong>Přidání bloku:</strong> Klikněte na "+ Přidat blok" nebo klikněte na prázdné místo v časové ose.</li>
<li><strong>Přesun bloku:</strong> Chytněte blok a táhněte doleva/doprava po časové ose. Snap na 15 min.</li>
<li><strong>Přidání:</strong> Klikněte na + Přidat blok" nebo klikněte na prázdné místo v řádku dne.</li>
<li><strong>Přesun:</strong> Chytněte blok a táhněte doleva/doprava. Snap na 15 minut.</li>
<li><strong>Změna délky:</strong> Chytněte pravý okraj bloku a táhněte.</li>
<li><strong>Úprava bloku:</strong> Klikněte na blok.</li>
<li><strong>Smazání bloku:</strong> V editačním okně klikněte na "Smazat blok".</li>
<li><strong>Úprava:</strong> Klikněte na blok — otevře se formulář.</li>
<li><strong>Smazání:</strong> V editačním formuláři klikněte na Smazat blok".</li>
</ul>
<h3>Čas bloku</h3>
<h3>Formulář bloku</h3>
<ul>
<li>Zadejte <strong>Začátek</strong> a <strong>Konec</strong> (HH:MM).</li>
<li>Nebo zadejte <strong>Začátek</strong> a <strong>Trvání</strong> (HH:MM) — konec se vypočítá automaticky.</li>
<li>Lze zadat i <strong>program přes půlnoc</strong>konec může být menší než začátek (např. 23:00 → 01:30).</li>
<li><strong>Den</strong> — výběr pouze z nastavených dní akce (ne volné datum).</li>
<li><strong>Začátek / Konec</strong> — čas HH:MM.</li>
<li><strong>Nebo trvání</strong>zadáte hodiny a minuty → Konec se vypočítá. Funguje i opačně (zadáte Konec → trvání se aktualizuje).</li>
<li><strong>Program přes půlnoc</strong> — Konec &lt; Začátek je validní (blok přechází přes půlnoc). V editoru označen „→", v PDF správně vykreslí.</li>
<li><strong>Garant</strong> — zobrazí se v bloku v editoru i v PDF (pod názvem bloku).</li>
<li><strong>Poznámka</strong> — nezobrazuje se v editoru, pouze v PDF jako horní index (¹ ²...) u názvu bloku. Všechny poznámky jsou vypsány na 2. stránce PDF.</li>
</ul>
<h3>Typy programů a barvy</h3>
<h3>Export / Import</h3>
<ul>
<li>Přidejte typ kliknutím na "+ Přidat typ".</li>
<li>Nastavte název a barvu pomocí barevného výběru.</li>
</ul>
<h3>Export</h3>
<ul>
<li><strong>Export JSON</strong> — uloží scénář jako .json soubor pro pozdější použití.</li>
<li><strong>Generovat PDF</strong> — vytvoří tisknutelný PDF timetable (A4, jedna stránka, barvy, legenda).</li>
<li><strong>Vzorový JSON</strong><a href="/api/sample">stáhnout sample.json</a></li>
<li><strong>Export JSON</strong> — stáhne celý scénář jako .json soubor. Uložte pro pozdější editaci.</li>
<li><strong>Import JSON</strong> — načte dříve uložený .json soubor.</li>
<li><strong>Generovat PDF</strong> — vytvoří tisknutelný harmonogram:
<ul>
<li>Stránka 1: timetable (řádky = dny, sloupce = čas, barvy dle typů, legenda)</li>
<li>Stránka 2 (pokud jsou poznámky): výpis poznámek ke scénáři s čísly</li>
</ul>
</li>
</ul>
<h3>Formát JSON</h3>
<table class="docs-table">
<thead><tr><th>Pole</th><th>Typ</th><th>Popis</th></tr></thead>
<tbody>
<tr><td>event.title</td><td>string</td><td>Název akce</td></tr>
<tr><td>event.date_from</td><td>string</td><td>Začátek akce (YYYY-MM-DD)</td></tr>
<tr><td>event.date_to</td><td>string</td><td>Konec akce (YYYY-MM-DD)</td></tr>
<tr><td>event.location</td><td>string?</td><td>Místo konání</td></tr>
<tr><td>program_types[].id</td><td>string</td><td>Unikátní ID typu</td></tr>
<tr><td>program_types[].name</td><td>string</td><td>Název typu</td></tr>
<tr><td>program_types[].color</td><td>string</td><td>Barva (#RRGGBB)</td></tr>
<tr><td>blocks[].date</td><td>string</td><td>Datum bloku (YYYY-MM-DD)</td></tr>
<tr><td>blocks[].start</td><td>string</td><td>Začátek (HH:MM)</td></tr>
<tr><td>blocks[].end</td><td>string</td><td>Konec (HH:MM) — může být &lt; start pro přes-půlnoční blok</td></tr>
<tr><td>blocks[].title</td><td>string</td><td>Název bloku</td></tr>
<tr><td>blocks[].type_id</td><td>string</td><td>ID typu programu</td></tr>
<tr><td>blocks[].responsible</td><td>string?</td><td>Garant</td></tr>
<tr><td>blocks[].notes</td><td>string?</td><td>Poznámka</td></tr>
<tr><td>event.title</td><td>string</td><td>Název akce (povinné)</td></tr>
<tr><td>event.date_from</td><td>string</td><td>První den akce (YYYY-MM-DD)</td></tr>
<tr><td>event.date_to</td><td>string</td><td>Poslední den akce (YYYY-MM-DD)</td></tr>
<tr><td>event.subtitle</td><td>string?</td><td>Podtitul (nepovinné)</td></tr>
<tr><td>event.location</td><td>string?</td><td>Místo konání (nepovinné)</td></tr>
<tr><td>program_types[].id</td><td>string</td><td>Unikátní identifikátor typu</td></tr>
<tr><td>program_types[].name</td><td>string</td><td>Název typu (zobrazí se v legendě)</td></tr>
<tr><td>program_types[].color</td><td>string</td><td>Barva v hex formátu (#RRGGBB)</td></tr>
<tr><td>blocks[].id</td><td>string</td><td>Unikátní ID bloku (auto-generováno)</td></tr>
<tr><td>blocks[].date</td><td>string</td><td>Den bloku (YYYY-MM-DD)</td></tr>
<tr><td>blocks[].start</td><td>string</td><td>Čas začátku (HH:MM)</td></tr>
<tr><td>blocks[].end</td><td>string</td><td>Čas konce (HH:MM) — pokud end &lt; start, jde přes půlnoc</td></tr>
<tr><td>blocks[].title</td><td>string</td><td>Název bloku (povinné)</td></tr>
<tr><td>blocks[].type_id</td><td>string</td><td>ID typu programu (musí existovat v program_types)</td></tr>
<tr><td>blocks[].responsible</td><td>string?</td><td>Garant — zobrazí se v editoru i PDF</td></tr>
<tr><td>blocks[].notes</td><td>string?</td><td>Poznámka — jen v PDF, jako horní index + stránka 2</td></tr>
</tbody>
</table>
<h3>API dokumentace</h3>
<p><a href="/docs" class="btn btn-secondary btn-sm" target="_blank">Swagger UI</a></p>
<h3>Tipy</h3>
<ul>
<li>Scénář se <strong>neukládá automaticky</strong> — použijte Export JSON pro zálohování.</li>
<li>Barvy typů se projeví okamžitě v editoru i v PDF.</li>
<li>Pro tisk doporučujeme PDF v orientaci na šířku (A4 landscape).</li>
<li>Pokud blok přechází přes půlnoc, vidíte ho s šipkou „→" — v PDF se zobrazí do konce dne.</li>
</ul>
</div>
</div>

View File

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

View File

@@ -1 +0,0 @@
# Scenar Creator core module

View File

@@ -1,556 +0,0 @@
"""
Core logic for Scenar Creator — Excel parsing, timetable generation, validation.
Separates business logic from CGI/HTTP concerns.
"""
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
from io import BytesIO
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)}"
)
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 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
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

View File

@@ -1,20 +0,0 @@
#!/usr/bin/env bash
set -eu
# Usage: ./scripts/build_image.sh [image_tag]
IMAGE_TAG=${1:-scenar-creator:latest}
# Ensure podman machine is running (macOS/Windows)
if ! podman machine info >/dev/null 2>&1; then
echo "Starting podman machine..."
podman machine start || true
sleep 2
fi
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
echo "Building image '$IMAGE_TAG' from $REPO_ROOT..."
cd "$REPO_ROOT"
podman build -t "$IMAGE_TAG" .
echo "Image '$IMAGE_TAG' built successfully."
echo "Start container with: ./scripts/start_scenar.sh $IMAGE_TAG"

View File

@@ -1,14 +0,0 @@
#!/usr/bin/env bash
set -eu
# Install repository-local git hooks by setting core.hooksPath
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
HOOKS_DIR="$REPO_ROOT/.githooks"
if [ ! -d "$HOOKS_DIR" ]; then
echo "No .githooks directory found in repo root: $HOOKS_DIR"
exit 1
fi
git config core.hooksPath "$HOOKS_DIR"
echo "Installed git hooks path: $HOOKS_DIR"
echo "You can revert with: git config --unset core.hooksPath"

View File

@@ -1,17 +0,0 @@
#!/usr/bin/env bash
set -eu
# Usage: ./scripts/start_scenar.sh [image] [container_name] [port]
IMAGE=${1:-scenar-creator:latest}
NAME=${2:-scenar-creator}
PORT=${3:-8080}
# Ensure podman machine is running (macOS/Windows)
if ! podman machine info >/dev/null 2>&1; then
echo "Starting podman machine..."
podman machine start || true
sleep 2
fi
echo "Starting container '$NAME' from image '$IMAGE' on port $PORT..."
podman run -d --name "$NAME" -p "$PORT:8080" "$IMAGE"
echo "Container started."

View File

@@ -1,8 +0,0 @@
#!/usr/bin/env bash
set -eu
# Usage: ./scripts/stop_scenar.sh [container_name]
NAME=${1:-scenar-creator}
echo "Stopping and removing container '$NAME'..."
podman rm -f "$NAME" >/dev/null 2>&1 || true
echo "Container removed (if it existed)."