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
Some checks failed
Build & Push Docker / build (push) Has been cancelled
This commit is contained in:
79
.github/copilot-instructions.md
vendored
79
.github/copilot-instructions.md
vendored
@@ -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`).
|
|
||||||
@@ -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
350
README.md
@@ -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
|
## Funkce
|
||||||
- ✅ **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
|
|
||||||
|
|
||||||
## 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
|
```bash
|
||||||
git clone <repo>
|
# Klonování
|
||||||
|
git clone https://git.apps.sukany.cz/martin/scenar-creator.git
|
||||||
cd scenar-creator
|
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
|
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
|
```bash
|
||||||
python -m http.server --cgi 8000
|
python3 -m pytest tests/ -v
|
||||||
# Otevři v prohlížeči: http://localhost:8000/cgi-bin/scenar.py
|
|
||||||
```
|
```
|
||||||
|
|
||||||
3) **Spusť testy:**
|
35 testů pokrývá API endpointy, PDF generátor a validaci dat.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build a deploy
|
||||||
|
|
||||||
|
### Manuální postup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pytest -q
|
# 1. Build image
|
||||||
# Bez integračních testů (Docker):
|
podman build --format docker -t git.apps.sukany.cz/martin/scenar-creator:latest .
|
||||||
pytest -q -m "not integration"
|
|
||||||
|
# 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
|
Kubernetes manifest: `sukany-org/rke2-deployments` → `scenar/scenar.yaml`
|
||||||
./scripts/build_image.sh
|
|
||||||
# nebo ručně:
|
---
|
||||||
podman build -t scenar-creator:latest .
|
|
||||||
|
## 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
|
**Zpětná kompatibilita:** pole `date` (jednodnevní starý formát) je stále akceptováno.
|
||||||
./scripts/start_scenar.sh
|
|
||||||
# Aplikace bude dostupná na http://127.0.0.1:8080/
|
|
||||||
```
|
|
||||||
|
|
||||||
3) **Zastavit kontejner:**
|
---
|
||||||
|
|
||||||
```bash
|
## API endpointy
|
||||||
./scripts/stop_scenar.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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
|
## Struktura projektu
|
||||||
|
|
||||||
```
|
```
|
||||||
.
|
app/
|
||||||
├── .github/copilot-instructions.md # AI instrukce pro agenty
|
api/ REST endpointy (scenario.py, pdf.py, router.py)
|
||||||
├── .githooks/
|
core/ Business logika (pdf_generator.py, validator.py)
|
||||||
│ └── pre-commit # Git hook se spuštěním testů
|
models/ Pydantic modely (event.py, responses.py)
|
||||||
├── cgi-bin/
|
static/ Frontend (index.html, css/, js/)
|
||||||
│ └── scenar.py # Hlavní CGI aplikace (UI)
|
js/
|
||||||
├── scenar/
|
app.js State management, modal logika
|
||||||
│ ├── __init__.py
|
canvas.js Horizontální canvas editor (interact.js)
|
||||||
│ └── core.py # Jádro logiky (bez CGI)
|
api.js Fetch wrapper
|
||||||
├── scripts/
|
export.js JSON import/export
|
||||||
│ ├── build_image.sh # Postav Podman image
|
tests/ 35 pytest testů
|
||||||
│ ├── start_scenar.sh # Spusť kontejner
|
Dockerfile python:3.12-slim + fonts-liberation
|
||||||
│ ├── stop_scenar.sh # Zastavit kontejner
|
requirements.txt
|
||||||
│ └── 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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
145
TASK.md
@@ -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
|
|
||||||
@@ -923,3 +923,26 @@ body {
|
|||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
font-style: italic;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -83,73 +83,84 @@
|
|||||||
<!-- Documentation tab -->
|
<!-- Documentation tab -->
|
||||||
<div class="tab-content hidden" id="tab-docs">
|
<div class="tab-content hidden" id="tab-docs">
|
||||||
<div class="docs-container">
|
<div class="docs-container">
|
||||||
<h2>Dokumentace — Scenár Creator v4</h2>
|
<h2>Scenár Creator — Dokumentace</h2>
|
||||||
|
<p class="docs-version">Verze 4.2 | <a href="/docs" target="_blank">Swagger API</a> | <a href="/api/sample">Vzorový JSON</a></p>
|
||||||
|
|
||||||
<h3>Jak začít</h3>
|
<h3>Jak začít</h3>
|
||||||
<ol>
|
<ol>
|
||||||
<li><strong>Nový scénář</strong> — klikněte na "Nový scénář" v záhlaví.</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 uložený .json soubor.</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>
|
</ol>
|
||||||
|
|
||||||
<h3>Nastavení akce</h3>
|
<h3>Nastavení akce (postranní panel)</h3>
|
||||||
<p>V postranním panelu nastavte:</p>
|
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>Od / Do</strong> — rozsah dat akce. Každý den se zobrazí jako jeden řádek v editoru.</li>
|
<li><strong>Název / Podtitul / Místo</strong> — zobrazí se v záhlaví PDF.</li>
|
||||||
<li>Jedno datum = jednodnenní akce, více datumů = vícedenní kurz.</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>
|
</ul>
|
||||||
|
|
||||||
<h3>Práce s bloky</h3>
|
<h3>Práce s bloky</h3>
|
||||||
<ul>
|
<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ř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 bloku:</strong> Chytněte blok a táhněte doleva/doprava po časové ose. Snap na 15 min.</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>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>Úprava:</strong> Klikněte na blok — otevře se formulář.</li>
|
||||||
<li><strong>Smazání bloku:</strong> V editačním okně klikněte na "Smazat blok".</li>
|
<li><strong>Smazání:</strong> V editačním formuláři klikněte na „Smazat blok".</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h3>Čas bloku</h3>
|
<h3>Formulář bloku</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Zadejte <strong>Začátek</strong> a <strong>Konec</strong> (HH:MM).</li>
|
<li><strong>Den</strong> — výběr pouze z nastavených dní akce (ne volné datum).</li>
|
||||||
<li>Nebo zadejte <strong>Začátek</strong> a <strong>Trvání</strong> (HH:MM) — konec se vypočítá automaticky.</li>
|
<li><strong>Začátek / Konec</strong> — čas HH:MM.</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>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 < 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>
|
</ul>
|
||||||
|
|
||||||
<h3>Typy programů a barvy</h3>
|
<h3>Export / Import</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Přidejte typ kliknutím na "+ Přidat typ".</li>
|
<li><strong>Export JSON</strong> — stáhne celý scénář jako .json soubor. Uložte pro pozdější editaci.</li>
|
||||||
<li>Nastavte název a barvu pomocí barevného výběru.</li>
|
<li><strong>Import JSON</strong> — načte dříve uložený .json soubor.</li>
|
||||||
</ul>
|
<li><strong>Generovat PDF</strong> — vytvoří tisknutelný harmonogram:
|
||||||
|
<ul>
|
||||||
<h3>Export</h3>
|
<li>Stránka 1: timetable (řádky = dny, sloupce = čas, barvy dle typů, legenda)</li>
|
||||||
<ul>
|
<li>Stránka 2 (pokud jsou poznámky): výpis poznámek ke scénáři s čísly</li>
|
||||||
<li><strong>Export JSON</strong> — uloží scénář jako .json soubor pro pozdější použití.</li>
|
</ul>
|
||||||
<li><strong>Generovat PDF</strong> — vytvoří tisknutelný PDF timetable (A4, jedna stránka, barvy, legenda).</li>
|
</li>
|
||||||
<li><strong>Vzorový JSON</strong> — <a href="/api/sample">stáhnout sample.json</a></li>
|
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h3>Formát JSON</h3>
|
<h3>Formát JSON</h3>
|
||||||
<table class="docs-table">
|
<table class="docs-table">
|
||||||
<thead><tr><th>Pole</th><th>Typ</th><th>Popis</th></tr></thead>
|
<thead><tr><th>Pole</th><th>Typ</th><th>Popis</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr><td>event.title</td><td>string</td><td>Název akce</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>Začátek akce (YYYY-MM-DD)</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>Konec 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.location</td><td>string?</td><td>Místo konání</td></tr>
|
<tr><td>event.subtitle</td><td>string?</td><td>Podtitul (nepovinné)</td></tr>
|
||||||
<tr><td>program_types[].id</td><td>string</td><td>Unikátní ID typu</td></tr>
|
<tr><td>event.location</td><td>string?</td><td>Místo konání (nepovinné)</td></tr>
|
||||||
<tr><td>program_types[].name</td><td>string</td><td>Název typu</td></tr>
|
<tr><td>program_types[].id</td><td>string</td><td>Unikátní identifikátor typu</td></tr>
|
||||||
<tr><td>program_types[].color</td><td>string</td><td>Barva (#RRGGBB)</td></tr>
|
<tr><td>program_types[].name</td><td>string</td><td>Název typu (zobrazí se v legendě)</td></tr>
|
||||||
<tr><td>blocks[].date</td><td>string</td><td>Datum bloku (YYYY-MM-DD)</td></tr>
|
<tr><td>program_types[].color</td><td>string</td><td>Barva v hex formátu (#RRGGBB)</td></tr>
|
||||||
<tr><td>blocks[].start</td><td>string</td><td>Začátek (HH:MM)</td></tr>
|
<tr><td>blocks[].id</td><td>string</td><td>Unikátní ID bloku (auto-generováno)</td></tr>
|
||||||
<tr><td>blocks[].end</td><td>string</td><td>Konec (HH:MM) — může být < start pro přes-půlnoční blok</td></tr>
|
<tr><td>blocks[].date</td><td>string</td><td>Den bloku (YYYY-MM-DD)</td></tr>
|
||||||
<tr><td>blocks[].title</td><td>string</td><td>Název bloku</td></tr>
|
<tr><td>blocks[].start</td><td>string</td><td>Čas začátku (HH:MM)</td></tr>
|
||||||
<tr><td>blocks[].type_id</td><td>string</td><td>ID typu programu</td></tr>
|
<tr><td>blocks[].end</td><td>string</td><td>Čas konce (HH:MM) — pokud end < start, jde přes půlnoc</td></tr>
|
||||||
<tr><td>blocks[].responsible</td><td>string?</td><td>Garant</td></tr>
|
<tr><td>blocks[].title</td><td>string</td><td>Název bloku (povinné)</td></tr>
|
||||||
<tr><td>blocks[].notes</td><td>string?</td><td>Poznámka</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<h3>API dokumentace</h3>
|
<h3>Tipy</h3>
|
||||||
<p><a href="/docs" class="btn btn-secondary btn-sm" target="_blank">Swagger UI</a></p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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.")
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
# Scenar Creator core module
|
|
||||||
556
scenar/core.py
556
scenar/core.py
@@ -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
|
|
||||||
@@ -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"
|
|
||||||
@@ -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"
|
|
||||||
@@ -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."
|
|
||||||
@@ -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)."
|
|
||||||
Reference in New Issue
Block a user