""" Timetable generation logic for Scenar Creator. Extracted from scenar/core.py — create_timetable (Excel output). """ import pandas as pd from openpyxl import Workbook from openpyxl.styles import Alignment, Border, Font, PatternFill, Side from openpyxl.utils import get_column_letter from datetime import datetime import logging from .validator import ScenarsError logger = logging.getLogger(__name__) def calculate_row_height(cell_value, column_width): """Calculate row height based on content.""" if not cell_value: return 15 max_line_length = column_width * 1.2 lines = str(cell_value).split('\n') line_count = 0 for line in lines: line_count += len(line) // max_line_length + 1 return line_count * 15 def calculate_column_width(text): """Calculate column width based on text length.""" max_length = max(len(line) for line in str(text).split('\n')) return max_length * 1.2 def create_timetable(data: pd.DataFrame, title: str, detail: str, program_descriptions: dict, program_colors: dict) -> Workbook: """ Create an OpenPyXL timetable workbook. Args: data: DataFrame with validated schedule data title: Event title detail: Event detail/description program_descriptions: {type: description} program_colors: {type: color_hex} Returns: openpyxl.Workbook Raises: ScenarsError: if data is invalid or types are missing """ if data.empty: raise ScenarsError("Data is empty after validation") missing_types = [typ for typ in data["Typ"].unique() if typ not in program_colors] if missing_types: raise ScenarsError( f"Missing type definitions: {', '.join(missing_types)}. " "Please define all program types." ) wb = Workbook() ws = wb.active thick_border = Border(left=Side(style='thick', color='000000'), right=Side(style='thick', color='000000'), top=Side(style='thick', color='000000'), bottom=Side(style='thick', color='000000')) # Title and detail ws['A1'] = title ws['A1'].alignment = Alignment(horizontal="center", vertical="center") ws['A1'].font = Font(size=24, bold=True) ws['A1'].border = thick_border ws['A2'] = detail ws['A2'].alignment = Alignment(horizontal="center", vertical="center") ws['A2'].font = Font(size=16, italic=True) ws['A2'].border = thick_border if ws.column_dimensions[get_column_letter(1)].width is None: ws.column_dimensions[get_column_letter(1)].width = 40 title_row_height = calculate_row_height(title, ws.column_dimensions[get_column_letter(1)].width) detail_row_height = calculate_row_height(detail, ws.column_dimensions[get_column_letter(1)].width) ws.row_dimensions[1].height = title_row_height ws.row_dimensions[2].height = detail_row_height data = data.sort_values(by=["Datum", "Zacatek"]) start_times = data["Zacatek"] end_times = data["Konec"] if start_times.isnull().any() or end_times.isnull().any(): raise ScenarsError("Data contains invalid time values") try: min_time = min(start_times) max_time = max(end_times) except ValueError as e: raise ScenarsError(f"Error determining time range: {e}") time_slots = pd.date_range( datetime.combine(datetime.today(), min_time), datetime.combine(datetime.today(), max_time), freq='15min' ).time total_columns = len(time_slots) + 1 ws.merge_cells(start_row=1, start_column=1, end_row=1, end_column=total_columns) ws.merge_cells(start_row=2, start_column=1, end_row=2, end_column=total_columns) row_offset = 3 col_offset = 1 cell = ws.cell(row=row_offset, column=col_offset, value="Datum") cell.fill = PatternFill(start_color="D3D3D3", end_color="D3D3D3", fill_type="solid") cell.alignment = Alignment(horizontal="center", vertical="center") cell.font = Font(bold=True) cell.border = thick_border for i, time_slot in enumerate(time_slots, start=col_offset + 1): cell = ws.cell(row=row_offset, column=i, value=time_slot.strftime("%H:%M")) cell.fill = PatternFill(start_color="D3D3D3", end_color="D3D3D3", fill_type="solid") cell.alignment = Alignment(horizontal="center", vertical="center") cell.font = Font(bold=True) cell.border = thick_border current_row = row_offset + 1 grouped_data = data.groupby(data['Datum']) for date, group in grouped_data: day_name = date.strftime("%A") date_str = date.strftime(f"%d.%m {day_name}") cell = ws.cell(row=current_row, column=col_offset, value=date_str) cell.alignment = Alignment(horizontal="center", vertical="center") cell.fill = PatternFill(start_color="D3D3D3", end_color="D3D3D3", fill_type="solid") cell.font = Font(bold=True, size=14) cell.border = thick_border # Track which cells are already filled (for overlap detection) date_row = current_row occupied_cells = set() # (row, col) pairs already filled for _, row in group.iterrows(): start_time = row["Zacatek"] end_time = row["Konec"] try: start_index = list(time_slots).index(start_time) + col_offset + 1 end_index = list(time_slots).index(end_time) + col_offset + 1 except ValueError as e: logger.error(f"Time slot not found: {start_time} to {end_time}") continue cell_value = f"{row['Program']}" if pd.notna(row['Garant']): cell_value += f"\n{row['Garant']}" if pd.notna(row['Poznamka']): cell_value += f"\n\n{row['Poznamka']}" # Check for overlaps working_row = date_row + 1 conflict = False for col in range(start_index, end_index): if (working_row, col) in occupied_cells: conflict = True break # If conflict, find next available row if conflict: while any((working_row, col) in occupied_cells for col in range(start_index, end_index)): working_row += 1 # Mark cells as occupied for col in range(start_index, end_index): occupied_cells.add((working_row, col)) try: ws.merge_cells(start_row=working_row, start_column=start_index, end_row=working_row, end_column=end_index - 1) # Get the first cell of the merge (not the merged cell) cell = ws.cell(row=working_row, column=start_index) cell.value = cell_value except Exception as e: raise ScenarsError(f"Error creating timetable cell: {str(e)}") cell.alignment = Alignment(wrap_text=True, horizontal="center", vertical="center") lines = str(cell_value).split("\n") for idx, _ in enumerate(lines): if idx == 0: cell.font = Font(bold=True) elif idx == 1: cell.font = Font(bold=False) elif idx > 1 and pd.notna(row['Poznamka']): cell.font = Font(italic=True) cell.fill = PatternFill(start_color=program_colors[row["Typ"]], end_color=program_colors[row["Typ"]], fill_type="solid") cell.border = thick_border # Update current_row to be after all rows for this date if occupied_cells: max_row_for_date = max(r for r, c in occupied_cells) current_row = max_row_for_date + 1 else: current_row += 1 # Legend legend_row = current_row + 2 legend_max_length = 0 ws.cell(row=legend_row, column=1, value="Legenda:").font = Font(bold=True) legend_row += 1 for typ, desc in program_descriptions.items(): legend_text = f"{desc} ({typ})" legend_cell = ws.cell(row=legend_row, column=1, value=legend_text) legend_cell.fill = PatternFill(start_color=program_colors[typ], fill_type="solid") legend_max_length = max(legend_max_length, calculate_column_width(legend_text)) legend_row += 1 ws.column_dimensions[get_column_letter(1)].width = legend_max_length for col in range(2, total_columns + 1): ws.column_dimensions[get_column_letter(col)].width = 15 for row in ws.iter_rows(min_row=1, max_row=current_row - 1, min_col=1, max_col=total_columns): for cell in row: cell.alignment = Alignment(wrap_text=True, horizontal="center", vertical="center") cell.border = thick_border for row in ws.iter_rows(min_row=1, max_row=current_row - 1): max_height = 0 for cell in row: if cell.value: height = calculate_row_height(cell.value, ws.column_dimensions[get_column_letter(cell.column)].width) if height > max_height: max_height = height ws.row_dimensions[row[0].row].height = max_height return wb