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>
This commit is contained in:
2026-02-20 17:02:51 +01:00
parent e2bdadd0ce
commit 25fd578543
27 changed files with 2004 additions and 3016 deletions

View File

@@ -1,17 +1,17 @@
"""
PDF generation for Scenar Creator using ReportLab.
PDF generation for Scenar Creator v3 using ReportLab.
Generates A4 landscape timetable PDF with colored blocks and legend.
"""
import pandas as pd
from io import BytesIO
from datetime import datetime
from collections import defaultdict
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4, landscape
from reportlab.lib.units import mm
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.enums import TA_CENTER, TA_LEFT
from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT
import logging
from .validator import ScenarsError
@@ -20,175 +20,270 @@ logger = logging.getLogger(__name__)
def hex_to_reportlab_color(hex_color: str) -> colors.Color:
"""Convert hex color (AARRGGBB or #RRGGBB) to ReportLab color."""
"""Convert #RRGGBB hex color to ReportLab color."""
h = hex_color.lstrip('#')
if len(h) == 8: # AARRGGBB format
h = h[2:] # strip alpha
if len(h) == 8:
h = h[2:]
if len(h) == 6:
r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
return colors.Color(r / 255.0, g / 255.0, b / 255.0)
return colors.white
def generate_pdf(data: pd.DataFrame, title: str, detail: str,
program_descriptions: dict, program_colors: dict) -> bytes:
def is_light_color(hex_color: str) -> bool:
"""Check if a color is light (needs dark text)."""
h = hex_color.lstrip('#')
if len(h) == 8:
h = h[2:]
if len(h) == 6:
r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
return luminance > 0.6
return False
def time_to_minutes(time_str: str) -> int:
"""Convert HH:MM to minutes since midnight."""
parts = time_str.split(":")
return int(parts[0]) * 60 + int(parts[1])
def generate_pdf(doc) -> bytes:
"""
Generate a PDF timetable.
Generate a PDF timetable from a ScenarioDocument.
Args:
data: DataFrame with validated schedule data
title: Event title
detail: Event detail/description
program_descriptions: {type: description}
program_colors: {type: color_hex in AARRGGBB format}
doc: ScenarioDocument instance
Returns:
bytes: PDF file content
Raises:
ScenarsError: if data is invalid
"""
if data.empty:
raise ScenarsError("Data is empty after validation")
if not doc.blocks:
raise ScenarsError("No blocks provided")
missing_types = [typ for typ in data["Typ"].unique() if typ not in program_colors]
if missing_types:
raise ScenarsError(
f"Missing type definitions: {', '.join(missing_types)}. "
"Please define all program types."
)
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}'. "
"Please define all program types."
)
buffer = BytesIO()
doc = SimpleDocTemplate(
page_w, page_h = landscape(A4)
doc_pdf = SimpleDocTemplate(
buffer,
pagesize=landscape(A4),
leftMargin=10 * mm,
rightMargin=10 * mm,
topMargin=10 * mm,
bottomMargin=10 * mm,
leftMargin=12 * mm,
rightMargin=12 * mm,
topMargin=12 * mm,
bottomMargin=12 * mm,
)
styles = getSampleStyleSheet()
title_style = ParagraphStyle(
'TimetableTitle', parent=styles['Title'],
fontSize=18, alignment=TA_CENTER, spaceAfter=2 * mm
fontSize=20, alignment=TA_LEFT, spaceAfter=1 * mm,
textColor=colors.Color(0.118, 0.161, 0.231),
fontName='Helvetica-Bold'
)
detail_style = ParagraphStyle(
'TimetableDetail', parent=styles['Normal'],
fontSize=12, alignment=TA_CENTER, spaceAfter=4 * mm,
textColor=colors.gray
subtitle_style = ParagraphStyle(
'TimetableSubtitle', parent=styles['Normal'],
fontSize=12, alignment=TA_LEFT, spaceAfter=1 * mm,
textColor=colors.Color(0.4, 0.4, 0.4),
fontName='Helvetica'
)
cell_style = ParagraphStyle(
'CellStyle', parent=styles['Normal'],
fontSize=7, alignment=TA_CENTER, leading=9
info_style = ParagraphStyle(
'InfoStyle', parent=styles['Normal'],
fontSize=10, alignment=TA_LEFT, spaceAfter=4 * mm,
textColor=colors.Color(0.5, 0.5, 0.5),
fontName='Helvetica'
)
cell_style_white = ParagraphStyle(
'CellStyleWhite', parent=styles['Normal'],
fontSize=8, alignment=TA_CENTER, leading=10,
textColor=colors.white, fontName='Helvetica-Bold'
)
cell_style_dark = ParagraphStyle(
'CellStyleDark', parent=styles['Normal'],
fontSize=8, alignment=TA_CENTER, leading=10,
textColor=colors.Color(0.1, 0.1, 0.1), fontName='Helvetica-Bold'
)
time_style = ParagraphStyle(
'TimeStyle', parent=styles['Normal'],
fontSize=7, alignment=TA_RIGHT, leading=9,
textColor=colors.Color(0.5, 0.5, 0.5), fontName='Helvetica'
)
legend_style = ParagraphStyle(
'LegendStyle', parent=styles['Normal'],
fontSize=8, alignment=TA_LEFT
fontSize=9, alignment=TA_LEFT, fontName='Helvetica'
)
footer_style = ParagraphStyle(
'FooterStyle', parent=styles['Normal'],
fontSize=8, alignment=TA_CENTER,
textColor=colors.Color(0.6, 0.6, 0.6), fontName='Helvetica-Oblique'
)
elements = []
elements.append(Paragraph(title, title_style))
elements.append(Paragraph(detail, detail_style))
data = data.sort_values(by=["Datum", "Zacatek"])
# Header
elements.append(Paragraph(doc.event.title, title_style))
if doc.event.subtitle:
elements.append(Paragraph(doc.event.subtitle, subtitle_style))
start_times = data["Zacatek"]
end_times = data["Konec"]
info_parts = []
if doc.event.date:
info_parts.append(f"Datum: {doc.event.date}")
if doc.event.location:
info_parts.append(f"M\u00edsto: {doc.event.location}")
if info_parts:
elements.append(Paragraph(" | ".join(info_parts), info_style))
min_time = min(start_times)
max_time = max(end_times)
elements.append(Spacer(1, 3 * mm))
time_slots = pd.date_range(
datetime.combine(datetime.today(), min_time),
datetime.combine(datetime.today(), max_time),
freq='15min'
).time
# Group blocks by date
blocks_by_date = defaultdict(list)
for block in doc.blocks:
blocks_by_date[block.date].append(block)
# Build header row
header = ["Datum"] + [t.strftime("%H:%M") for t in time_slots]
sorted_dates = sorted(blocks_by_date.keys())
# Find global time range
all_starts = [time_to_minutes(b.start) for b in doc.blocks]
all_ends = [time_to_minutes(b.end) for b in doc.blocks]
global_start = (min(all_starts) // 30) * 30
global_end = ((max(all_ends) + 29) // 30) * 30
# Generate 30-min time slots
time_slots = []
t = global_start
while t <= global_end:
h, m = divmod(t, 60)
time_slots.append(f"{h:02d}:{m:02d}")
t += 30
# Build table: time column + one column per day
header = [""] + [d for d in sorted_dates]
table_data = [header]
cell_colors = [] # list of (row, col, color) for styling
slot_count = len(time_slots) - 1
grouped_data = data.groupby(data['Datum'])
row_idx = 1
# Build grid and track colored cells
cell_colors_list = []
for date_val, group in grouped_data:
day_name = date_val.strftime("%A")
date_str = date_val.strftime(f"%d.%m {day_name}")
for slot_idx in range(slot_count):
slot_start = time_slots[slot_idx]
slot_end = time_slots[slot_idx + 1]
row = [Paragraph(slot_start, time_style)]
for date_key in sorted_dates:
cell_content = ""
for block in blocks_by_date[date_key]:
block_start_min = time_to_minutes(block.start)
block_end_min = time_to_minutes(block.end)
slot_start_min = time_to_minutes(slot_start)
slot_end_min = time_to_minutes(slot_end)
if block_start_min <= slot_start_min and block_end_min >= slot_end_min:
pt = type_map[block.type_id]
light = is_light_color(pt.color)
cs = cell_style_dark if light else cell_style_white
if block_start_min == slot_start_min:
label = block.title
if block.responsible:
label += f"<br/><font size='6'>{block.responsible}</font>"
cell_content = Paragraph(label, cs)
else:
cell_content = ""
cell_colors_list.append((len(table_data), len(row), pt.color))
break
row.append(cell_content if cell_content else "")
row = [date_str] + [""] * len(time_slots)
table_data.append(row)
row_idx += 1
# Create a sub-row for blocks
block_row = [""] * (len(time_slots) + 1)
for _, blk in group.iterrows():
try:
start_idx = list(time_slots).index(blk["Zacatek"]) + 1
end_idx = list(time_slots).index(blk["Konec"]) + 1
except ValueError:
continue
# Column widths
avail_width = page_w - 24 * mm
time_col_width = 18 * mm
day_col_width = (avail_width - time_col_width) / max(len(sorted_dates), 1)
col_widths = [time_col_width] + [day_col_width] * len(sorted_dates)
label = blk['Program']
if pd.notna(blk.get('Garant')):
label += f"\n{blk['Garant']}"
block_row[start_idx] = Paragraph(label.replace('\n', '<br/>'), cell_style)
rl_color = hex_to_reportlab_color(program_colors[blk["Typ"]])
for ci in range(start_idx, end_idx):
cell_colors.append((row_idx, ci, rl_color))
table_data.append(block_row)
row_idx += 1
# Calculate column widths
avail_width = landscape(A4)[0] - 20 * mm
date_col_width = 30 * mm
slot_width = max(12 * mm, (avail_width - date_col_width) / max(len(time_slots), 1))
col_widths = [date_col_width] + [slot_width] * len(time_slots)
table = Table(table_data, colWidths=col_widths, repeatRows=1)
row_height = 20
table = Table(table_data, colWidths=col_widths, rowHeights=[24] + [row_height] * slot_count)
style_cmds = [
('BACKGROUND', (0, 0), (-1, 0), colors.Color(0.83, 0.83, 0.83)),
('TEXTCOLOR', (0, 0), (-1, 0), colors.black),
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
('BACKGROUND', (0, 0), (-1, 0), colors.Color(0.118, 0.161, 0.231)),
('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, 0), 10),
('ALIGN', (0, 0), (-1, 0), 'CENTER'),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('FONTSIZE', (0, 0), (-1, 0), 7),
('FONTSIZE', (0, 1), (-1, -1), 6),
('GRID', (0, 0), (-1, -1), 0.5, colors.black),
('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.Color(0.97, 0.97, 0.97)]),
('ALIGN', (0, 1), (0, -1), 'RIGHT'),
('FONTSIZE', (0, 1), (-1, -1), 7),
('GRID', (0, 0), (-1, -1), 0.5, colors.Color(0.85, 0.85, 0.85)),
('LINEBELOW', (0, 0), (-1, 0), 1.5, colors.Color(0.118, 0.161, 0.231)),
('ROWBACKGROUNDS', (1, 1), (-1, -1), [colors.white, colors.Color(0.98, 0.98, 0.98)]),
]
for r, c, clr in cell_colors:
style_cmds.append(('BACKGROUND', (c, r), (c, r), clr))
for r, c, hex_clr in cell_colors_list:
rl_color = hex_to_reportlab_color(hex_clr)
style_cmds.append(('BACKGROUND', (c, r), (c, r), rl_color))
# Merge cells for blocks spanning multiple time slots
for date_idx, date_key in enumerate(sorted_dates):
col = date_idx + 1
for block in blocks_by_date[date_key]:
block_start_min = time_to_minutes(block.start)
block_end_min = time_to_minutes(block.end)
start_row = None
end_row = None
for slot_idx in range(slot_count):
slot_min = time_to_minutes(time_slots[slot_idx])
if slot_min == block_start_min:
start_row = slot_idx + 1
if slot_idx + 1 < len(time_slots):
next_slot_min = time_to_minutes(time_slots[slot_idx + 1])
if next_slot_min == block_end_min:
end_row = slot_idx + 1
if start_row is not None and end_row is not None and end_row > start_row:
style_cmds.append(('SPAN', (col, start_row), (col, end_row)))
table.setStyle(TableStyle(style_cmds))
elements.append(table)
# Legend
elements.append(Spacer(1, 5 * mm))
elements.append(Paragraph("<b>Legenda:</b>", legend_style))
legend_items = []
for pt in doc.program_types:
legend_items.append([Paragraph(f" {pt.name}", legend_style)])
legend_data = []
legend_colors_list = []
for i, (typ, desc) in enumerate(program_descriptions.items()):
legend_data.append([Paragraph(f"{desc} ({typ})", legend_style)])
legend_colors_list.append(hex_to_reportlab_color(program_colors[typ]))
if legend_data:
legend_table = Table(legend_data, colWidths=[80 * mm])
if legend_items:
elements.append(Paragraph("<b>Legenda:</b>", legend_style))
elements.append(Spacer(1, 2 * mm))
legend_table = Table(legend_items, colWidths=[60 * mm])
legend_cmds = [
('GRID', (0, 0), (-1, -1), 0.5, colors.black),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('BOX', (0, 0), (-1, -1), 0.5, colors.Color(0.85, 0.85, 0.85)),
('INNERGRID', (0, 0), (-1, -1), 0.5, colors.Color(0.85, 0.85, 0.85)),
]
for i, clr in enumerate(legend_colors_list):
legend_cmds.append(('BACKGROUND', (0, i), (0, i), clr))
for i, pt in enumerate(doc.program_types):
rl_color = hex_to_reportlab_color(pt.color)
legend_cmds.append(('BACKGROUND', (0, i), (0, i), rl_color))
if not is_light_color(pt.color):
legend_cmds.append(('TEXTCOLOR', (0, i), (0, i), colors.white))
legend_table.setStyle(TableStyle(legend_cmds))
elements.append(legend_table)
doc.build(elements)
# Footer
elements.append(Spacer(1, 5 * mm))
gen_date = datetime.now().strftime("%d.%m.%Y %H:%M")
elements.append(Paragraph(
f"Vygenerov\u00e1no Scen\u00e1r Creatorem | {gen_date}",
footer_style
))
doc_pdf.build(elements)
return buffer.getvalue()