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

{html.escape(message)}

-

← Zpět na úvodní formulář

-
- -''') - - -def render_home() -> None: - """Render home page with import and builder tabs.""" - print(''' - - - -Scenar Creator - Nový scénář - - - -
-

�� Scenar Creator

- -
- - -
- -
-
-

Importovat scénář z Excelu

-

📥 Stáhnout šablonu

-
-
- - -
-
- - -
-
- - -
-
- - -
- - -
-
-
- -
-
-

Vytvořit scénář přímo

-
-
- - -
-
- - -
- -

Program (řádky)

-
- - - - - - - - - - - - - - - - - - - - - - - - - -
DatumZačátekKonecProgramTypGarantPoznámkaAkce
- - - -
-
- - -

Typy programu

-
-
- - - - -
-
- - - - -
- - -
-
-
- - - -''') - - -# ====== 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(''' - - - -Scenar Creator - Upravit scénář - - - -
-

Upravit scénář

-
-
-
- - -
-
- - -
-
- -
-

Program (řádky)

-
- - - - - - - - - - - - - - -''') - - # 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''' - - - - - - - - - -''') - row_counter += 1 - - print(''' -
DatumZačátekKonecProgramTypGarantPoznámkaAkce
- - - -
-
- -
- -
-

Typy programu (nastavení barev a popisů)

-
-''') - - # Load type definitions - type_counter = 0 - for type_name in program_types: - print(f'''
- - - -
-''') - type_counter += 1 - - print('''
- -
- -
- - -
-
-
- - - -''') - - 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''' - - - -Scenar Creator - Výsledek - - - -
-
-

✅ Scénář úspěšně vygenerován!

-

{html.escape(title)}

-

{html.escape(detail)}

- 📥 Stáhnout scénář -

- ← Zpět na úvodní formulář -

-
-
- -''') - - 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.") diff --git a/scenar/__init__.py b/scenar/__init__.py deleted file mode 100644 index aef8b5d..0000000 --- a/scenar/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Scenar Creator core module diff --git a/scenar/core.py b/scenar/core.py deleted file mode 100644 index 91b2908..0000000 --- a/scenar/core.py +++ /dev/null @@ -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 diff --git a/scripts/build_image.sh b/scripts/build_image.sh deleted file mode 100755 index 66d80ac..0000000 --- a/scripts/build_image.sh +++ /dev/null @@ -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" diff --git a/scripts/install_hooks.sh b/scripts/install_hooks.sh deleted file mode 100755 index b0c2ebe..0000000 --- a/scripts/install_hooks.sh +++ /dev/null @@ -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" diff --git a/scripts/start_scenar.sh b/scripts/start_scenar.sh deleted file mode 100755 index 0b99245..0000000 --- a/scripts/start_scenar.sh +++ /dev/null @@ -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." diff --git a/scripts/stop_scenar.sh b/scripts/stop_scenar.sh deleted file mode 100755 index 8d4e2b3..0000000 --- a/scripts/stop_scenar.sh +++ /dev/null @@ -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)."