diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..93235b1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +.gitea +__pycache__ +*.pyc +*.pyo +.pytest_cache +tmp/ +*.core +cgi-bin/*.core diff --git a/Dockerfile b/Dockerfile index 963a8df..762ef12 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,64 +1,21 @@ FROM python:3.12-slim -ENV DEBIAN_FRONTEND=noninteractive \ - PYTHONDONTWRITEBYTECODE=1 \ +ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 -# OS packages: Apache + curl RUN apt-get update \ - && apt-get install -y --no-install-recommends apache2 curl \ + && apt-get install -y --no-install-recommends curl \ && rm -rf /var/lib/apt/lists/* -# Enable CGI and disable default /usr/lib/cgi-bin alias -RUN a2enmod cgid && a2disconf serve-cgi-bin || true +WORKDIR /app -# Use /var/www/htdocs as DocumentRoot to match your layout -RUN mkdir -p /var/www/htdocs -WORKDIR /var/www/htdocs - -# Copy app (including scenar package for imports) -COPY cgi-bin ./cgi-bin -COPY templates ./templates -COPY scenar ./scenar -COPY requirements.txt ./requirements.txt - -# Ensure CGI scripts are executable -RUN find /var/www/htdocs/cgi-bin -type f -name "*.py" -exec chmod 0755 {} \; - -# Writable tmp + kompatibilita s /scripts/tmp (skrypt nic neupravujeme) -RUN mkdir -p /var/www/htdocs/tmp \ - /var/www/htdocs/scripts/tmp \ - && chown -R www-data:www-data /var/www/htdocs/tmp /var/www/htdocs/scripts \ - && chmod 0775 /var/www/htdocs/tmp /var/www/htdocs/scripts/tmp - -# --- Python dependencies (from requirements.txt) --- +COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -# Listen on 8080 -RUN sed -ri 's/Listen 80/Listen 8080/g' /etc/apache2/ports.conf - -# Vhost: enable CGI in DocumentRoot; index => scenar.py -RUN printf '%s\n' \ -'' \ -' ServerName localhost' \ -' ServerAdmin webmaster@localhost' \ -' DocumentRoot /var/www/htdocs' \ -'' \ -' ' \ -' Options +ExecCGI -Indexes' \ -' AllowOverride None' \ -' Require all granted' \ -' AddHandler cgi-script .py' \ -' DirectoryIndex /cgi-bin/scenar.py' \ -' ' \ -'' \ -' ErrorLog ${APACHE_LOG_DIR}/error.log' \ -' CustomLog ${APACHE_LOG_DIR}/access.log combined' \ -'' \ -> /etc/apache2/sites-available/000-default.conf - -HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ - CMD curl -fsS http://127.0.0.1:8080/ >/dev/null || exit 1 +COPY . . EXPOSE 8080 -CMD ["apachectl", "-D", "FOREGROUND"] + +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"] diff --git a/TASK.md b/TASK.md new file mode 100644 index 0000000..2a98563 --- /dev/null +++ b/TASK.md @@ -0,0 +1,145 @@ +# Ú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/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e92c9f1 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +"""Scenar Creator - FastAPI application for timetable generation.""" diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..d819caa --- /dev/null +++ b/app/api/__init__.py @@ -0,0 +1 @@ +"""API routes for Scenar Creator.""" diff --git a/app/api/pdf.py b/app/api/pdf.py new file mode 100644 index 0000000..5193564 --- /dev/null +++ b/app/api/pdf.py @@ -0,0 +1,56 @@ +"""PDF generation API endpoint.""" + +from io import BytesIO + +import pandas as pd +from fastapi import APIRouter, HTTPException +from fastapi.responses import StreamingResponse + +from app.models.event import ScenarioDocument +from app.core.validator import validate_inputs, ValidationError, ScenarsError +from app.core.pdf_generator import generate_pdf + +router = APIRouter() + + +@router.post("/generate-pdf") +async def generate_pdf_endpoint(doc: ScenarioDocument): + """Generate PDF timetable from ScenarioDocument.""" + try: + validate_inputs(doc.event.title, doc.event.detail, 0) + except ValidationError as e: + raise HTTPException(status_code=422, detail=str(e)) + + # Convert to DataFrame + rows = [] + for block in doc.blocks: + rows.append({ + 'Datum': block.datum, + 'Zacatek': block.zacatek, + 'Konec': block.konec, + 'Program': block.program, + 'Typ': block.typ, + 'Garant': block.garant, + 'Poznamka': block.poznamka, + }) + + df = pd.DataFrame(rows) + + if df.empty: + raise HTTPException(status_code=422, detail="No blocks provided") + + # Build program descriptions and colors + program_descriptions = {pt.code: pt.description for pt in doc.program_types} + program_colors = {pt.code: 'FF' + pt.color.lstrip('#') for pt in doc.program_types} + + try: + pdf_bytes = generate_pdf(df, doc.event.title, doc.event.detail, + program_descriptions, program_colors) + except ScenarsError as e: + raise HTTPException(status_code=422, detail=str(e)) + + return StreamingResponse( + BytesIO(pdf_bytes), + media_type="application/pdf", + headers={"Content-Disposition": "attachment; filename=scenar_timetable.pdf"} + ) diff --git a/app/api/router.py b/app/api/router.py new file mode 100644 index 0000000..4c9b227 --- /dev/null +++ b/app/api/router.py @@ -0,0 +1,10 @@ +"""Main API router combining all sub-routers.""" + +from fastapi import APIRouter + +from . import scenario, pdf + +api_router = APIRouter(prefix="/api") + +api_router.include_router(scenario.router) +api_router.include_router(pdf.router) diff --git a/app/api/scenario.py b/app/api/scenario.py new file mode 100644 index 0000000..3b21ce7 --- /dev/null +++ b/app/api/scenario.py @@ -0,0 +1,168 @@ +"""Scenario API endpoints: validate, import-excel, generate-excel, export-json, template.""" + +import os +from io import BytesIO +from datetime import date, time as dt_time + +import pandas as pd +from fastapi import APIRouter, UploadFile, File, Form, HTTPException +from fastapi.responses import StreamingResponse, FileResponse + +from app.config import VERSION, MAX_FILE_SIZE_MB +from app.models.event import ScenarioDocument, Block, ProgramType, EventInfo +from app.models.responses import HealthResponse, ValidationResponse, ImportExcelResponse +from app.core.validator import validate_inputs, ValidationError, TemplateError, ScenarsError +from app.core.excel_reader import read_excel, parse_inline_schedule, parse_inline_types +from app.core.timetable import create_timetable + +router = APIRouter() + + +@router.get("/health", response_model=HealthResponse) +async def health(): + return HealthResponse(version=VERSION) + + +@router.post("/validate", response_model=ValidationResponse) +async def validate_scenario(doc: ScenarioDocument): + """Validate a ScenarioDocument.""" + errors = [] + + if not doc.blocks: + errors.append("No blocks defined") + + if not doc.program_types: + errors.append("No program types defined") + + type_codes = {pt.code for pt in doc.program_types} + for i, block in enumerate(doc.blocks): + if block.typ not in type_codes: + errors.append(f"Block {i+1}: unknown type '{block.typ}'") + if block.zacatek >= block.konec: + errors.append(f"Block {i+1}: start time must be before end time") + + return ValidationResponse(valid=len(errors) == 0, errors=errors) + + +@router.post("/import-excel") +async def import_excel( + file: UploadFile = File(...), + title: str = Form("Imported Event"), + detail: str = Form("Imported from Excel"), +): + """Upload Excel file and return ScenarioDocument JSON.""" + content = await file.read() + + if len(content) > MAX_FILE_SIZE_MB * 1024 * 1024: + raise HTTPException(status_code=413, detail=f"File exceeds {MAX_FILE_SIZE_MB}MB limit") + + try: + valid_data, error_rows = read_excel(content) + except TemplateError as e: + raise HTTPException(status_code=422, detail=str(e)) + + if valid_data.empty: + raise HTTPException(status_code=422, detail="No valid rows found in Excel file") + + # Extract unique types + types_in_data = valid_data["Typ"].dropna().unique().tolist() + program_types = [ + ProgramType(code=t, description=str(t), color="#0070C0") + for t in types_in_data + ] + + # Convert rows to blocks + blocks = [] + for _, row in valid_data.iterrows(): + blocks.append(Block( + datum=row["Datum"], + zacatek=row["Zacatek"], + konec=row["Konec"], + program=str(row["Program"]), + typ=str(row["Typ"]), + garant=str(row["Garant"]) if pd.notna(row.get("Garant")) else None, + poznamka=str(row["Poznamka"]) if pd.notna(row.get("Poznamka")) else None, + )) + + doc = ScenarioDocument( + event=EventInfo(title=title, detail=detail), + program_types=program_types, + blocks=blocks, + ) + + warnings = [f"Row {e['index']}: {e.get('error', e.get('Error', 'unknown'))}" for e in error_rows] + + return ImportExcelResponse( + success=True, + document=doc.model_dump(mode="json"), + warnings=warnings, + ) + + +@router.post("/generate-excel") +async def generate_excel(doc: ScenarioDocument): + """Generate Excel timetable from ScenarioDocument.""" + try: + validate_inputs(doc.event.title, doc.event.detail, 0) + except ValidationError as e: + raise HTTPException(status_code=422, detail=str(e)) + + # Convert to DataFrame + rows = [] + for block in doc.blocks: + rows.append({ + 'Datum': block.datum, + 'Zacatek': block.zacatek, + 'Konec': block.konec, + 'Program': block.program, + 'Typ': block.typ, + 'Garant': block.garant, + 'Poznamka': block.poznamka, + }) + + df = pd.DataFrame(rows) + + if df.empty: + raise HTTPException(status_code=422, detail="No blocks provided") + + # Build program descriptions and colors + program_descriptions = {pt.code: pt.description for pt in doc.program_types} + program_colors = {pt.code: 'FF' + pt.color.lstrip('#') for pt in doc.program_types} + + try: + wb = create_timetable(df, doc.event.title, doc.event.detail, + program_descriptions, program_colors) + except ScenarsError as e: + raise HTTPException(status_code=422, detail=str(e)) + + output = BytesIO() + wb.save(output) + output.seek(0) + + return StreamingResponse( + output, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": "attachment; filename=scenar_timetable.xlsx"} + ) + + +@router.post("/export-json") +async def export_json(doc: ScenarioDocument): + """Export ScenarioDocument as JSON.""" + return doc.model_dump(mode="json") + + +@router.get("/template") +async def download_template(): + """Download the Excel template file.""" + template_path = os.path.join(os.path.dirname(__file__), "..", "..", "templates", "scenar_template.xlsx") + template_path = os.path.abspath(template_path) + + if not os.path.exists(template_path): + raise HTTPException(status_code=404, detail="Template file not found") + + return FileResponse( + template_path, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + filename="scenar_template.xlsx" + ) diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..1651943 --- /dev/null +++ b/app/config.py @@ -0,0 +1,5 @@ +"""Application configuration.""" + +VERSION = "2.0.0" +MAX_FILE_SIZE_MB = 10 +DEFAULT_COLOR = "#ffffff" diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..ca49866 --- /dev/null +++ b/app/core/__init__.py @@ -0,0 +1,28 @@ +"""Core business logic for Scenar Creator.""" + +from .validator import ( + ScenarsError, + ValidationError, + TemplateError, + validate_inputs, + validate_excel_template, + normalize_time, +) +from .timetable import create_timetable, calculate_row_height, calculate_column_width +from .excel_reader import read_excel, get_program_types, parse_inline_schedule, parse_inline_types + +__all__ = [ + "ScenarsError", + "ValidationError", + "TemplateError", + "validate_inputs", + "validate_excel_template", + "normalize_time", + "create_timetable", + "calculate_row_height", + "calculate_column_width", + "read_excel", + "get_program_types", + "parse_inline_schedule", + "parse_inline_types", +] diff --git a/app/core/excel_reader.py b/app/core/excel_reader.py new file mode 100644 index 0000000..dc7ed61 --- /dev/null +++ b/app/core/excel_reader.py @@ -0,0 +1,274 @@ +""" +Excel reading and form parsing logic for Scenar Creator. +Extracted from scenar/core.py — read_excel, get_program_types, parse_inline_schedule, parse_inline_types. +""" + +import pandas as pd +from io import BytesIO +import logging + +from .validator import ( + validate_excel_template, + normalize_time, + ValidationError, + TemplateError, + DEFAULT_COLOR, +) + +logger = logging.getLogger(__name__) + + +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 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/app/core/pdf_generator.py b/app/core/pdf_generator.py new file mode 100644 index 0000000..b1458f5 --- /dev/null +++ b/app/core/pdf_generator.py @@ -0,0 +1,194 @@ +""" +PDF generation for Scenar Creator using ReportLab. +Generates A4 landscape timetable PDF with colored blocks and legend. +""" + +import pandas as pd +from io import BytesIO +from datetime import datetime +from reportlab.lib import colors +from reportlab.lib.pagesizes import A4, landscape +from reportlab.lib.units import mm +from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer +from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle +from reportlab.lib.enums import TA_CENTER, TA_LEFT +import logging + +from .validator import ScenarsError + +logger = logging.getLogger(__name__) + + +def hex_to_reportlab_color(hex_color: str) -> colors.Color: + """Convert hex color (AARRGGBB or #RRGGBB) to ReportLab color.""" + h = hex_color.lstrip('#') + if len(h) == 8: # AARRGGBB format + h = h[2:] # strip alpha + if len(h) == 6: + r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16) + return colors.Color(r / 255.0, g / 255.0, b / 255.0) + return colors.white + + +def generate_pdf(data: pd.DataFrame, title: str, detail: str, + program_descriptions: dict, program_colors: dict) -> bytes: + """ + Generate a PDF timetable. + + Args: + data: DataFrame with validated schedule data + title: Event title + detail: Event detail/description + program_descriptions: {type: description} + program_colors: {type: color_hex in AARRGGBB format} + + Returns: + bytes: PDF file content + + Raises: + ScenarsError: if data is invalid + """ + 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." + ) + + buffer = BytesIO() + doc = SimpleDocTemplate( + buffer, + pagesize=landscape(A4), + leftMargin=10 * mm, + rightMargin=10 * mm, + topMargin=10 * mm, + bottomMargin=10 * mm, + ) + + styles = getSampleStyleSheet() + title_style = ParagraphStyle( + 'TimetableTitle', parent=styles['Title'], + fontSize=18, alignment=TA_CENTER, spaceAfter=2 * mm + ) + detail_style = ParagraphStyle( + 'TimetableDetail', parent=styles['Normal'], + fontSize=12, alignment=TA_CENTER, spaceAfter=4 * mm, + textColor=colors.gray + ) + cell_style = ParagraphStyle( + 'CellStyle', parent=styles['Normal'], + fontSize=7, alignment=TA_CENTER, leading=9 + ) + legend_style = ParagraphStyle( + 'LegendStyle', parent=styles['Normal'], + fontSize=8, alignment=TA_LEFT + ) + + elements = [] + elements.append(Paragraph(title, title_style)) + elements.append(Paragraph(detail, detail_style)) + + data = data.sort_values(by=["Datum", "Zacatek"]) + + start_times = data["Zacatek"] + end_times = data["Konec"] + + min_time = min(start_times) + max_time = max(end_times) + + time_slots = pd.date_range( + datetime.combine(datetime.today(), min_time), + datetime.combine(datetime.today(), max_time), + freq='15min' + ).time + + # Build header row + header = ["Datum"] + [t.strftime("%H:%M") for t in time_slots] + + table_data = [header] + cell_colors = [] # list of (row, col, color) for styling + + grouped_data = data.groupby(data['Datum']) + row_idx = 1 + + for date_val, group in grouped_data: + day_name = date_val.strftime("%A") + date_str = date_val.strftime(f"%d.%m {day_name}") + + row = [date_str] + [""] * len(time_slots) + table_data.append(row) + row_idx += 1 + + # Create a sub-row for blocks + block_row = [""] * (len(time_slots) + 1) + for _, blk in group.iterrows(): + try: + start_idx = list(time_slots).index(blk["Zacatek"]) + 1 + end_idx = list(time_slots).index(blk["Konec"]) + 1 + except ValueError: + continue + + label = blk['Program'] + if pd.notna(blk.get('Garant')): + label += f"\n{blk['Garant']}" + + block_row[start_idx] = Paragraph(label.replace('\n', '
'), cell_style) + + rl_color = hex_to_reportlab_color(program_colors[blk["Typ"]]) + for ci in range(start_idx, end_idx): + cell_colors.append((row_idx, ci, rl_color)) + + table_data.append(block_row) + row_idx += 1 + + # Calculate column widths + avail_width = landscape(A4)[0] - 20 * mm + date_col_width = 30 * mm + slot_width = max(12 * mm, (avail_width - date_col_width) / max(len(time_slots), 1)) + col_widths = [date_col_width] + [slot_width] * len(time_slots) + + table = Table(table_data, colWidths=col_widths, repeatRows=1) + + style_cmds = [ + ('BACKGROUND', (0, 0), (-1, 0), colors.Color(0.83, 0.83, 0.83)), + ('TEXTCOLOR', (0, 0), (-1, 0), colors.black), + ('ALIGN', (0, 0), (-1, -1), 'CENTER'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('FONTSIZE', (0, 0), (-1, 0), 7), + ('FONTSIZE', (0, 1), (-1, -1), 6), + ('GRID', (0, 0), (-1, -1), 0.5, colors.black), + ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.Color(0.97, 0.97, 0.97)]), + ] + + for r, c, clr in cell_colors: + style_cmds.append(('BACKGROUND', (c, r), (c, r), clr)) + + table.setStyle(TableStyle(style_cmds)) + elements.append(table) + + # Legend + elements.append(Spacer(1, 5 * mm)) + elements.append(Paragraph("Legenda:", legend_style)) + + legend_data = [] + legend_colors_list = [] + for i, (typ, desc) in enumerate(program_descriptions.items()): + legend_data.append([Paragraph(f"{desc} ({typ})", legend_style)]) + legend_colors_list.append(hex_to_reportlab_color(program_colors[typ])) + + if legend_data: + legend_table = Table(legend_data, colWidths=[80 * mm]) + legend_cmds = [ + ('GRID', (0, 0), (-1, -1), 0.5, colors.black), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ] + for i, clr in enumerate(legend_colors_list): + legend_cmds.append(('BACKGROUND', (0, i), (0, i), clr)) + legend_table.setStyle(TableStyle(legend_cmds)) + elements.append(legend_table) + + doc.build(elements) + return buffer.getvalue() diff --git a/app/core/timetable.py b/app/core/timetable.py new file mode 100644 index 0000000..af97851 --- /dev/null +++ b/app/core/timetable.py @@ -0,0 +1,242 @@ +""" +Timetable generation logic for Scenar Creator. +Extracted from scenar/core.py — create_timetable (Excel output). +""" + +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 +import logging + +from .validator import ScenarsError + +logger = logging.getLogger(__name__) + + +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 diff --git a/app/core/validator.py b/app/core/validator.py new file mode 100644 index 0000000..94b9dc5 --- /dev/null +++ b/app/core/validator.py @@ -0,0 +1,69 @@ +""" +Validation logic for Scenar Creator. +Extracted from scenar/core.py — validate_inputs, validate_excel_template, overlap detection. +""" + +import pandas as pd +from datetime import datetime +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)}" + ) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..451384d --- /dev/null +++ b/app/main.py @@ -0,0 +1,26 @@ +"""FastAPI application entry point.""" + +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse +import os + +from app.api.router import api_router +from app.config import VERSION + +app = FastAPI( + title="Scenar Creator", + description="Web tool for creating timetable scenarios from Excel or inline forms", + version=VERSION, +) + +app.include_router(api_router) + +# Static files +static_dir = os.path.join(os.path.dirname(__file__), "static") +app.mount("/static", StaticFiles(directory=static_dir), name="static") + + +@app.get("/", include_in_schema=False) +async def root(): + return FileResponse(os.path.join(static_dir, "index.html")) diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..bcd4556 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,3 @@ +from .event import Block, ProgramType, EventInfo, ScenarioDocument + +__all__ = ["Block", "ProgramType", "EventInfo", "ScenarioDocument"] diff --git a/app/models/event.py b/app/models/event.py new file mode 100644 index 0000000..a87b72c --- /dev/null +++ b/app/models/event.py @@ -0,0 +1,34 @@ +"""Pydantic v2 models for Scenar Creator.""" + +from datetime import date, time +from typing import List, Optional + +from pydantic import BaseModel, Field + + +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] diff --git a/app/models/responses.py b/app/models/responses.py new file mode 100644 index 0000000..d642e0d --- /dev/null +++ b/app/models/responses.py @@ -0,0 +1,22 @@ +"""API response models.""" + +from typing import Any, List, Optional + +from pydantic import BaseModel + + +class HealthResponse(BaseModel): + status: str = "ok" + version: str + + +class ValidationResponse(BaseModel): + valid: bool + errors: List[str] = [] + + +class ImportExcelResponse(BaseModel): + success: bool + document: Optional[Any] = None + errors: List[str] = [] + warnings: List[str] = [] diff --git a/app/static/css/app.css b/app/static/css/app.css new file mode 100644 index 0000000..6409944 --- /dev/null +++ b/app/static/css/app.css @@ -0,0 +1,293 @@ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background: #f5f5f5; + color: #333; + line-height: 1.6; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 20px; +} + +h1 { + text-align: center; + color: #2c3e50; + margin-bottom: 5px; +} + +.subtitle { + text-align: center; + color: #7f8c8d; + margin-bottom: 20px; +} + +/* Tabs */ +.tabs { + display: flex; + gap: 0; + margin-bottom: 0; + border-bottom: 2px solid #3498db; +} + +.tab { + padding: 10px 24px; + border: 1px solid #ddd; + border-bottom: none; + background: #ecf0f1; + cursor: pointer; + font-size: 14px; + border-radius: 6px 6px 0 0; + transition: background 0.2s; +} + +.tab.active { + background: #fff; + border-color: #3498db; + border-bottom: 2px solid #fff; + margin-bottom: -2px; + font-weight: bold; +} + +.tab:hover:not(.active) { + background: #d5dbdb; +} + +.tab-content { + display: none; + background: #fff; + padding: 20px; + border: 1px solid #ddd; + border-top: none; + border-radius: 0 0 6px 6px; +} + +.tab-content.active { + display: block; +} + +/* Form */ +.form-group { + margin-bottom: 15px; +} + +.form-group label { + display: block; + font-weight: 600; + margin-bottom: 4px; +} + +.form-group input[type="text"], +.form-group input[type="file"] { + width: 100%; + padding: 8px 12px; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 14px; +} + +.form-actions { + margin-top: 20px; + display: flex; + gap: 10px; +} + +/* Buttons */ +.btn { + display: inline-block; + padding: 10px 20px; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + text-decoration: none; + text-align: center; + transition: background 0.2s; +} + +.btn-primary { + background: #3498db; + color: #fff; +} + +.btn-primary:hover { + background: #2980b9; +} + +.btn-secondary { + background: #95a5a6; + color: #fff; +} + +.btn-secondary:hover { + background: #7f8c8d; +} + +.btn-danger { + background: #e74c3c; + color: #fff; +} + +.btn-danger:hover { + background: #c0392b; +} + +.btn-sm { + padding: 4px 10px; + font-size: 12px; +} + +/* Types */ +.type-row { + display: flex; + gap: 8px; + margin-bottom: 8px; + align-items: center; +} + +.type-row input[type="text"] { + flex: 1; + padding: 6px 10px; + border: 1px solid #ccc; + border-radius: 4px; +} + +.type-row input[type="color"] { + width: 40px; + height: 32px; + border: 1px solid #ccc; + border-radius: 4px; + cursor: pointer; +} + +.type-code { + max-width: 200px; +} + +/* Schedule table */ +#scheduleTable { + width: 100%; + border-collapse: collapse; + margin: 10px 0; +} + +#scheduleTable th, +#scheduleTable td { + padding: 6px 8px; + border: 1px solid #ddd; + text-align: left; +} + +#scheduleTable th { + background: #ecf0f1; + font-weight: 600; + font-size: 13px; +} + +#scheduleTable input { + width: 100%; + padding: 4px 6px; + border: 1px solid #ccc; + border-radius: 3px; + font-size: 13px; +} + +#scheduleTable input[type="date"] { + min-width: 130px; +} + +#scheduleTable input[type="time"] { + min-width: 90px; +} + +/* Editor area */ +.editor-area { + background: #fff; + padding: 20px; + border: 1px solid #ddd; + border-radius: 6px; + margin-top: 20px; +} + +.editor-area h2 { + margin-bottom: 10px; + color: #2c3e50; +} + +.editor-area h3 { + margin: 15px 0 8px; + color: #34495e; +} + +/* Imported type editor */ +.imported-type-row { + display: flex; + gap: 8px; + margin-bottom: 6px; + align-items: center; +} + +.imported-type-row input[type="text"] { + flex: 1; + padding: 5px 8px; + border: 1px solid #ccc; + border-radius: 3px; +} + +.imported-type-row input[type="color"] { + width: 36px; + height: 28px; + border: 1px solid #ccc; + border-radius: 3px; +} + +/* Block list */ +.block-item { + background: #f9f9f9; + padding: 8px 12px; + margin-bottom: 4px; + border-radius: 4px; + border-left: 4px solid #3498db; + font-size: 13px; +} + +/* Status message */ +.status-message { + margin-top: 15px; + padding: 12px; + border-radius: 4px; + font-size: 14px; +} + +.status-message.success { + background: #d5f5e3; + color: #27ae60; + border: 1px solid #27ae60; +} + +.status-message.error { + background: #fadbd8; + color: #e74c3c; + border: 1px solid #e74c3c; +} + +/* JSON import */ +.json-import { + margin-top: 20px; + padding: 10px; + background: #fafafa; + border-radius: 4px; + font-size: 13px; +} + +h3 { + margin: 20px 0 10px; + color: #2c3e50; +} diff --git a/app/static/index.html b/app/static/index.html new file mode 100644 index 0000000..6093bbe --- /dev/null +++ b/app/static/index.html @@ -0,0 +1,134 @@ + + + + + + Scenar Creator + + + +
+

Scenar Creator

+

Tvorba časových harmonogramů

+ + +
+ + +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + Stáhnout šablonu +
+
+
+ + +
+
+
+ + +
+
+ + +
+ +

Typy programů

+
+
+ + + + +
+
+ + +

Časový harmonogram

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
DatumZačátekKonecProgramTypGarantPoznámka
+
+ + +
+ + +
+
+
+ + + + + +
+ +
+ + +
+ + + + + + diff --git a/app/static/js/api.js b/app/static/js/api.js new file mode 100644 index 0000000..910cbff --- /dev/null +++ b/app/static/js/api.js @@ -0,0 +1,61 @@ +/** + * API fetch wrapper for Scenar Creator. + */ + +const API = { + async post(url, body, isJson = true) { + const opts = { method: 'POST' }; + if (isJson) { + opts.headers = { 'Content-Type': 'application/json' }; + opts.body = JSON.stringify(body); + } else { + opts.body = body; // FormData + } + const res = await fetch(url, opts); + return res; + }, + + async postJson(url, body) { + const res = await this.post(url, body, true); + if (!res.ok) { + const err = await res.json().catch(() => ({ detail: res.statusText })); + throw new Error(err.detail || 'API error'); + } + return res.json(); + }, + + async postBlob(url, body) { + const res = await this.post(url, body, true); + if (!res.ok) { + const err = await res.json().catch(() => ({ detail: res.statusText })); + throw new Error(err.detail || 'API error'); + } + return res.blob(); + }, + + async postFormData(url, formData) { + const res = await this.post(url, formData, false); + if (!res.ok) { + const err = await res.json().catch(() => ({ detail: res.statusText })); + throw new Error(err.detail || 'API error'); + } + return res.json(); + }, + + async get(url) { + const res = await fetch(url); + if (!res.ok) throw new Error(res.statusText); + return res.json(); + }, + + downloadBlob(blob, filename) { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } +}; diff --git a/app/static/js/app.js b/app/static/js/app.js new file mode 100644 index 0000000..4265731 --- /dev/null +++ b/app/static/js/app.js @@ -0,0 +1,258 @@ +/** + * Main application logic for Scenar Creator SPA. + */ + +window.currentDocument = null; +let typeCounter = 1; +let scheduleCounter = 1; + +/* --- Tab switching --- */ +function switchTab(event, tabId) { + document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); + document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); + event.target.classList.add('active'); + document.getElementById(tabId).classList.add('active'); +} + +/* --- Status messages --- */ +function showStatus(message, type) { + const el = document.getElementById('statusMessage'); + el.textContent = message; + el.className = 'status-message ' + type; + el.style.display = 'block'; + setTimeout(() => { el.style.display = 'none'; }, 5000); +} + +/* --- Type management --- */ +function addTypeRow() { + const container = document.getElementById('typesContainer'); + const idx = typeCounter++; + const div = document.createElement('div'); + div.className = 'type-row'; + div.setAttribute('data-index', idx); + div.innerHTML = ` + + + + + `; + container.appendChild(div); + updateTypeDatalist(); +} + +function removeTypeRow(idx) { + const row = document.querySelector(`.type-row[data-index="${idx}"]`); + if (row) row.remove(); + updateTypeDatalist(); +} + +function updateTypeDatalist() { + const datalist = document.getElementById('availableTypes'); + datalist.innerHTML = ''; + document.querySelectorAll('#typesContainer .type-row').forEach(row => { + const nameInput = row.querySelector('input[name^="type_name_"]'); + if (nameInput && nameInput.value.trim()) { + const opt = document.createElement('option'); + opt.value = nameInput.value.trim(); + datalist.appendChild(opt); + } + }); +} + +// Update datalist on type name changes +document.getElementById('typesContainer').addEventListener('input', function (e) { + if (e.target.name && e.target.name.startsWith('type_name_')) { + updateTypeDatalist(); + } +}); + +/* --- Schedule management --- */ +function addScheduleRow() { + const tbody = document.getElementById('scheduleBody'); + const idx = scheduleCounter++; + const tr = document.createElement('tr'); + tr.setAttribute('data-index', idx); + tr.innerHTML = ` + + + + + + + + + `; + tbody.appendChild(tr); +} + +function removeScheduleRow(idx) { + const row = document.querySelector(`#scheduleBody tr[data-index="${idx}"]`); + if (row) row.remove(); +} + +/* --- Build ScenarioDocument from builder form --- */ +function buildDocumentFromForm() { + const title = document.getElementById('builderTitle').value.trim(); + const detail = document.getElementById('builderDetail').value.trim(); + + if (!title || !detail) { + throw new Error('Název akce a detail jsou povinné'); + } + + // Collect types + const programTypes = []; + document.querySelectorAll('#typesContainer .type-row').forEach(row => { + const code = row.querySelector('input[name^="type_name_"]').value.trim(); + const desc = row.querySelector('input[name^="type_desc_"]').value.trim(); + const color = row.querySelector('input[name^="type_color_"]').value; + if (code) { + programTypes.push({ code, description: desc, color }); + } + }); + + // Collect blocks + const blocks = []; + document.querySelectorAll('#scheduleBody tr').forEach(tr => { + const inputs = tr.querySelectorAll('input'); + const datum = inputs[0].value; + const zacatek = inputs[1].value; + const konec = inputs[2].value; + const program = inputs[3].value.trim(); + const typ = inputs[4].value.trim(); + const garant = inputs[5].value.trim() || null; + const poznamka = inputs[6].value.trim() || null; + + if (datum && zacatek && konec && program && typ) { + blocks.push({ datum, zacatek, konec, program, typ, garant, poznamka }); + } + }); + + if (blocks.length === 0) { + throw new Error('Přidejte alespoň jeden blok'); + } + + return { + version: "1.0", + event: { title, detail }, + program_types: programTypes, + blocks + }; +} + +/* --- Handle builder form submit (Excel) --- */ +async function handleBuild(event) { + event.preventDefault(); + try { + const doc = buildDocumentFromForm(); + const blob = await API.postBlob('/api/generate-excel', doc); + API.downloadBlob(blob, 'scenar_timetable.xlsx'); + showStatus('Excel vygenerován', 'success'); + } catch (err) { + showStatus('Chyba: ' + err.message, 'error'); + } + return false; +} + +/* --- Handle builder PDF --- */ +async function handleBuildPdf() { + try { + const doc = buildDocumentFromForm(); + const blob = await API.postBlob('/api/generate-pdf', doc); + API.downloadBlob(blob, 'scenar_timetable.pdf'); + showStatus('PDF vygenerován', 'success'); + } catch (err) { + showStatus('Chyba: ' + err.message, 'error'); + } +} + +/* --- Handle Excel import --- */ +async function handleImport(event) { + event.preventDefault(); + const form = document.getElementById('importForm'); + const formData = new FormData(form); + + try { + const result = await API.postFormData('/api/import-excel', formData); + if (result.success && result.document) { + window.currentDocument = result.document; + showImportedDocument(result.document); + if (result.warnings && result.warnings.length > 0) { + showStatus('Import OK, warnings: ' + result.warnings.join('; '), 'success'); + } else { + showStatus('Excel importován', 'success'); + } + } else { + showStatus('Import failed: ' + (result.errors || []).join('; '), 'error'); + } + } catch (err) { + showStatus('Chyba importu: ' + err.message, 'error'); + } + return false; +} + +/* --- Show imported document in editor --- */ +function showImportedDocument(doc) { + const area = document.getElementById('editorArea'); + area.style.display = 'block'; + + // Info + document.getElementById('importedInfo').innerHTML = + `${doc.event.title} — ${doc.event.detail}`; + + // Types + const typesHtml = doc.program_types.map((pt, i) => ` +
+ + + +
+ `).join(''); + document.getElementById('importedTypesContainer').innerHTML = typesHtml; + + // Blocks + const blocksHtml = doc.blocks.map(b => + `
${b.datum} ${b.zacatek}–${b.konec} | ${b.program} [${b.typ}] ${b.garant || ''}
` + ).join(''); + document.getElementById('importedBlocksContainer').innerHTML = blocksHtml; +} + +/* --- Get current document (with any edits from import editor) --- */ +function getCurrentDocument() { + if (!window.currentDocument) { + throw new Error('No document loaded'); + } + // Update types from editor + const typeRows = document.querySelectorAll('#importedTypesContainer .imported-type-row'); + if (typeRows.length > 0) { + window.currentDocument.program_types = Array.from(typeRows).map(row => ({ + code: row.querySelector('[data-field="code"]').value.trim(), + description: row.querySelector('[data-field="description"]').value.trim(), + color: row.querySelector('[data-field="color"]').value, + })); + } + return window.currentDocument; +} + +/* --- Generate Excel from imported data --- */ +async function generateExcelFromImport() { + try { + const doc = getCurrentDocument(); + const blob = await API.postBlob('/api/generate-excel', doc); + API.downloadBlob(blob, 'scenar_timetable.xlsx'); + showStatus('Excel vygenerován', 'success'); + } catch (err) { + showStatus('Chyba: ' + err.message, 'error'); + } +} + +/* --- Generate PDF from imported data --- */ +async function generatePdfFromImport() { + try { + const doc = getCurrentDocument(); + const blob = await API.postBlob('/api/generate-pdf', doc); + API.downloadBlob(blob, 'scenar_timetable.pdf'); + showStatus('PDF vygenerován', 'success'); + } catch (err) { + showStatus('Chyba: ' + err.message, 'error'); + } +} diff --git a/app/static/js/export.js b/app/static/js/export.js new file mode 100644 index 0000000..cddaaa6 --- /dev/null +++ b/app/static/js/export.js @@ -0,0 +1,34 @@ +/** + * JSON import/export for Scenar Creator. + */ + +function exportJson() { + if (!window.currentDocument) { + showStatus('No document to export', 'error'); + return; + } + const json = JSON.stringify(window.currentDocument, null, 2); + const blob = new Blob([json], { type: 'application/json' }); + API.downloadBlob(blob, 'scenar_export.json'); +} + +function handleJsonImport(event) { + const file = event.target.files[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = function (e) { + try { + const doc = JSON.parse(e.target.result); + if (!doc.event || !doc.blocks || !doc.program_types) { + throw new Error('Invalid ScenarioDocument format'); + } + window.currentDocument = doc; + showImportedDocument(doc); + showStatus('JSON imported successfully', 'success'); + } catch (err) { + showStatus('JSON import error: ' + err.message, 'error'); + } + }; + reader.readAsText(file); +} diff --git a/cgi-bin/gio-querymodules.core b/cgi-bin/gio-querymodules.core deleted file mode 100644 index a79e943..0000000 Binary files a/cgi-bin/gio-querymodules.core and /dev/null differ diff --git a/cgi-bin/glib-compile-schemas.core b/cgi-bin/glib-compile-schemas.core deleted file mode 100644 index 6d0bf42..0000000 Binary files a/cgi-bin/glib-compile-schemas.core and /dev/null differ diff --git a/cgi-bin/python3.10.core b/cgi-bin/python3.10.core deleted file mode 100644 index 421fe7a..0000000 Binary files a/cgi-bin/python3.10.core and /dev/null differ diff --git a/cgi-bin/python3.9.core b/cgi-bin/python3.9.core deleted file mode 100644 index 3997175..0000000 Binary files a/cgi-bin/python3.9.core and /dev/null differ diff --git a/cgi-bin/update-desktop-database.core b/cgi-bin/update-desktop-database.core deleted file mode 100644 index 90a7925..0000000 Binary files a/cgi-bin/update-desktop-database.core and /dev/null differ diff --git a/requirements.txt b/requirements.txt index b1ce9ae..0a5a1d9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,8 @@ -pandas==2.1.3 -openpyxl==3.1.5 -pytest==7.4.3 +fastapi>=0.115 +uvicorn[standard]>=0.34 +python-multipart>=0.0.20 +reportlab>=4.0 +pandas>=2.1.3 +openpyxl>=3.1.5 +pytest>=7.4.3 +httpx>=0.27 diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..da9f4c2 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,180 @@ +""" +API endpoint tests using FastAPI TestClient. +""" + +import io +import json +import pandas as pd +import pytest +from fastapi.testclient import TestClient + +from app.main import app + + +@pytest.fixture +def client(): + return TestClient(app) + + +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_health(client): + r = client.get("/api/health") + assert r.status_code == 200 + data = r.json() + assert data["status"] == "ok" + assert data["version"] == "2.0.0" + + +def test_root_returns_html(client): + r = client.get("/") + assert r.status_code == 200 + assert "text/html" in r.headers["content-type"] + assert "Scenar Creator" in r.text + + +def test_validate_valid(client): + doc = { + "event": {"title": "Test", "detail": "Detail"}, + "program_types": [{"code": "WS", "description": "Workshop", "color": "#FF0000"}], + "blocks": [{ + "datum": "2025-11-13", + "zacatek": "09:00:00", + "konec": "10:00:00", + "program": "Opening", + "typ": "WS" + }] + } + r = client.post("/api/validate", json=doc) + assert r.status_code == 200 + data = r.json() + assert data["valid"] is True + assert data["errors"] == [] + + +def test_validate_unknown_type(client): + doc = { + "event": {"title": "Test", "detail": "Detail"}, + "program_types": [{"code": "WS", "description": "Workshop", "color": "#FF0000"}], + "blocks": [{ + "datum": "2025-11-13", + "zacatek": "09:00:00", + "konec": "10:00:00", + "program": "Opening", + "typ": "UNKNOWN" + }] + } + r = client.post("/api/validate", json=doc) + assert r.status_code == 200 + data = r.json() + assert data["valid"] is False + assert any("UNKNOWN" in e for e in data["errors"]) + + +def test_validate_bad_time_order(client): + doc = { + "event": {"title": "Test", "detail": "Detail"}, + "program_types": [{"code": "WS", "description": "Workshop", "color": "#FF0000"}], + "blocks": [{ + "datum": "2025-11-13", + "zacatek": "10:00:00", + "konec": "09:00:00", + "program": "Bad", + "typ": "WS" + }] + } + r = client.post("/api/validate", json=doc) + assert r.status_code == 200 + data = r.json() + assert data["valid"] is False + + +def test_import_excel(client): + df = pd.DataFrame({ + 'Datum': [pd.Timestamp('2025-11-13').date()], + 'Zacatek': ['09:00'], + 'Konec': ['10:00'], + 'Program': ['Test Program'], + 'Typ': ['WORKSHOP'], + 'Garant': ['John'], + 'Poznamka': ['Note'] + }) + + content = make_excel_bytes(df) + r = client.post( + "/api/import-excel", + files={"file": ("test.xlsx", content, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")}, + data={"title": "Imported Event", "detail": "From Excel"} + ) + + assert r.status_code == 200 + data = r.json() + assert data["success"] is True + assert data["document"] is not None + assert data["document"]["event"]["title"] == "Imported Event" + assert len(data["document"]["blocks"]) == 1 + + +def test_generate_excel(client): + doc = { + "event": {"title": "Test Event", "detail": "Test Detail"}, + "program_types": [{"code": "WS", "description": "Workshop", "color": "#0070C0"}], + "blocks": [{ + "datum": "2025-11-13", + "zacatek": "09:00:00", + "konec": "10:00:00", + "program": "Opening", + "typ": "WS", + "garant": "John", + "poznamka": "Note" + }] + } + r = client.post("/api/generate-excel", json=doc) + assert r.status_code == 200 + assert "spreadsheetml" in r.headers["content-type"] + assert len(r.content) > 0 + + +def test_generate_excel_no_blocks(client): + doc = { + "event": {"title": "Test", "detail": "Detail"}, + "program_types": [{"code": "WS", "description": "Workshop", "color": "#FF0000"}], + "blocks": [] + } + r = client.post("/api/generate-excel", json=doc) + assert r.status_code == 422 + + +def test_export_json(client): + doc = { + "event": {"title": "Test", "detail": "Detail"}, + "program_types": [{"code": "WS", "description": "Workshop", "color": "#FF0000"}], + "blocks": [{ + "datum": "2025-11-13", + "zacatek": "09:00:00", + "konec": "10:00:00", + "program": "Opening", + "typ": "WS" + }] + } + r = client.post("/api/export-json", json=doc) + assert r.status_code == 200 + data = r.json() + assert data["event"]["title"] == "Test" + assert len(data["blocks"]) == 1 + + +def test_template_download(client): + r = client.get("/api/template") + # Template might not exist in test env, but endpoint should work + assert r.status_code in [200, 404] + + +def test_swagger_docs(client): + r = client.get("/docs") + assert r.status_code == 200 diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..a1de2f8 --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,532 @@ +""" +Core business logic tests — adapted from original test_read_excel.py and test_inline_builder.py. +Tests the refactored app.core modules. +""" + +import io +import pandas as pd +import pytest +from datetime import date, time + +from app.core import ( + read_excel, create_timetable, get_program_types, ScenarsError, + parse_inline_schedule, parse_inline_types, ValidationError, + validate_inputs, normalize_time, +) + + +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() + + +# --- Validator tests --- + +def test_validate_inputs_valid(): + validate_inputs("Title", "Detail", 100) + + +def test_validate_inputs_empty_title(): + with pytest.raises(ValidationError): + validate_inputs("", "Detail", 100) + + +def test_validate_inputs_long_title(): + with pytest.raises(ValidationError): + validate_inputs("x" * 201, "Detail", 100) + + +def test_validate_inputs_file_too_large(): + with pytest.raises(ValidationError): + validate_inputs("Title", "Detail", 11 * 1024 * 1024) + + +def test_normalize_time_hhmm(): + t = normalize_time("09:00") + assert t == time(9, 0) + + +def test_normalize_time_hhmmss(): + t = normalize_time("09:00:00") + assert t == time(9, 0) + + +def test_normalize_time_invalid(): + assert normalize_time("invalid") is None + + +# --- Excel reader tests --- + +def test_read_excel_happy_path(): + 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 = 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(): + 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 = read_excel(content) + + assert isinstance(errors, list) + assert len(errors) >= 1 + + +def test_get_program_types(): + form_data = { + 'type_code_0': 'WORKSHOP', + 'type_code_1': 'LECTURE', + 'desc_0': 'Workshop description', + 'desc_1': 'Lecture description', + 'color_0': '#FF0000', + 'color_1': '#00FF00', + } + + descriptions, colors = get_program_types(form_data) + + assert len(descriptions) == 2 + assert descriptions['WORKSHOP'] == 'Workshop description' + assert descriptions['LECTURE'] == 'Lecture description' + assert colors['WORKSHOP'] == 'FFFF0000' + assert colors['LECTURE'] == 'FF00FF00' + + +# --- Timetable tests --- + +def test_create_timetable(): + df = pd.DataFrame({ + 'Datum': [pd.Timestamp('2025-11-13').date()], + 'Zacatek': [time(9, 0)], + 'Konec': [time(10, 0)], + 'Program': ['Test Program'], + 'Typ': ['WORKSHOP'], + 'Garant': ['John Doe'], + 'Poznamka': ['Test note'], + }) + + program_descriptions = {'WORKSHOP': 'Workshop Type'} + program_colors = {'WORKSHOP': 'FF0070C0'} + + wb = create_timetable( + df, title="Test Timetable", detail="Test Detail", + program_descriptions=program_descriptions, + program_colors=program_colors + ) + + assert wb is not None + assert len(wb.sheetnames) > 0 + ws = wb.active + assert ws['A1'].value == "Test Timetable" + assert ws['A2'].value == "Test Detail" + + +def test_create_timetable_with_color_dict(): + df = pd.DataFrame({ + 'Datum': [pd.Timestamp('2025-11-13').date()], + 'Zacatek': [time(9, 0)], + 'Konec': [time(10, 0)], + 'Program': ['Lecture 101'], + 'Typ': ['LECTURE'], + 'Garant': ['Dr. Smith'], + 'Poznamka': [None], + }) + + program_descriptions = {'LECTURE': 'Standard Lecture'} + program_colors = {'LECTURE': 'FFFF6600'} + + wb = create_timetable( + df, title="Advanced Timetable", detail="With color dict", + program_descriptions=program_descriptions, + program_colors=program_colors + ) + + assert wb is not None + ws = wb.active + assert ws['A1'].value == "Advanced Timetable" + + +# --- Inline schedule tests --- + +def test_parse_inline_schedule(): + form_data = { + 'datum_0': '2025-11-13', + 'zacatek_0': '09:00', + 'konec_0': '10:00', + 'program_0': 'Test Program', + 'typ_0': 'WORKSHOP', + 'garant_0': 'John Doe', + 'poznamka_0': 'Test note', + 'datum_1': '2025-11-13', + 'zacatek_1': '10:30', + 'konec_1': '11:30', + 'program_1': 'Another Program', + 'typ_1': 'LECTURE', + 'garant_1': 'Jane Smith', + 'poznamka_1': '', + } + + df = parse_inline_schedule(form_data) + + assert isinstance(df, pd.DataFrame) + assert len(df) == 2 + assert df.iloc[0]['Program'] == 'Test Program' + assert df.iloc[0]['Typ'] == 'WORKSHOP' + assert df.iloc[1]['Program'] == 'Another Program' + assert df.iloc[1]['Typ'] == 'LECTURE' + + +def test_parse_inline_schedule_missing_required(): + form_data = { + 'datum_0': '2025-11-13', + 'zacatek_0': '09:00', + } + + with pytest.raises(ValidationError): + parse_inline_schedule(form_data) + + +def test_parse_inline_types(): + form_data = { + 'type_name_0': 'WORKSHOP', + 'type_desc_0': 'Workshop Type', + 'type_color_0': '#0070C0', + 'type_name_1': 'LECTURE', + 'type_desc_1': 'Lecture Type', + 'type_color_1': '#FF6600', + } + + descriptions, colors = parse_inline_types(form_data) + + assert len(descriptions) == 2 + assert descriptions['WORKSHOP'] == 'Workshop Type' + assert descriptions['LECTURE'] == 'Lecture Type' + assert colors['WORKSHOP'] == 'FF0070C0' + assert colors['LECTURE'] == 'FFFF6600' + + +# --- Integration tests --- + +def test_inline_workflow_integration(): + schedule_form = { + 'datum_0': '2025-11-13', + 'zacatek_0': '09:00', + 'konec_0': '10:00', + 'program_0': 'Opening', + 'typ_0': 'KEYNOTE', + 'garant_0': 'Dr. Smith', + 'poznamka_0': 'Start of event', + } + + types_form = { + 'type_name_0': 'KEYNOTE', + 'type_desc_0': 'Keynote Speech', + 'type_color_0': '#FF0000', + } + + form_data = {**schedule_form, **types_form} + + df = parse_inline_schedule(form_data) + descriptions, colors = parse_inline_types(form_data) + + wb = create_timetable( + df, title="Integration Test Event", detail="Testing inline workflow", + program_descriptions=descriptions, program_colors=colors + ) + + assert wb is not None + ws = wb.active + assert ws['A1'].value == "Integration Test Event" + assert ws['A2'].value == "Testing inline workflow" + + +def test_excel_import_to_step2_workflow(): + import base64 + + df = pd.DataFrame({ + 'Datum': [pd.Timestamp('2025-11-13').date()], + 'Zacatek': ['09:00'], + 'Konec': ['10:00'], + 'Program': ['Opening Keynote'], + 'Typ': ['KEYNOTE'], + 'Garant': ['John Smith'], + 'Poznamka': ['Welcome speech'] + }) + + file_content = make_excel_bytes(df) + + valid_data, errors = read_excel(file_content) + assert len(errors) == 0 + assert len(valid_data) == 1 + + program_types = sorted([str(t).strip() for t in valid_data["Typ"].dropna().unique()]) + assert program_types == ['KEYNOTE'] + + file_content_base64 = base64.b64encode(file_content).decode('utf-8') + + form_data = { + 'title': 'Test Event', + 'detail': 'Test Detail', + 'file_content_base64': file_content_base64, + 'type_code_0': 'KEYNOTE', + 'desc_0': 'Main keynote presentation', + 'color_0': '#FF0000', + 'step': '3' + } + + descriptions, colors = get_program_types(form_data) + + assert descriptions['KEYNOTE'] == 'Main keynote presentation' + assert colors['KEYNOTE'] == 'FFFF0000' + + file_content_decoded = base64.b64decode(form_data['file_content_base64']) + data, _ = read_excel(file_content_decoded) + + wb = create_timetable( + data, title=form_data['title'], detail=form_data['detail'], + program_descriptions=descriptions, program_colors=colors + ) + + assert wb is not None + ws = wb.active + assert ws['A1'].value == 'Test Event' + assert ws['A2'].value == 'Test Detail' + + +def test_excel_import_to_inline_editor_workflow(): + import base64 + + df = pd.DataFrame({ + 'Datum': [pd.Timestamp('2025-11-13').date(), pd.Timestamp('2025-11-14').date()], + 'Zacatek': ['09:00', '14:00'], + 'Konec': ['10:00', '15:30'], + 'Program': ['Morning Session', 'Afternoon Workshop'], + 'Typ': ['KEYNOTE', 'WORKSHOP'], + 'Garant': ['Alice', 'Bob'], + 'Poznamka': ['', 'Hands-on'] + }) + + file_content = make_excel_bytes(df) + + valid_data, errors = read_excel(file_content) + assert len(errors) == 0 + assert len(valid_data) == 2 + + file_content_base64 = base64.b64encode(file_content).decode('utf-8') + + file_content_decoded = base64.b64decode(file_content_base64) + data_in_editor, _ = read_excel(file_content_decoded) + + assert len(data_in_editor) == 2 + assert data_in_editor.iloc[0]['Program'] == 'Morning Session' + assert data_in_editor.iloc[1]['Program'] == 'Afternoon Workshop' + + program_types = sorted([str(t).strip() for t in data_in_editor["Typ"].dropna().unique()]) + assert set(program_types) == {'KEYNOTE', 'WORKSHOP'} + + +# --- Inline builder tests (from test_inline_builder.py) --- + +def test_inline_builder_valid_form(): + form_data = { + 'title': 'Test Conference', + 'detail': 'Testing inline builder', + 'step': 'builder', + 'datum_0': '2025-11-13', + 'zacatek_0': '09:00', + 'konec_0': '10:00', + 'program_0': 'Opening Keynote', + 'typ_0': 'KEYNOTE', + 'garant_0': 'Dr. Smith', + 'poznamka_0': 'Welcome speech', + 'datum_1': '2025-11-13', + 'zacatek_1': '10:30', + 'konec_1': '11:30', + 'program_1': 'Workshop: Python', + 'typ_1': 'WORKSHOP', + 'garant_1': 'Jane Doe', + 'poznamka_1': 'Hands-on coding', + 'type_name_0': 'KEYNOTE', + 'type_desc_0': 'Main Address', + 'type_color_0': '#FF0000', + 'type_name_1': 'WORKSHOP', + 'type_desc_1': 'Interactive Session', + 'type_color_1': '#0070C0', + } + + schedule = parse_inline_schedule(form_data) + assert len(schedule) == 2 + assert schedule.iloc[0]['Program'] == 'Opening Keynote' + + descriptions, colors = parse_inline_types(form_data) + assert len(descriptions) == 2 + assert descriptions['KEYNOTE'] == 'Main Address' + assert colors['KEYNOTE'] == 'FFFF0000' + + wb = create_timetable( + schedule, title=form_data['title'], detail=form_data['detail'], + program_descriptions=descriptions, program_colors=colors + ) + + assert wb is not None + ws = wb.active + assert ws['A1'].value == 'Test Conference' + + +def test_inline_builder_missing_type_definition(): + form_data = { + 'datum_0': '2025-11-13', + 'zacatek_0': '09:00', + 'konec_0': '10:00', + 'program_0': 'Unknown Program', + 'typ_0': 'UNKNOWN_TYPE', + 'garant_0': 'Someone', + 'poznamka_0': '', + 'type_name_0': 'LECTURE', + 'type_desc_0': 'Standard Lecture', + 'type_color_0': '#3498db', + } + + schedule = parse_inline_schedule(form_data) + descriptions, colors = parse_inline_types(form_data) + + with pytest.raises(Exception): + create_timetable(schedule, 'Bad', 'Bad', descriptions, colors) + + +def test_inline_builder_empty_type_definition(): + form_data = { + 'type_name_0': '', + 'type_desc_0': 'Empty type', + 'type_color_0': '#FF0000', + 'type_name_1': 'WORKSHOP', + 'type_desc_1': 'Workshop Type', + 'type_color_1': '#0070C0', + } + + descriptions, colors = parse_inline_types(form_data) + + assert 'WORKSHOP' in descriptions + assert '' not in descriptions + + +def test_inline_builder_overlapping_times(): + form_data = { + 'datum_0': '2025-11-13', + 'zacatek_0': '09:00', + 'konec_0': '10:00', + 'program_0': 'Program A', + 'typ_0': 'WORKSHOP', + 'garant_0': 'John', + 'poznamka_0': '', + 'datum_1': '2025-11-13', + 'zacatek_1': '09:30', + 'konec_1': '10:30', + 'program_1': 'Program B', + 'typ_1': 'WORKSHOP', + 'garant_1': 'Jane', + 'poznamka_1': '', + 'type_name_0': 'WORKSHOP', + 'type_desc_0': 'Workshop', + 'type_color_0': '#0070C0', + } + + schedule = parse_inline_schedule(form_data) + descriptions, colors = parse_inline_types(form_data) + + assert len(schedule) == 2 + + wb = create_timetable(schedule, 'Conf', 'Test', descriptions, colors) + assert wb is not None + + +def test_inline_builder_multiday(): + form_data = { + 'datum_0': '2025-11-13', + 'zacatek_0': '09:00', + 'konec_0': '10:00', + 'program_0': 'Day 1 Opening', + 'typ_0': 'KEYNOTE', + 'garant_0': 'Dr. A', + 'poznamka_0': '', + 'datum_1': '2025-11-14', + 'zacatek_1': '09:00', + 'konec_1': '10:00', + 'program_1': 'Day 2 Opening', + 'typ_1': 'KEYNOTE', + 'garant_1': 'Dr. B', + 'poznamka_1': '', + 'type_name_0': 'KEYNOTE', + 'type_desc_0': 'Keynote Speech', + 'type_color_0': '#FF6600', + } + + schedule = parse_inline_schedule(form_data) + descriptions, colors = parse_inline_types(form_data) + + wb = create_timetable(schedule, 'Multi-day', 'Test', descriptions, colors) + + assert wb is not None + ws = wb.active + assert ws['A1'].value == 'Multi-day' + + +def test_inline_builder_validation_errors(): + form_data_missing = { + 'datum_0': '2025-11-13', + 'garant_0': 'John', + } + + with pytest.raises(ValidationError): + parse_inline_schedule(form_data_missing) + + +def test_inline_builder_with_empty_rows(): + form_data = { + 'datum_0': '2025-11-13', + 'zacatek_0': '09:00', + 'konec_0': '10:00', + 'program_0': 'Program 1', + 'typ_0': 'WORKSHOP', + 'garant_0': 'John', + 'poznamka_0': '', + 'datum_1': '', + 'zacatek_1': '', + 'konec_1': '', + 'program_1': '', + 'typ_1': '', + 'garant_1': '', + 'poznamka_1': '', + 'type_name_0': 'WORKSHOP', + 'type_desc_0': 'Workshop', + 'type_color_0': '#0070C0', + } + + schedule = parse_inline_schedule(form_data) + + assert len(schedule) == 1 + assert schedule.iloc[0]['Program'] == 'Program 1' diff --git a/tests/test_pdf.py b/tests/test_pdf.py new file mode 100644 index 0000000..193669a --- /dev/null +++ b/tests/test_pdf.py @@ -0,0 +1,100 @@ +""" +PDF generation tests. +""" + +import pandas as pd +import pytest +from datetime import time +from fastapi.testclient import TestClient + +from app.core.pdf_generator import generate_pdf +from app.core.validator import ScenarsError +from app.main import app + + +@pytest.fixture +def client(): + return TestClient(app) + + +def test_generate_pdf_basic(): + df = pd.DataFrame({ + 'Datum': [pd.Timestamp('2025-11-13').date()], + 'Zacatek': [time(9, 0)], + 'Konec': [time(10, 0)], + 'Program': ['Test Program'], + 'Typ': ['WORKSHOP'], + 'Garant': ['John Doe'], + 'Poznamka': ['Test note'], + }) + + descriptions = {'WORKSHOP': 'Workshop Type'} + colors = {'WORKSHOP': 'FF0070C0'} + + pdf_bytes = generate_pdf(df, "Test PDF", "PDF Detail", descriptions, colors) + + assert isinstance(pdf_bytes, bytes) + assert len(pdf_bytes) > 0 + assert pdf_bytes[:5] == b'%PDF-' + + +def test_generate_pdf_multiday(): + df = pd.DataFrame({ + 'Datum': [pd.Timestamp('2025-11-13').date(), pd.Timestamp('2025-11-14').date()], + 'Zacatek': [time(9, 0), time(14, 0)], + 'Konec': [time(10, 0), time(15, 0)], + 'Program': ['Day 1', 'Day 2'], + 'Typ': ['KEYNOTE', 'WORKSHOP'], + 'Garant': ['Alice', 'Bob'], + 'Poznamka': [None, 'Hands-on'], + }) + + descriptions = {'KEYNOTE': 'Keynote', 'WORKSHOP': 'Workshop'} + colors = {'KEYNOTE': 'FFFF0000', 'WORKSHOP': 'FF0070C0'} + + pdf_bytes = generate_pdf(df, "Multi-day", "Two days", descriptions, colors) + + assert isinstance(pdf_bytes, bytes) + assert pdf_bytes[:5] == b'%PDF-' + + +def test_generate_pdf_empty_data(): + df = pd.DataFrame(columns=['Datum', 'Zacatek', 'Konec', 'Program', 'Typ', 'Garant', 'Poznamka']) + + with pytest.raises(ScenarsError): + generate_pdf(df, "Empty", "Detail", {}, {}) + + +def test_generate_pdf_missing_type(): + df = pd.DataFrame({ + 'Datum': [pd.Timestamp('2025-11-13').date()], + 'Zacatek': [time(9, 0)], + 'Konec': [time(10, 0)], + 'Program': ['Test'], + 'Typ': ['UNKNOWN'], + 'Garant': [None], + 'Poznamka': [None], + }) + + with pytest.raises(ScenarsError): + generate_pdf(df, "Test", "Detail", {}, {}) + + +def test_generate_pdf_api(client): + doc = { + "event": {"title": "PDF Test", "detail": "API PDF"}, + "program_types": [{"code": "WS", "description": "Workshop", "color": "#0070C0"}], + "blocks": [{ + "datum": "2025-11-13", + "zacatek": "09:00:00", + "konec": "10:00:00", + "program": "Opening", + "typ": "WS", + "garant": "John", + "poznamka": "Note" + }] + } + r = client.post("/api/generate-pdf", json=doc) + assert r.status_code == 200 + assert r.headers["content-type"] == "application/pdf" + assert r.content[:5] == b'%PDF-'