From 9a7ffdeb2c01418a014ed9255071efda3ccc6743 Mon Sep 17 00:00:00 2001 From: Martin Sukany Date: Thu, 13 Nov 2025 11:37:28 +0100 Subject: [PATCH] copilot test --- .githooks/pre-commit | 14 ++++++ .github/copilot-instructions.md | 79 +++++++++++++++++++++++++++++ .gitignore | 9 ++++ README.md | 71 +++++++++++++++++++++++++- cgi-bin/scenar.py | 5 ++ pytest.ini | 3 ++ requirements.txt | 3 ++ scripts/install_hooks.sh | 14 ++++++ scripts/start_scenar.sh | 10 ++++ scripts/stop_scenar.sh | 8 +++ tests/test_docker_integration.py | 56 +++++++++++++++++++++ tests/test_read_excel.py | 86 ++++++++++++++++++++++++++++++++ 12 files changed, 356 insertions(+), 2 deletions(-) create mode 100755 .githooks/pre-commit create mode 100644 .github/copilot-instructions.md create mode 100644 .gitignore create mode 100644 pytest.ini create mode 100644 requirements.txt create mode 100755 scripts/install_hooks.sh create mode 100644 scripts/start_scenar.sh create mode 100644 scripts/stop_scenar.sh create mode 100644 tests/test_docker_integration.py create mode 100644 tests/test_read_excel.py diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..08cf75a --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail +# Pre-commit hook: run tests and prevent commit on failures. +# By default run fast tests (exclude integration). To include integration tests set RUN_INTEGRATION=1. + +echo "Running pytest (fast) before commit..." +if [ "${RUN_INTEGRATION:-0}" = "1" ]; then + echo "RUN_INTEGRATION=1: including integration tests" + pytest -q +else + pytest -q -m "not integration" +fi + +echo "Tests passed. Proceeding with commit." diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..e9100ff --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,79 @@ +## Úč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/.gitignore b/.gitignore new file mode 100644 index 0000000..9ef078b --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# Runtime / local env +.venv/ +tmp/ +.pytest_cache/ +__pycache__/ +.DS_Store + +# test artifacts +tests/tmp/ diff --git a/README.md b/README.md index 4cd15a9..1817734 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,70 @@ -# Scenar-creator +# Scenar Creator -Repozitar obsahuje nastroj pro vytvareni scenaru pro zazitkove kurzy +Jednoduchá CGI aplikace pro vytváření časových plánů (Excel) z nahraného Excelu se seznamem programů. + +Krátký, praktický popis, jak projekt spustit, testovat a kde hledat důležité části kódu. + +## Co to dělá + +- `cgi-bin/scenar.py` načte Excel s řádky obsahujícími Datum, Začátek, Konec, Program, Typ, Garant, Poznámka. +- Na základě polí `Typ` vytvoří HTML formulář pro přiřazení popisů a barev, a nakonec vygeneruje výsledný sešit (OpenPyXL) s časovou osou. + +## Rychlý start (lokálně, bez Dockeru) + +1) V kořeni repozitáře spusť jednoduchý CGI server (vyžaduje Python): + +```bash +python -m http.server --cgi 8000 +# Otevři v prohlížeči: http://localhost:8000/cgi-bin/scenar.py +``` + +2) Alternativně připrav virtuální prostředí a spusť testy: + +```bash +python -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +pytest -q +``` + +## Docker (produkční přiblížení) + +Dockerfile vytváří image na `python:3.12-slim`, instaluje `pandas` a `openpyxl`, nastaví Apache s povoleným CGI a DocumentRoot na `/var/www/htdocs`. Nasazení: + +```bash +docker build -t scenar-creator . +docker run -p 8080:8080 scenar-creator +# Pak otevři: http://localhost:8080/ +``` + +## Testy + +- Testy jsou v `tests/` a používají `pytest`. +- Přidal jsem testy pro funkci `read_excel` (happy path + invalid time). Spuštění viz výše. + +## Struktura projektu (klíčové soubory) + +- `cgi-bin/scenar.py` — hlavní CGI skript (HTML generováno inline). Hledej funkce: + - `read_excel(file_content)` — parsování vstupního xlsx (pandas) → vrací (valid_data, error_rows) + - `create_timetable(...)` — vytváří OpenPyXL sešit + - `get_program_types(form)` — načítání dynamických polí `type_code_{i}`, `desc_{i}`, `color_{i}` +- `templates/scenar_template.xlsx` — šablona pro uživatele +- `tmp/` — místo, kam se výsledné soubory ukládají při běhu (v Dockeru `/var/www/htdocs/tmp`) +- `.github/copilot-instructions.md` — instrukce pro AI agenty (přehled konvencí a místa úprav) + +## Konvence a poznámky pro vývojáře + +- HTML jsou inline stringy v `scenar.py` (sekce `if step == '1'`, `if step == '2'`, `if step == '3'`). Pokud budeš měnit UI, uprav tyto bloky. +- Výstupní soubor se ukládá s "safe" jménem: povolena písmena, čísla a `. _ -` a mezera. +- Barvy pro OpenPyXL: v kódu se převádějí na AARRGGBB pomocí `'FF' + raw_color.lstrip('#')`. +- Parsování času: `normalize_time` podporuje `%H:%M` a `%H:%M:%S`. + +## Doporučené další kroky + +- Přidat `requirements.txt` (hotovo) s pevnými verzemi pro deterministické buildy. +- Přesunout jádro logiky z `cgi-bin/scenar.py` do importovatelného modulu (např. `scenar_core.py`) pro snazší testování a údržbu. +- Přidat GitHub Actions workflow, který spouští `pytest` na PR. + +## Kontakt + +Autor: Martin Sukaný — martin@sukany.cz diff --git a/cgi-bin/scenar.py b/cgi-bin/scenar.py index 58ffeaa..a7db64f 100755 --- a/cgi-bin/scenar.py +++ b/cgi-bin/scenar.py @@ -92,6 +92,11 @@ def read_excel(file_content): error_rows.append({"index": index, "row": row, "error": str(e)}) valid_data = pd.DataFrame(valid_data) + # Pokud nejsou žádné validní řádky, vrať prázdný DataFrame a chyby. + if valid_data.empty: + if show_debug: + print("
No valid rows after parsing
") + return valid_data.drop(columns='index', errors='ignore'), error_rows if show_debug: print("
Cleaned data:\n")
diff --git a/pytest.ini b/pytest.ini
new file mode 100644
index 0000000..f344ca4
--- /dev/null
+++ b/pytest.ini
@@ -0,0 +1,3 @@
+[pytest]
+markers =
+    integration: marks tests as integration (docker builds / long-running)
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..53e70ad
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,3 @@
+pandas
+openpyxl
+pytest
diff --git a/scripts/install_hooks.sh b/scripts/install_hooks.sh
new file mode 100755
index 0000000..b0c2ebe
--- /dev/null
+++ b/scripts/install_hooks.sh
@@ -0,0 +1,14 @@
+#!/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
new file mode 100644
index 0000000..44a883a
--- /dev/null
+++ b/scripts/start_scenar.sh
@@ -0,0 +1,10 @@
+#!/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}
+
+echo "Starting container '$NAME' from image '$IMAGE' on port $PORT..."
+docker run -d --name "$NAME" -p "$PORT:8080" "$IMAGE"
+echo "Container started."
diff --git a/scripts/stop_scenar.sh b/scripts/stop_scenar.sh
new file mode 100644
index 0000000..914df30
--- /dev/null
+++ b/scripts/stop_scenar.sh
@@ -0,0 +1,8 @@
+#!/usr/bin/env bash
+set -eu
+# Usage: ./scripts/stop_scenar.sh [container_name]
+NAME=${1:-scenar-creator}
+
+echo "Stopping and removing container '$NAME'..."
+docker rm -f "$NAME" >/dev/null 2>&1 || true
+echo "Container removed (if it existed)."
diff --git a/tests/test_docker_integration.py b/tests/test_docker_integration.py
new file mode 100644
index 0000000..9a47ed5
--- /dev/null
+++ b/tests/test_docker_integration.py
@@ -0,0 +1,56 @@
+import os
+import shutil
+import subprocess
+import time
+import socket
+import urllib.request
+
+import pytest
+
+
+def docker_available():
+    return shutil.which("docker") is not None
+
+
+def wait_for_http(url, timeout=30):
+    end = time.time() + timeout
+    last_exc = None
+    while time.time() < end:
+        try:
+            with urllib.request.urlopen(url, timeout=3) as r:
+                body = r.read().decode('utf-8', errors='ignore')
+                return r.getcode(), body
+        except Exception as e:
+            last_exc = e
+            time.sleep(0.5)
+    raise RuntimeError(f"HTTP check failed after {timeout}s: {last_exc}")
+
+
+@pytest.mark.skipif(not docker_available(), reason="Docker is not available on this runner")
+@pytest.mark.integration
+def test_build_run_and_cleanup_docker():
+    image_tag = "scenar-creator:latest"
+    container_name = "scenar-creator-test"
+    port = int(os.environ.get('SCENAR_TEST_PORT', '8080'))
+
+    # Build image
+    subprocess.run(["docker", "build", "-t", image_tag, "."], check=True)
+
+    # Ensure no leftover container
+    subprocess.run(["docker", "rm", "-f", container_name], check=False)
+
+    try:
+        # Run container
+        subprocess.run([
+            "docker", "run", "-d", "--name", container_name, "-p", f"{port}:8080", image_tag
+        ], check=True)
+
+        # Wait for HTTP and verify content
+        code, body = wait_for_http(f"http://127.0.0.1:{port}/")
+        assert code == 200
+        assert "Vytvoření Scénáře" in body or "Scenar" in body or "Vytvoření" in body
+
+    finally:
+        # Cleanup container and image
+        subprocess.run(["docker", "rm", "-f", container_name], check=False)
+        subprocess.run(["docker", "rmi", image_tag], check=False)
diff --git a/tests/test_read_excel.py b/tests/test_read_excel.py
new file mode 100644
index 0000000..c8262dc
--- /dev/null
+++ b/tests/test_read_excel.py
@@ -0,0 +1,86 @@
+import io
+import os
+import pandas as pd
+import importlib.util
+import sys
+import types
+
+
+def load_scenar_module():
+    repo_root = os.path.dirname(os.path.dirname(__file__))
+    scenar_path = os.path.join(repo_root, 'cgi-bin', 'scenar.py')
+    # Provide a minimal fake `cgi` module so top-level imports in the CGI script don't fail
+    if 'cgi' not in sys.modules:
+        fake_cgi = types.ModuleType('cgi')
+        class FakeFieldStorage:
+            def getvalue(self, key, default=None):
+                return default
+            def keys(self):
+                return []
+            def __contains__(self, item):
+                return False
+        fake_cgi.FieldStorage = FakeFieldStorage
+        sys.modules['cgi'] = fake_cgi
+    # minimal fake cgitb (some environments don't expose it)
+    if 'cgitb' not in sys.modules:
+        fake_cgitb = types.ModuleType('cgitb')
+        def fake_enable():
+            return None
+        fake_cgitb.enable = fake_enable
+        sys.modules['cgitb'] = fake_cgitb
+
+    spec = importlib.util.spec_from_file_location('scenar', scenar_path)
+    module = importlib.util.module_from_spec(spec)
+    # executing the module will run top-level CGI code (prints etc.) but defines functions we need
+    spec.loader.exec_module(module)
+    return module
+
+
+def make_excel_bytes(df: pd.DataFrame) -> bytes:
+    bio = io.BytesIO()
+    with pd.ExcelWriter(bio, engine='openpyxl') as writer:
+        df.to_excel(writer, index=False)
+    return bio.getvalue()
+
+
+def test_read_excel_happy_path():
+    scenar = load_scenar_module()
+
+    df = pd.DataFrame({
+        'Datum': [pd.Timestamp('2025-11-13').date()],
+        'Zacatek': ['09:00'],
+        'Konec': ['10:00'],
+        'Program': ['Test program'],
+        'Typ': ['WORKSHOP'],
+        'Garant': ['Garant Name'],
+        'Poznamka': ['Pozn']
+    })
+
+    content = make_excel_bytes(df)
+    valid, errors = scenar.read_excel(content)
+
+    assert isinstance(valid, pd.DataFrame)
+    assert len(errors) == 0
+    assert len(valid) == 1
+    assert valid.iloc[0]['Program'] == 'Test program'
+
+
+def test_read_excel_invalid_time():
+    scenar = load_scenar_module()
+
+    df = pd.DataFrame({
+        'Datum': [pd.Timestamp('2025-11-13').date()],
+        'Zacatek': ['not-a-time'],
+        'Konec': ['10:00'],
+        'Program': ['Bad Time'],
+        'Typ': ['LECTURE'],
+        'Garant': [None],
+        'Poznamka': [None]
+    })
+
+    content = make_excel_bytes(df)
+    valid, errors = scenar.read_excel(content)
+
+    # invalid time should produce at least one error row and valid may be empty
+    assert isinstance(errors, list)
+    assert len(errors) >= 1