copilot test
This commit is contained in:
14
.githooks/pre-commit
Executable file
14
.githooks/pre-commit
Executable file
@@ -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."
|
||||||
79
.github/copilot-instructions.md
vendored
Normal file
79
.github/copilot-instructions.md
vendored
Normal file
@@ -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/<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`).
|
||||||
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Runtime / local env
|
||||||
|
.venv/
|
||||||
|
tmp/
|
||||||
|
.pytest_cache/
|
||||||
|
__pycache__/
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# test artifacts
|
||||||
|
tests/tmp/
|
||||||
71
README.md
71
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
|
||||||
|
|||||||
@@ -92,6 +92,11 @@ def read_excel(file_content):
|
|||||||
error_rows.append({"index": index, "row": row, "error": str(e)})
|
error_rows.append({"index": index, "row": row, "error": str(e)})
|
||||||
|
|
||||||
valid_data = pd.DataFrame(valid_data)
|
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("<pre>No valid rows after parsing</pre>")
|
||||||
|
return valid_data.drop(columns='index', errors='ignore'), error_rows
|
||||||
|
|
||||||
if show_debug:
|
if show_debug:
|
||||||
print("<pre>Cleaned data:\n")
|
print("<pre>Cleaned data:\n")
|
||||||
|
|||||||
3
pytest.ini
Normal file
3
pytest.ini
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[pytest]
|
||||||
|
markers =
|
||||||
|
integration: marks tests as integration (docker builds / long-running)
|
||||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
pandas
|
||||||
|
openpyxl
|
||||||
|
pytest
|
||||||
14
scripts/install_hooks.sh
Executable file
14
scripts/install_hooks.sh
Executable file
@@ -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"
|
||||||
10
scripts/start_scenar.sh
Normal file
10
scripts/start_scenar.sh
Normal file
@@ -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."
|
||||||
8
scripts/stop_scenar.sh
Normal file
8
scripts/stop_scenar.sh
Normal file
@@ -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)."
|
||||||
56
tests/test_docker_integration.py
Normal file
56
tests/test_docker_integration.py
Normal file
@@ -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)
|
||||||
86
tests/test_read_excel.py
Normal file
86
tests/test_read_excel.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user