feat: refactor to FastAPI architecture v2.0
Some checks failed
Build & Push Docker / build (push) Has been cancelled
Some checks failed
Build & Push Docker / build (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
.git
|
||||
.gitea
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
.pytest_cache
|
||||
tmp/
|
||||
*.core
|
||||
cgi-bin/*.core
|
||||
61
Dockerfile
61
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' \
|
||||
'<VirtualHost *:8080>' \
|
||||
' ServerName localhost' \
|
||||
' ServerAdmin webmaster@localhost' \
|
||||
' DocumentRoot /var/www/htdocs' \
|
||||
'' \
|
||||
' <Directory /var/www/htdocs>' \
|
||||
' Options +ExecCGI -Indexes' \
|
||||
' AllowOverride None' \
|
||||
' Require all granted' \
|
||||
' AddHandler cgi-script .py' \
|
||||
' DirectoryIndex /cgi-bin/scenar.py' \
|
||||
' </Directory>' \
|
||||
'' \
|
||||
' ErrorLog ${APACHE_LOG_DIR}/error.log' \
|
||||
' CustomLog ${APACHE_LOG_DIR}/access.log combined' \
|
||||
'</VirtualHost>' \
|
||||
> /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"]
|
||||
|
||||
145
TASK.md
Normal file
145
TASK.md
Normal file
@@ -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
|
||||
1
app/__init__.py
Normal file
1
app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Scenar Creator - FastAPI application for timetable generation."""
|
||||
1
app/api/__init__.py
Normal file
1
app/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""API routes for Scenar Creator."""
|
||||
56
app/api/pdf.py
Normal file
56
app/api/pdf.py
Normal file
@@ -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"}
|
||||
)
|
||||
10
app/api/router.py
Normal file
10
app/api/router.py
Normal file
@@ -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)
|
||||
168
app/api/scenario.py
Normal file
168
app/api/scenario.py
Normal file
@@ -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"
|
||||
)
|
||||
5
app/config.py
Normal file
5
app/config.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Application configuration."""
|
||||
|
||||
VERSION = "2.0.0"
|
||||
MAX_FILE_SIZE_MB = 10
|
||||
DEFAULT_COLOR = "#ffffff"
|
||||
28
app/core/__init__.py
Normal file
28
app/core/__init__.py
Normal file
@@ -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",
|
||||
]
|
||||
274
app/core/excel_reader.py
Normal file
274
app/core/excel_reader.py
Normal file
@@ -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
|
||||
194
app/core/pdf_generator.py
Normal file
194
app/core/pdf_generator.py
Normal file
@@ -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', '<br/>'), 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("<b>Legenda:</b>", 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()
|
||||
242
app/core/timetable.py
Normal file
242
app/core/timetable.py
Normal file
@@ -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
|
||||
69
app/core/validator.py
Normal file
69
app/core/validator.py
Normal file
@@ -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)}"
|
||||
)
|
||||
26
app/main.py
Normal file
26
app/main.py
Normal file
@@ -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"))
|
||||
3
app/models/__init__.py
Normal file
3
app/models/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .event import Block, ProgramType, EventInfo, ScenarioDocument
|
||||
|
||||
__all__ = ["Block", "ProgramType", "EventInfo", "ScenarioDocument"]
|
||||
34
app/models/event.py
Normal file
34
app/models/event.py
Normal file
@@ -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]
|
||||
22
app/models/responses.py
Normal file
22
app/models/responses.py
Normal file
@@ -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] = []
|
||||
293
app/static/css/app.css
Normal file
293
app/static/css/app.css
Normal file
@@ -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;
|
||||
}
|
||||
134
app/static/index.html
Normal file
134
app/static/index.html
Normal file
@@ -0,0 +1,134 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="cs">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Scenar Creator</title>
|
||||
<link rel="stylesheet" href="/static/css/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Scenar Creator</h1>
|
||||
<p class="subtitle">Tvorba časových harmonogramů</p>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="tabs">
|
||||
<button class="tab active" onclick="switchTab(event, 'importTab')">Importovat Excel</button>
|
||||
<button class="tab" onclick="switchTab(event, 'builderTab')">Vytvořit inline</button>
|
||||
</div>
|
||||
|
||||
<!-- Import Excel Tab -->
|
||||
<div id="importTab" class="tab-content active">
|
||||
<form id="importForm" onsubmit="return handleImport(event)">
|
||||
<div class="form-group">
|
||||
<label for="importTitle">Název akce:</label>
|
||||
<input type="text" id="importTitle" name="title" maxlength="200" required placeholder="Název události">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="importDetail">Detail:</label>
|
||||
<input type="text" id="importDetail" name="detail" maxlength="500" required placeholder="Popis události">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="excelFile">Excel soubor:</label>
|
||||
<input type="file" id="excelFile" name="file" accept=".xlsx,.xls" required>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">Importovat</button>
|
||||
<a href="/api/template" class="btn btn-secondary">Stáhnout šablonu</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Builder Tab -->
|
||||
<div id="builderTab" class="tab-content">
|
||||
<form id="builderForm" onsubmit="return handleBuild(event)">
|
||||
<div class="form-group">
|
||||
<label for="builderTitle">Název akce:</label>
|
||||
<input type="text" id="builderTitle" name="title" maxlength="200" required placeholder="Název události">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="builderDetail">Detail:</label>
|
||||
<input type="text" id="builderDetail" name="detail" maxlength="500" required placeholder="Popis události">
|
||||
</div>
|
||||
|
||||
<h3>Typy programů</h3>
|
||||
<div id="typesContainer">
|
||||
<div class="type-row" data-index="0">
|
||||
<input type="text" name="type_name_0" placeholder="Kód typu (např. WORKSHOP)" class="type-code">
|
||||
<input type="text" name="type_desc_0" placeholder="Popis">
|
||||
<input type="color" name="type_color_0" value="#0070C0">
|
||||
<button type="button" class="btn btn-danger btn-sm" onclick="removeTypeRow(0)">X</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary btn-sm" onclick="addTypeRow()">+ Přidat typ</button>
|
||||
|
||||
<h3>Časový harmonogram</h3>
|
||||
<datalist id="availableTypes"></datalist>
|
||||
<div id="scheduleContainer">
|
||||
<table id="scheduleTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Datum</th>
|
||||
<th>Začátek</th>
|
||||
<th>Konec</th>
|
||||
<th>Program</th>
|
||||
<th>Typ</th>
|
||||
<th>Garant</th>
|
||||
<th>Poznámka</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="scheduleBody">
|
||||
<tr data-index="0">
|
||||
<td><input type="date" name="datum_0" required></td>
|
||||
<td><input type="time" name="zacatek_0" required></td>
|
||||
<td><input type="time" name="konec_0" required></td>
|
||||
<td><input type="text" name="program_0" required placeholder="Název bloku"></td>
|
||||
<td><input type="text" name="typ_0" list="availableTypes" required placeholder="Typ"></td>
|
||||
<td><input type="text" name="garant_0" placeholder="Garant"></td>
|
||||
<td><input type="text" name="poznamka_0" placeholder="Poznámka"></td>
|
||||
<td><button type="button" class="btn btn-danger btn-sm" onclick="removeScheduleRow(0)">X</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary btn-sm" onclick="addScheduleRow()">+ Přidat řádek</button>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary" name="format" value="excel">Stáhnout Excel</button>
|
||||
<button type="button" class="btn btn-primary" onclick="handleBuildPdf()">Stáhnout PDF</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Import results / editor area -->
|
||||
<div id="editorArea" class="editor-area" style="display:none;">
|
||||
<h2>Importovaná data</h2>
|
||||
<div id="importedInfo"></div>
|
||||
|
||||
<h3>Typy programů</h3>
|
||||
<div id="importedTypesContainer"></div>
|
||||
|
||||
<h3>Bloky</h3>
|
||||
<div id="importedBlocksContainer"></div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button class="btn btn-primary" onclick="generateExcelFromImport()">Stáhnout Excel</button>
|
||||
<button class="btn btn-primary" onclick="generatePdfFromImport()">Stáhnout PDF</button>
|
||||
<button class="btn btn-secondary" onclick="exportJson()">Exportovat JSON</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JSON import -->
|
||||
<div class="json-import">
|
||||
<label>Importovat JSON: <input type="file" id="jsonFile" accept=".json" onchange="handleJsonImport(event)"></label>
|
||||
</div>
|
||||
|
||||
<div id="statusMessage" class="status-message" style="display:none;"></div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/api.js"></script>
|
||||
<script src="/static/js/export.js"></script>
|
||||
<script src="/static/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
61
app/static/js/api.js
Normal file
61
app/static/js/api.js
Normal file
@@ -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);
|
||||
}
|
||||
};
|
||||
258
app/static/js/app.js
Normal file
258
app/static/js/app.js
Normal file
@@ -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 = `
|
||||
<input type="text" name="type_name_${idx}" placeholder="Kód typu" class="type-code">
|
||||
<input type="text" name="type_desc_${idx}" placeholder="Popis">
|
||||
<input type="color" name="type_color_${idx}" value="#0070C0">
|
||||
<button type="button" class="btn btn-danger btn-sm" onclick="removeTypeRow(${idx})">X</button>
|
||||
`;
|
||||
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 = `
|
||||
<td><input type="date" name="datum_${idx}" required></td>
|
||||
<td><input type="time" name="zacatek_${idx}" required></td>
|
||||
<td><input type="time" name="konec_${idx}" required></td>
|
||||
<td><input type="text" name="program_${idx}" required placeholder="Název bloku"></td>
|
||||
<td><input type="text" name="typ_${idx}" list="availableTypes" required placeholder="Typ"></td>
|
||||
<td><input type="text" name="garant_${idx}" placeholder="Garant"></td>
|
||||
<td><input type="text" name="poznamka_${idx}" placeholder="Poznámka"></td>
|
||||
<td><button type="button" class="btn btn-danger btn-sm" onclick="removeScheduleRow(${idx})">X</button></td>
|
||||
`;
|
||||
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 =
|
||||
`<strong>${doc.event.title}</strong> — ${doc.event.detail}`;
|
||||
|
||||
// Types
|
||||
const typesHtml = doc.program_types.map((pt, i) => `
|
||||
<div class="imported-type-row">
|
||||
<input type="text" value="${pt.code}" data-field="code" data-idx="${i}">
|
||||
<input type="text" value="${pt.description}" data-field="description" data-idx="${i}">
|
||||
<input type="color" value="${pt.color}" data-field="color" data-idx="${i}">
|
||||
</div>
|
||||
`).join('');
|
||||
document.getElementById('importedTypesContainer').innerHTML = typesHtml;
|
||||
|
||||
// Blocks
|
||||
const blocksHtml = doc.blocks.map(b =>
|
||||
`<div class="block-item">${b.datum} ${b.zacatek}–${b.konec} | <strong>${b.program}</strong> [${b.typ}] ${b.garant || ''}</div>`
|
||||
).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');
|
||||
}
|
||||
}
|
||||
34
app/static/js/export.js
Normal file
34
app/static/js/export.js
Normal file
@@ -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);
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
|
||||
180
tests/test_api.py
Normal file
180
tests/test_api.py
Normal file
@@ -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
|
||||
532
tests/test_core.py
Normal file
532
tests/test_core.py
Normal file
@@ -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'
|
||||
100
tests/test_pdf.py
Normal file
100
tests/test_pdf.py
Normal file
@@ -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-'
|
||||
Reference in New Issue
Block a user