Compare commits

..

21 Commits

Author SHA1 Message Date
5d712494a5 feat: v4.7.0 - resize handle cursor col-resize (explicit div, z-index), grabbing cursor during drag
Some checks failed
Build & Push Docker / build (push) Has been cancelled
2026-02-20 19:21:55 +01:00
d38d7588f3 fix: v4.6.0 - cross-day drag: releasePointerCapture + bounding-rect day detection (no elementFromPoint)
Some checks failed
Build & Push Docker / build (push) Has been cancelled
2026-02-20 19:16:32 +01:00
ad41f338f8 fix: v4.5.0 - cross-day drag (ghost element, no overflow issue); adaptive block labels; PDF fit_text truncation
Some checks failed
Build & Push Docker / build (push) Has been cancelled
2026-02-20 19:11:05 +01:00
f7f2987f86 feat: v4.4.0 - export filename: <slug_nazvu>-<YYYYMMDD-HHMM>.{json,pdf}
Some checks failed
Build & Push Docker / build (push) Has been cancelled
2026-02-20 18:38:34 +01:00
751ffe6f82 feat: v4.3.0 - cross-day drag (blocks can move between day rows)
Some checks failed
Build & Push Docker / build (push) Has been cancelled
2026-02-20 18:36:51 +01:00
f3e2ae2cda fix: replace type=time with text inputs to force 24h HH:MM format (no AM/PM)
Some checks failed
Build & Push Docker / build (push) Has been cancelled
2026-02-20 18:22:48 +01:00
624157ef57 fix: rename display name Scenár → Scénář Creator (title, header, PDF footer, README)
Some checks failed
Build & Push Docker / build (push) Has been cancelled
2026-02-20 18:06:19 +01:00
b494d29790 feat: v4.2.0 - series blocks (add to all days, delete one/all in series); 37 tests
Some checks failed
Build & Push Docker / build (push) Has been cancelled
2026-02-20 17:58:56 +01:00
b91f336c12 chore: cleanup - remove CGI/old scripts/TASK/copilot files; update README + in-app docs
Some checks failed
Build & Push Docker / build (push) Has been cancelled
2026-02-20 17:48:29 +01:00
feb75219a7 feat: v4.2 - garant v editoru+PDF, footnote index v bloků, stránka s poznámkami
Some checks failed
Build & Push Docker / build (push) Has been cancelled
2026-02-20 17:43:19 +01:00
6c4ca5e9be feat: v4.1 - Czech diacritics in PDF (Liberation fonts), hour/min duration fields, day select in modal
Some checks failed
Build & Push Docker / build (push) Has been cancelled
2026-02-20 17:39:38 +01:00
f0e7c3b093 feat: Czech day name format 'Pondělí (20.2)' in canvas + PDF; wider date column
Some checks failed
Build & Push Docker / build (push) Has been cancelled
2026-02-20 17:34:14 +01:00
47add509ca feat: v4.0 - multi-day horizontal canvas, duration input, overnight blocks, PDF horizontal layout
Some checks failed
Build & Push Docker / build (push) Has been cancelled
2026-02-20 17:31:41 +01:00
e3a5330cc2 fix: PDF generator - always one page, canvas API, improved layout
Some checks failed
Build & Push Docker / build (push) Has been cancelled
2026-02-20 17:09:12 +01:00
7c74af96fb cleanup: remove Excel test files, update COMPLETION.md for v3.0
Some checks failed
Build & Push Docker / build (push) Has been cancelled
2026-02-20 17:06:39 +01:00
25fd578543 feat: v3.0 - canvas editor, JSON-only, no Excel, new UI
Some checks failed
Build & Push Docker / build (push) Has been cancelled
- Remove all Excel code (import, export, template, pandas, openpyxl)
- New canvas-based schedule editor with drag & drop (interact.js)
- Modern 3-panel UI: sidebar, canvas, documentation tab
- New data model: Block with id/date/start/end, ProgramType with id/name/color
- Clean API: GET /api/health, POST /api/validate, GET /api/sample, POST /api/generate-pdf
- Rewritten PDF generator using ScenarioDocument directly (no DataFrame)
- Professional PDF output: dark header, colored blocks, merged cells, legend, footer
- Sample JSON: "Zimní výjezd oddílu" with 11 blocks, 3 program types
- 30 tests passing (API, core models, PDF generation)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 17:02:51 +01:00
e2bdadd0ce feat: refactor to FastAPI architecture v2.0
Some checks failed
Build & Push Docker / build (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 16:28:21 +01:00
Martin Sukany
87f1fc2c7a Refactor: Excel import jde přímo do inline editoru s výběrem barev
Some checks failed
Build & Push Docker / build (push) Has been cancelled
Problém:
- V kroku 2 uživatel nastavoval barvy, ale ty se neaplikovaly v kroku 3
- Zbytečný mezikrok kde uživatel musel klikat 'Generovat' nebo 'Upravit'

Řešení:
- Step 2 nyní přímo zobrazuje inline editor s načtenými daty z Excelu
- Uživatel může upravit řádky, typy a nastavit barvy přímo v jednom kroku
- Odstraněn step 2b (duplikát) a step 3 (již nepotřebný)
- Zjednodušen workflow: Import Excel -> Inline editor -> Generovat

Změny:
- cgi-bin/scenar.py: Step 2 nyní renderuje inline editor přímo
- Odstraněny nepotřebné kroky 2b a 3
- Barvy se nyní nastavují přímo v inline editoru u typů programu

Testy: 18/18 unit testů prošlo 
2025-11-13 16:24:33 +01:00
Martin Sukany
2f4c930739 Fix: step 2 -> step 2b/3 workflow
Some checks failed
Build & Push Docker / build (push) Has been cancelled
Problem:
- Když uživatel importoval Excel a klikl 'Upravit v inline editoru' nebo 'Generovat',
  dostal chybu 'Neplatný krok nebo chybějící data'
- step 2b vyžadoval file_item, ale formulář posílal file_content_base64

Řešení:
- Upravena podmínka pro step=2b aby akceptovala i file_content_base64
- Přidány 2 nové testy:
  * test_excel_import_to_step2_workflow - testuje import -> step 2 -> step 3 (generate)
  * test_excel_import_to_inline_editor_workflow - testuje import -> step 2 -> step 2b (inline editor)

Testy: 18/18 unit testů prošlo 
2025-11-13 16:15:23 +01:00
Martin Sukany
b7b56fe15f Refactor: Oddělení business logiky + inline editor
Some checks failed
Build & Push Docker / build (push) Has been cancelled
- Nový modul scenar/core.py (491 řádků čisté logiky)
- Refactored cgi-bin/scenar.py (450 řádků CGI wrapper)
- Inline editor s JavaScript row managementem
- Custom exceptions (ScenarsError, ValidationError, TemplateError)
- Kompletní test coverage (10 testů, všechny )
- Fixed Dockerfile (COPY scenar/, requirements.txt)
- Fixed requirements.txt (openpyxl==3.1.5)
- Fixed pytest.ini (pythonpath = .)
- Nové testy: test_http_inline.py, test_inline_builder.py
- HTTP testy označeny jako @pytest.mark.integration
- Build script: scripts/build_image.sh
- Dokumentace: COMPLETION.md
2025-11-13 16:06:32 +01:00
Martin Sukany
9a7ffdeb2c copilot test 2025-11-13 11:37:28 +01:00
49 changed files with 3918 additions and 543 deletions

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
.git
.gitea
__pycache__
*.pyc
*.pyo
.pytest_cache
tmp/
*.core
cgi-bin/*.core

14
.githooks/pre-commit Executable file
View File

@@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
# Pre-commit hook: run tests and prevent commit on failures.
# By default run fast tests (exclude integration). To include integration tests set RUN_INTEGRATION=1.
echo "Running pytest (fast) before commit..."
if [ "${RUN_INTEGRATION:-0}" = "1" ]; then
echo "RUN_INTEGRATION=1: including integration tests"
pytest -q
else
pytest -q -m "not integration"
fi
echo "Tests passed. Proceeding with commit."

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
# Runtime / local env
.venv/
tmp/
.pytest_cache/
__pycache__/
.DS_Store
# test artifacts
tests/tmp/

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"github-enterprise.uri": "https://git.apps.sukany.cz"
}

View File

@@ -1,62 +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 fonts-liberation \
&& 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 requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy app
COPY cgi-bin ./cgi-bin
COPY templates ./templates
# 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 (add more as needed) ---
RUN pip install --no-cache-dir pandas openpyxl
# 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"]

166
README.md
View File

@@ -1,3 +1,165 @@
# Scenar-creator
# Scénář Creator
Repozitar obsahuje nastroj pro vytvareni scenaru pro zazitkove kurzy
Webový nástroj pro tvorbu časových scénářů zážitkových kurzů a výjezdů.
**Live:** https://scenar.apps.sukany.cz
---
## Funkce
- **Grafický editor** — bloky na časové ose, přetahování myší, změna délky tažením pravého okraje, snap na 15 minut
- **Vícedenní scénář** — nastavíš rozsah Od/Do, každý den = jeden řádek
- **Série bloků** — checkbox „Přidat do každého dne kurzu" vytvoří identický blok pro všechny dny najednou; při smazání lze smazat jen jeden blok nebo celou sérii
- **JSON import/export** — uložíš scénář, kdykoli ho znovu načteš
- **Vzorový JSON** — `GET /api/sample`
- **PDF výstup** — A4 na šířku, vždy 1 stránka, barevné bloky dle typů, legenda
- Garant viditelný přímo v bloku
- Bloky s poznámkou mají horní index (¹ ² ³...)
- Stránka 2 (pokud jsou poznámky): výpis všech poznámek ke scénáři
- **České dny** — v editoru i PDF formát „Pondělí (20.2.)", LiberationSans font pro správnou diakritiku
- **Dokumentace na webu** — záložka "Dokumentace" přímo v aplikaci
- **Swagger UI** — `GET /docs`
---
## Tech stack
| Vrstva | Technologie |
|---|---|
| Backend | FastAPI + Uvicorn (Python 3.12) |
| Frontend | Vanilla JS + [interact.js](https://interactjs.io/) (drag & drop) |
| PDF | ReportLab Canvas API + LiberationSans (česká diakritika) |
| Data | JSON (bez databáze, bez Excelu) |
| Container | Docker / Podman, python:3.12-slim |
| Deployment | Kubernetes (RKE2), namespace `scenar` |
---
## Rychlý start (lokální vývoj)
```bash
# Klonování
git clone https://git.apps.sukany.cz/martin/scenar-creator.git
cd scenar-creator
# Virtuální prostředí
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
# Spuštění
uvicorn app.main:app --reload --port 8080
# Otevři v prohlížeči
open http://localhost:8080
```
---
## Testy
```bash
python3 -m pytest tests/ -v
```
37 testů pokrývá API endpointy, PDF generátor, validaci dat, overnight bloky a series_id.
---
## Build a deploy
### Manuální postup
```bash
# 1. Build image
podman build --format docker -t git.apps.sukany.cz/martin/scenar-creator:latest .
# 2. Push do Gitea registry
podman login git.apps.sukany.cz -u <user>
podman push git.apps.sukany.cz/martin/scenar-creator:latest
# 3. Restart deploymentu na clusteru
ssh root@infra01.sukany.cz \
"kubectl -n scenar rollout restart deployment/scenar && \
kubectl -n scenar rollout status deployment/scenar"
# 4. Ověření
curl https://scenar.apps.sukany.cz/api/health
```
### Automatický build (Gitea CI/CD)
Push na `main` spustí `.gitea/workflows/build-and-push.yaml`, který automaticky builduje a pushuje image do Gitea registry.
Deployment na cluster je stále manuální (rollout restart).
Kubernetes manifest: `sukany-org/rke2-deployments``scenar/scenar.yaml`
---
## Formát JSON
```json
{
"version": "1.0",
"event": {
"title": "Název akce",
"subtitle": "Volitelný podtitul",
"date_from": "YYYY-MM-DD",
"date_to": "YYYY-MM-DD",
"location": "Místo konání"
},
"program_types": [
{ "id": "main", "name": "Hlavní program", "color": "#3B82F6" }
],
"blocks": [
{
"id": "b1",
"date": "YYYY-MM-DD",
"start": "HH:MM",
"end": "HH:MM",
"title": "Název bloku",
"type_id": "main",
"responsible": "Garant (volitelné)",
"notes": "Poznámka → horní index v PDF (volitelné)",
"series_id": "ID sdílené série (volitelné, generováno automaticky)"
}
]
}
```
**Overnight bloky:** `end < start` → blok přechází přes půlnoc (validní).
**Zpětná kompatibilita:** pole `date` (jednodnevní starý formát) je stále akceptováno.
---
## API endpointy
| Metoda | URL | Popis |
|---|---|---|
| GET | `/` | Hlavní UI |
| GET | `/api/health` | Health check (verze) |
| GET | `/api/sample` | Vzorový JSON ke stažení |
| POST | `/api/validate` | Validace ScenarioDocument |
| POST | `/api/generate-pdf` | Generování PDF |
| GET | `/docs` | Swagger UI |
---
## Struktura projektu
```
app/
api/ REST endpointy (scenario.py, pdf.py, router.py)
core/ Business logika (pdf_generator.py, validator.py)
models/ Pydantic modely (event.py, responses.py)
static/ Frontend (index.html, css/, js/)
js/
app.js State management, modal logika
canvas.js Horizontální canvas editor (interact.js)
api.js Fetch wrapper
export.js JSON import/export
tests/ 35 pytest testů
Dockerfile python:3.12-slim + fonts-liberation
requirements.txt
```

1
app/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Scenar Creator - FastAPI application for timetable generation."""

1
app/api/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""API routes for Scenar Creator."""

27
app/api/pdf.py Normal file
View File

@@ -0,0 +1,27 @@
"""PDF generation API endpoint."""
from io import BytesIO
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from app.models.event import ScenarioDocument
from app.core.validator import 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:
pdf_bytes = generate_pdf(doc)
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
View 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)

55
app/api/scenario.py Normal file
View File

@@ -0,0 +1,55 @@
"""Scenario API endpoints: health, validate, sample."""
import json
import os
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from app.config import VERSION
from app.models.event import ScenarioDocument
from app.models.responses import HealthResponse, ValidationResponse
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_ids = {pt.id for pt in doc.program_types}
for i, block in enumerate(doc.blocks):
if block.type_id not in type_ids:
errors.append(f"Block {i+1}: unknown type '{block.type_id}'")
if block.start == block.end:
errors.append(f"Block {i+1}: start time must differ from end time")
# Note: end < start is allowed for overnight blocks (block crosses midnight)
return ValidationResponse(valid=len(errors) == 0, errors=errors)
@router.get("/sample")
async def get_sample():
"""Return sample ScenarioDocument JSON."""
sample_path = os.path.join(os.path.dirname(__file__), "..", "static", "sample.json")
sample_path = os.path.abspath(sample_path)
with open(sample_path, "r", encoding="utf-8") as f:
data = json.load(f)
return JSONResponse(
content=data,
headers={"Content-Disposition": "attachment; filename=sample.json"}
)

5
app/config.py Normal file
View File

@@ -0,0 +1,5 @@
"""Application configuration."""
VERSION = "4.7.0"
MAX_FILE_SIZE_MB = 10
DEFAULT_COLOR = "#ffffff"

10
app/core/__init__.py Normal file
View File

@@ -0,0 +1,10 @@
"""Core business logic for Scenar Creator v3."""
from .validator import ScenarsError, ValidationError
from .pdf_generator import generate_pdf
__all__ = [
"ScenarsError",
"ValidationError",
"generate_pdf",
]

559
app/core/pdf_generator.py Normal file
View File

@@ -0,0 +1,559 @@
"""
PDF generation for Scenar Creator v4 using ReportLab Canvas API.
Layout: rows = days, columns = time slots (15 min).
Always exactly one page, A4 landscape.
"""
from io import BytesIO
from datetime import datetime
from collections import defaultdict
import os
import logging
from reportlab.lib.pagesizes import A4, landscape
from reportlab.lib.units import mm
from reportlab.pdfgen import canvas as rl_canvas
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from .validator import ScenarsError
logger = logging.getLogger(__name__)
# ── Font registration ─────────────────────────────────────────────────────────
# LiberationSans supports Czech diacritics. Fallback to Helvetica if not found.
_FONT_REGULAR = 'Helvetica'
_FONT_BOLD = 'Helvetica-Bold'
_FONT_ITALIC = 'Helvetica-Oblique'
_LIBERATION_PATHS = [
'/usr/share/fonts/truetype/liberation',
'/usr/share/fonts/liberation',
'/usr/share/fonts/truetype',
]
def _find_font(filename: str):
for base in _LIBERATION_PATHS:
path = os.path.join(base, filename)
if os.path.isfile(path):
return path
return None
def _register_fonts():
global _FONT_REGULAR, _FONT_BOLD, _FONT_ITALIC
regular = _find_font('LiberationSans-Regular.ttf')
bold = _find_font('LiberationSans-Bold.ttf')
italic = _find_font('LiberationSans-Italic.ttf')
if regular and bold and italic:
try:
pdfmetrics.registerFont(TTFont('LiberationSans', regular))
pdfmetrics.registerFont(TTFont('LiberationSans-Bold', bold))
pdfmetrics.registerFont(TTFont('LiberationSans-Italic', italic))
_FONT_REGULAR = 'LiberationSans'
_FONT_BOLD = 'LiberationSans-Bold'
_FONT_ITALIC = 'LiberationSans-Italic'
logger.info('PDF: Using LiberationSans (Czech diacritics supported)')
except Exception as e:
logger.warning(f'PDF: Font registration failed: {e}')
else:
logger.warning('PDF: LiberationSans not found, Czech diacritics may be broken')
_register_fonts()
PAGE_W, PAGE_H = landscape(A4)
MARGIN = 10 * mm
# Colors
HEADER_BG = (0.118, 0.161, 0.231) # dark navy
HEADER_TEXT = (1.0, 1.0, 1.0)
AXIS_BG = (0.96, 0.96, 0.97)
AXIS_TEXT = (0.45, 0.45, 0.45)
GRID_HOUR = (0.78, 0.78, 0.82)
GRID_15MIN = (0.90, 0.90, 0.93)
ALT_ROW = (0.975, 0.975, 0.98)
FOOTER_TEXT = (0.6, 0.6, 0.6)
BORDER = (0.82, 0.82, 0.86)
def hex_to_rgb(hex_color: str) -> tuple:
h = (hex_color or '#888888').lstrip('#')
if len(h) == 8:
h = h[2:]
if len(h) != 6:
return (0.7, 0.7, 0.7)
return (int(h[0:2], 16) / 255.0, int(h[2:4], 16) / 255.0, int(h[4:6], 16) / 255.0)
def is_light(hex_color: str) -> bool:
r, g, b = hex_to_rgb(hex_color)
return (0.299 * r + 0.587 * g + 0.114 * b) > 0.6
_CS_WEEKDAYS = ['Pondělí', 'Úterý', 'Středa', 'Čtvrtek', 'Pátek', 'Sobota', 'Neděle']
def format_date_cs(date_str: str) -> str:
"""Format date as 'Pondělí (20.2)'."""
from datetime import date as dt_date
try:
d = dt_date.fromisoformat(date_str)
weekday = _CS_WEEKDAYS[d.weekday()]
return f"{weekday} ({d.day}.{d.month})"
except Exception:
return date_str
def time_to_min(s: str) -> int:
parts = s.split(':')
return int(parts[0]) * 60 + int(parts[1])
def fmt_time(total_minutes: int) -> str:
norm = total_minutes % 1440
return f"{norm // 60:02d}:{norm % 60:02d}"
def set_fill(c, rgb):
c.setFillColorRGB(*rgb)
def set_stroke(c, rgb):
c.setStrokeColorRGB(*rgb)
def fill_rect(c, x, y, w, h, fill, stroke=None, sw=0.4):
set_fill(c, fill)
if stroke:
set_stroke(c, stroke)
c.setLineWidth(sw)
c.rect(x, y, w, h, fill=1, stroke=1)
else:
c.rect(x, y, w, h, fill=1, stroke=0)
def draw_clipped_text(c, text, x, y, w, h, font, size, rgb, align='center'):
if not text:
return
c.saveState()
p = c.beginPath()
p.rect(x + 1, y, w - 2, h)
c.clipPath(p, stroke=0, fill=0)
set_fill(c, rgb)
c.setFont(font, size)
ty = y + (h - size) / 2
if align == 'center':
c.drawCentredString(x + w / 2, ty, text)
elif align == 'right':
c.drawRightString(x + w - 2, ty, text)
else:
c.drawString(x + 2, ty, text)
c.restoreState()
def fit_text(c, text: str, font: str, size: float, max_w: float) -> str:
"""Truncate text with ellipsis so it fits within max_w points."""
if not text:
return text
if c.stringWidth(text, font, size) <= max_w:
return text
# Binary-search trim
ellipsis = ''
ellipsis_w = c.stringWidth(ellipsis, font, size)
lo, hi = 0, len(text)
while lo < hi:
mid = (lo + hi + 1) // 2
if c.stringWidth(text[:mid], font, size) + ellipsis_w <= max_w:
lo = mid
else:
hi = mid - 1
return (text[:lo] + ellipsis) if lo < len(text) else text
def generate_pdf(doc) -> bytes:
if not doc.blocks:
raise ScenarsError("No blocks provided")
type_map = {pt.id: pt for pt in doc.program_types}
for block in doc.blocks:
if block.type_id not in type_map:
raise ScenarsError(f"Missing type definition: '{block.type_id}'")
# Collect dates + time range
sorted_dates = doc.get_sorted_dates()
num_days = len(sorted_dates)
all_starts = [time_to_min(b.start) for b in doc.blocks]
all_ends_raw = []
for b in doc.blocks:
s = time_to_min(b.start)
e = time_to_min(b.end)
if e <= s:
e += 24 * 60 # overnight: extend past midnight
all_ends_raw.append(e)
t_start = (min(all_starts) // 60) * 60
t_end = ((max(all_ends_raw) + 14) // 15) * 15
# Guard: clamp to reasonable range
t_start = max(0, t_start)
t_end = min(t_start + 24 * 60, t_end)
total_min = t_end - t_start
buf = BytesIO()
c = rl_canvas.Canvas(buf, pagesize=landscape(A4))
# ── Layout ────────────────────────────────────────────────────────
x0 = MARGIN
y_top = PAGE_H - MARGIN
# Header block (title + subtitle + info)
TITLE_SIZE = 16
SUB_SIZE = 10
INFO_SIZE = 8
header_h = TITLE_SIZE + 5
if doc.event.subtitle:
header_h += SUB_SIZE + 3
has_info = bool(doc.event.date or doc.event.date_from or doc.event.location)
if has_info:
header_h += INFO_SIZE + 3
header_h += 4
# Legend: one row per type, multi-column
LEGEND_ITEM_H = 12
LEGEND_BOX_W = 10 * mm
LEGEND_TEXT_W = 48 * mm
LEGEND_STRIDE = LEGEND_BOX_W + LEGEND_TEXT_W + 3 * mm
available_w_for_legend = PAGE_W - 2 * MARGIN
legend_cols = max(1, int(available_w_for_legend / LEGEND_STRIDE))
legend_rows = (len(doc.program_types) + legend_cols - 1) // legend_cols
LEGEND_H = legend_rows * LEGEND_ITEM_H + LEGEND_ITEM_H + 4 # +label row
FOOTER_H = 10
TIME_AXIS_H = 18
DATE_COL_W = 28 * mm # wider for Czech day names like "Pondělí (20.2)"
# Available area for the timetable grid
avail_h = PAGE_H - 2 * MARGIN - header_h - LEGEND_H - FOOTER_H - TIME_AXIS_H - 6
row_h = max(10, avail_h / max(num_days, 1))
avail_w = PAGE_W - 2 * MARGIN - DATE_COL_W
# 15-min slot width
num_15min_slots = total_min // 15
slot_w = avail_w / max(num_15min_slots, 1)
# font sizes scale with row/col
date_font = max(5.5, min(8.5, row_h * 0.38))
block_title_font = max(5.0, min(8.0, min(row_h, slot_w * 4) * 0.38))
block_time_font = max(4.0, min(6.0, block_title_font - 1.0))
time_axis_font = max(5.5, min(8.0, slot_w * 3))
# ── Draw header ───────────────────────────────────────────────────
y = y_top
c.setFont(_FONT_BOLD, TITLE_SIZE)
set_fill(c, HEADER_BG)
c.drawString(x0, y - TITLE_SIZE, doc.event.title)
y -= TITLE_SIZE + 5
if doc.event.subtitle:
c.setFont(_FONT_REGULAR, SUB_SIZE)
set_fill(c, (0.4, 0.4, 0.4))
c.drawString(x0, y - SUB_SIZE, doc.event.subtitle)
y -= SUB_SIZE + 3
if has_info:
parts = []
date_display = doc.event.date_from or doc.event.date
date_to_display = doc.event.date_to
if date_display:
if date_to_display and date_to_display != date_display:
parts.append(f'Datum: {date_display} {date_to_display}')
else:
parts.append(f'Datum: {date_display}')
if doc.event.location:
parts.append(f'Místo: {doc.event.location}')
c.setFont(_FONT_REGULAR, INFO_SIZE)
set_fill(c, (0.5, 0.5, 0.5))
c.drawString(x0, y - INFO_SIZE, ' | '.join(parts))
y -= INFO_SIZE + 3
y -= 4 # padding
# ── Time axis header ──────────────────────────────────────────────
table_top = y - TIME_AXIS_H
# Date column header (empty corner)
fill_rect(c, x0, table_top, DATE_COL_W, TIME_AXIS_H, AXIS_BG, BORDER, 0.4)
# Time labels (only whole hours)
for m in range(t_start, t_end + 1, 60):
slot_idx = (m - t_start) // 15
tx = x0 + DATE_COL_W + slot_idx * slot_w
# tick line
set_stroke(c, GRID_HOUR)
c.setLineWidth(0.5)
c.line(tx, table_top, tx, table_top + TIME_AXIS_H)
# label
label = fmt_time(m)
c.setFont(_FONT_REGULAR, time_axis_font)
set_fill(c, AXIS_TEXT)
c.drawCentredString(tx, table_top + (TIME_AXIS_H - time_axis_font) / 2, label)
# Right border of time axis
fill_rect(c, x0, table_top, DATE_COL_W + avail_w, TIME_AXIS_H, AXIS_BG, BORDER, 0.3)
# Re-draw to not cover tick lines: draw border rectangle only
set_stroke(c, BORDER)
c.setLineWidth(0.4)
c.rect(x0, table_top, DATE_COL_W + avail_w, TIME_AXIS_H, fill=0, stroke=1)
# Re-draw time labels on top of border rect
for m in range(t_start, t_end + 1, 60):
slot_idx = (m - t_start) // 15
tx = x0 + DATE_COL_W + slot_idx * slot_w
c.setFont(_FONT_REGULAR, time_axis_font)
set_fill(c, AXIS_TEXT)
c.drawCentredString(tx, table_top + (TIME_AXIS_H - time_axis_font) / 2, fmt_time(m))
# ── Footnote map: blocks with notes get sequential numbers ────────
footnotes = [] # [(num, block), ...]
footnote_map = {} # block.id → footnote number
for b in sorted(doc.blocks, key=lambda x: (x.date, x.start)):
if b.notes:
num = len(footnotes) + 1
footnotes.append((num, b))
footnote_map[b.id] = num
# ── Draw day rows ─────────────────────────────────────────────────
blocks_by_date = defaultdict(list)
for b in doc.blocks:
blocks_by_date[b.date].append(b)
for di, date_key in enumerate(sorted_dates):
row_y = table_top - (di + 1) * row_h
# Alternating row background
row_bg = (1.0, 1.0, 1.0) if di % 2 == 0 else ALT_ROW
fill_rect(c, x0 + DATE_COL_W, row_y, avail_w, row_h, row_bg, BORDER, 0.3)
# Date label cell
fill_rect(c, x0, row_y, DATE_COL_W, row_h, AXIS_BG, BORDER, 0.4)
draw_clipped_text(c, format_date_cs(date_key), x0, row_y, DATE_COL_W, row_h,
_FONT_BOLD, date_font, AXIS_TEXT, 'center')
# Vertical grid lines (15-min slots, hour lines darker)
for slot_i in range(num_15min_slots + 1):
min_at_slot = t_start + slot_i * 15
tx = x0 + DATE_COL_W + slot_i * slot_w
is_hour = (min_at_slot % 60 == 0)
line_col = GRID_HOUR if is_hour else GRID_15MIN
set_stroke(c, line_col)
c.setLineWidth(0.5 if is_hour else 0.25)
c.line(tx, row_y, tx, row_y + row_h)
# Draw program blocks
for block in blocks_by_date[date_key]:
s = time_to_min(block.start)
e = time_to_min(block.end)
overnight = e <= s
if overnight:
e_draw = min(t_end, s + (e + 1440 - s)) # cap at t_end
else:
e_draw = e
cs = max(s, t_start)
ce = min(e_draw, t_end)
if ce <= cs:
continue
bx = x0 + DATE_COL_W + (cs - t_start) / 15 * slot_w
bw = (ce - cs) / 15 * slot_w
pt = type_map[block.type_id]
fill_rgb = hex_to_rgb(pt.color)
text_rgb = (0.08, 0.08, 0.08) if is_light(pt.color) else (1.0, 1.0, 1.0)
inset = 1.0
c.saveState()
# Draw block rectangle
set_fill(c, fill_rgb)
set_stroke(c, (0.0, 0.0, 0.0) if False else fill_rgb) # no border stroke
c.roundRect(bx + inset, row_y + inset, bw - 2 * inset, row_h - 2 * inset,
2, fill=1, stroke=0)
# Draw text clipped to block
p = c.beginPath()
p.rect(bx + inset + 1, row_y + inset + 1, bw - 2 * inset - 2, row_h - 2 * inset - 2)
c.clipPath(p, stroke=0, fill=0)
set_fill(c, text_rgb)
fn_num = footnote_map.get(block.id)
title_text = block.title + ('' if overnight else '')
dim_rgb = ((text_rgb[0] * 0.78, text_rgb[1] * 0.78, text_rgb[2] * 0.78)
if is_light(pt.color) else (0.82, 0.82, 0.82))
# Available width for text (inset + 2pt padding each side)
text_w_avail = max(1.0, bw - 2 * inset - 4)
sup_size = max(4.0, block_title_font * 0.65)
resp_size = max(4.0, block_time_font)
# Truncate title to fit (leave room for superscript number)
sup_reserve = (c.stringWidth(str(fn_num), _FONT_BOLD, sup_size) + 1.5
if fn_num else 0)
fitted_title = fit_text(c, title_text, _FONT_BOLD, block_title_font,
text_w_avail - sup_reserve)
# Determine vertical layout: how many lines fit?
has_responsible = bool(block.responsible)
if has_responsible and row_h >= block_title_font + resp_size + 3:
# Two-line: title + responsible
title_y = row_y + row_h * 0.55
resp_y = row_y + row_h * 0.55 - block_title_font - 1
fitted_resp = fit_text(c, block.responsible, _FONT_ITALIC, resp_size,
text_w_avail)
c.setFont(_FONT_ITALIC, resp_size)
set_fill(c, dim_rgb)
c.drawCentredString(bx + bw / 2, resp_y, fitted_resp)
else:
# Single line: title centred
title_y = row_y + (row_h - block_title_font) / 2
# Title
c.setFont(_FONT_BOLD, block_title_font)
set_fill(c, text_rgb)
if fn_num is not None:
# Draw title then superscript footnote number
title_w = c.stringWidth(fitted_title, _FONT_BOLD, block_title_font)
tx = bx + bw / 2 - (title_w + sup_reserve) / 2
c.drawString(tx, title_y, fitted_title)
c.setFont(_FONT_BOLD, sup_size)
set_fill(c, dim_rgb)
c.drawString(tx + title_w + 0.5, title_y + block_title_font * 0.45,
str(fn_num))
else:
c.drawCentredString(bx + bw / 2, title_y, fitted_title)
c.restoreState()
# ── Legend ────────────────────────────────────────────────────────
legend_y_top = table_top - num_days * row_h - 6
c.setFont(_FONT_BOLD, 7)
set_fill(c, HEADER_BG)
c.drawString(x0, legend_y_top, 'Legenda:')
legend_y_top -= LEGEND_ITEM_H
for i, pt in enumerate(doc.program_types):
col = i % legend_cols
row_idx = i // legend_cols
lx = x0 + col * LEGEND_STRIDE
ly = legend_y_top - row_idx * LEGEND_ITEM_H
fill_rgb = hex_to_rgb(pt.color)
# Colored square (NO text inside, just the color)
fill_rect(c, lx, ly - LEGEND_ITEM_H + 2, LEGEND_BOX_W, LEGEND_ITEM_H - 2,
fill_rgb, BORDER, 0.3)
# Type name NEXT TO the square
c.setFont(_FONT_REGULAR, 7)
set_fill(c, (0.15, 0.15, 0.15))
c.drawString(lx + LEGEND_BOX_W + 3,
ly - LEGEND_ITEM_H + 2 + (LEGEND_ITEM_H - 2 - 7) / 2,
pt.name)
# ── Footer (page 1) ───────────────────────────────────────────────
gen_date = datetime.now().strftime('%d.%m.%Y %H:%M')
c.setFont(_FONT_ITALIC, 6.5)
set_fill(c, FOOTER_TEXT)
footer_note = ' | Poznámky na str. 2' if footnotes else ''
c.drawCentredString(PAGE_W / 2, MARGIN - 2,
f'Vygenerováno Scénář Creatorem | {gen_date}{footer_note}')
# ── Page 2: Poznámky ke scénáři ───────────────────────────────────
if footnotes:
c.showPage()
NOTE_MARGIN = 15 * mm
ny = PAGE_H - NOTE_MARGIN
# Page title
c.setFont(_FONT_BOLD, 14)
set_fill(c, HEADER_BG)
c.drawString(NOTE_MARGIN, ny - 14, 'Poznámky ke scénáři')
ny -= 14 + 4
# Subtitle: event title + date
ev_info = doc.event.title
date_display = doc.event.date_from or doc.event.date
date_to_display = doc.event.date_to
if date_display:
if date_to_display and date_to_display != date_display:
ev_info += f' | {date_display} {date_to_display}'
else:
ev_info += f' | {date_display}'
c.setFont(_FONT_REGULAR, 9)
set_fill(c, AXIS_TEXT)
c.drawString(NOTE_MARGIN, ny - 9, ev_info)
ny -= 9 + 8
# Separator line
set_stroke(c, GRID_HOUR)
c.setLineWidth(0.5)
c.line(NOTE_MARGIN, ny, PAGE_W - NOTE_MARGIN, ny)
ny -= 8
# Footnote entries
for fn_num, block in footnotes:
# Block header: number + title + day + time
day_label = format_date_cs(block.date)
time_str = f'{block.start}{block.end}'
resp_str = f' ({block.responsible})' if block.responsible else ''
header_text = f'{fn_num}. {block.title}{resp_str}{day_label}, {time_str}'
# Check space, add new page if needed
if ny < NOTE_MARGIN + 30:
c.showPage()
ny = PAGE_H - NOTE_MARGIN
c.setFont(_FONT_BOLD, 9)
set_fill(c, HEADER_BG)
c.drawString(NOTE_MARGIN, ny - 9, header_text)
ny -= 9 + 3
# Note text (wrapped manually)
note_text = block.notes or ''
words = note_text.split()
line_w = PAGE_W - 2 * NOTE_MARGIN - 10
c.setFont(_FONT_REGULAR, 8.5)
set_fill(c, (0.15, 0.15, 0.15))
line = ''
for word in words:
test_line = (line + ' ' + word).strip()
if c.stringWidth(test_line, _FONT_REGULAR, 8.5) > line_w:
if ny < NOTE_MARGIN + 15:
c.showPage()
ny = PAGE_H - NOTE_MARGIN
c.drawString(NOTE_MARGIN + 8, ny - 8.5, line)
ny -= 8.5 + 2
line = word
else:
line = test_line
if line:
if ny < NOTE_MARGIN + 15:
c.showPage()
ny = PAGE_H - NOTE_MARGIN
c.drawString(NOTE_MARGIN + 8, ny - 8.5, line)
ny -= 8.5 + 2
ny -= 5 # spacing between footnotes
# Footer on notes page
c.setFont(_FONT_ITALIC, 6.5)
set_fill(c, FOOTER_TEXT)
c.drawCentredString(PAGE_W / 2, MARGIN - 2,
f'Poznámky ke scénáři — {doc.event.title} | {gen_date}')
c.save()
return buf.getvalue()

15
app/core/validator.py Normal file
View File

@@ -0,0 +1,15 @@
"""Validation logic for Scenar Creator v3."""
import logging
logger = logging.getLogger(__name__)
class ScenarsError(Exception):
"""Base exception for Scenar Creator."""
pass
class ValidationError(ScenarsError):
"""Raised when input validation fails."""
pass

26
app/main.py Normal file
View 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="Scénář Creator",
description="Web tool for creating experience course scenarios with canvas editor",
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
View File

@@ -0,0 +1,3 @@
from .event import Block, ProgramType, EventInfo, ScenarioDocument
__all__ = ["Block", "ProgramType", "EventInfo", "ScenarioDocument"]

47
app/models/event.py Normal file
View File

@@ -0,0 +1,47 @@
"""Pydantic v2 models for Scenar Creator v4."""
import uuid
from datetime import date as date_type, timedelta
from typing import List, Optional
from pydantic import BaseModel, Field
class Block(BaseModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
date: str # "YYYY-MM-DD"
start: str # "HH:MM" (can be > 24:00 for overnight continuation)
end: str # "HH:MM" (if end < start → overnight block)
title: str
type_id: str
responsible: Optional[str] = None
notes: Optional[str] = None
series_id: Optional[str] = None # shared across blocks added via "add to all days"
class ProgramType(BaseModel):
id: str
name: str
color: str # "#RRGGBB"
class EventInfo(BaseModel):
title: str
subtitle: Optional[str] = None
# Multi-day: date_from → date_to (inclusive). Backward compat: date = date_from.
date: Optional[str] = None # legacy / backward compat
date_from: Optional[str] = None
date_to: Optional[str] = None
location: Optional[str] = None
class ScenarioDocument(BaseModel):
version: str = "1.0"
event: EventInfo
program_types: List[ProgramType]
blocks: List[Block]
def get_sorted_dates(self) -> List[str]:
"""Return sorted list of unique block dates."""
dates = sorted(set(b.date for b in self.blocks))
return dates

15
app/models/responses.py Normal file
View File

@@ -0,0 +1,15 @@
"""API response models."""
from typing import List
from pydantic import BaseModel
class HealthResponse(BaseModel):
status: str = "ok"
version: str
class ValidationResponse(BaseModel):
valid: bool
errors: List[str] = []

1032
app/static/css/app.css Normal file

File diff suppressed because it is too large Load Diff

254
app/static/index.html Normal file
View File

@@ -0,0 +1,254 @@
<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Scénář Creator</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/app.css">
</head>
<body>
<header class="header">
<div class="header-left">
<h1 class="header-title">Scénář Creator</h1>
<span class="header-version">v4.7</span>
</div>
<div class="header-actions">
<label class="btn btn-secondary btn-sm" id="importJsonBtn">
<input type="file" accept=".json" id="importJsonInput" hidden>
Import JSON
</label>
<button class="btn btn-secondary btn-sm" id="newScenarioBtn">Nový scénář</button>
<button class="btn btn-secondary btn-sm" id="exportJsonBtn">Export JSON</button>
<button class="btn btn-primary btn-sm" id="generatePdfBtn">Generovat PDF</button>
</div>
</header>
<div class="tabs">
<button class="tab active" data-tab="editor">Editor</button>
<button class="tab" data-tab="docs">Dokumentace</button>
</div>
<div class="tab-content" id="tab-editor">
<div class="app-layout">
<aside class="sidebar">
<section class="sidebar-section">
<h3 class="sidebar-heading">Informace o akci</h3>
<div class="form-group">
<label>Název</label>
<input type="text" id="eventTitle" placeholder="Název akce">
</div>
<div class="form-group">
<label>Podtitul</label>
<input type="text" id="eventSubtitle" placeholder="Podtitul">
</div>
<div class="form-row">
<div class="form-group">
<label>Od</label>
<input type="date" id="eventDateFrom">
</div>
<div class="form-group">
<label>Do</label>
<input type="date" id="eventDateTo">
</div>
</div>
<div class="form-group">
<label>Místo</label>
<input type="text" id="eventLocation" placeholder="Místo konání">
</div>
</section>
<section class="sidebar-section">
<h3 class="sidebar-heading">Typy programů</h3>
<div id="programTypesContainer"></div>
<button class="btn btn-secondary btn-xs" id="addTypeBtn">+ Přidat typ</button>
</section>
<section class="sidebar-section">
<button class="btn btn-primary btn-block" id="addBlockBtn">+ Přidat blok</button>
</section>
</aside>
<main class="canvas-wrapper">
<div id="canvasScrollArea" class="canvas-scroll-area">
<div id="timeAxis" class="time-axis-row"></div>
<div id="dayRows" class="day-rows"></div>
</div>
</main>
</div>
</div>
<!-- Documentation tab -->
<div class="tab-content hidden" id="tab-docs">
<div class="docs-container">
<h2>Scénář Creator — Dokumentace</h2>
<p class="docs-version">Verze 4.3.0 &nbsp;|&nbsp; <a href="/docs" target="_blank">Swagger API</a> &nbsp;|&nbsp; <a href="/api/sample">Vzorový JSON</a></p>
<h3>Jak začít</h3>
<ol>
<li><strong>Nový scénář</strong> — klikněte na „Nový scénář" v záhlaví. Vytvoří se prázdný scénář pro dnešní den.</li>
<li><strong>Import JSON</strong> — klikněte na „Import JSON" a vyberte dříve uložený .json soubor.</li>
<li><strong>Vzorový JSON</strong> — stáhněte <a href="/api/sample">sample.json</a> jako šablonu a importujte ho.</li>
</ol>
<h3>Nastavení akce (postranní panel)</h3>
<ul>
<li><strong>Název / Podtitul / Místo</strong> — zobrazí se v záhlaví PDF.</li>
<li><strong>Od / Do</strong> — rozsah dat akce. Každý den = jeden řádek v editoru. Jednodnevní kurz = stejné datum v obou polích.</li>
<li><strong>Typy programů</strong> — přidejte typ kliknutím na „+ Přidat typ", nastavte název a barvu. Každý blok patří k jednomu typu.</li>
</ul>
<h3>Práce s bloky</h3>
<ul>
<li><strong>Přidání:</strong> Klikněte na „+ Přidat blok" nebo klikněte na prázdné místo v řádku dne.</li>
<li><strong>Přidání do všech dnů:</strong> V modalu nového bloku zaškrtněte „Přidat do každého dne kurzu" — vytvoří identický blok pro každý den akce (sdílená série).</li>
<li><strong>Přesun v rámci dne:</strong> Chytněte blok a táhněte doleva/doprava. Snap na 15 minut.</li>
<li><strong>Přesun mezi dny:</strong> Táhněte blok nahoru/dolů — zvýrazní se cílový den (modrý rámeček). Pusťte na novém dni.</li>
<li><strong>Změna délky:</strong> Chytněte pravý okraj bloku a táhněte.</li>
<li><strong>Úprava:</strong> Klikněte na blok — otevře se formulář s editací jednoho bloku.</li>
<li><strong>Smazání jednoho bloku:</strong> V editačním formuláři klikněte na „Smazat jen tento".</li>
<li><strong>Smazání celé série:</strong> Pokud byl blok přidán jako součást série (zaškrtávací políčko), zobrazí se tlačítko „Smazat sérii" — smaže všechny bloky se stejným series_id.</li>
</ul>
<h3>Formulář bloku</h3>
<ul>
<li><strong>Den</strong> — výběr pouze z nastavených dní akce (ne volné datum).</li>
<li><strong>Začátek / Konec</strong> — čas HH:MM.</li>
<li><strong>Nebo trvání</strong> — zadáte hodiny a minuty → Konec se vypočítá. Funguje i opačně (zadáte Konec → trvání se aktualizuje).</li>
<li><strong>Program přes půlnoc</strong> — Konec &lt; Začátek je validní (blok přechází přes půlnoc). V editoru označen „→", v PDF správně vykreslí.</li>
<li><strong>Garant</strong> — zobrazí se v bloku v editoru i v PDF (pod názvem bloku).</li>
<li><strong>Poznámka</strong> — nezobrazuje se v editoru, pouze v PDF jako horní index (¹ ²...) u názvu bloku. Všechny poznámky jsou vypsány na 2. stránce PDF.</li>
</ul>
<h3>Export / Import</h3>
<ul>
<li><strong>Export JSON</strong> — stáhne celý scénář jako .json soubor. Uložte pro pozdější editaci.</li>
<li><strong>Import JSON</strong> — načte dříve uložený .json soubor.</li>
<li><strong>Generovat PDF</strong> — vytvoří tisknutelný harmonogram:
<ul>
<li>Stránka 1: timetable (řádky = dny, sloupce = čas, barvy dle typů, legenda)</li>
<li>Stránka 2 (pokud jsou poznámky): výpis poznámek ke scénáři s čísly</li>
</ul>
</li>
</ul>
<h3>Formát JSON</h3>
<table class="docs-table">
<thead><tr><th>Pole</th><th>Typ</th><th>Popis</th></tr></thead>
<tbody>
<tr><td>event.title</td><td>string</td><td>Název akce (povinné)</td></tr>
<tr><td>event.date_from</td><td>string</td><td>První den akce (YYYY-MM-DD)</td></tr>
<tr><td>event.date_to</td><td>string</td><td>Poslední den akce (YYYY-MM-DD)</td></tr>
<tr><td>event.subtitle</td><td>string?</td><td>Podtitul (nepovinné)</td></tr>
<tr><td>event.location</td><td>string?</td><td>Místo konání (nepovinné)</td></tr>
<tr><td>program_types[].id</td><td>string</td><td>Unikátní identifikátor typu</td></tr>
<tr><td>program_types[].name</td><td>string</td><td>Název typu (zobrazí se v legendě)</td></tr>
<tr><td>program_types[].color</td><td>string</td><td>Barva v hex formátu (#RRGGBB)</td></tr>
<tr><td>blocks[].id</td><td>string</td><td>Unikátní ID bloku (auto-generováno)</td></tr>
<tr><td>blocks[].date</td><td>string</td><td>Den bloku (YYYY-MM-DD)</td></tr>
<tr><td>blocks[].start</td><td>string</td><td>Čas začátku (HH:MM)</td></tr>
<tr><td>blocks[].end</td><td>string</td><td>Čas konce (HH:MM) — pokud end &lt; start, jde přes půlnoc</td></tr>
<tr><td>blocks[].title</td><td>string</td><td>Název bloku (povinné)</td></tr>
<tr><td>blocks[].type_id</td><td>string</td><td>ID typu programu (musí existovat v program_types)</td></tr>
<tr><td>blocks[].responsible</td><td>string?</td><td>Garant — zobrazí se v editoru i PDF</td></tr>
<tr><td>blocks[].notes</td><td>string?</td><td>Poznámka — jen v PDF, jako horní index + stránka 2</td></tr>
<tr><td>blocks[].series_id</td><td>string?</td><td>Sdílené ID série — bloky přidané přes „Přidat do všech dnů" sdílejí toto ID</td></tr>
</tbody>
</table>
<h3>Tipy</h3>
<ul>
<li>Scénář se <strong>neukládá automaticky</strong> — použijte Export JSON pro zálohování.</li>
<li>Barvy typů se projeví okamžitě v editoru i v PDF.</li>
<li>Pro tisk doporučujeme PDF v orientaci na šířku (A4 landscape).</li>
<li>Pokud blok přechází přes půlnoc, vidíte ho s šipkou „→" — v PDF se zobrazí do konce dne.</li>
</ul>
</div>
</div>
<!-- Block edit/create modal -->
<div class="modal-overlay hidden" id="blockModal">
<div class="modal">
<div class="modal-header">
<h3 id="modalTitle">Upravit blok</h3>
<button class="modal-close" id="modalClose">&times;</button>
</div>
<div class="modal-body">
<input type="hidden" id="modalBlockId">
<div class="form-group">
<label>Název bloku</label>
<input type="text" id="modalBlockTitle" placeholder="Název">
</div>
<div class="form-group">
<label>Den</label>
<select id="modalBlockDate"></select>
</div>
<div class="form-group">
<label>Typ programu</label>
<select id="modalBlockType"></select>
</div>
<div class="form-row">
<div class="form-group">
<label>Začátek</label>
<input type="text" id="modalBlockStart" placeholder="HH:MM" maxlength="5" class="time-input" autocomplete="off">
</div>
<div class="form-group">
<label>Konec</label>
<input type="text" id="modalBlockEnd" placeholder="HH:MM" maxlength="5" class="time-input" autocomplete="off">
</div>
</div>
<div class="form-group">
<label>Nebo trvání</label>
<div class="duration-row">
<div class="duration-field">
<input type="number" id="modalDurHours" min="0" max="23" placeholder="0">
<span class="duration-unit">hod</span>
</div>
<div class="duration-field">
<input type="number" id="modalDurMinutes" min="0" max="59" step="15" placeholder="0">
<span class="duration-unit">min</span>
</div>
</div>
</div>
<div class="form-group">
<label>Garant</label>
<input type="text" id="modalBlockResponsible" placeholder="Garant">
</div>
<div class="form-group">
<label>Poznámka</label>
<textarea id="modalBlockNotes" rows="3" placeholder="Poznámka"></textarea>
</div>
<!-- Shown only when creating a new block -->
<div class="form-group series-row hidden" id="seriesRow">
<label class="series-label">
<input type="checkbox" id="modalAddToAllDays">
Přidat do každého dne kurzu
</label>
<p class="series-hint">Vytvoří identický blok pro každý den akce (sdílená série).</p>
</div>
</div>
<div class="modal-footer">
<div class="modal-footer-left">
<button class="btn btn-danger btn-sm hidden" id="modalDeleteBtn">Smazat jen tento</button>
<button class="btn btn-danger-outline btn-sm hidden" id="modalDeleteSeriesBtn">Smazat sérii</button>
</div>
<button class="btn btn-primary btn-sm" id="modalSaveBtn">Uložit</button>
</div>
</div>
</div>
<!-- Status toast -->
<div class="toast hidden" id="toast"></div>
<script src="https://cdn.jsdelivr.net/npm/interactjs@1.10.27/dist/interact.min.js"></script>
<script src="/static/js/api.js"></script>
<script src="/static/js/canvas.js"></script>
<script src="/static/js/export.js"></script>
<script src="/static/js/app.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => App.init());
</script>
</body>
</html>

57
app/static/js/api.js Normal file
View File

@@ -0,0 +1,57 @@
/**
* API fetch wrapper for Scenar Creator v3.
*/
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;
}
return fetch(url, opts);
},
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 get(url) {
const res = await fetch(url);
if (!res.ok) throw new Error(res.statusText);
return res.json();
},
async getBlob(url) {
const res = await fetch(url);
if (!res.ok) throw new Error(res.statusText);
return res.blob();
},
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);
}
};

