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>
This commit is contained in:
2026-02-20 16:28:21 +01:00
parent 87f1fc2c7a
commit e2bdadd0ce
32 changed files with 2896 additions and 55 deletions

242
app/core/timetable.py Normal file
View File

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