549
app/static/js/app.js Normal file
View File

@@ -0,0 +1,549 @@
/**
* Main application logic for Scenar Creator v4.
* Multi-day state, duration input, horizontal canvas.
*/
const App = {
state: {
event: { title: '', subtitle: '', date_from: '', date_to: '', location: '' },
program_types: [],
blocks: []
},
init() {
this.bindEvents();
this.newScenario();
},
// ─── Helpers ───────────────────────────────────────────────────────
uid() {
return 'b_' + Math.random().toString(36).slice(2, 10);
},
// Build download filename: "<slug_nazvu>-<YYYYMMDD-HHMM>.<ext>"
buildFilename(ext) {
const title = (this.state.event.title || 'scenar').trim();
// Remove diacritics, lowercase, replace non-alphanum with dash, collapse dashes
const slug = title
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'scenar';
const now = new Date();
const ts = now.getFullYear().toString()
+ String(now.getMonth() + 1).padStart(2, '0')
+ String(now.getDate()).padStart(2, '0')
+ '-'
+ String(now.getHours()).padStart(2, '0')
+ String(now.getMinutes()).padStart(2, '0');
return `${slug}-${ts}.${ext}`;
},
parseTimeToMin(str) {
if (!str) return 0;
const [h, m] = str.split(':').map(Number);
return (h || 0) * 60 + (m || 0);
},
minutesToTime(totalMin) {
const h = Math.floor(totalMin / 60);
const m = totalMin % 60;
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
},
// Return sorted list of all dates from date_from to date_to (inclusive)
getDates() {
const from = this.state.event.date_from || this.state.event.date;
const to = this.state.event.date_to || from;
if (!from) return [];
const dates = [];
const cur = new Date(from + 'T12:00:00');
const end = new Date((to || from) + 'T12:00:00');
// Safety: max 31 days
let safety = 0;
while (cur <= end && safety < 31) {
dates.push(cur.toISOString().slice(0, 10));
cur.setDate(cur.getDate() + 1);
safety++;
}
return dates;
},
// ─── State ────────────────────────────────────────────────────────
getDocument() {
this.syncEventFromUI();
return {
version: '1.0',
event: {
...this.state.event,
date: this.state.event.date_from, // backward compat
},
program_types: this.state.program_types.map(pt => ({ ...pt })),
blocks: this.state.blocks.map(b => ({ ...b }))
};
},
loadDocument(doc) {
const ev = doc.event || {};
// Backward compat: if only date exists, use it as date_from = date_to
const date_from = ev.date_from || ev.date || '';
const date_to = ev.date_to || date_from;
this.state.event = {
title: ev.title || '',
subtitle: ev.subtitle || '',
date_from,
date_to,
location: ev.location || '',
};
this.state.program_types = (doc.program_types || []).map(pt => ({ ...pt }));
this.state.blocks = (doc.blocks || []).map(b => ({
...b,
id: b.id || this.uid()
}));
this.syncEventToUI();
this.renderTypes();
this.renderCanvas();
},
newScenario() {
const today = new Date().toISOString().slice(0, 10);
this.state = {
event: { title: 'Nová akce', subtitle: '', date_from: today, date_to: today, location: '' },
program_types: [
{ id: 'main', name: 'Hlavní program', color: '#3B82F6' },
{ id: 'rest', name: 'Odpočinek', color: '#22C55E' }
],
blocks: []
};
this.syncEventToUI();
this.renderTypes();
this.renderCanvas();
},
// ─── Sync sidebar <-> state ───────────────────────────────────────
syncEventFromUI() {
this.state.event.title = document.getElementById('eventTitle').value.trim() || 'Nová akce';
this.state.event.subtitle = document.getElementById('eventSubtitle').value.trim() || null;
this.state.event.date_from = document.getElementById('eventDateFrom').value || null;
this.state.event.date_to = document.getElementById('eventDateTo').value || this.state.event.date_from;
this.state.event.location = document.getElementById('eventLocation').value.trim() || null;
},
syncEventToUI() {
document.getElementById('eventTitle').value = this.state.event.title || '';
document.getElementById('eventSubtitle').value = this.state.event.subtitle || '';
document.getElementById('eventDateFrom').value = this.state.event.date_from || '';
document.getElementById('eventDateTo').value = this.state.event.date_to || this.state.event.date_from || '';
document.getElementById('eventLocation').value = this.state.event.location || '';
},
// ─── Program types ────────────────────────────────────────────────
renderTypes() {
const container = document.getElementById('programTypesContainer');
container.innerHTML = '';
this.state.program_types.forEach((pt, i) => {
const row = document.createElement('div');
row.className = 'type-row';
row.innerHTML = `
<input type="color" value="${pt.color}" data-idx="${i}">
<input type="text" value="${pt.name}" placeholder="Název typu" data-idx="${i}">
<button class="type-remove" data-idx="${i}">&times;</button>
`;
row.querySelector('input[type="color"]').addEventListener('change', (e) => {
this.state.program_types[i].color = e.target.value;
this.renderCanvas();
});
row.querySelector('input[type="text"]').addEventListener('change', (e) => {
this.state.program_types[i].name = e.target.value.trim();
});
row.querySelector('.type-remove').addEventListener('click', () => {
this.state.program_types.splice(i, 1);
this.renderTypes();
this.renderCanvas();
});
container.appendChild(row);
});
},
// ─── Canvas ───────────────────────────────────────────────────────
renderCanvas() {
Canvas.render(this.state);
},
// ─── Block modal ──────────────────────────────────────────────────
// Open modal to edit existing block
openBlockModal(blockId) {
const block = this.state.blocks.find(b => b.id === blockId);
if (!block) return;
document.getElementById('modalTitle').textContent = 'Upravit blok';
document.getElementById('modalBlockId').value = block.id;
document.getElementById('modalBlockTitle').value = block.title || '';
document.getElementById('modalBlockStart').value = block.start || '';
document.getElementById('modalBlockEnd').value = block.end || '';
document.getElementById('modalBlockResponsible').value = block.responsible || '';
document.getElementById('modalBlockNotes').value = block.notes || '';
this._populateTypeSelect(block.type_id);
this._populateDaySelect(block.date);
this._updateDuration();
// Show delete buttons; series delete only if block belongs to a series
document.getElementById('modalDeleteBtn').classList.remove('hidden');
document.getElementById('seriesRow').classList.add('hidden');
const seriesBtn = document.getElementById('modalDeleteSeriesBtn');
if (block.series_id) {
const seriesCount = this.state.blocks.filter(b => b.series_id === block.series_id).length;
seriesBtn.textContent = `Smazat sérii (${seriesCount} bloků)`;
seriesBtn.classList.remove('hidden');
} else {
seriesBtn.classList.add('hidden');
}
document.getElementById('blockModal').classList.remove('hidden');
},
// Open modal to create new block
openNewBlockModal(date, start, end) {
document.getElementById('modalTitle').textContent = 'Nový blok';
document.getElementById('modalBlockId').value = '';
document.getElementById('modalBlockTitle').value = '';
document.getElementById('modalBlockStart').value = start || '09:00';
document.getElementById('modalBlockEnd').value = end || '10:00';
document.getElementById('modalBlockResponsible').value = '';
document.getElementById('modalBlockNotes').value = '';
this._populateTypeSelect(null);
this._populateDaySelect(date);
this._updateDuration();
// Hide delete buttons, show series row
document.getElementById('modalDeleteBtn').classList.add('hidden');
document.getElementById('modalDeleteSeriesBtn').classList.add('hidden');
document.getElementById('seriesRow').classList.remove('hidden');
document.getElementById('modalAddToAllDays').checked = false;
document.getElementById('blockModal').classList.remove('hidden');
},
_populateTypeSelect(selectedId) {
const sel = document.getElementById('modalBlockType');
sel.innerHTML = '';
this.state.program_types.forEach(pt => {
const opt = document.createElement('option');
opt.value = pt.id;
opt.textContent = pt.name;
if (pt.id === selectedId) opt.selected = true;
sel.appendChild(opt);
});
},
_populateDaySelect(selectedDate) {
const sel = document.getElementById('modalBlockDate');
sel.innerHTML = '';
const dates = this.getDates();
if (dates.length === 0) {
// Fallback: show today
const today = new Date().toISOString().slice(0, 10);
const opt = document.createElement('option');
opt.value = today;
opt.textContent = this._formatDateLabel(today);
sel.appendChild(opt);
return;
}
dates.forEach(date => {
const opt = document.createElement('option');
opt.value = date;
opt.textContent = this._formatDateLabel(date);
if (date === selectedDate) opt.selected = true;
sel.appendChild(opt);
});
// If none selected, default to first
if (!selectedDate || !dates.includes(selectedDate)) {
sel.value = dates[0];
}
},
_formatDateLabel(dateStr) {
const d = new Date(dateStr + 'T12:00:00');
const weekday = d.toLocaleDateString('cs-CZ', { weekday: 'long' });
const weekdayCap = weekday.charAt(0).toUpperCase() + weekday.slice(1);
return `${weekdayCap} (${d.getDate()}.${d.getMonth() + 1})`;
},
_updateDuration() {
const startVal = document.getElementById('modalBlockStart').value;
const endVal = document.getElementById('modalBlockEnd').value;
if (!startVal || !endVal) {
document.getElementById('modalDurHours').value = '';
document.getElementById('modalDurMinutes').value = '';
return;
}
const s = this.parseTimeToMin(startVal);
let e = this.parseTimeToMin(endVal);
if (e <= s) e += 24 * 60; // overnight
const dur = e - s;
document.getElementById('modalDurHours').value = Math.floor(dur / 60);
document.getElementById('modalDurMinutes').value = dur % 60;
},
_getDurationMinutes() {
const h = parseInt(document.getElementById('modalDurHours').value) || 0;
const m = parseInt(document.getElementById('modalDurMinutes').value) || 0;
return h * 60 + m;
},
_saveModal() {
const blockId = document.getElementById('modalBlockId').value;
const date = document.getElementById('modalBlockDate').value;
const title = document.getElementById('modalBlockTitle').value.trim();
const type_id = document.getElementById('modalBlockType').value;
const start = document.getElementById('modalBlockStart').value;
const end = document.getElementById('modalBlockEnd').value;
const responsible = document.getElementById('modalBlockResponsible').value.trim() || null;
const notes = document.getElementById('modalBlockNotes').value.trim() || null;
const timeRe = /^\d{2}:\d{2}$/;
if (!title) { this.toast('Zadejte název bloku', 'error'); return; }
if (!start || !end) { this.toast('Zadejte čas začátku a konce', 'error'); return; }
if (!timeRe.test(start) || !timeRe.test(end)) { this.toast('Neplatný formát času (HH:MM)', 'error'); return; }
if (blockId) {
// Edit existing block (no series expansion on edit — user edits only this one)
const idx = this.state.blocks.findIndex(b => b.id === blockId);
if (idx !== -1) {
const existing = this.state.blocks[idx];
Object.assign(this.state.blocks[idx], {
date, title, type_id, start, end, responsible, notes,
series_id: existing.series_id || null
});
}
} else {
// New block
const addToAll = document.getElementById('modalAddToAllDays').checked;
if (addToAll) {
// Add a copy to every day in the event range, all sharing a series_id
const series_id = this.uid();
const dates = this.getDates();
for (const d of dates) {
this.state.blocks.push({ id: this.uid(), date: d, title, type_id, start, end, responsible, notes, series_id });
}
document.getElementById('blockModal').classList.add('hidden');
this.renderCanvas();
this.toast(`Blok přidán do ${dates.length} dnů`, 'success');
return;
} else {
this.state.blocks.push({ id: this.uid(), date, title, type_id, start, end, responsible, notes, series_id: null });
}
}
document.getElementById('blockModal').classList.add('hidden');
this.renderCanvas();
this.toast('Blok uložen', 'success');
},
_deleteBlock() {
const blockId = document.getElementById('modalBlockId').value;
if (!blockId) return;
this.state.blocks = this.state.blocks.filter(b => b.id !== blockId);
document.getElementById('blockModal').classList.add('hidden');
this.renderCanvas();
this.toast('Blok smazán', 'success');
},
_deleteBlockSeries() {
const blockId = document.getElementById('modalBlockId').value;
if (!blockId) return;
const block = this.state.blocks.find(b => b.id === blockId);
if (!block || !block.series_id) {
// Fallback: delete just this one
this._deleteBlock();
return;
}
const seriesId = block.series_id;
const count = this.state.blocks.filter(b => b.series_id === seriesId).length;
this.state.blocks = this.state.blocks.filter(b => b.series_id !== seriesId);
document.getElementById('blockModal').classList.add('hidden');
this.renderCanvas();
this.toast(`Série smazána (${count} bloků)`, 'success');
},
// ─── Toast ────────────────────────────────────────────────────────
toast(message, type = 'success') {
const el = document.getElementById('toast');
if (!el) return;
el.textContent = message;
el.className = `toast ${type}`;
el.classList.remove('hidden');
clearTimeout(this._toastTimer);
this._toastTimer = setTimeout(() => el.classList.add('hidden'), 3000);
},
// ─── Events ───────────────────────────────────────────────────────
// ─── Time input helpers ───────────────────────────────────────────
_initTimeInput(el) {
// Auto-format: allow only digits + colon, insert ':' after 2 digits
el.addEventListener('input', (e) => {
let v = e.target.value.replace(/[^0-9:]/g, '');
// Strip colons and rebuild
const digits = v.replace(/:/g, '');
if (digits.length >= 3) {
v = digits.slice(0, 2) + ':' + digits.slice(2, 4);
} else {
v = digits;
}
e.target.value = v;
});
el.addEventListener('blur', (e) => {
const v = e.target.value;
if (!v) return;
// Validate HH:MM format
if (!/^\d{2}:\d{2}$/.test(v)) {
e.target.classList.add('input-error');
this.toast('Neplatný čas (formát HH:MM)', 'error');
} else {
const [h, m] = v.split(':').map(Number);
if (h > 23 || m > 59) {
e.target.classList.add('input-error');
this.toast('Neplatný čas (00:0023:59)', 'error');
} else {
e.target.classList.remove('input-error');
}
}
});
el.addEventListener('focus', (e) => {
e.target.classList.remove('input-error');
});
},
bindEvents() {
// Init time inputs
this._initTimeInput(document.getElementById('modalBlockStart'));
this._initTimeInput(document.getElementById('modalBlockEnd'));
// Import JSON
document.getElementById('importJsonInput').addEventListener('change', (e) => {
if (e.target.files[0]) importJson(e.target.files[0]);
e.target.value = '';
});
// Export JSON
document.getElementById('exportJsonBtn').addEventListener('click', () => exportJson());
// New scenario
document.getElementById('newScenarioBtn').addEventListener('click', () => {
if (!confirm('Vytvořit nový scénář? Neuložené změny budou ztraceny.')) return;
this.newScenario();
});
// Generate PDF
document.getElementById('generatePdfBtn').addEventListener('click', async () => {
this.syncEventFromUI();
const doc = this.getDocument();
if (!doc.blocks.length) {
this.toast('Žádné bloky k exportu', 'error');
return;
}
try {
this.toast('Generuji PDF…', 'success');
const blob = await API.postBlob('/api/generate-pdf', doc);
API.downloadBlob(blob, App.buildFilename('pdf'));
this.toast('PDF staženo', 'success');
} catch (err) {
this.toast('Chyba PDF: ' + err.message, 'error');
}
});
// Add block button
document.getElementById('addBlockBtn').addEventListener('click', () => {
const dates = this.getDates();
const date = dates[0] || new Date().toISOString().slice(0, 10);
this.openNewBlockModal(date, '09:00', '10:00');
});
// Add type
document.getElementById('addTypeBtn').addEventListener('click', () => {
this.state.program_types.push({
id: 'type_' + Math.random().toString(36).slice(2, 6),
name: 'Nový typ',
color: '#' + Math.floor(Math.random() * 0xFFFFFF).toString(16).padStart(6, '0')
});
this.renderTypes();
});
// Modal close
document.getElementById('modalClose').addEventListener('click', () => {
document.getElementById('blockModal').classList.add('hidden');
});
document.getElementById('modalSaveBtn').addEventListener('click', () => this._saveModal());
document.getElementById('modalDeleteBtn').addEventListener('click', () => this._deleteBlock());
document.getElementById('modalDeleteSeriesBtn').addEventListener('click', () => this._deleteBlockSeries());
// Duration ↔ end sync (hours + minutes fields)
const durUpdater = () => {
const startVal = document.getElementById('modalBlockStart').value;
const durMin = this._getDurationMinutes();
if (!startVal || durMin <= 0) return;
const startMin = this.parseTimeToMin(startVal);
const endMin = startMin + durMin;
// Allow overnight (> 24h notation handled as raw minutes)
const h = Math.floor(endMin / 60) % 24;
const m = endMin % 60;
document.getElementById('modalBlockEnd').value =
`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
};
document.getElementById('modalDurHours').addEventListener('input', durUpdater);
document.getElementById('modalDurMinutes').addEventListener('input', durUpdater);
document.getElementById('modalBlockEnd').addEventListener('input', () => {
this._updateDuration();
});
document.getElementById('modalBlockStart').addEventListener('input', () => {
this._updateDuration();
});
// Date range sync: if dateFrom changes and dateTo < dateFrom, set dateTo = dateFrom
document.getElementById('eventDateFrom').addEventListener('change', (e) => {
const toEl = document.getElementById('eventDateTo');
if (!toEl.value || toEl.value < e.target.value) {
toEl.value = e.target.value;
}
this.syncEventFromUI();
this.renderCanvas();
});
document.getElementById('eventDateTo').addEventListener('change', () => {
this.syncEventFromUI();
this.renderCanvas();
});
// Tabs
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.add('hidden'));
tab.classList.add('active');
document.getElementById('tab-' + tab.dataset.tab).classList.remove('hidden');
});
});
// Close modal on overlay click
document.getElementById('blockModal').addEventListener('click', (e) => {
if (e.target === document.getElementById('blockModal')) {
document.getElementById('blockModal').classList.add('hidden');
}
});
},
};

432
app/static/js/canvas.js Normal file
View File

@@ -0,0 +1,432 @@
/**
* Canvas editor for Scenar Creator v4.
* Horizontal layout: X = time, Y = days.
* interact.js for drag (horizontal) and resize (right edge).
*/
const Canvas = {
GRID_MINUTES: 15,
ROW_H: 52, // px height of one day row
TIME_LABEL_W: 110, // px width of date label column (Czech day names: "Pondělí (20.2)")
HEADER_H: 28, // px height of time axis header
MIN_BLOCK_MIN: 15, // minimum block duration in minutes
// Time range (auto-derived from blocks, with fallback)
_startMin: 7 * 60,
_endMin: 22 * 60,
get pxPerMinute() {
const slots = (this._endMin - this._startMin) / this.GRID_MINUTES;
const container = document.getElementById('canvasScrollArea');
if (!container) return 2;
const avail = container.clientWidth - this.TIME_LABEL_W - 4;
return avail / ((this._endMin - this._startMin));
},
minutesToPx(minutes) {
return (minutes - this._startMin) * this.pxPerMinute;
},
pxToMinutes(px) {
return px / this.pxPerMinute + this._startMin;
},
snapMinutes(minutes) {
return Math.round(minutes / this.GRID_MINUTES) * this.GRID_MINUTES;
},
formatTime(totalMinutes) {
const norm = ((totalMinutes % 1440) + 1440) % 1440;
const h = Math.floor(norm / 60);
const m = norm % 60;
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
},
parseTime(str) {
if (!str) return 0;
const [h, m] = str.split(':').map(Number);
return (h || 0) * 60 + (m || 0);
},
isLightColor(hex) {
const h = (hex || '#888888').replace('#', '');
const r = parseInt(h.substring(0, 2), 16) || 128;
const g = parseInt(h.substring(2, 4), 16) || 128;
const b = parseInt(h.substring(4, 6), 16) || 128;
return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.6;
},
// Derive time range from blocks (with default fallback)
_computeRange(blocks) {
if (!blocks || blocks.length === 0) {
this._startMin = 7 * 60;
this._endMin = 22 * 60;
return;
}
let minStart = 24 * 60;
let maxEnd = 0;
for (const b of blocks) {
const s = this.parseTime(b.start);
let e = this.parseTime(b.end);
if (e <= s) e += 24 * 60; // overnight
if (s < minStart) minStart = s;
if (e > maxEnd) maxEnd = e;
}
// Round to hour boundaries, add small padding
this._startMin = Math.max(0, Math.floor(minStart / 60) * 60);
this._endMin = Math.min(48 * 60, Math.ceil(maxEnd / 60) * 60);
// Ensure minimum range of 2h
if (this._endMin - this._startMin < 120) {
this._endMin = this._startMin + 120;
}
},
render(state) {
const dates = App.getDates();
this._computeRange(state.blocks);
const timeAxisEl = document.getElementById('timeAxis');
const dayRowsEl = document.getElementById('dayRows');
if (!timeAxisEl || !dayRowsEl) return;
timeAxisEl.innerHTML = '';
dayRowsEl.innerHTML = '';
this._renderTimeAxis(timeAxisEl, dates);
this._renderDayRows(dayRowsEl, dates, state);
},
_renderTimeAxis(container, dates) {
container.style.display = 'flex';
container.style.alignItems = 'stretch';
container.style.height = this.HEADER_H + 'px';
container.style.minWidth = (this.TIME_LABEL_W + this._canvasWidth()) + 'px';
// Date label placeholder
const corner = document.createElement('div');
corner.className = 'time-corner';
corner.style.cssText = `width:${this.TIME_LABEL_W}px;min-width:${this.TIME_LABEL_W}px;`;
container.appendChild(corner);
// Time labels wrapper
const labelsWrap = document.createElement('div');
labelsWrap.style.cssText = `position:relative;flex:1;height:${this.HEADER_H}px;`;
const totalMin = this._endMin - this._startMin;
for (let m = this._startMin; m <= this._endMin; m += 60) {
const pct = (m - this._startMin) / totalMin * 100;
const label = document.createElement('div');
label.className = 'time-tick';
label.style.left = `calc(${pct}% - 1px)`;
label.textContent = this.formatTime(m);
labelsWrap.appendChild(label);
}
container.appendChild(labelsWrap);
},
_canvasWidth() {
const container = document.getElementById('canvasScrollArea');
if (!container) return 800;
return Math.max(600, container.clientWidth - this.TIME_LABEL_W - 20);
},
_renderDayRows(container, dates, state) {
const typeMap = {};
for (const pt of state.program_types) typeMap[pt.id] = pt;
for (const date of dates) {
const row = document.createElement('div');
row.className = 'day-row';
row.style.height = this.ROW_H + 'px';
row.dataset.date = date;
// Date label
const label = document.createElement('div');
label.className = 'day-label';
label.style.width = this.TIME_LABEL_W + 'px';
label.style.minWidth = this.TIME_LABEL_W + 'px';
label.textContent = this._formatDate(date);
row.appendChild(label);
// Timeline area
const timeline = document.createElement('div');
timeline.className = 'day-timeline';
timeline.style.position = 'relative';
timeline.dataset.date = date;
// Grid lines (every hour)
const totalMin = this._endMin - this._startMin;
for (let m = this._startMin; m < this._endMin; m += 60) {
const line = document.createElement('div');
line.className = 'grid-line';
line.style.left = ((m - this._startMin) / totalMin * 100) + '%';
timeline.appendChild(line);
}
// Click on empty timeline to add block
timeline.addEventListener('click', (e) => {
if (e.target !== timeline) return;
const rect = timeline.getBoundingClientRect();
const relX = e.clientX - rect.left;
const totalW = rect.width;
const clickMin = this._startMin + (relX / totalW) * (this._endMin - this._startMin);
const snapStart = this.snapMinutes(clickMin);
const snapEnd = snapStart + 60; // default 1h
App.openNewBlockModal(date, this.formatTime(snapStart), this.formatTime(Math.min(snapEnd, this._endMin)));
});
// Render blocks for this date
const dayBlocks = state.blocks.filter(b => b.date === date);
for (const block of dayBlocks) {
const el = this._createBlockEl(block, typeMap, totalMin);
if (el) timeline.appendChild(el);
}
row.appendChild(timeline);
container.appendChild(row);
}
},
_formatDate(dateStr) {
const d = new Date(dateStr + 'T12:00:00');
const weekday = d.toLocaleDateString('cs-CZ', { weekday: 'long' });
// Capitalize first letter
const weekdayCap = weekday.charAt(0).toUpperCase() + weekday.slice(1);
const day = d.getDate();
const month = d.getMonth() + 1;
return `${weekdayCap} (${day}.${month})`;
},
_createBlockEl(block, typeMap, totalMin) {
const pt = typeMap[block.type_id];
const color = pt ? pt.color : '#888888';
const light = this.isLightColor(color);
const s = this.parseTime(block.start);
let e = this.parseTime(block.end);
const isOvernight = e <= s;
if (isOvernight) e = this._endMin; // clip to end of day
// Clamp to time range
const cs = Math.max(s, this._startMin);
const ce = Math.min(e, this._endMin);
if (ce <= cs) return null;
const totalRange = this._endMin - this._startMin;
const leftPct = (cs - this._startMin) / totalRange * 100;
const widthPct = (ce - cs) / totalRange * 100;
const el = document.createElement('div');
el.className = 'block-el' + (isOvernight ? ' overnight' : '');
el.dataset.id = block.id;
el.style.cssText = `
left:${leftPct}%;
width:${widthPct}%;
background:${color};
color:${light ? '#1a1a1a' : '#ffffff'};
top:4px;
height:${this.ROW_H - 8}px;
position:absolute;
`;
// Block label — adaptive based on available width
const widthPx = (ce - cs) * this.pxPerMinute;
const inner = document.createElement('div');
inner.className = 'block-inner';
if (widthPx >= 28) {
const nameEl = document.createElement('span');
nameEl.className = 'block-title';
nameEl.textContent = block.title + (block.notes ? ' *' : '');
inner.appendChild(nameEl);
if (widthPx >= 72) {
const timeLabel = `${block.start}${block.end}`;
const timeEl = document.createElement('span');
timeEl.className = 'block-time';
timeEl.textContent = timeLabel + (isOvernight ? ' →' : '');
inner.appendChild(timeEl);
}
if (widthPx >= 90 && block.responsible) {
const respEl = document.createElement('span');
respEl.className = 'block-responsible';
respEl.textContent = block.responsible;
inner.appendChild(respEl);
}
}
// Tooltip always available for narrow blocks
el.title = `${block.title} (${block.start}${block.end})` +
(block.responsible ? ` · ${block.responsible}` : '');
el.appendChild(inner);
// Resize handle — explicit element so cursor is always correct
const resizeHandle = document.createElement('div');
resizeHandle.className = 'block-resize-handle';
el.appendChild(resizeHandle);
// Click to edit
el.addEventListener('click', (e) => {
e.stopPropagation();
App.openBlockModal(block.id);
});
// Native pointer drag — skip if clicking the resize handle (interact.js owns that)
el.addEventListener('pointerdown', (e) => {
if (e.target.closest('.block-resize-handle')) return;
e.stopPropagation();
this._startPointerDrag(e, el, block);
});
// interact.js resize only (right edge)
if (window.interact) {
interact(el)
.resizable({
edges: { right: true },
listeners: {
move: (event) => this._onResizeMove(event, block),
end: (event) => this._onResizeEnd(event, block),
},
modifiers: [
interact.modifiers.snapSize({
targets: [interact.snappers.grid({ width: this._minutesPx(this.GRID_MINUTES) })]
}),
interact.modifiers.restrictSize({ minWidth: this._minutesPx(this.MIN_BLOCK_MIN) })
],
});
}
return el;
},
_minutesPx(minutes) {
return minutes * this.pxPerMinute;
},
// Native pointer drag — ghost on document.body (no overflow/clipping issues)
// Day detection uses pre-captured bounding rects, NOT elementFromPoint (unreliable with ghost)
_startPointerDrag(e, el, block) {
const startX = e.clientX;
const startY = e.clientY;
const elRect = el.getBoundingClientRect();
const startMin = this.parseTime(block.start);
const duration = this.parseTime(block.end) - startMin;
const snapGrid = this._minutesPx(this.GRID_MINUTES);
// ── Capture day row positions BEFORE any DOM change ──────────
const dayTimelines = Array.from(document.querySelectorAll('.day-timeline'));
const dayRows = dayTimelines.map(t => {
const r = t.getBoundingClientRect();
return { date: t.dataset.date, el: t, top: r.top, bottom: r.bottom };
});
// Release implicit pointer capture so pointermove fires freely on document
try { el.releasePointerCapture(e.pointerId); } catch (_) {}
// Prevent text selection + set grabbing cursor during drag
document.body.style.userSelect = 'none';
document.body.style.cursor = 'grabbing';
// ── Ghost element ─────────────────────────────────────────────
const ghost = document.createElement('div');
ghost.className = 'block-el drag-ghost';
ghost.innerHTML = el.innerHTML;
Object.assign(ghost.style, {
position: 'fixed',
left: elRect.left + 'px',
top: elRect.top + 'px',
width: elRect.width + 'px',
height: elRect.height + 'px',
background: el.style.background,
color: el.style.color,
zIndex: '9999',
opacity: '0.88',
pointerEvents: 'none',
cursor: 'grabbing',
boxShadow: '0 6px 20px rgba(0,0,0,0.3)',
borderRadius: '4px',
transition: 'none',
margin: '0',
});
document.body.appendChild(ghost);
el.style.opacity = '0.2';
// ── Helpers ───────────────────────────────────────────────────
const findRow = (clientY) => {
for (const d of dayRows) {
if (clientY >= d.top && clientY <= d.bottom) return d;
}
// Clamp to nearest row when pointer is between rows or outside canvas
if (!dayRows.length) return null;
if (clientY < dayRows[0].top) return dayRows[0];
return dayRows[dayRows.length - 1];
};
const clearHighlights = () =>
dayTimelines.forEach(r => r.classList.remove('drag-target'));
// ── Drag move ─────────────────────────────────────────────────
const onMove = (ev) => {
ghost.style.left = (elRect.left + ev.clientX - startX) + 'px';
ghost.style.top = (elRect.top + ev.clientY - startY) + 'px';
clearHighlights();
const row = findRow(ev.clientY);
if (row) row.el.classList.add('drag-target');
};
// ── Drag end ──────────────────────────────────────────────────
const onUp = (ev) => {
document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', onUp);
ghost.remove();
el.style.opacity = '';
clearHighlights();
document.body.style.userSelect = '';
document.body.style.cursor = '';
const dx = ev.clientX - startX;
const dy = ev.clientY - startY;
// Ignore micro-movements (treat as click, let click handler open modal)
if (Math.abs(dx) < 5 && Math.abs(dy) < 5) return;
// ── Update time (X axis) ──────────────────────────────────
const snappedDx = Math.round(dx / snapGrid) * snapGrid;
const deltaMin = snappedDx / this.pxPerMinute;
const newStart = this.snapMinutes(startMin + deltaMin);
const clamped = Math.max(this._startMin, Math.min(this._endMin - duration, newStart));
block.start = this.formatTime(clamped);
block.end = this.formatTime(clamped + duration);
// ── Update day (Y axis) — bounding rects, NO elementFromPoint ──
const row = findRow(ev.clientY);
if (row) block.date = row.date;
App.renderCanvas();
};
document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', onUp);
},
_onResizeMove(event, block) {
const target = event.target;
const newWidthPx = event.rect.width;
const totalRange = this._endMin - this._startMin;
const containerW = target.parentElement.clientWidth;
const minutesPerPx = totalRange / containerW;
const newDuration = this.snapMinutes(Math.round(newWidthPx * minutesPerPx));
const clampedDuration = Math.max(this.MIN_BLOCK_MIN, newDuration);
const startMin = this.parseTime(block.start);
const endMin = Math.min(this._endMin, startMin + clampedDuration);
block.end = this.formatTime(endMin);
const widthPct = (endMin - startMin) / totalRange * 100;
target.style.width = widthPct + '%';
},
_onResizeEnd(event, block) {
App.renderCanvas();
},
};

32
app/static/js/export.js Normal file
View File

@@ -0,0 +1,32 @@
/**
* JSON import/export for Scenar Creator v3.
*/
function exportJson() {
const doc = App.getDocument();
if (!doc) {
App.toast('Žádný scénář k exportu', 'error');
return;
}
const json = JSON.stringify(doc, null, 2);
const blob = new Blob([json], { type: 'application/json' });
API.downloadBlob(blob, App.buildFilename('json'));
App.toast('JSON exportován', 'success');
}
function importJson(file) {
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('Neplatný formát ScenarioDocument');
}
App.loadDocument(doc);
App.toast('JSON importován', 'success');
} catch (err) {
App.toast('Chyba importu: ' + err.message, 'error');
}
};
reader.readAsText(file);
}

103
app/static/sample.json Normal file
View File

@@ -0,0 +1,103 @@
{
"version": "1.0",
"event": {
"title": "Zimní výjezd oddílu",
"subtitle": "Víkendový zážitkový kurz pro mladé",
"date_from": "2026-03-01",
"date_to": "2026-03-02",
"date": "2026-03-01",
"location": "Chata Horní Lhota"
},
"program_types": [
{ "id": "morning", "name": "Ranní program", "color": "#F97316" },
{ "id": "main", "name": "Hlavní program", "color": "#3B82F6" },
{ "id": "rest", "name": "Odpočinek / jídlo", "color": "#22C55E" },
{ "id": "evening", "name": "Večerní program", "color": "#8B5CF6" }
],
"blocks": [
{
"id": "d1_b1", "date": "2026-03-01",
"start": "08:00", "end": "08:30",
"title": "Budíček a rozcvička", "type_id": "morning",
"responsible": "Kuba", "notes": "Venkovní rozcvička"
},
{
"id": "d1_b2", "date": "2026-03-01",
"start": "08:30", "end": "09:00",
"title": "Snídaně", "type_id": "rest",
"responsible": "Kuchyň", "notes": null
},
{
"id": "d1_b3", "date": "2026-03-01",
"start": "09:00", "end": "11:00",
"title": "Stopovací hra v lese", "type_id": "main",
"responsible": "Lucka", "notes": "4 skupiny"
},
{
"id": "d1_b4", "date": "2026-03-01",
"start": "11:00", "end": "11:15",
"title": "Svačina", "type_id": "rest",
"responsible": null, "notes": null
},
{
"id": "d1_b5", "date": "2026-03-01",
"start": "11:15", "end": "13:00",
"title": "Stavba přístřešků", "type_id": "main",
"responsible": "Petr", "notes": "Soutěž"
},
{
"id": "d1_b6", "date": "2026-03-01",
"start": "13:00", "end": "14:30",
"title": "Oběd a polední klid", "type_id": "rest",
"responsible": "Kuchyň", "notes": "Guláš"
},
{
"id": "d1_b7", "date": "2026-03-01",
"start": "14:30", "end": "17:00",
"title": "Orientační běh", "type_id": "main",
"responsible": "Honza", "notes": "Trasa 3 km"
},
{
"id": "d1_b8", "date": "2026-03-01",
"start": "17:00", "end": "18:00",
"title": "Workshopy", "type_id": "morning",
"responsible": "Lucka + Petr", "notes": "Uzlování / Orientace"
},
{
"id": "d1_b9", "date": "2026-03-01",
"start": "18:00", "end": "19:00",
"title": "Večeře", "type_id": "rest",
"responsible": "Kuchyň", "notes": null
},
{
"id": "d1_b10", "date": "2026-03-01",
"start": "19:00", "end": "21:30",
"title": "Noční hra Světlušky", "type_id": "evening",
"responsible": "Honza + Kuba", "notes": "Čelovky, reflexní pásky"
},
{
"id": "d2_b1", "date": "2026-03-02",
"start": "08:00", "end": "09:00",
"title": "Snídaně + balení", "type_id": "rest",
"responsible": "Kuchyň", "notes": null
},
{
"id": "d2_b2", "date": "2026-03-02",
"start": "09:00", "end": "11:30",
"title": "Závěrečná hra Poklad", "type_id": "main",
"responsible": "Lucka", "notes": "Finálová aktivita víkendu"
},
{
"id": "d2_b3", "date": "2026-03-02",
"start": "11:30", "end": "12:30",
"title": "Oběd + vyhodnocení", "type_id": "rest",
"responsible": "Všichni", "notes": "Ceny pro vítěze"
},
{
"id": "d2_b4", "date": "2026-03-02",
"start": "12:30", "end": "14:00",
"title": "Odjezd domů", "type_id": "morning",
"responsible": "Martin", "notes": "Autobus 13:45"
}
]
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,490 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import cgi
import cgitb
import html
import pandas as pd
from openpyxl import Workbook
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from openpyxl.utils import get_column_letter
from datetime import datetime
from io import BytesIO
import base64
import os
# ===== Config =====
DOCROOT = "/var/www/htdocs"
TMP_DIR = os.path.join(DOCROOT, "tmp") # soubory budou dostupné jako /tmp/<soubor>
DEFAULT_COLOR = "#ffffff" # výchozí barva pro <input type="color">
# ===================
cgitb.enable()
print("Content-Type: text/html; charset=utf-8")
print()
form = cgi.FieldStorage()
title = form.getvalue('title')
detail = form.getvalue('detail')
show_debug = form.getvalue('debug') == 'on'
step = form.getvalue('step', '1')
file_item = form['file'] if 'file' in form else None
def get_program_types(form):
program_descriptions = {}
program_colors = {}
for key in form.keys():
if key.startswith('type_code_'):
index = key.split('_')[-1]
type_code = form.getvalue(f'type_code_{index}')
description = form.getvalue(f'desc_{index}', '')
raw_color = form.getvalue(f'color_{index}', DEFAULT_COLOR) or DEFAULT_COLOR
color_hex = 'FF' + raw_color.lstrip('#') # openpyxl chce AARRGGBB
program_descriptions[type_code] = description
program_colors[type_code] = color_hex
return program_descriptions, program_colors
program_descriptions, program_colors = get_program_types(form)
def normalize_time(time_str):
for fmt in ('%H:%M', '%H:%M:%S'):
try:
return datetime.strptime(time_str, fmt).time()
except ValueError:
continue
return None
def read_excel(file_content):
excel_data = pd.read_excel(BytesIO(file_content), skiprows=0)
excel_data.columns = ["Datum", "Zacatek", "Konec", "Program", "Typ", "Garant", "Poznamka"]
if show_debug:
print("<pre>Raw data:\n")
print(excel_data.head())
print("</pre>")
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)
if show_debug:
print("<pre>Cleaned data:\n")
print(valid_data.head())
print("</pre>")
print("<pre>Error rows:\n")
for er in error_rows:
print(f"Index: {er['index']}, Error: {er['error']}")
print(er['row'])
print("</pre>")
# Detekce překryvů
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:
print("<pre>Overlap errors:\n")
for e in overlap_errors:
print(e)
print("</pre>")
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 calculate_row_height(cell_value, column_width):
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):
max_length = max(len(line) for line in str(text).split('\n'))
return max_length * 1.2
def create_timetable(data, title, detail, program_descriptions, program_colors):
if data.empty:
print("<p>Chyba: Načtená data jsou prázdná nebo obsahují neplatné hodnoty. Zkontrolujte vstupní soubor.</p>")
return None
missing_types = [typ for typ in data["Typ"].unique() if typ not in program_colors]
if missing_types:
print(f"<p>Chyba: Následující typy programu nejsou specifikovány: {', '.join(missing_types)}. Zkontrolujte vstupní soubor a formulář.</p>")
return None
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'))
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
# rozumný default šířky prvního sloupce
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():
print("<p>Chyba: Načtená data obsahují neplatné hodnoty času. Zkontrolujte vstupní soubor.</p>")
if show_debug:
print("<pre>Start times:\n")
print(start_times)
print("\nEnd times:\n")
print(end_times)
print("</pre>")
return None
try:
min_time = min(start_times)
max_time = max(end_times)
except ValueError as e:
print(f"<p>Chyba při zjišťování minimálního a maximálního času: {e}</p>")
if show_debug:
print("<pre>Start times:\n")
print(start_times)
print("\nEnd times:\n")
print(end_times)
print("</pre>")
return None
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
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:
print(f"<p>Chyba při hledání indexu časového slotu: {e}</p>")
if show_debug:
print("<pre>Start time: {}\nEnd time: {}</pre>".format(start_time, 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']}"
try:
ws.merge_cells(start_row=current_row, start_column=start_index, end_row=current_row, end_column=end_index - 1)
cell = ws.cell(row=current_row, column=start_index)
cell.value = cell_value
except AttributeError as e:
print(f"<p>Chyba: {str(e)}. Zkontrolujte vstupní data, která způsobují překrývající se časy bloků.</p>")
if show_debug:
print("<pre>Overlapping block:\n{}</pre>".format(row))
return None
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
current_row += 1
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
# ====== HTML flow ======
if step == '1':
print('''<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<title>Vytvoření Scénáře - Krok 1</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; background-color: #f0f4f8; color: #333; }
.form-container { max-width: 600px; margin: auto; padding: 20px; background-color: #fff;
box-shadow: 0 0 10px rgba(0,0,0,0.1); border-radius: 8px; }
.form-group { margin-bottom: 15px; }
.form-group label { display: block; font-weight: bold; margin-bottom: 5px; color: #555; }
.form-group input, .form-group textarea, .form-group select {
width: 100%; padding: 10px; border: 1px solid #ccc; border-radius: 5px; box-sizing: border-box; margin-top: 5px; }
.form-group input[type="color"] { width: auto; padding: 5px; }
.form-group button { padding: 10px 15px; border: none; background-color: #007BFF; color: white;
border-radius: 5px; cursor: pointer; }
.form-group button:hover { background-color: #0056b3; }
.footer { margin-top: 20px; text-align: center; font-size: 0.9em; color: #777; }
</style>
</head>
<body>
<div class="form-container">
<h1>Vytvoření Scénáře - Krok 1</h1>
<p><a href="/templates/scenar_template.xlsx">STÁHNOUT ŠABLONU SCÉNÁŘE ZDE</a></p>
<form action="/cgi-bin/scenar.py" method="post" enctype="multipart/form-data">
<div class="form-group">
<label for="title">Název akce:</label>
<input type="text" id="title" name="title" required>
</div>
<div class="form-group">
<label for="detail">Detail akce:</label>
<input type="text" id="detail" name="detail" required>
</div>
<div class="form-group">
<label for="file">Excel soubor:</label>
<input type="file" id="file" name="file" accept=".xlsx" required>
</div>
<div class="form-group">
<label for="debug">Zobrazit debug informace:</label>
<input type="checkbox" id="debug" name="debug">
</div>
<div class="form-group">
<input type="hidden" name="step" value="2">
<input type="submit" value="Načíst typy programu">
</div>
</form>
</div>
<div class="footer">
<p>© 2024 Martin Sukaný • <a href="mailto:martin@sukany.cz">martin@sukany.cz</a></p>
</div>
</body>
</html>''')
elif step == '2' and file_item is not None and file_item.filename:
file_content = file_item.file.read()
file_content_base64 = base64.b64encode(file_content).decode('utf-8')
data, error_rows = read_excel(file_content)
if data.empty:
print("<p>Chyba: Načtená data jsou prázdná nebo obsahují neplatné hodnoty. Zkontrolujte vstupní soubor.</p>")
else:
program_types = data["Typ"].dropna().unique()
program_types = [typ.strip() for typ in program_types]
print('''<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<title>Vytvoření Scénáře - Krok 2</title>
<style>
body {{ font-family: Arial, sans-serif; margin: 20px; background-color: #f0f4f8; color: #333; }}
.form-container {{ max-width: 600px; margin: auto; padding: 20px; background-color: #fff;
box-shadow: 0 0 10px rgba(0,0,0,0.1); border-radius: 8px; }}
.form-group {{ margin-bottom: 15px; }}
.form-group label {{ display: block; font-weight: bold; margin-bottom: 5px; color: #555; }}
.form-group input, .form-group textarea, .form-group select {{
width: 100%; padding: 10px; border: 1px solid #ccc; border-radius: 5px; box-sizing: border-box; margin-top: 5px; }}
.form-group input[type="color"] {{ width: auto; padding: 5px; }}
.form-group button {{ padding: 10px 15px; border: none; background-color: #007BFF; color: white;
border-radius: 5px; cursor: pointer; }}
.form-group button:hover {{ background-color: #0056b3; }}
.footer {{ margin-top: 20px; text-align: center; font-size: 0.9em; color: #777; }}
</style>
</head>
<body>
<div class="form-container">
<h1>Vytvoření Scénáře - Krok 2</h1>
<p>Vyplň tituly a barvy pro nalezené typy programu a odešli.</p>
<form action="/cgi-bin/scenar.py" method="post">
<input type="hidden" name="title" value="{title}">
<input type="hidden" name="detail" value="{detail}">
<input type="hidden" name="file_content_base64" value="{fcb64}">
<input type="hidden" name="step" value="3">'''.format(
title=html.escape(title or ""),
detail=html.escape(detail or ""),
fcb64=html.escape(file_content_base64))
)
for i, typ in enumerate(program_types, start=1):
print('''
<div class="form-group program-type">
<label for="type_code_{i}">Typ programu {i}:</label>
<input type="text" id="type_code_{i}" name="type_code_{i}" value="{typ}" required>
<input type="text" id="desc_{i}" name="desc_{i}" required>
<input type="color" id="color_{i}" name="color_{i}" value="{default_color}" required>
</div>'''.format(i=i, typ=html.escape(typ), default_color=DEFAULT_COLOR))
print('''
<div class="form-group">
<label for="debug">Zobrazit debug informace:</label>
<input type="checkbox" id="debug" name="debug"{checked}>
</div>
<div class="form-group">
<input type="submit" value="Vygenerovat scénář">
</div>
</form>
</div>
<div class="footer">
<p>© 2024 Martin Sukaný • <a href="mailto:martin@sukany.cz">martin@sukany.cz</a></p>
</div>
</body>
</html>'''.format(checked=' checked' if show_debug else ''))
elif step == '3' and title and detail:
file_content_base64 = form.getvalue('file_content_base64')
if file_content_base64:
file_content = base64.b64decode(file_content_base64)
data, error_rows = read_excel(file_content)
if data.empty:
print("<p>Chyba: Načtená data jsou prázdná nebo obsahují neplatné hodnoty. Zkontrolujte vstupní soubor.</p>")
else:
# z POSTu teď přijdou zvolené popisy a barvy
program_descriptions, program_colors = get_program_types(form)
wb = create_timetable(data, title, detail, program_descriptions, program_colors)
if wb:
os.makedirs(TMP_DIR, exist_ok=True)
filename = f"{title}.xlsx"
safe_name = "".join(ch if ch.isalnum() or ch in "._- " else "_" for ch in filename)
file_path = os.path.join(TMP_DIR, safe_name)
wb.save(file_path)
print('''<div class="output-container">
<h2>Výsledky zpracování</h2>
<p><a href="/tmp/{name}" download>Stáhnout scénář pro {title} ZDE</a></p>
<p><strong>Název akce:</strong> {title}</p>
<p><strong>Detail akce:</strong> {detail}</p>
<h3>Data ze souboru:</h3>
{table}
<p><a href="/tmp/{name}" download>Stáhnout scénář pro {title} ZDE</a></p>
</div>'''.format(
name=html.escape(safe_name),
title=html.escape(title or ""),
detail=html.escape(detail or ""),
table=data.to_html(index=False)))
else:
print("<p>Chyba: Soubor nebyl nalezen. Zkontrolujte vstupní data.</p>")
else:
print("<p>Chyba: Není vybrán žádný soubor nebo došlo k chybě ve formuláři.</p>")

Binary file not shown.

4
pytest.ini Normal file
View File

@@ -0,0 +1,4 @@
[pytest]
pythonpath = .
markers =
integration: marks tests as integration (docker builds / long-running)

6
requirements.txt Normal file
View File

@@ -0,0 +1,6 @@
fastapi>=0.115
uvicorn[standard]>=0.34
python-multipart>=0.0.20
reportlab>=4.0
pytest>=7.4.3
httpx>=0.27

Binary file not shown.

187
tests/test_api.py Normal file
View File

@@ -0,0 +1,187 @@
"""
API endpoint tests for Scenar Creator v4.
"""
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.config import VERSION
@pytest.fixture
def client():
return TestClient(app)
def make_valid_doc(multiday=False):
blocks = [{
"id": "b1",
"date": "2026-03-01",
"start": "09:00",
"end": "10:00",
"title": "Opening",
"type_id": "ws"
}]
if multiday:
blocks.append({
"id": "b2",
"date": "2026-03-02",
"start": "10:00",
"end": "11:30",
"title": "Day 2 Session",
"type_id": "ws"
})
return {
"version": "1.0",
"event": {
"title": "Test Event",
"date_from": "2026-03-01",
"date_to": "2026-03-02" if multiday else "2026-03-01"
},
"program_types": [{"id": "ws", "name": "Workshop", "color": "#FF0000"}],
"blocks": blocks
}
def test_health(client):
r = client.get("/api/health")
assert r.status_code == 200
data = r.json()
assert data["status"] == "ok"
assert data["version"] == VERSION
def test_root_returns_html(client):
r = client.get("/")
assert r.status_code == 200
assert "text/html" in r.headers["content-type"]
assert "Scen" in r.text and "Creator" in r.text
def test_validate_valid(client):
doc = make_valid_doc()
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 = make_valid_doc()
doc["blocks"][0]["type_id"] = "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_no_blocks(client):
doc = make_valid_doc()
doc["blocks"] = []
r = client.post("/api/validate", json=doc)
assert r.status_code == 200
data = r.json()
assert data["valid"] is False
def test_validate_no_types(client):
doc = make_valid_doc()
doc["program_types"] = []
r = client.post("/api/validate", json=doc)
assert r.status_code == 200
data = r.json()
assert data["valid"] is False
def test_sample_endpoint(client):
r = client.get("/api/sample")
assert r.status_code == 200
data = r.json()
assert data["version"] == "1.0"
assert data["event"]["title"] == "Zimní výjezd oddílu"
assert len(data["program_types"]) >= 3
# multi-day sample
assert data["event"]["date_from"] == "2026-03-01"
assert data["event"]["date_to"] == "2026-03-02"
# blocks for both days
dates = {b["date"] for b in data["blocks"]}
assert "2026-03-01" in dates
assert "2026-03-02" in dates
def test_generate_pdf(client):
doc = make_valid_doc()
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-'
def test_generate_pdf_multiday(client):
doc = make_valid_doc(multiday=True)
r = client.post("/api/generate-pdf", json=doc)
assert r.status_code == 200
assert r.content[:5] == b'%PDF-'
def test_generate_pdf_overnight_block(client):
"""Block that crosses midnight: end < start."""
doc = make_valid_doc()
doc["blocks"][0]["start"] = "23:00"
doc["blocks"][0]["end"] = "01:30" # overnight
r = client.post("/api/generate-pdf", json=doc)
assert r.status_code == 200
assert r.content[:5] == b'%PDF-'
def test_generate_pdf_no_blocks(client):
doc = make_valid_doc()
doc["blocks"] = []
r = client.post("/api/generate-pdf", json=doc)
assert r.status_code == 422
def test_swagger_docs(client):
r = client.get("/docs")
assert r.status_code == 200
def test_series_id_accepted(client):
"""Blocks with series_id should be accepted by the validate endpoint."""
doc = {
"version": "1.0",
"event": {"title": "Series Test", "date_from": "2026-03-01", "date_to": "2026-03-02"},
"program_types": [{"id": "ws", "name": "Workshop", "color": "#FF0000"}],
"blocks": [
{"id": "b1", "date": "2026-03-01", "start": "09:00", "end": "10:00",
"title": "Morning", "type_id": "ws", "series_id": "s_001"},
{"id": "b2", "date": "2026-03-02", "start": "09:00", "end": "10:00",
"title": "Morning", "type_id": "ws", "series_id": "s_001"},
]
}
r = client.post("/api/validate", json=doc)
assert r.status_code == 200
assert r.json()["valid"] is True
def test_backward_compat_date_field(client):
"""Old JSON with 'date' (not date_from/date_to) should still validate."""
doc = {
"version": "1.0",
"event": {"title": "Old Format", "date": "2026-03-01"},
"program_types": [{"id": "t1", "name": "Type", "color": "#0000FF"}],
"blocks": [{
"id": "bx",
"date": "2026-03-01",
"start": "10:00",
"end": "11:00",
"title": "Session",
"type_id": "t1"
}]
}
r = client.post("/api/validate", json=doc)
assert r.status_code == 200
assert r.json()["valid"] is True

104
tests/test_core.py Normal file
View File

@@ -0,0 +1,104 @@
"""
Core logic tests for Scenar Creator v3.
Tests models, validation, and document structure.
"""
import pytest
from pydantic import ValidationError as PydanticValidationError
from app.models.event import Block, ProgramType, EventInfo, ScenarioDocument
from app.core.validator import ScenarsError, ValidationError
# --- Model tests ---
def test_block_default_id():
b = Block(date="2026-03-01", start="09:00", end="10:00", title="Test", type_id="ws")
assert b.id is not None
assert len(b.id) > 0
def test_block_optional_fields():
b = Block(date="2026-03-01", start="09:00", end="10:00", title="Test", type_id="ws")
assert b.responsible is None
assert b.notes is None
assert b.series_id is None
def test_block_series_id():
b = Block(date="2026-03-01", start="09:00", end="10:00", title="Test", type_id="ws",
series_id="s_abc123")
assert b.series_id == "s_abc123"
def test_block_with_all_fields():
b = Block(
id="custom-id", date="2026-03-01", start="09:00", end="10:00",
title="Full Block", type_id="ws", responsible="John", notes="A note"
)
assert b.id == "custom-id"
assert b.responsible == "John"
assert b.notes == "A note"
def test_program_type():
pt = ProgramType(id="main", name="Main Program", color="#3B82F6")
assert pt.id == "main"
assert pt.name == "Main Program"
assert pt.color == "#3B82F6"
def test_event_info_minimal():
e = EventInfo(title="Test")
assert e.title == "Test"
assert e.subtitle is None
assert e.date is None
assert e.location is None
def test_event_info_full():
e = EventInfo(title="Event", subtitle="Sub", date="2026-03-01", location="Prague")
assert e.location == "Prague"
def test_scenario_document():
doc = ScenarioDocument(
event=EventInfo(title="Test"),
program_types=[ProgramType(id="ws", name="Workshop", color="#FF0000")],
blocks=[Block(date="2026-03-01", start="09:00", end="10:00", title="B1", type_id="ws")]
)
assert doc.version == "1.0"
assert len(doc.blocks) == 1
assert len(doc.program_types) == 1
def test_scenario_document_serialization():
doc = ScenarioDocument(
event=EventInfo(title="Test"),
program_types=[ProgramType(id="ws", name="Workshop", color="#FF0000")],
blocks=[Block(id="b1", date="2026-03-01", start="09:00", end="10:00", title="B1", type_id="ws")]
)
data = doc.model_dump(mode="json")
assert data["event"]["title"] == "Test"
assert data["blocks"][0]["type_id"] == "ws"
assert data["blocks"][0]["id"] == "b1"
def test_scenario_document_missing_title():
with pytest.raises(PydanticValidationError):
ScenarioDocument(
event=EventInfo(),
program_types=[],
blocks=[]
)
# --- Validator tests ---
def test_scenars_error_hierarchy():
assert issubclass(ValidationError, ScenarsError)
def test_validation_error_message():
err = ValidationError("test error")
assert str(err) == "test error"

175
tests/test_pdf.py Normal file
View File

@@ -0,0 +1,175 @@
"""
PDF generation tests for Scenar Creator v4.
"""
import pytest
from app.core.pdf_generator import generate_pdf
from app.core.validator import ScenarsError
from app.models.event import ScenarioDocument, Block, ProgramType, EventInfo
def make_doc(**kwargs):
defaults = {
"version": "1.0",
"event": EventInfo(title="Test PDF", subtitle="Subtitle",
date_from="2026-03-01", date_to="2026-03-01"),
"program_types": [
ProgramType(id="ws", name="Workshop", color="#0070C0"),
],
"blocks": [
Block(id="b1", date="2026-03-01", start="09:00", end="10:00",
title="Test Program", type_id="ws", responsible="John"),
]
}
defaults.update(kwargs)
return ScenarioDocument(**defaults)
def test_generate_pdf_basic():
doc = make_doc()
pdf_bytes = generate_pdf(doc)
assert isinstance(pdf_bytes, bytes)
assert len(pdf_bytes) > 0
assert pdf_bytes[:5] == b'%PDF-'
def test_generate_pdf_is_single_page():
import re
doc = make_doc()
pdf_bytes = generate_pdf(doc)
# Count /Type /Page (not /Pages) occurrences
pages = len(re.findall(rb'/Type\s*/Page[^s]', pdf_bytes))
assert pages == 1, f"Expected 1 page, got {pages}"
def test_generate_pdf_multiday():
doc = make_doc(
event=EventInfo(title="Multi-day", date_from="2026-03-01", date_to="2026-03-02"),
program_types=[
ProgramType(id="key", name="Keynote", color="#FF0000"),
ProgramType(id="ws", name="Workshop", color="#0070C0"),
],
blocks=[
Block(id="b1", date="2026-03-01", start="09:00", end="10:00",
title="Day 1", type_id="key", responsible="Alice"),
Block(id="b2", date="2026-03-02", start="14:00", end="15:00",
title="Day 2", type_id="ws", responsible="Bob"),
]
)
pdf_bytes = generate_pdf(doc)
assert isinstance(pdf_bytes, bytes)
assert pdf_bytes[:5] == b'%PDF-'
def test_generate_pdf_overnight_block():
"""Block crossing midnight (end < start) should render without error."""
doc = make_doc(
blocks=[
Block(id="b1", date="2026-03-01", start="22:00", end="01:30",
title="Noční hra", type_id="ws", responsible="Honza"),
]
)
pdf_bytes = generate_pdf(doc)
assert pdf_bytes[:5] == b'%PDF-'
def test_generate_pdf_empty_blocks():
doc = make_doc(blocks=[])
with pytest.raises(ScenarsError):
generate_pdf(doc)
def test_generate_pdf_missing_type():
doc = make_doc(
blocks=[
Block(id="b1", date="2026-03-01", start="09:00", end="10:00",
title="Test", type_id="UNKNOWN"),
]
)
with pytest.raises(ScenarsError):
generate_pdf(doc)
def test_generate_pdf_with_full_event_info():
doc = make_doc(
event=EventInfo(
title="Full Event",
subtitle="With all fields",
date_from="2026-03-01",
date_to="2026-03-03",
location="Prague"
),
program_types=[
ProgramType(id="ws", name="Workshop", color="#0070C0"),
ProgramType(id="rest", name="Odpočinek", color="#22C55E"),
],
blocks=[
Block(id="b1", date="2026-03-01", start="09:00", end="10:00",
title="Session 1", type_id="ws"),
Block(id="b2", date="2026-03-02", start="10:00", end="11:00",
title="Session 2", type_id="rest"),
Block(id="b3", date="2026-03-03", start="08:00", end="09:00",
title="Session 3", type_id="ws"),
]
)
pdf_bytes = generate_pdf(doc)
assert pdf_bytes[:5] == b'%PDF-'
def test_generate_pdf_multiple_blocks_same_day():
doc = make_doc(
blocks=[
Block(id="b1", date="2026-03-01", start="09:00", end="10:00",
title="Morning", type_id="ws"),
Block(id="b2", date="2026-03-01", start="10:00", end="11:30",
title="Midday", type_id="ws"),
Block(id="b3", date="2026-03-01", start="14:00", end="16:00",
title="Afternoon", type_id="ws", responsible="Team"),
]
)
pdf_bytes = generate_pdf(doc)
assert pdf_bytes[:5] == b'%PDF-'
def test_generate_pdf_many_types_legend():
"""Many program types should fit in multi-column legend."""
types = [ProgramType(id=f"t{i}", name=f"Typ {i}", color=f"#{'%06x' % (i * 20000)}")
for i in range(1, 9)]
blocks = [Block(id=f"b{i}", date="2026-03-01", start=f"{8+i}:00", end=f"{9+i}:00",
title=f"Blok {i}", type_id=f"t{i}") for i in range(1, 9)]
doc = make_doc(program_types=types, blocks=blocks)
pdf_bytes = generate_pdf(doc)
assert pdf_bytes[:5] == b'%PDF-'
def test_generate_pdf_with_notes_creates_second_page():
"""Blocks with notes should produce a 2-page PDF (timetable + notes)."""
import re
types = [ProgramType(id="ws", name="Workshop", color="#0070C0")]
blocks = [
Block(id="b1", date="2026-03-01", start="09:00", end="10:00",
title="Opening", type_id="ws", notes="Bring the flipchart and markers."),
Block(id="b2", date="2026-03-01", start="10:00", end="11:00",
title="Teambuilding", type_id="ws", notes="Outdoor if weather permits."),
Block(id="b3", date="2026-03-01", start="11:00", end="12:00",
title="Lunch", type_id="ws"), # no notes
]
doc = make_doc(program_types=types, blocks=blocks)
pdf_bytes = generate_pdf(doc)
assert pdf_bytes[:5] == b'%PDF-'
pages = len(re.findall(rb'/Type\s*/Page[^s]', pdf_bytes))
assert pages == 2, f"Expected 2 pages (timetable + notes), got {pages}"
def test_generate_pdf_no_notes_single_page():
"""Without notes, PDF should be exactly 1 page."""
import re
doc = make_doc(
blocks=[
Block(id="b1", date="2026-03-01", start="09:00", end="10:00",
title="No notes here", type_id="ws"),
]
)
pdf_bytes = generate_pdf(doc)
pages = len(re.findall(rb'/Type\s*/Page[^s]', pdf_bytes))
assert pages == 1, f"Expected 1 page, got {pages}"

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.