diff --git a/app/api/pdf.py b/app/api/pdf.py index 5193564..24fac2c 100644 --- a/app/api/pdf.py +++ b/app/api/pdf.py @@ -2,12 +2,11 @@ from io import BytesIO -import pandas as pd from fastapi import APIRouter, HTTPException from fastapi.responses import StreamingResponse from app.models.event import ScenarioDocument -from app.core.validator import validate_inputs, ValidationError, ScenarsError +from app.core.validator import ScenarsError from app.core.pdf_generator import generate_pdf router = APIRouter() @@ -17,35 +16,7 @@ router = APIRouter() async def generate_pdf_endpoint(doc: ScenarioDocument): """Generate PDF timetable from ScenarioDocument.""" try: - validate_inputs(doc.event.title, doc.event.detail, 0) - except ValidationError as e: - raise HTTPException(status_code=422, detail=str(e)) - - # Convert to DataFrame - rows = [] - for block in doc.blocks: - rows.append({ - 'Datum': block.datum, - 'Zacatek': block.zacatek, - 'Konec': block.konec, - 'Program': block.program, - 'Typ': block.typ, - 'Garant': block.garant, - 'Poznamka': block.poznamka, - }) - - df = pd.DataFrame(rows) - - if df.empty: - raise HTTPException(status_code=422, detail="No blocks provided") - - # Build program descriptions and colors - program_descriptions = {pt.code: pt.description for pt in doc.program_types} - program_colors = {pt.code: 'FF' + pt.color.lstrip('#') for pt in doc.program_types} - - try: - pdf_bytes = generate_pdf(df, doc.event.title, doc.event.detail, - program_descriptions, program_colors) + pdf_bytes = generate_pdf(doc) except ScenarsError as e: raise HTTPException(status_code=422, detail=str(e)) diff --git a/app/api/scenario.py b/app/api/scenario.py index 3b21ce7..915078d 100644 --- a/app/api/scenario.py +++ b/app/api/scenario.py @@ -1,19 +1,14 @@ -"""Scenario API endpoints: validate, import-excel, generate-excel, export-json, template.""" +"""Scenario API endpoints: health, validate, sample.""" +import json import os -from io import BytesIO -from datetime import date, time as dt_time -import pandas as pd -from fastapi import APIRouter, UploadFile, File, Form, HTTPException -from fastapi.responses import StreamingResponse, FileResponse +from fastapi import APIRouter +from fastapi.responses import JSONResponse -from app.config import VERSION, MAX_FILE_SIZE_MB -from app.models.event import ScenarioDocument, Block, ProgramType, EventInfo -from app.models.responses import HealthResponse, ValidationResponse, ImportExcelResponse -from app.core.validator import validate_inputs, ValidationError, TemplateError, ScenarsError -from app.core.excel_reader import read_excel, parse_inline_schedule, parse_inline_types -from app.core.timetable import create_timetable +from app.config import VERSION +from app.models.event import ScenarioDocument +from app.models.responses import HealthResponse, ValidationResponse router = APIRouter() @@ -34,135 +29,26 @@ async def validate_scenario(doc: ScenarioDocument): if not doc.program_types: errors.append("No program types defined") - type_codes = {pt.code for pt in doc.program_types} + type_ids = {pt.id for pt in doc.program_types} for i, block in enumerate(doc.blocks): - if block.typ not in type_codes: - errors.append(f"Block {i+1}: unknown type '{block.typ}'") - if block.zacatek >= block.konec: + 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 be before end time") return ValidationResponse(valid=len(errors) == 0, errors=errors) -@router.post("/import-excel") -async def import_excel( - file: UploadFile = File(...), - title: str = Form("Imported Event"), - detail: str = Form("Imported from Excel"), -): - """Upload Excel file and return ScenarioDocument JSON.""" - content = await file.read() +@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) - if len(content) > MAX_FILE_SIZE_MB * 1024 * 1024: - raise HTTPException(status_code=413, detail=f"File exceeds {MAX_FILE_SIZE_MB}MB limit") + with open(sample_path, "r", encoding="utf-8") as f: + data = json.load(f) - try: - valid_data, error_rows = read_excel(content) - except TemplateError as e: - raise HTTPException(status_code=422, detail=str(e)) - - if valid_data.empty: - raise HTTPException(status_code=422, detail="No valid rows found in Excel file") - - # Extract unique types - types_in_data = valid_data["Typ"].dropna().unique().tolist() - program_types = [ - ProgramType(code=t, description=str(t), color="#0070C0") - for t in types_in_data - ] - - # Convert rows to blocks - blocks = [] - for _, row in valid_data.iterrows(): - blocks.append(Block( - datum=row["Datum"], - zacatek=row["Zacatek"], - konec=row["Konec"], - program=str(row["Program"]), - typ=str(row["Typ"]), - garant=str(row["Garant"]) if pd.notna(row.get("Garant")) else None, - poznamka=str(row["Poznamka"]) if pd.notna(row.get("Poznamka")) else None, - )) - - doc = ScenarioDocument( - event=EventInfo(title=title, detail=detail), - program_types=program_types, - blocks=blocks, - ) - - warnings = [f"Row {e['index']}: {e.get('error', e.get('Error', 'unknown'))}" for e in error_rows] - - return ImportExcelResponse( - success=True, - document=doc.model_dump(mode="json"), - warnings=warnings, - ) - - -@router.post("/generate-excel") -async def generate_excel(doc: ScenarioDocument): - """Generate Excel timetable from ScenarioDocument.""" - try: - validate_inputs(doc.event.title, doc.event.detail, 0) - except ValidationError as e: - raise HTTPException(status_code=422, detail=str(e)) - - # Convert to DataFrame - rows = [] - for block in doc.blocks: - rows.append({ - 'Datum': block.datum, - 'Zacatek': block.zacatek, - 'Konec': block.konec, - 'Program': block.program, - 'Typ': block.typ, - 'Garant': block.garant, - 'Poznamka': block.poznamka, - }) - - df = pd.DataFrame(rows) - - if df.empty: - raise HTTPException(status_code=422, detail="No blocks provided") - - # Build program descriptions and colors - program_descriptions = {pt.code: pt.description for pt in doc.program_types} - program_colors = {pt.code: 'FF' + pt.color.lstrip('#') for pt in doc.program_types} - - try: - wb = create_timetable(df, doc.event.title, doc.event.detail, - program_descriptions, program_colors) - except ScenarsError as e: - raise HTTPException(status_code=422, detail=str(e)) - - output = BytesIO() - wb.save(output) - output.seek(0) - - return StreamingResponse( - output, - media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - headers={"Content-Disposition": "attachment; filename=scenar_timetable.xlsx"} - ) - - -@router.post("/export-json") -async def export_json(doc: ScenarioDocument): - """Export ScenarioDocument as JSON.""" - return doc.model_dump(mode="json") - - -@router.get("/template") -async def download_template(): - """Download the Excel template file.""" - template_path = os.path.join(os.path.dirname(__file__), "..", "..", "templates", "scenar_template.xlsx") - template_path = os.path.abspath(template_path) - - if not os.path.exists(template_path): - raise HTTPException(status_code=404, detail="Template file not found") - - return FileResponse( - template_path, - media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - filename="scenar_template.xlsx" + return JSONResponse( + content=data, + headers={"Content-Disposition": "attachment; filename=sample.json"} ) diff --git a/app/config.py b/app/config.py index 1651943..2966b6e 100644 --- a/app/config.py +++ b/app/config.py @@ -1,5 +1,5 @@ """Application configuration.""" -VERSION = "2.0.0" +VERSION = "3.0.0" MAX_FILE_SIZE_MB = 10 DEFAULT_COLOR = "#ffffff" diff --git a/app/core/__init__.py b/app/core/__init__.py index ca49866..dd3f06e 100644 --- a/app/core/__init__.py +++ b/app/core/__init__.py @@ -1,28 +1,10 @@ -"""Core business logic for Scenar Creator.""" +"""Core business logic for Scenar Creator v3.""" -from .validator import ( - ScenarsError, - ValidationError, - TemplateError, - validate_inputs, - validate_excel_template, - normalize_time, -) -from .timetable import create_timetable, calculate_row_height, calculate_column_width -from .excel_reader import read_excel, get_program_types, parse_inline_schedule, parse_inline_types +from .validator import ScenarsError, ValidationError +from .pdf_generator import generate_pdf __all__ = [ "ScenarsError", "ValidationError", - "TemplateError", - "validate_inputs", - "validate_excel_template", - "normalize_time", - "create_timetable", - "calculate_row_height", - "calculate_column_width", - "read_excel", - "get_program_types", - "parse_inline_schedule", - "parse_inline_types", + "generate_pdf", ] diff --git a/app/core/excel_reader.py b/app/core/excel_reader.py deleted file mode 100644 index dc7ed61..0000000 --- a/app/core/excel_reader.py +++ /dev/null @@ -1,274 +0,0 @@ -""" -Excel reading and form parsing logic for Scenar Creator. -Extracted from scenar/core.py — read_excel, get_program_types, parse_inline_schedule, parse_inline_types. -""" - -import pandas as pd -from io import BytesIO -import logging - -from .validator import ( - validate_excel_template, - normalize_time, - ValidationError, - TemplateError, - DEFAULT_COLOR, -) - -logger = logging.getLogger(__name__) - - -def read_excel(file_content: bytes, show_debug: bool = False) -> tuple: - """ - Parse Excel file and return (valid_data, error_rows). - - Handles different column naming conventions: - - Old format: Datum, Zacatek, Konec, Program, Typ, Garant, Poznamka - - New template: Datum, Zacatek bloku, Konec bloku, Nazev bloku, Typ bloku, Garant, Poznamka - - Returns: - tuple: (pandas.DataFrame with valid rows, list of dicts with error details) - """ - try: - excel_data = pd.read_excel(BytesIO(file_content), skiprows=0) - except Exception as e: - raise TemplateError(f"Failed to read Excel file: {str(e)}") - - # Map column names from various possible names to our standard names - column_mapping = { - 'Zacatek bloku': 'Zacatek', - 'Konec bloku': 'Konec', - 'Nazev bloku': 'Program', - 'Typ bloku': 'Typ', - } - - excel_data = excel_data.rename(columns=column_mapping) - - # Validate template - validate_excel_template(excel_data) - - if show_debug: - logger.debug(f"Raw data:\n{excel_data.head()}") - - error_rows = [] - valid_data = [] - - for index, row in excel_data.iterrows(): - try: - datum = pd.to_datetime(row["Datum"], errors='coerce').date() - zacatek = normalize_time(str(row["Zacatek"])) - konec = normalize_time(str(row["Konec"])) - - if pd.isna(datum) or zacatek is None or konec is None: - raise ValueError("Invalid date or time format") - - valid_data.append({ - "index": index, - "Datum": datum, - "Zacatek": zacatek, - "Konec": konec, - "Program": row["Program"], - "Typ": row["Typ"], - "Garant": row["Garant"], - "Poznamka": row["Poznamka"], - "row_data": row - }) - except Exception as e: - error_rows.append({"index": index, "row": row, "error": str(e)}) - - valid_data = pd.DataFrame(valid_data) - - # Early return if no valid rows - if valid_data.empty: - logger.warning("No valid rows after parsing") - return valid_data.drop(columns='index', errors='ignore'), error_rows - - if show_debug: - logger.debug(f"Cleaned data:\n{valid_data.head()}") - logger.debug(f"Error rows: {error_rows}") - - # Detect overlaps - overlap_errors = [] - for date, group in valid_data.groupby('Datum'): - sorted_group = group.sort_values(by='Zacatek') - previous_end_time = None - for _, r in sorted_group.iterrows(): - if previous_end_time and r['Zacatek'] < previous_end_time: - overlap_errors.append({ - "index": r["index"], - "Datum": r["Datum"], - "Zacatek": r["Zacatek"], - "Konec": r["Konec"], - "Program": r["Program"], - "Typ": r["Typ"], - "Garant": r["Garant"], - "Poznamka": r["Poznamka"], - "Error": f"Overlapping time block with previous block ending at {previous_end_time}", - "row_data": r["row_data"] - }) - previous_end_time = r['Konec'] - - if overlap_errors: - if show_debug: - logger.debug(f"Overlap errors: {overlap_errors}") - valid_data = valid_data[~valid_data.index.isin([e['index'] for e in overlap_errors])] - error_rows.extend(overlap_errors) - - return valid_data.drop(columns='index'), error_rows - - -def get_program_types(form_data: dict) -> tuple: - """ - Extract program types from form data. - - Form fields: type_code_{i}, desc_{i}, color_{i} - - Returns: - tuple: (program_descriptions dict, program_colors dict) - """ - program_descriptions = {} - program_colors = {} - - def get_value(data, key, default=''): - # Support both dict-like and cgi.FieldStorage objects - if hasattr(data, 'getvalue'): - return data.getvalue(key, default) - return data.get(key, default) - - for key in list(form_data.keys()): - if key.startswith('type_code_'): - index = key.split('_')[-1] - type_code = (get_value(form_data, f'type_code_{index}', '') or '').strip() - description = (get_value(form_data, f'desc_{index}', '') or '').strip() - raw_color = (get_value(form_data, f'color_{index}', DEFAULT_COLOR) or DEFAULT_COLOR) - - if not type_code: - continue - - color_hex = 'FF' + str(raw_color).lstrip('#') - program_descriptions[type_code] = description - program_colors[type_code] = color_hex - - return program_descriptions, program_colors - - -def parse_inline_schedule(form_data) -> pd.DataFrame: - """ - Parse inline schedule form data into DataFrame. - - Form fields: - datum_{i}, zacatek_{i}, konec_{i}, program_{i}, typ_{i}, garant_{i}, poznamka_{i} - - Args: - form_data: dict or cgi.FieldStorage with form data - - Returns: - DataFrame with parsed schedule data - - Raises: - ValidationError: if required fields missing or invalid - """ - rows = [] - row_indices = set() - - # Helper to get value from both dict and FieldStorage - def get_value(data, key, default=''): - if hasattr(data, 'getvalue'): # cgi.FieldStorage - return data.getvalue(key, default).strip() - else: # dict - return data.get(key, default).strip() - - # Find all row indices - for key in form_data.keys(): - if key.startswith('datum_'): - idx = key.split('_')[-1] - row_indices.add(idx) - - for idx in sorted(row_indices, key=int): - datum_str = get_value(form_data, f'datum_{idx}', '') - zacatek_str = get_value(form_data, f'zacatek_{idx}', '') - konec_str = get_value(form_data, f'konec_{idx}', '') - program = get_value(form_data, f'program_{idx}', '') - typ = get_value(form_data, f'typ_{idx}', '') - garant = get_value(form_data, f'garant_{idx}', '') - poznamka = get_value(form_data, f'poznamka_{idx}', '') - - # Skip empty rows - if not any([datum_str, zacatek_str, konec_str, program, typ]): - continue - - # Validate required fields - if not all([datum_str, zacatek_str, konec_str, program, typ]): - raise ValidationError( - f"Řádek {int(idx)+1}: Všechna povinná pole (Datum, Začátek, Konec, Program, Typ) musí být vyplněna" - ) - - try: - datum = pd.to_datetime(datum_str).date() - except Exception: - raise ValidationError(f"Řádek {int(idx)+1}: Neplatné datum") - - zacatek = normalize_time(zacatek_str) - konec = normalize_time(konec_str) - - if zacatek is None or konec is None: - raise ValidationError(f"Řádek {int(idx)+1}: Neplatný čas (použijte HH:MM nebo HH:MM:SS)") - - rows.append({ - 'Datum': datum, - 'Zacatek': zacatek, - 'Konec': konec, - 'Program': program, - 'Typ': typ, - 'Garant': garant if garant else None, - 'Poznamka': poznamka if poznamka else None, - }) - - if not rows: - raise ValidationError("Žádné platné řádky ve formuláři") - - return pd.DataFrame(rows) - - -def parse_inline_types(form_data) -> tuple: - """ - Parse inline type definitions from form data. - - Form fields: type_name_{i}, type_desc_{i}, type_color_{i} - - Args: - form_data: dict or cgi.FieldStorage with form data - - Returns: - tuple: (program_descriptions dict, program_colors dict) - """ - descriptions = {} - colors = {} - type_indices = set() - - # Helper to get value from both dict and FieldStorage - def get_value(data, key, default=''): - if hasattr(data, 'getvalue'): # cgi.FieldStorage - return data.getvalue(key, default).strip() - else: # dict - return data.get(key, default).strip() - - # Find all type indices - for key in form_data.keys(): - if key.startswith('type_name_'): - idx = key.split('_')[-1] - type_indices.add(idx) - - for idx in sorted(type_indices, key=int): - type_name = get_value(form_data, f'type_name_{idx}', '') - type_desc = get_value(form_data, f'type_desc_{idx}', '') - type_color = get_value(form_data, f'type_color_{idx}', DEFAULT_COLOR) - - # Skip empty types - if not type_name: - continue - - descriptions[type_name] = type_desc - colors[type_name] = 'FF' + type_color.lstrip('#') - - return descriptions, colors diff --git a/app/core/pdf_generator.py b/app/core/pdf_generator.py index b1458f5..9991a63 100644 --- a/app/core/pdf_generator.py +++ b/app/core/pdf_generator.py @@ -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"
{block.responsible}" + 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', '
'), 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("Legenda:", 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("Legenda:", 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() diff --git a/app/core/timetable.py b/app/core/timetable.py deleted file mode 100644 index af97851..0000000 --- a/app/core/timetable.py +++ /dev/null @@ -1,242 +0,0 @@ -""" -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 diff --git a/app/core/validator.py b/app/core/validator.py index 94b9dc5..37aba51 100644 --- a/app/core/validator.py +++ b/app/core/validator.py @@ -1,18 +1,9 @@ -""" -Validation logic for Scenar Creator. -Extracted from scenar/core.py — validate_inputs, validate_excel_template, overlap detection. -""" +"""Validation logic for Scenar Creator v3.""" -import pandas as pd -from datetime import datetime import logging logger = logging.getLogger(__name__) -DEFAULT_COLOR = "#ffffff" -MAX_FILE_SIZE_MB = 10 -REQUIRED_COLUMNS = ["Datum", "Zacatek", "Konec", "Program", "Typ", "Garant", "Poznamka"] - class ScenarsError(Exception): """Base exception for Scenar Creator.""" @@ -22,48 +13,3 @@ class ScenarsError(Exception): class ValidationError(ScenarsError): """Raised when input validation fails.""" pass - - -class TemplateError(ScenarsError): - """Raised when Excel template is invalid.""" - pass - - -def validate_inputs(title: str, detail: str, file_size: int) -> None: - """Validate user inputs for security and sanity.""" - if not title or not isinstance(title, str): - raise ValidationError("Title is required and must be a string") - if len(title.strip()) == 0: - raise ValidationError("Title cannot be empty") - if len(title) > 200: - raise ValidationError("Title is too long (max 200 characters)") - - if not detail or not isinstance(detail, str): - raise ValidationError("Detail is required and must be a string") - if len(detail.strip()) == 0: - raise ValidationError("Detail cannot be empty") - if len(detail) > 500: - raise ValidationError("Detail is too long (max 500 characters)") - - if file_size > MAX_FILE_SIZE_MB * 1024 * 1024: - raise ValidationError(f"File size exceeds {MAX_FILE_SIZE_MB} MB limit") - - -def normalize_time(time_str: str): - """Parse time string in formats %H:%M or %H:%M:%S.""" - for fmt in ('%H:%M', '%H:%M:%S'): - try: - return datetime.strptime(time_str, fmt).time() - except ValueError: - continue - return None - - -def validate_excel_template(df: pd.DataFrame) -> None: - """Validate that Excel has required columns.""" - missing_cols = set(REQUIRED_COLUMNS) - set(df.columns) - if missing_cols: - raise TemplateError( - f"Excel template missing required columns: {', '.join(missing_cols)}. " - f"Expected: {', '.join(REQUIRED_COLUMNS)}" - ) diff --git a/app/main.py b/app/main.py index 451384d..d12185e 100644 --- a/app/main.py +++ b/app/main.py @@ -10,7 +10,7 @@ from app.config import VERSION app = FastAPI( title="Scenar Creator", - description="Web tool for creating timetable scenarios from Excel or inline forms", + description="Web tool for creating experience course scenarios with canvas editor", version=VERSION, ) diff --git a/app/models/event.py b/app/models/event.py index a87b72c..128a4f6 100644 --- a/app/models/event.py +++ b/app/models/event.py @@ -1,30 +1,33 @@ -"""Pydantic v2 models for Scenar Creator.""" +"""Pydantic v2 models for Scenar Creator v3.""" -from datetime import date, time +import uuid from typing import List, Optional from pydantic import BaseModel, Field class Block(BaseModel): - datum: date - zacatek: time - konec: time - program: str - typ: str - garant: Optional[str] = None - poznamka: Optional[str] = None + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + date: str # "YYYY-MM-DD" + start: str # "HH:MM" + end: str # "HH:MM" + title: str + type_id: str + responsible: Optional[str] = None + notes: Optional[str] = None class ProgramType(BaseModel): - code: str - description: str - color: str # hex #RRGGBB + id: str + name: str + color: str # "#RRGGBB" class EventInfo(BaseModel): - title: str = Field(..., max_length=200) - detail: str = Field(..., max_length=500) + title: str + subtitle: Optional[str] = None + date: Optional[str] = None + location: Optional[str] = None class ScenarioDocument(BaseModel): diff --git a/app/models/responses.py b/app/models/responses.py index d642e0d..5535ab0 100644 --- a/app/models/responses.py +++ b/app/models/responses.py @@ -1,6 +1,6 @@ """API response models.""" -from typing import Any, List, Optional +from typing import List from pydantic import BaseModel @@ -13,10 +13,3 @@ class HealthResponse(BaseModel): class ValidationResponse(BaseModel): valid: bool errors: List[str] = [] - - -class ImportExcelResponse(BaseModel): - success: bool - document: Optional[Any] = None - errors: List[str] = [] - warnings: List[str] = [] diff --git a/app/static/css/app.css b/app/static/css/app.css index 6409944..8ebb531 100644 --- a/app/static/css/app.css +++ b/app/static/css/app.css @@ -1,293 +1,711 @@ +/* Scenar Creator v3 — Main Stylesheet */ + +:root { + --header-bg: #1e293b; + --accent: #3b82f6; + --accent-hover: #2563eb; + --danger: #ef4444; + --danger-hover: #dc2626; + --success: #22c55e; + --text: #1e293b; + --text-light: #64748b; + --border: #e2e8f0; + --bg: #f8fafc; + --white: #ffffff; + --sidebar-width: 280px; + --header-height: 56px; + --tab-height: 40px; + --grid-row-height: 30px; + --radius: 8px; + --radius-sm: 4px; + --shadow: 0 1px 3px rgba(0,0,0,0.1); + --shadow-lg: 0 4px 12px rgba(0,0,0,0.15); +} + * { - box-sizing: border-box; margin: 0; padding: 0; + box-sizing: border-box; } body { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - background: #f5f5f5; - color: #333; - line-height: 1.6; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--bg); + color: var(--text); + font-size: 14px; + line-height: 1.5; + overflow: hidden; + height: 100vh; } -.container { - max-width: 1200px; - margin: 0 auto; - padding: 20px; +/* Header */ +.header { + height: var(--header-height); + background: var(--header-bg); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 20px; + color: white; + z-index: 100; } -h1 { - text-align: center; - color: #2c3e50; - margin-bottom: 5px; +.header-left { + display: flex; + align-items: center; + gap: 10px; } -.subtitle { - text-align: center; - color: #7f8c8d; - margin-bottom: 20px; +.header-title { + font-size: 18px; + font-weight: 600; + letter-spacing: -0.3px; +} + +.header-version { + font-size: 11px; + background: rgba(255,255,255,0.15); + padding: 2px 8px; + border-radius: 10px; + font-weight: 500; +} + +.header-actions { + display: flex; + gap: 8px; + align-items: center; } /* Tabs */ .tabs { + height: var(--tab-height); + background: var(--white); + border-bottom: 1px solid var(--border); display: flex; + padding: 0 20px; gap: 0; - margin-bottom: 0; - border-bottom: 2px solid #3498db; } .tab { - padding: 10px 24px; - border: 1px solid #ddd; - border-bottom: none; - background: #ecf0f1; + padding: 0 16px; + height: 100%; + border: none; + background: none; cursor: pointer; - font-size: 14px; - border-radius: 6px 6px 0 0; - transition: background 0.2s; + font-size: 13px; + font-weight: 500; + color: var(--text-light); + border-bottom: 2px solid transparent; + transition: all 0.15s; + font-family: inherit; +} + +.tab:hover { + color: var(--text); } .tab.active { - background: #fff; - border-color: #3498db; - border-bottom: 2px solid #fff; - margin-bottom: -2px; - font-weight: bold; -} - -.tab:hover:not(.active) { - background: #d5dbdb; -} - -.tab-content { - display: none; - background: #fff; - padding: 20px; - border: 1px solid #ddd; - border-top: none; - border-radius: 0 0 6px 6px; -} - -.tab-content.active { - display: block; -} - -/* Form */ -.form-group { - margin-bottom: 15px; -} - -.form-group label { - display: block; - font-weight: 600; - margin-bottom: 4px; -} - -.form-group input[type="text"], -.form-group input[type="file"] { - width: 100%; - padding: 8px 12px; - border: 1px solid #ccc; - border-radius: 4px; - font-size: 14px; -} - -.form-actions { - margin-top: 20px; - display: flex; - gap: 10px; + color: var(--accent); + border-bottom-color: var(--accent); } /* Buttons */ .btn { - display: inline-block; - padding: 10px 20px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 8px 16px; border: none; - border-radius: 4px; + border-radius: var(--radius-sm); + font-size: 13px; + font-weight: 500; cursor: pointer; - font-size: 14px; - text-decoration: none; - text-align: center; - transition: background 0.2s; + transition: all 0.15s; + font-family: inherit; + white-space: nowrap; } .btn-primary { - background: #3498db; - color: #fff; + background: var(--accent); + color: white; } .btn-primary:hover { - background: #2980b9; + background: var(--accent-hover); } .btn-secondary { - background: #95a5a6; - color: #fff; + background: rgba(255,255,255,0.12); + color: white; + border: 1px solid rgba(255,255,255,0.2); } .btn-secondary:hover { - background: #7f8c8d; + background: rgba(255,255,255,0.2); } .btn-danger { - background: #e74c3c; - color: #fff; + background: var(--danger); + color: white; } .btn-danger:hover { - background: #c0392b; + background: var(--danger-hover); } .btn-sm { - padding: 4px 10px; + padding: 6px 12px; font-size: 12px; } -/* Types */ +.btn-xs { + padding: 4px 10px; + font-size: 11px; + background: var(--bg); + color: var(--text-light); + border: 1px solid var(--border); +} + +.btn-xs:hover { + background: var(--border); + color: var(--text); +} + +.btn-block { + width: 100%; +} + +/* Layout */ +.tab-content { + height: calc(100vh - var(--header-height) - var(--tab-height)); + overflow: hidden; +} + +.tab-content.hidden { + display: none; +} + +.app-layout { + display: flex; + height: 100%; +} + +/* Sidebar */ +.sidebar { + width: var(--sidebar-width); + min-width: var(--sidebar-width); + background: var(--white); + border-right: 1px solid var(--border); + overflow-y: auto; + padding: 16px; +} + +.sidebar-section { + margin-bottom: 20px; +} + +.sidebar-heading { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-light); + margin-bottom: 10px; + font-weight: 600; +} + +.form-group { + margin-bottom: 10px; +} + +.form-group label { + display: block; + font-size: 12px; + font-weight: 500; + color: var(--text-light); + margin-bottom: 4px; +} + +.form-group input[type="text"], +.form-group input[type="date"], +.form-group input[type="time"], +.form-group select, +.form-group textarea { + width: 100%; + padding: 7px 10px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + font-size: 13px; + font-family: inherit; + background: var(--white); + color: var(--text); + transition: border-color 0.15s; +} + +.form-group input:focus, +.form-group select:focus, +.form-group textarea:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(59,130,246,0.1); +} + +.form-row { + display: flex; + gap: 10px; +} + +.form-row .form-group { + flex: 1; +} + +/* Program type rows */ .type-row { display: flex; - gap: 8px; - margin-bottom: 8px; align-items: center; + gap: 6px; + margin-bottom: 8px; + padding: 6px 8px; + background: var(--bg); + border-radius: var(--radius-sm); +} + +.type-row input[type="color"] { + width: 28px; + height: 28px; + border: none; + border-radius: var(--radius-sm); + cursor: pointer; + padding: 0; + background: none; } .type-row input[type="text"] { flex: 1; - padding: 6px 10px; - border: 1px solid #ccc; - border-radius: 4px; + padding: 5px 8px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + font-size: 12px; + font-family: inherit; } -.type-row input[type="color"] { - width: 40px; - height: 32px; - border: 1px solid #ccc; - border-radius: 4px; +.type-row input[type="text"]:focus { + outline: none; + border-color: var(--accent); +} + +.type-remove { + width: 22px; + height: 22px; + border: none; + background: none; + color: var(--text-light); cursor: pointer; + font-size: 16px; + line-height: 1; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; } -.type-code { - max-width: 200px; +.type-remove:hover { + background: var(--danger); + color: white; } -/* Schedule table */ -#scheduleTable { +/* Canvas */ +.canvas-wrapper { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + background: var(--bg); +} + +.canvas-header { + display: flex; + padding: 0 0 0 60px; + background: var(--white); + border-bottom: 1px solid var(--border); + min-height: 36px; + align-items: stretch; +} + +.canvas-header .day-header { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 600; + color: var(--text); + border-left: 1px solid var(--border); + padding: 8px; +} + +.canvas-header .day-header:first-child { + border-left: none; +} + +.canvas-scroll { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + position: relative; +} + +.canvas { + display: flex; + position: relative; + min-height: 600px; +} + +.time-axis { + width: 60px; + min-width: 60px; + position: relative; + background: var(--white); + border-right: 1px solid var(--border); +} + +.time-label { + position: absolute; + right: 8px; + font-size: 11px; + color: var(--text-light); + transform: translateY(-50%); + font-variant-numeric: tabular-nums; +} + +.day-columns { + flex: 1; + display: flex; + position: relative; +} + +.day-column { + flex: 1; + position: relative; + border-left: 1px solid var(--border); + min-width: 200px; +} + +.day-column:first-child { + border-left: none; +} + +/* Grid lines */ +.grid-line { + position: absolute; + left: 0; + right: 0; + height: 1px; + background: var(--border); + pointer-events: none; +} + +.grid-line.hour { + background: var(--border); +} + +.grid-line.half { + background: #f1f5f9; +} + +/* Schedule blocks */ +.schedule-block { + position: absolute; + left: 4px; + right: 4px; + border-radius: 6px; + padding: 6px 8px; + cursor: grab; + overflow: hidden; + transition: box-shadow 0.15s; + z-index: 10; + display: flex; + flex-direction: column; + min-height: 20px; + user-select: none; + touch-action: none; +} + +.schedule-block:hover { + box-shadow: var(--shadow-lg); + z-index: 20; +} + +.schedule-block:active { + cursor: grabbing; +} + +.schedule-block .block-color-bar { + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 4px; + border-radius: 6px 0 0 6px; +} + +.schedule-block .block-title { + font-size: 12px; + font-weight: 600; + color: white; + line-height: 1.2; + padding-left: 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.schedule-block .block-time { + font-size: 10px; + color: rgba(255,255,255,0.8); + padding-left: 4px; +} + +.schedule-block .block-responsible { + font-size: 10px; + color: rgba(255,255,255,0.7); + padding-left: 4px; + margin-top: auto; +} + +.schedule-block.light-bg .block-title { + color: var(--text); +} + +.schedule-block.light-bg .block-time { + color: var(--text-light); +} + +.schedule-block.light-bg .block-responsible { + color: var(--text-light); +} + +/* Resize handle */ +.schedule-block .resize-handle { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 8px; + cursor: s-resize; + background: transparent; +} + +.schedule-block:hover .resize-handle { + background: rgba(0,0,0,0.1); + border-radius: 0 0 6px 6px; +} + +/* Modal */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.4); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modal-overlay.hidden { + display: none; +} + +.modal { + background: var(--white); + border-radius: var(--radius); + box-shadow: var(--shadow-lg); + width: 420px; + max-width: 90vw; + max-height: 90vh; + overflow-y: auto; +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid var(--border); +} + +.modal-header h3 { + font-size: 16px; + font-weight: 600; +} + +.modal-close { + width: 30px; + height: 30px; + border: none; + background: none; + font-size: 20px; + cursor: pointer; + color: var(--text-light); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + +.modal-close:hover { + background: var(--bg); +} + +.modal-body { + padding: 20px; +} + +.modal-footer { + display: flex; + justify-content: space-between; + padding: 16px 20px; + border-top: 1px solid var(--border); +} + +/* Toast notification */ +.toast { + position: fixed; + bottom: 24px; + right: 24px; + padding: 12px 20px; + border-radius: var(--radius); + font-size: 13px; + font-weight: 500; + z-index: 2000; + transition: opacity 0.3s, transform 0.3s; + box-shadow: var(--shadow-lg); +} + +.toast.hidden { + opacity: 0; + transform: translateY(10px); + pointer-events: none; +} + +.toast.success { + background: var(--success); + color: white; +} + +.toast.error { + background: var(--danger); + color: white; +} + +.toast.info { + background: var(--accent); + color: white; +} + +/* Documentation */ +.docs-container { + max-width: 800px; + margin: 0 auto; + padding: 32px 24px; + overflow-y: auto; + height: 100%; +} + +.docs-container h2 { + font-size: 24px; + font-weight: 700; + margin-bottom: 24px; + color: var(--text); +} + +.docs-container h3 { + font-size: 16px; + font-weight: 600; + margin-top: 24px; + margin-bottom: 12px; + color: var(--text); +} + +.docs-container p { + margin-bottom: 12px; + color: var(--text-light); + line-height: 1.7; +} + +.docs-container ul, +.docs-container ol { + margin-bottom: 12px; + padding-left: 24px; + color: var(--text-light); +} + +.docs-container li { + margin-bottom: 6px; + line-height: 1.6; +} + +.docs-table { width: 100%; border-collapse: collapse; - margin: 10px 0; -} - -#scheduleTable th, -#scheduleTable td { - padding: 6px 8px; - border: 1px solid #ddd; - text-align: left; -} - -#scheduleTable th { - background: #ecf0f1; - font-weight: 600; + margin: 16px 0; font-size: 13px; } -#scheduleTable input { - width: 100%; - padding: 4px 6px; - border: 1px solid #ccc; - border-radius: 3px; - font-size: 13px; -} - -#scheduleTable input[type="date"] { - min-width: 130px; -} - -#scheduleTable input[type="time"] { - min-width: 90px; -} - -/* Editor area */ -.editor-area { - background: #fff; - padding: 20px; - border: 1px solid #ddd; - border-radius: 6px; - margin-top: 20px; -} - -.editor-area h2 { - margin-bottom: 10px; - color: #2c3e50; -} - -.editor-area h3 { - margin: 15px 0 8px; - color: #34495e; -} - -/* Imported type editor */ -.imported-type-row { - display: flex; - gap: 8px; - margin-bottom: 6px; - align-items: center; -} - -.imported-type-row input[type="text"] { - flex: 1; - padding: 5px 8px; - border: 1px solid #ccc; - border-radius: 3px; -} - -.imported-type-row input[type="color"] { - width: 36px; - height: 28px; - border: 1px solid #ccc; - border-radius: 3px; -} - -/* Block list */ -.block-item { - background: #f9f9f9; +.docs-table th, +.docs-table td { padding: 8px 12px; - margin-bottom: 4px; - border-radius: 4px; - border-left: 4px solid #3498db; - font-size: 13px; + text-align: left; + border: 1px solid var(--border); } -/* Status message */ -.status-message { - margin-top: 15px; - padding: 12px; - border-radius: 4px; - font-size: 14px; +.docs-table th { + background: var(--bg); + font-weight: 600; + color: var(--text); } -.status-message.success { - background: #d5f5e3; - color: #27ae60; - border: 1px solid #27ae60; +.docs-table td { + color: var(--text-light); } -.status-message.error { - background: #fadbd8; - color: #e74c3c; - border: 1px solid #e74c3c; +/* Canvas click area for creating blocks */ +.day-column-click-area { + position: absolute; + inset: 0; + z-index: 1; } -/* JSON import */ -.json-import { - margin-top: 20px; - padding: 10px; - background: #fafafa; - border-radius: 4px; - font-size: 13px; +/* Scrollbar */ +.canvas-scroll::-webkit-scrollbar, +.sidebar::-webkit-scrollbar { + width: 6px; } -h3 { - margin: 20px 0 10px; - color: #2c3e50; +.canvas-scroll::-webkit-scrollbar-track, +.sidebar::-webkit-scrollbar-track { + background: transparent; +} + +.canvas-scroll::-webkit-scrollbar-thumb, +.sidebar::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 3px; +} + +.canvas-scroll::-webkit-scrollbar-thumb:hover, +.sidebar::-webkit-scrollbar-thumb:hover { + background: var(--text-light); +} + +/* Sidebar buttons in non-header context */ +.sidebar .btn-secondary { + background: var(--bg); + color: var(--text-light); + border: 1px solid var(--border); +} + +.sidebar .btn-secondary:hover { + background: var(--border); + color: var(--text); } diff --git a/app/static/index.html b/app/static/index.html index 6093bbe..58d0305 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -3,131 +3,193 @@ - Scenar Creator + Scenár Creator + + + -
-

Scenar Creator

-

Tvorba časových harmonogramů

- - -
- - +
+
+

Scenár Creator

+ v3.0
- - -
-
-
- - -
-
- - -
-
- - -
-
- - Stáhnout šablonu -
-
+
+ + + +
+
- -
-
-
- - -
-
- - -
- -

Typy programů

-
-
- - - - -
-
- - -

Časový harmonogram

- -
- - - - - - - - - - - - - - - - - - - - - - - - - -
DatumZačátekKonecProgramTypGarantPoznámka
-
- - -
- - -
-
-
- - - - - -
- -
- - +
+ +
+
+
+ + +
+
+
+
+
+
+
+
+
+
+
+ + + + + + + + + + + diff --git a/app/static/js/api.js b/app/static/js/api.js index 910cbff..f00d8c3 100644 --- a/app/static/js/api.js +++ b/app/static/js/api.js @@ -1,5 +1,5 @@ /** - * API fetch wrapper for Scenar Creator. + * API fetch wrapper for Scenar Creator v3. */ const API = { @@ -9,10 +9,9 @@ const API = { opts.headers = { 'Content-Type': 'application/json' }; opts.body = JSON.stringify(body); } else { - opts.body = body; // FormData + opts.body = body; } - const res = await fetch(url, opts); - return res; + return fetch(url, opts); }, async postJson(url, body) { @@ -33,21 +32,18 @@ const API = { return res.blob(); }, - async postFormData(url, formData) { - const res = await this.post(url, formData, false); - if (!res.ok) { - const err = await res.json().catch(() => ({ detail: res.statusText })); - throw new Error(err.detail || 'API error'); - } - return res.json(); - }, - async get(url) { const res = await fetch(url); if (!res.ok) throw new Error(res.statusText); return res.json(); }, + 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'); diff --git a/app/static/js/app.js b/app/static/js/app.js index 4265731..fed79ff 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -1,258 +1,304 @@ /** - * Main application logic for Scenar Creator SPA. + * Main application logic for Scenar Creator v3. + * State management, UI wiring, modal handling. */ -window.currentDocument = null; -let typeCounter = 1; -let scheduleCounter = 1; +const App = { + state: { + event: { title: '', subtitle: '', date: '', location: '' }, + program_types: [], + blocks: [] + }, -/* --- Tab switching --- */ -function switchTab(event, tabId) { - document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); - document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); - event.target.classList.add('active'); - document.getElementById(tabId).classList.add('active'); -} + init() { + this.bindEvents(); + this.newScenario(); + }, -/* --- Status messages --- */ -function showStatus(message, type) { - const el = document.getElementById('statusMessage'); - el.textContent = message; - el.className = 'status-message ' + type; - el.style.display = 'block'; - setTimeout(() => { el.style.display = 'none'; }, 5000); -} + // --- State --- -/* --- Type management --- */ -function addTypeRow() { - const container = document.getElementById('typesContainer'); - const idx = typeCounter++; - const div = document.createElement('div'); - div.className = 'type-row'; - div.setAttribute('data-index', idx); - div.innerHTML = ` - - - - - `; - container.appendChild(div); - updateTypeDatalist(); -} + getDocument() { + this.syncEventFromUI(); + return { + version: '1.0', + event: { ...this.state.event }, + program_types: this.state.program_types.map(pt => ({ ...pt })), + blocks: this.state.blocks.map(b => ({ ...b })) + }; + }, -function removeTypeRow(idx) { - const row = document.querySelector(`.type-row[data-index="${idx}"]`); - if (row) row.remove(); - updateTypeDatalist(); -} - -function updateTypeDatalist() { - const datalist = document.getElementById('availableTypes'); - datalist.innerHTML = ''; - document.querySelectorAll('#typesContainer .type-row').forEach(row => { - const nameInput = row.querySelector('input[name^="type_name_"]'); - if (nameInput && nameInput.value.trim()) { - const opt = document.createElement('option'); - opt.value = nameInput.value.trim(); - datalist.appendChild(opt); - } - }); -} - -// Update datalist on type name changes -document.getElementById('typesContainer').addEventListener('input', function (e) { - if (e.target.name && e.target.name.startsWith('type_name_')) { - updateTypeDatalist(); - } -}); - -/* --- Schedule management --- */ -function addScheduleRow() { - const tbody = document.getElementById('scheduleBody'); - const idx = scheduleCounter++; - const tr = document.createElement('tr'); - tr.setAttribute('data-index', idx); - tr.innerHTML = ` - - - - - - - - - `; - tbody.appendChild(tr); -} - -function removeScheduleRow(idx) { - const row = document.querySelector(`#scheduleBody tr[data-index="${idx}"]`); - if (row) row.remove(); -} - -/* --- Build ScenarioDocument from builder form --- */ -function buildDocumentFromForm() { - const title = document.getElementById('builderTitle').value.trim(); - const detail = document.getElementById('builderDetail').value.trim(); - - if (!title || !detail) { - throw new Error('Název akce a detail jsou povinné'); - } - - // Collect types - const programTypes = []; - document.querySelectorAll('#typesContainer .type-row').forEach(row => { - const code = row.querySelector('input[name^="type_name_"]').value.trim(); - const desc = row.querySelector('input[name^="type_desc_"]').value.trim(); - const color = row.querySelector('input[name^="type_color_"]').value; - if (code) { - programTypes.push({ code, description: desc, color }); - } - }); - - // Collect blocks - const blocks = []; - document.querySelectorAll('#scheduleBody tr').forEach(tr => { - const inputs = tr.querySelectorAll('input'); - const datum = inputs[0].value; - const zacatek = inputs[1].value; - const konec = inputs[2].value; - const program = inputs[3].value.trim(); - const typ = inputs[4].value.trim(); - const garant = inputs[5].value.trim() || null; - const poznamka = inputs[6].value.trim() || null; - - if (datum && zacatek && konec && program && typ) { - blocks.push({ datum, zacatek, konec, program, typ, garant, poznamka }); - } - }); - - if (blocks.length === 0) { - throw new Error('Přidejte alespoň jeden blok'); - } - - return { - version: "1.0", - event: { title, detail }, - program_types: programTypes, - blocks - }; -} - -/* --- Handle builder form submit (Excel) --- */ -async function handleBuild(event) { - event.preventDefault(); - try { - const doc = buildDocumentFromForm(); - const blob = await API.postBlob('/api/generate-excel', doc); - API.downloadBlob(blob, 'scenar_timetable.xlsx'); - showStatus('Excel vygenerován', 'success'); - } catch (err) { - showStatus('Chyba: ' + err.message, 'error'); - } - return false; -} - -/* --- Handle builder PDF --- */ -async function handleBuildPdf() { - try { - const doc = buildDocumentFromForm(); - const blob = await API.postBlob('/api/generate-pdf', doc); - API.downloadBlob(blob, 'scenar_timetable.pdf'); - showStatus('PDF vygenerován', 'success'); - } catch (err) { - showStatus('Chyba: ' + err.message, 'error'); - } -} - -/* --- Handle Excel import --- */ -async function handleImport(event) { - event.preventDefault(); - const form = document.getElementById('importForm'); - const formData = new FormData(form); - - try { - const result = await API.postFormData('/api/import-excel', formData); - if (result.success && result.document) { - window.currentDocument = result.document; - showImportedDocument(result.document); - if (result.warnings && result.warnings.length > 0) { - showStatus('Import OK, warnings: ' + result.warnings.join('; '), 'success'); - } else { - showStatus('Excel importován', 'success'); - } - } else { - showStatus('Import failed: ' + (result.errors || []).join('; '), 'error'); - } - } catch (err) { - showStatus('Chyba importu: ' + err.message, 'error'); - } - return false; -} - -/* --- Show imported document in editor --- */ -function showImportedDocument(doc) { - const area = document.getElementById('editorArea'); - area.style.display = 'block'; - - // Info - document.getElementById('importedInfo').innerHTML = - `${doc.event.title} — ${doc.event.detail}`; - - // Types - const typesHtml = doc.program_types.map((pt, i) => ` -
- - - -
- `).join(''); - document.getElementById('importedTypesContainer').innerHTML = typesHtml; - - // Blocks - const blocksHtml = doc.blocks.map(b => - `
${b.datum} ${b.zacatek}–${b.konec} | ${b.program} [${b.typ}] ${b.garant || ''}
` - ).join(''); - document.getElementById('importedBlocksContainer').innerHTML = blocksHtml; -} - -/* --- Get current document (with any edits from import editor) --- */ -function getCurrentDocument() { - if (!window.currentDocument) { - throw new Error('No document loaded'); - } - // Update types from editor - const typeRows = document.querySelectorAll('#importedTypesContainer .imported-type-row'); - if (typeRows.length > 0) { - window.currentDocument.program_types = Array.from(typeRows).map(row => ({ - code: row.querySelector('[data-field="code"]').value.trim(), - description: row.querySelector('[data-field="description"]').value.trim(), - color: row.querySelector('[data-field="color"]').value, + loadDocument(doc) { + this.state.event = { ...doc.event }; + this.state.program_types = (doc.program_types || []).map(pt => ({ ...pt })); + this.state.blocks = (doc.blocks || []).map(b => ({ + ...b, + id: b.id || this.uid() })); - } - return window.currentDocument; -} + this.syncEventToUI(); + this.renderTypes(); + this.renderCanvas(); + }, -/* --- Generate Excel from imported data --- */ -async function generateExcelFromImport() { - try { - const doc = getCurrentDocument(); - const blob = await API.postBlob('/api/generate-excel', doc); - API.downloadBlob(blob, 'scenar_timetable.xlsx'); - showStatus('Excel vygenerován', 'success'); - } catch (err) { - showStatus('Chyba: ' + err.message, 'error'); - } -} + newScenario() { + const today = new Date().toISOString().split('T')[0]; + this.state = { + event: { title: 'Nová akce', subtitle: '', date: 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(); + }, -/* --- Generate PDF from imported data --- */ -async function generatePdfFromImport() { - try { - const doc = getCurrentDocument(); - const blob = await API.postBlob('/api/generate-pdf', doc); - API.downloadBlob(blob, 'scenar_timetable.pdf'); - showStatus('PDF vygenerován', 'success'); - } catch (err) { - showStatus('Chyba: ' + err.message, 'error'); + // --- 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 = document.getElementById('eventDate').value || null; + 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('eventDate').value = this.state.event.date || ''; + 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 = ` + + + + `; + // Color change + row.querySelector('input[type="color"]').addEventListener('change', (e) => { + this.state.program_types[i].color = e.target.value; + this.renderCanvas(); + }); + // Name change + row.querySelector('input[type="text"]').addEventListener('change', (e) => { + this.state.program_types[i].name = e.target.value.trim(); + }); + // Remove + row.querySelector('.type-remove').addEventListener('click', () => { + this.state.program_types.splice(i, 1); + this.renderTypes(); + this.renderCanvas(); + }); + container.appendChild(row); + }); + }, + + addType() { + const id = 'type_' + this.uid().substring(0, 6); + this.state.program_types.push({ + id, + name: 'Nový typ', + color: '#' + Math.floor(Math.random() * 16777215).toString(16).padStart(6, '0') + }); + this.renderTypes(); + }, + + // --- Blocks --- + + createBlock(date, start, end) { + const typeId = this.state.program_types.length > 0 ? this.state.program_types[0].id : 'main'; + const block = { + id: this.uid(), + date: date || this.state.event.date || new Date().toISOString().split('T')[0], + start: start || '09:00', + end: end || '10:00', + title: 'Nový blok', + type_id: typeId, + responsible: null, + notes: null + }; + this.state.blocks.push(block); + this.renderCanvas(); + this.editBlock(block.id); + }, + + updateBlockTime(blockId, start, end) { + const block = this.state.blocks.find(b => b.id === blockId); + if (block) { + block.start = start; + block.end = end; + this.renderCanvas(); + } + }, + + deleteBlock(blockId) { + this.state.blocks = this.state.blocks.filter(b => b.id !== blockId); + this.renderCanvas(); + }, + + // --- Canvas rendering --- + + renderCanvas() { + const dates = this.getUniqueDates(); + Canvas.renderTimeAxis(); + Canvas.renderDayColumns(dates); + Canvas.renderBlocks(this.state.blocks, this.state.program_types); + }, + + getUniqueDates() { + const dateSet = new Set(); + this.state.blocks.forEach(b => dateSet.add(b.date)); + if (this.state.event.date) dateSet.add(this.state.event.date); + const dates = Array.from(dateSet).sort(); + return dates.length > 0 ? dates : [new Date().toISOString().split('T')[0]]; + }, + + // --- Modal --- + + editBlock(blockId) { + const block = this.state.blocks.find(b => b.id === blockId); + if (!block) return; + + 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 || ''; + + // Populate type select + const select = document.getElementById('modalBlockType'); + select.innerHTML = ''; + this.state.program_types.forEach(pt => { + const opt = document.createElement('option'); + opt.value = pt.id; + opt.textContent = pt.name; + if (pt.id === block.type_id) opt.selected = true; + select.appendChild(opt); + }); + + document.getElementById('blockModal').classList.remove('hidden'); + }, + + saveBlockFromModal() { + const blockId = document.getElementById('modalBlockId').value; + const block = this.state.blocks.find(b => b.id === blockId); + if (!block) return; + + block.title = document.getElementById('modalBlockTitle').value.trim() || 'Blok'; + block.type_id = document.getElementById('modalBlockType').value; + block.start = document.getElementById('modalBlockStart').value; + block.end = document.getElementById('modalBlockEnd').value; + block.responsible = document.getElementById('modalBlockResponsible').value.trim() || null; + block.notes = document.getElementById('modalBlockNotes').value.trim() || null; + + this.closeModal(); + this.renderCanvas(); + }, + + closeModal() { + document.getElementById('blockModal').classList.add('hidden'); + }, + + // --- Toast --- + + toast(message, type) { + const el = document.getElementById('toast'); + el.textContent = message; + el.className = 'toast ' + (type || 'info'); + setTimeout(() => el.classList.add('hidden'), 3000); + }, + + // --- PDF --- + + async generatePdf() { + try { + const doc = this.getDocument(); + if (doc.blocks.length === 0) { + this.toast('Přidejte alespoň jeden blok', 'error'); + return; + } + const blob = await API.postBlob('/api/generate-pdf', doc); + API.downloadBlob(blob, 'scenar_timetable.pdf'); + this.toast('PDF vygenerován', 'success'); + } catch (err) { + this.toast('Chyba: ' + err.message, 'error'); + } + }, + + // --- Utility --- + + uid() { + return 'xxxx-xxxx-xxxx'.replace(/x/g, () => + Math.floor(Math.random() * 16).toString(16) + ); + }, + + // --- Event binding --- + + bindEvents() { + // 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'); + }); + }); + + // Header buttons + document.getElementById('newScenarioBtn').addEventListener('click', () => this.newScenario()); + document.getElementById('exportJsonBtn').addEventListener('click', () => exportJson()); + document.getElementById('generatePdfBtn').addEventListener('click', () => this.generatePdf()); + + // Import JSON + document.getElementById('importJsonInput').addEventListener('change', (e) => { + if (e.target.files[0]) importJson(e.target.files[0]); + e.target.value = ''; + }); + + // Sidebar + document.getElementById('addTypeBtn').addEventListener('click', () => this.addType()); + document.getElementById('addBlockBtn').addEventListener('click', () => this.createBlock()); + + // Modal + document.getElementById('modalSaveBtn').addEventListener('click', () => this.saveBlockFromModal()); + document.getElementById('modalClose').addEventListener('click', () => this.closeModal()); + document.getElementById('modalDeleteBtn').addEventListener('click', () => { + const blockId = document.getElementById('modalBlockId').value; + this.closeModal(); + this.deleteBlock(blockId); + }); + + // Close modal on overlay click + document.getElementById('blockModal').addEventListener('click', (e) => { + if (e.target.classList.contains('modal-overlay')) this.closeModal(); + }); + + // Drag & drop JSON on canvas + const canvasWrapper = document.querySelector('.canvas-wrapper'); + if (canvasWrapper) { + canvasWrapper.addEventListener('dragover', (e) => e.preventDefault()); + canvasWrapper.addEventListener('drop', (e) => { + e.preventDefault(); + const file = e.dataTransfer.files[0]; + if (file && file.name.endsWith('.json')) { + importJson(file); + } + }); + } } -} +}; + +// Boot +document.addEventListener('DOMContentLoaded', () => App.init()); diff --git a/app/static/js/canvas.js b/app/static/js/canvas.js new file mode 100644 index 0000000..2e58e53 --- /dev/null +++ b/app/static/js/canvas.js @@ -0,0 +1,267 @@ +/** + * Canvas editor for Scenar Creator v3. + * Renders schedule blocks on a time-grid canvas with drag & drop via interact.js. + */ + +const Canvas = { + GRID_MINUTES: 15, + PX_PER_SLOT: 30, // 30px per 15 min + START_HOUR: 7, + END_HOUR: 23, + + get pxPerMinute() { + return this.PX_PER_SLOT / this.GRID_MINUTES; + }, + + get totalSlots() { + return ((this.END_HOUR - this.START_HOUR) * 60) / this.GRID_MINUTES; + }, + + get totalHeight() { + return this.totalSlots * this.PX_PER_SLOT; + }, + + minutesToPx(minutes) { + return (minutes - this.START_HOUR * 60) * this.pxPerMinute; + }, + + pxToMinutes(px) { + return Math.round(px / this.pxPerMinute + this.START_HOUR * 60); + }, + + snapMinutes(minutes) { + return Math.round(minutes / this.GRID_MINUTES) * this.GRID_MINUTES; + }, + + formatTime(totalMinutes) { + const h = Math.floor(totalMinutes / 60); + const m = totalMinutes % 60; + return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; + }, + + parseTime(str) { + const [h, m] = str.split(':').map(Number); + return h * 60 + m; + }, + + isLightColor(hex) { + const h = hex.replace('#', ''); + const r = parseInt(h.substring(0, 2), 16); + const g = parseInt(h.substring(2, 4), 16); + const b = parseInt(h.substring(4, 6), 16); + return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.6; + }, + + renderTimeAxis() { + const axis = document.getElementById('timeAxis'); + axis.innerHTML = ''; + axis.style.height = this.totalHeight + 'px'; + + for (let slot = 0; slot <= this.totalSlots; slot++) { + const minutes = this.START_HOUR * 60 + slot * this.GRID_MINUTES; + const isHour = minutes % 60 === 0; + const isHalf = minutes % 30 === 0; + + if (isHour || isHalf) { + const label = document.createElement('div'); + label.className = 'time-label'; + label.style.top = (slot * this.PX_PER_SLOT) + 'px'; + label.textContent = this.formatTime(minutes); + if (!isHour) { + label.style.fontSize = '9px'; + label.style.opacity = '0.6'; + } + axis.appendChild(label); + } + } + }, + + renderDayColumns(dates) { + const header = document.getElementById('canvasHeader'); + const columns = document.getElementById('dayColumns'); + header.innerHTML = ''; + columns.innerHTML = ''; + + if (dates.length === 0) dates = [new Date().toISOString().split('T')[0]]; + + dates.forEach(dateStr => { + // Header + const dh = document.createElement('div'); + dh.className = 'day-header'; + dh.textContent = dateStr; + header.appendChild(dh); + + // Column + const col = document.createElement('div'); + col.className = 'day-column'; + col.dataset.date = dateStr; + col.style.height = this.totalHeight + 'px'; + + // Grid lines + for (let slot = 0; slot <= this.totalSlots; slot++) { + const minutes = this.START_HOUR * 60 + slot * this.GRID_MINUTES; + const line = document.createElement('div'); + line.className = 'grid-line ' + (minutes % 60 === 0 ? 'hour' : 'half'); + line.style.top = (slot * this.PX_PER_SLOT) + 'px'; + col.appendChild(line); + } + + // Click area for creating blocks + const clickArea = document.createElement('div'); + clickArea.className = 'day-column-click-area'; + clickArea.addEventListener('click', (e) => { + if (e.target !== clickArea) return; + const rect = col.getBoundingClientRect(); + const y = e.clientY - rect.top; + const minutes = this.snapMinutes(this.pxToMinutes(y)); + const endMinutes = Math.min(minutes + 60, this.END_HOUR * 60); + App.createBlock(dateStr, this.formatTime(minutes), this.formatTime(endMinutes)); + }); + col.appendChild(clickArea); + + columns.appendChild(col); + }); + }, + + renderBlocks(blocks, programTypes) { + // Remove existing blocks + document.querySelectorAll('.schedule-block').forEach(el => el.remove()); + + const typeMap = {}; + programTypes.forEach(pt => { typeMap[pt.id] = pt; }); + + blocks.forEach(block => { + const col = document.querySelector(`.day-column[data-date="${block.date}"]`); + if (!col) return; + + const pt = typeMap[block.type_id] || { color: '#94a3b8', name: '?' }; + const startMin = this.parseTime(block.start); + const endMin = this.parseTime(block.end); + const top = this.minutesToPx(startMin); + const height = (endMin - startMin) * this.pxPerMinute; + const isLight = this.isLightColor(pt.color); + + const el = document.createElement('div'); + el.className = 'schedule-block' + (isLight ? ' light-bg' : ''); + el.dataset.blockId = block.id; + el.style.top = top + 'px'; + el.style.height = Math.max(height, 20) + 'px'; + el.style.backgroundColor = pt.color; + + el.innerHTML = ` +
+
${this.escapeHtml(block.title)}
+
${block.start} – ${block.end}
+ ${block.responsible ? `
${this.escapeHtml(block.responsible)}
` : ''} +
+ `; + + // Click to edit + el.addEventListener('click', (e) => { + if (e.target.classList.contains('resize-handle')) return; + App.editBlock(block.id); + }); + + col.appendChild(el); + }); + + this.initInteract(); + }, + + initInteract() { + if (typeof interact === 'undefined') return; + + interact('.schedule-block').draggable({ + inertia: false, + modifiers: [ + interact.modifiers.snap({ + targets: [interact.snappers.grid({ + x: 1, y: this.PX_PER_SLOT + })], + range: Infinity, + relativePoint: { x: 0, y: 0 } + }), + interact.modifiers.restrict({ + restriction: 'parent', + elementRect: { top: 0, left: 0, bottom: 1, right: 1 } + }) + ], + listeners: { + start: (event) => { + event.target.style.zIndex = 50; + event.target.style.opacity = '0.9'; + }, + move: (event) => { + const target = event.target; + const y = (parseFloat(target.dataset.dragY) || 0) + event.dy; + target.dataset.dragY = y; + const currentTop = parseFloat(target.style.top) || 0; + const newTop = currentTop + event.dy; + target.style.top = newTop + 'px'; + }, + end: (event) => { + const target = event.target; + target.style.zIndex = ''; + target.style.opacity = ''; + target.dataset.dragY = 0; + + const blockId = target.dataset.blockId; + const newTop = parseFloat(target.style.top); + const height = parseFloat(target.style.height); + + const startMin = Canvas.snapMinutes(Canvas.pxToMinutes(newTop)); + const endMin = Canvas.snapMinutes(Canvas.pxToMinutes(newTop + height)); + + App.updateBlockTime(blockId, Canvas.formatTime(startMin), Canvas.formatTime(endMin)); + } + } + }).resizable({ + edges: { bottom: '.resize-handle' }, + modifiers: [ + interact.modifiers.snap({ + targets: [interact.snappers.grid({ + x: 1, y: this.PX_PER_SLOT + })], + range: Infinity, + relativePoint: { x: 0, y: 0 }, + offset: 'self' + }), + interact.modifiers.restrictSize({ + min: { width: 0, height: this.PX_PER_SLOT } + }) + ], + listeners: { + move: (event) => { + const target = event.target; + target.style.height = event.rect.height + 'px'; + }, + end: (event) => { + const target = event.target; + const blockId = target.dataset.blockId; + const top = parseFloat(target.style.top); + const height = parseFloat(target.style.height); + + const startMin = Canvas.snapMinutes(Canvas.pxToMinutes(top)); + const endMin = Canvas.snapMinutes(Canvas.pxToMinutes(top + height)); + + App.updateBlockTime(blockId, Canvas.formatTime(startMin), Canvas.formatTime(endMin)); + } + } + }); + }, + + darkenColor(hex) { + const h = hex.replace('#', ''); + const r = Math.max(0, parseInt(h.substring(0, 2), 16) - 30); + const g = Math.max(0, parseInt(h.substring(2, 4), 16) - 30); + const b = Math.max(0, parseInt(h.substring(4, 6), 16) - 30); + return `rgb(${r},${g},${b})`; + }, + + escapeHtml(str) { + if (!str) return ''; + const d = document.createElement('div'); + d.textContent = str; + return d.innerHTML; + } +}; diff --git a/app/static/js/export.js b/app/static/js/export.js index cddaaa6..2fc20a2 100644 --- a/app/static/js/export.js +++ b/app/static/js/export.js @@ -1,33 +1,31 @@ /** - * JSON import/export for Scenar Creator. + * JSON import/export for Scenar Creator v3. */ function exportJson() { - if (!window.currentDocument) { - showStatus('No document to export', 'error'); + const doc = App.getDocument(); + if (!doc) { + App.toast('Žádný scénář k exportu', 'error'); return; } - const json = JSON.stringify(window.currentDocument, null, 2); + const json = JSON.stringify(doc, null, 2); const blob = new Blob([json], { type: 'application/json' }); API.downloadBlob(blob, 'scenar_export.json'); + App.toast('JSON exportován', 'success'); } -function handleJsonImport(event) { - const file = event.target.files[0]; - if (!file) return; - +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('Invalid ScenarioDocument format'); + throw new Error('Neplatný formát ScenarioDocument'); } - window.currentDocument = doc; - showImportedDocument(doc); - showStatus('JSON imported successfully', 'success'); + App.loadDocument(doc); + App.toast('JSON importován', 'success'); } catch (err) { - showStatus('JSON import error: ' + err.message, 'error'); + App.toast('Chyba importu: ' + err.message, 'error'); } }; reader.readAsText(file); diff --git a/app/static/sample.json b/app/static/sample.json new file mode 100644 index 0000000..d38f82d --- /dev/null +++ b/app/static/sample.json @@ -0,0 +1,138 @@ +{ + "version": "1.0", + "event": { + "title": "Zimní výjezd oddílu", + "subtitle": "Víkendový zážitkový kurz pro mladé", + "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", + "color": "#22C55E" + } + ], + "blocks": [ + { + "id": "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, pokud počasí dovolí" + }, + { + "id": "b2", + "date": "2026-03-01", + "start": "08:30", + "end": "09:00", + "title": "Snídaně", + "type_id": "rest", + "responsible": "Kuchyň", + "notes": null + }, + { + "id": "b3", + "date": "2026-03-01", + "start": "09:00", + "end": "10:30", + "title": "Stopovací hra v lese", + "type_id": "main", + "responsible": "Lucka", + "notes": "Rozdělení do 4 skupin, mapky připraveny" + }, + { + "id": "b4", + "date": "2026-03-01", + "start": "10:30", + "end": "11:00", + "title": "Svačina a pauza", + "type_id": "rest", + "responsible": null, + "notes": null + }, + { + "id": "b5", + "date": "2026-03-01", + "start": "11:00", + "end": "12:30", + "title": "Stavba iglú / přístřešků", + "type_id": "main", + "responsible": "Petr", + "notes": "Soutěž o nejlepší stavbu, potřeba lopaty a plachty" + }, + { + "id": "b6", + "date": "2026-03-01", + "start": "12:30", + "end": "14:00", + "title": "Oběd a polední klid", + "type_id": "rest", + "responsible": "Kuchyň", + "notes": "Guláš + čaj" + }, + { + "id": "b7", + "date": "2026-03-01", + "start": "14:00", + "end": "16:00", + "title": "Orientační běh", + "type_id": "main", + "responsible": "Honza", + "notes": "Trasa 3 km, kontroly rozmístěny od rána" + }, + { + "id": "b8", + "date": "2026-03-01", + "start": "16:00", + "end": "16:30", + "title": "Svačina", + "type_id": "rest", + "responsible": null, + "notes": null + }, + { + "id": "b9", + "date": "2026-03-01", + "start": "16:30", + "end": "18:00", + "title": "Workshopy dle výběru", + "type_id": "morning", + "responsible": "Lucka + Petr", + "notes": "Uzlování / Orientace s buzolou / Kresba mapy" + }, + { + "id": "b10", + "date": "2026-03-01", + "start": "18:00", + "end": "19:00", + "title": "Večeře", + "type_id": "rest", + "responsible": "Kuchyň", + "notes": null + }, + { + "id": "b11", + "date": "2026-03-01", + "start": "19:00", + "end": "21:00", + "title": "Noční hra – Světlušky", + "type_id": "main", + "responsible": "Honza + Kuba", + "notes": "Potřeba: čelovky, reflexní pásky, vysílačky" + } + ] +} diff --git a/requirements.txt b/requirements.txt index 0a5a1d9..6090311 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,5 @@ fastapi>=0.115 uvicorn[standard]>=0.34 python-multipart>=0.0.20 reportlab>=4.0 -pandas>=2.1.3 -openpyxl>=3.1.5 pytest>=7.4.3 httpx>=0.27 diff --git a/templates/scenar_template.xlsx b/templates/scenar_template.xlsx deleted file mode 100755 index 68d30bc..0000000 Binary files a/templates/scenar_template.xlsx and /dev/null differ diff --git a/tests/test_api.py b/tests/test_api.py index da9f4c2..cf1ff85 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,10 +1,7 @@ """ -API endpoint tests using FastAPI TestClient. +API endpoint tests for Scenar Creator v3. """ -import io -import json -import pandas as pd import pytest from fastapi.testclient import TestClient @@ -16,11 +13,20 @@ def client(): return TestClient(app) -def make_excel_bytes(df: pd.DataFrame) -> bytes: - bio = io.BytesIO() - with pd.ExcelWriter(bio, engine='openpyxl') as writer: - df.to_excel(writer, index=False) - return bio.getvalue() +def make_valid_doc(): + return { + "version": "1.0", + "event": {"title": "Test Event"}, + "program_types": [{"id": "ws", "name": "Workshop", "color": "#FF0000"}], + "blocks": [{ + "id": "b1", + "date": "2026-03-01", + "start": "09:00", + "end": "10:00", + "title": "Opening", + "type_id": "ws" + }] + } def test_health(client): @@ -28,28 +34,18 @@ def test_health(client): assert r.status_code == 200 data = r.json() assert data["status"] == "ok" - assert data["version"] == "2.0.0" + assert data["version"] == "3.0.0" def test_root_returns_html(client): r = client.get("/") assert r.status_code == 200 assert "text/html" in r.headers["content-type"] - assert "Scenar Creator" in r.text + assert "Scen" in r.text and "Creator" in r.text def test_validate_valid(client): - doc = { - "event": {"title": "Test", "detail": "Detail"}, - "program_types": [{"code": "WS", "description": "Workshop", "color": "#FF0000"}], - "blocks": [{ - "datum": "2025-11-13", - "zacatek": "09:00:00", - "konec": "10:00:00", - "program": "Opening", - "typ": "WS" - }] - } + doc = make_valid_doc() r = client.post("/api/validate", json=doc) assert r.status_code == 200 data = r.json() @@ -58,17 +54,8 @@ def test_validate_valid(client): def test_validate_unknown_type(client): - doc = { - "event": {"title": "Test", "detail": "Detail"}, - "program_types": [{"code": "WS", "description": "Workshop", "color": "#FF0000"}], - "blocks": [{ - "datum": "2025-11-13", - "zacatek": "09:00:00", - "konec": "10:00:00", - "program": "Opening", - "typ": "UNKNOWN" - }] - } + 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() @@ -77,102 +64,81 @@ def test_validate_unknown_type(client): def test_validate_bad_time_order(client): - doc = { - "event": {"title": "Test", "detail": "Detail"}, - "program_types": [{"code": "WS", "description": "Workshop", "color": "#FF0000"}], - "blocks": [{ - "datum": "2025-11-13", - "zacatek": "10:00:00", - "konec": "09:00:00", - "program": "Bad", - "typ": "WS" - }] - } + doc = make_valid_doc() + doc["blocks"][0]["start"] = "10:00" + doc["blocks"][0]["end"] = "09:00" + r = client.post("/api/validate", json=doc) + assert r.status_code == 200 + data = r.json() + assert data["valid"] is False + assert any("start time" 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_import_excel(client): - df = pd.DataFrame({ - 'Datum': [pd.Timestamp('2025-11-13').date()], - 'Zacatek': ['09:00'], - 'Konec': ['10:00'], - 'Program': ['Test Program'], - 'Typ': ['WORKSHOP'], - 'Garant': ['John'], - 'Poznamka': ['Note'] - }) - - content = make_excel_bytes(df) - r = client.post( - "/api/import-excel", - files={"file": ("test.xlsx", content, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")}, - data={"title": "Imported Event", "detail": "From Excel"} - ) - +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["success"] is True - assert data["document"] is not None - assert data["document"]["event"]["title"] == "Imported Event" - assert len(data["document"]["blocks"]) == 1 + assert data["valid"] is False -def test_generate_excel(client): - doc = { - "event": {"title": "Test Event", "detail": "Test Detail"}, - "program_types": [{"code": "WS", "description": "Workshop", "color": "#0070C0"}], - "blocks": [{ - "datum": "2025-11-13", - "zacatek": "09:00:00", - "konec": "10:00:00", - "program": "Opening", - "typ": "WS", - "garant": "John", - "poznamka": "Note" - }] - } - r = client.post("/api/generate-excel", json=doc) +def test_sample_endpoint(client): + r = client.get("/api/sample") assert r.status_code == 200 - assert "spreadsheetml" in r.headers["content-type"] - assert len(r.content) > 0 + data = r.json() + assert data["version"] == "1.0" + assert data["event"]["title"] == "Zimní výjezd oddílu" + assert len(data["program_types"]) == 3 + assert len(data["blocks"]) >= 8 -def test_generate_excel_no_blocks(client): - doc = { - "event": {"title": "Test", "detail": "Detail"}, - "program_types": [{"code": "WS", "description": "Workshop", "color": "#FF0000"}], - "blocks": [] - } - r = client.post("/api/generate-excel", json=doc) +def test_sample_blocks_valid(client): + r = client.get("/api/sample") + data = r.json() + type_ids = {pt["id"] for pt in data["program_types"]} + for block in data["blocks"]: + assert block["type_id"] in type_ids + assert block["start"] < block["end"] + + +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_no_blocks(client): + doc = make_valid_doc() + doc["blocks"] = [] + r = client.post("/api/generate-pdf", json=doc) assert r.status_code == 422 -def test_export_json(client): - doc = { - "event": {"title": "Test", "detail": "Detail"}, - "program_types": [{"code": "WS", "description": "Workshop", "color": "#FF0000"}], - "blocks": [{ - "datum": "2025-11-13", - "zacatek": "09:00:00", - "konec": "10:00:00", - "program": "Opening", - "typ": "WS" - }] - } - r = client.post("/api/export-json", json=doc) +def test_generate_pdf_multiday(client): + doc = make_valid_doc() + doc["blocks"].append({ + "id": "b2", + "date": "2026-03-02", + "start": "14:00", + "end": "15:00", + "title": "Day 2 Session", + "type_id": "ws" + }) + r = client.post("/api/generate-pdf", json=doc) assert r.status_code == 200 - data = r.json() - assert data["event"]["title"] == "Test" - assert len(data["blocks"]) == 1 - - -def test_template_download(client): - r = client.get("/api/template") - # Template might not exist in test env, but endpoint should work - assert r.status_code in [200, 404] + assert r.content[:5] == b'%PDF-' def test_swagger_docs(client): diff --git a/tests/test_core.py b/tests/test_core.py index a1de2f8..d05df87 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,532 +1,97 @@ """ -Core business logic tests — adapted from original test_read_excel.py and test_inline_builder.py. -Tests the refactored app.core modules. +Core logic tests for Scenar Creator v3. +Tests models, validation, and document structure. """ -import io -import pandas as pd import pytest -from datetime import date, time +from pydantic import ValidationError as PydanticValidationError -from app.core import ( - read_excel, create_timetable, get_program_types, ScenarsError, - parse_inline_schedule, parse_inline_types, ValidationError, - validate_inputs, normalize_time, -) +from app.models.event import Block, ProgramType, EventInfo, ScenarioDocument +from app.core.validator import ScenarsError, ValidationError -def make_excel_bytes(df: pd.DataFrame) -> bytes: - bio = io.BytesIO() - with pd.ExcelWriter(bio, engine='openpyxl') as writer: - df.to_excel(writer, index=False) - return bio.getvalue() +# --- 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 + + +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_validate_inputs_valid(): - validate_inputs("Title", "Detail", 100) +def test_scenars_error_hierarchy(): + assert issubclass(ValidationError, ScenarsError) -def test_validate_inputs_empty_title(): - with pytest.raises(ValidationError): - validate_inputs("", "Detail", 100) - - -def test_validate_inputs_long_title(): - with pytest.raises(ValidationError): - validate_inputs("x" * 201, "Detail", 100) - - -def test_validate_inputs_file_too_large(): - with pytest.raises(ValidationError): - validate_inputs("Title", "Detail", 11 * 1024 * 1024) - - -def test_normalize_time_hhmm(): - t = normalize_time("09:00") - assert t == time(9, 0) - - -def test_normalize_time_hhmmss(): - t = normalize_time("09:00:00") - assert t == time(9, 0) - - -def test_normalize_time_invalid(): - assert normalize_time("invalid") is None - - -# --- Excel reader tests --- - -def test_read_excel_happy_path(): - df = pd.DataFrame({ - 'Datum': [pd.Timestamp('2025-11-13').date()], - 'Zacatek': ['09:00'], - 'Konec': ['10:00'], - 'Program': ['Test program'], - 'Typ': ['WORKSHOP'], - 'Garant': ['Garant Name'], - 'Poznamka': ['Pozn'] - }) - - content = make_excel_bytes(df) - valid, errors = read_excel(content) - - assert isinstance(valid, pd.DataFrame) - assert len(errors) == 0 - assert len(valid) == 1 - assert valid.iloc[0]['Program'] == 'Test program' - - -def test_read_excel_invalid_time(): - df = pd.DataFrame({ - 'Datum': [pd.Timestamp('2025-11-13').date()], - 'Zacatek': ['not-a-time'], - 'Konec': ['10:00'], - 'Program': ['Bad Time'], - 'Typ': ['LECTURE'], - 'Garant': [None], - 'Poznamka': [None] - }) - - content = make_excel_bytes(df) - valid, errors = read_excel(content) - - assert isinstance(errors, list) - assert len(errors) >= 1 - - -def test_get_program_types(): - form_data = { - 'type_code_0': 'WORKSHOP', - 'type_code_1': 'LECTURE', - 'desc_0': 'Workshop description', - 'desc_1': 'Lecture description', - 'color_0': '#FF0000', - 'color_1': '#00FF00', - } - - descriptions, colors = get_program_types(form_data) - - assert len(descriptions) == 2 - assert descriptions['WORKSHOP'] == 'Workshop description' - assert descriptions['LECTURE'] == 'Lecture description' - assert colors['WORKSHOP'] == 'FFFF0000' - assert colors['LECTURE'] == 'FF00FF00' - - -# --- Timetable tests --- - -def test_create_timetable(): - df = pd.DataFrame({ - 'Datum': [pd.Timestamp('2025-11-13').date()], - 'Zacatek': [time(9, 0)], - 'Konec': [time(10, 0)], - 'Program': ['Test Program'], - 'Typ': ['WORKSHOP'], - 'Garant': ['John Doe'], - 'Poznamka': ['Test note'], - }) - - program_descriptions = {'WORKSHOP': 'Workshop Type'} - program_colors = {'WORKSHOP': 'FF0070C0'} - - wb = create_timetable( - df, title="Test Timetable", detail="Test Detail", - program_descriptions=program_descriptions, - program_colors=program_colors - ) - - assert wb is not None - assert len(wb.sheetnames) > 0 - ws = wb.active - assert ws['A1'].value == "Test Timetable" - assert ws['A2'].value == "Test Detail" - - -def test_create_timetable_with_color_dict(): - df = pd.DataFrame({ - 'Datum': [pd.Timestamp('2025-11-13').date()], - 'Zacatek': [time(9, 0)], - 'Konec': [time(10, 0)], - 'Program': ['Lecture 101'], - 'Typ': ['LECTURE'], - 'Garant': ['Dr. Smith'], - 'Poznamka': [None], - }) - - program_descriptions = {'LECTURE': 'Standard Lecture'} - program_colors = {'LECTURE': 'FFFF6600'} - - wb = create_timetable( - df, title="Advanced Timetable", detail="With color dict", - program_descriptions=program_descriptions, - program_colors=program_colors - ) - - assert wb is not None - ws = wb.active - assert ws['A1'].value == "Advanced Timetable" - - -# --- Inline schedule tests --- - -def test_parse_inline_schedule(): - form_data = { - 'datum_0': '2025-11-13', - 'zacatek_0': '09:00', - 'konec_0': '10:00', - 'program_0': 'Test Program', - 'typ_0': 'WORKSHOP', - 'garant_0': 'John Doe', - 'poznamka_0': 'Test note', - 'datum_1': '2025-11-13', - 'zacatek_1': '10:30', - 'konec_1': '11:30', - 'program_1': 'Another Program', - 'typ_1': 'LECTURE', - 'garant_1': 'Jane Smith', - 'poznamka_1': '', - } - - df = parse_inline_schedule(form_data) - - assert isinstance(df, pd.DataFrame) - assert len(df) == 2 - assert df.iloc[0]['Program'] == 'Test Program' - assert df.iloc[0]['Typ'] == 'WORKSHOP' - assert df.iloc[1]['Program'] == 'Another Program' - assert df.iloc[1]['Typ'] == 'LECTURE' - - -def test_parse_inline_schedule_missing_required(): - form_data = { - 'datum_0': '2025-11-13', - 'zacatek_0': '09:00', - } - - with pytest.raises(ValidationError): - parse_inline_schedule(form_data) - - -def test_parse_inline_types(): - form_data = { - 'type_name_0': 'WORKSHOP', - 'type_desc_0': 'Workshop Type', - 'type_color_0': '#0070C0', - 'type_name_1': 'LECTURE', - 'type_desc_1': 'Lecture Type', - 'type_color_1': '#FF6600', - } - - descriptions, colors = parse_inline_types(form_data) - - assert len(descriptions) == 2 - assert descriptions['WORKSHOP'] == 'Workshop Type' - assert descriptions['LECTURE'] == 'Lecture Type' - assert colors['WORKSHOP'] == 'FF0070C0' - assert colors['LECTURE'] == 'FFFF6600' - - -# --- Integration tests --- - -def test_inline_workflow_integration(): - schedule_form = { - 'datum_0': '2025-11-13', - 'zacatek_0': '09:00', - 'konec_0': '10:00', - 'program_0': 'Opening', - 'typ_0': 'KEYNOTE', - 'garant_0': 'Dr. Smith', - 'poznamka_0': 'Start of event', - } - - types_form = { - 'type_name_0': 'KEYNOTE', - 'type_desc_0': 'Keynote Speech', - 'type_color_0': '#FF0000', - } - - form_data = {**schedule_form, **types_form} - - df = parse_inline_schedule(form_data) - descriptions, colors = parse_inline_types(form_data) - - wb = create_timetable( - df, title="Integration Test Event", detail="Testing inline workflow", - program_descriptions=descriptions, program_colors=colors - ) - - assert wb is not None - ws = wb.active - assert ws['A1'].value == "Integration Test Event" - assert ws['A2'].value == "Testing inline workflow" - - -def test_excel_import_to_step2_workflow(): - import base64 - - df = pd.DataFrame({ - 'Datum': [pd.Timestamp('2025-11-13').date()], - 'Zacatek': ['09:00'], - 'Konec': ['10:00'], - 'Program': ['Opening Keynote'], - 'Typ': ['KEYNOTE'], - 'Garant': ['John Smith'], - 'Poznamka': ['Welcome speech'] - }) - - file_content = make_excel_bytes(df) - - valid_data, errors = read_excel(file_content) - assert len(errors) == 0 - assert len(valid_data) == 1 - - program_types = sorted([str(t).strip() for t in valid_data["Typ"].dropna().unique()]) - assert program_types == ['KEYNOTE'] - - file_content_base64 = base64.b64encode(file_content).decode('utf-8') - - form_data = { - 'title': 'Test Event', - 'detail': 'Test Detail', - 'file_content_base64': file_content_base64, - 'type_code_0': 'KEYNOTE', - 'desc_0': 'Main keynote presentation', - 'color_0': '#FF0000', - 'step': '3' - } - - descriptions, colors = get_program_types(form_data) - - assert descriptions['KEYNOTE'] == 'Main keynote presentation' - assert colors['KEYNOTE'] == 'FFFF0000' - - file_content_decoded = base64.b64decode(form_data['file_content_base64']) - data, _ = read_excel(file_content_decoded) - - wb = create_timetable( - data, title=form_data['title'], detail=form_data['detail'], - program_descriptions=descriptions, program_colors=colors - ) - - assert wb is not None - ws = wb.active - assert ws['A1'].value == 'Test Event' - assert ws['A2'].value == 'Test Detail' - - -def test_excel_import_to_inline_editor_workflow(): - import base64 - - df = pd.DataFrame({ - 'Datum': [pd.Timestamp('2025-11-13').date(), pd.Timestamp('2025-11-14').date()], - 'Zacatek': ['09:00', '14:00'], - 'Konec': ['10:00', '15:30'], - 'Program': ['Morning Session', 'Afternoon Workshop'], - 'Typ': ['KEYNOTE', 'WORKSHOP'], - 'Garant': ['Alice', 'Bob'], - 'Poznamka': ['', 'Hands-on'] - }) - - file_content = make_excel_bytes(df) - - valid_data, errors = read_excel(file_content) - assert len(errors) == 0 - assert len(valid_data) == 2 - - file_content_base64 = base64.b64encode(file_content).decode('utf-8') - - file_content_decoded = base64.b64decode(file_content_base64) - data_in_editor, _ = read_excel(file_content_decoded) - - assert len(data_in_editor) == 2 - assert data_in_editor.iloc[0]['Program'] == 'Morning Session' - assert data_in_editor.iloc[1]['Program'] == 'Afternoon Workshop' - - program_types = sorted([str(t).strip() for t in data_in_editor["Typ"].dropna().unique()]) - assert set(program_types) == {'KEYNOTE', 'WORKSHOP'} - - -# --- Inline builder tests (from test_inline_builder.py) --- - -def test_inline_builder_valid_form(): - form_data = { - 'title': 'Test Conference', - 'detail': 'Testing inline builder', - 'step': 'builder', - 'datum_0': '2025-11-13', - 'zacatek_0': '09:00', - 'konec_0': '10:00', - 'program_0': 'Opening Keynote', - 'typ_0': 'KEYNOTE', - 'garant_0': 'Dr. Smith', - 'poznamka_0': 'Welcome speech', - 'datum_1': '2025-11-13', - 'zacatek_1': '10:30', - 'konec_1': '11:30', - 'program_1': 'Workshop: Python', - 'typ_1': 'WORKSHOP', - 'garant_1': 'Jane Doe', - 'poznamka_1': 'Hands-on coding', - 'type_name_0': 'KEYNOTE', - 'type_desc_0': 'Main Address', - 'type_color_0': '#FF0000', - 'type_name_1': 'WORKSHOP', - 'type_desc_1': 'Interactive Session', - 'type_color_1': '#0070C0', - } - - schedule = parse_inline_schedule(form_data) - assert len(schedule) == 2 - assert schedule.iloc[0]['Program'] == 'Opening Keynote' - - descriptions, colors = parse_inline_types(form_data) - assert len(descriptions) == 2 - assert descriptions['KEYNOTE'] == 'Main Address' - assert colors['KEYNOTE'] == 'FFFF0000' - - wb = create_timetable( - schedule, title=form_data['title'], detail=form_data['detail'], - program_descriptions=descriptions, program_colors=colors - ) - - assert wb is not None - ws = wb.active - assert ws['A1'].value == 'Test Conference' - - -def test_inline_builder_missing_type_definition(): - form_data = { - 'datum_0': '2025-11-13', - 'zacatek_0': '09:00', - 'konec_0': '10:00', - 'program_0': 'Unknown Program', - 'typ_0': 'UNKNOWN_TYPE', - 'garant_0': 'Someone', - 'poznamka_0': '', - 'type_name_0': 'LECTURE', - 'type_desc_0': 'Standard Lecture', - 'type_color_0': '#3498db', - } - - schedule = parse_inline_schedule(form_data) - descriptions, colors = parse_inline_types(form_data) - - with pytest.raises(Exception): - create_timetable(schedule, 'Bad', 'Bad', descriptions, colors) - - -def test_inline_builder_empty_type_definition(): - form_data = { - 'type_name_0': '', - 'type_desc_0': 'Empty type', - 'type_color_0': '#FF0000', - 'type_name_1': 'WORKSHOP', - 'type_desc_1': 'Workshop Type', - 'type_color_1': '#0070C0', - } - - descriptions, colors = parse_inline_types(form_data) - - assert 'WORKSHOP' in descriptions - assert '' not in descriptions - - -def test_inline_builder_overlapping_times(): - form_data = { - 'datum_0': '2025-11-13', - 'zacatek_0': '09:00', - 'konec_0': '10:00', - 'program_0': 'Program A', - 'typ_0': 'WORKSHOP', - 'garant_0': 'John', - 'poznamka_0': '', - 'datum_1': '2025-11-13', - 'zacatek_1': '09:30', - 'konec_1': '10:30', - 'program_1': 'Program B', - 'typ_1': 'WORKSHOP', - 'garant_1': 'Jane', - 'poznamka_1': '', - 'type_name_0': 'WORKSHOP', - 'type_desc_0': 'Workshop', - 'type_color_0': '#0070C0', - } - - schedule = parse_inline_schedule(form_data) - descriptions, colors = parse_inline_types(form_data) - - assert len(schedule) == 2 - - wb = create_timetable(schedule, 'Conf', 'Test', descriptions, colors) - assert wb is not None - - -def test_inline_builder_multiday(): - form_data = { - 'datum_0': '2025-11-13', - 'zacatek_0': '09:00', - 'konec_0': '10:00', - 'program_0': 'Day 1 Opening', - 'typ_0': 'KEYNOTE', - 'garant_0': 'Dr. A', - 'poznamka_0': '', - 'datum_1': '2025-11-14', - 'zacatek_1': '09:00', - 'konec_1': '10:00', - 'program_1': 'Day 2 Opening', - 'typ_1': 'KEYNOTE', - 'garant_1': 'Dr. B', - 'poznamka_1': '', - 'type_name_0': 'KEYNOTE', - 'type_desc_0': 'Keynote Speech', - 'type_color_0': '#FF6600', - } - - schedule = parse_inline_schedule(form_data) - descriptions, colors = parse_inline_types(form_data) - - wb = create_timetable(schedule, 'Multi-day', 'Test', descriptions, colors) - - assert wb is not None - ws = wb.active - assert ws['A1'].value == 'Multi-day' - - -def test_inline_builder_validation_errors(): - form_data_missing = { - 'datum_0': '2025-11-13', - 'garant_0': 'John', - } - - with pytest.raises(ValidationError): - parse_inline_schedule(form_data_missing) - - -def test_inline_builder_with_empty_rows(): - form_data = { - 'datum_0': '2025-11-13', - 'zacatek_0': '09:00', - 'konec_0': '10:00', - 'program_0': 'Program 1', - 'typ_0': 'WORKSHOP', - 'garant_0': 'John', - 'poznamka_0': '', - 'datum_1': '', - 'zacatek_1': '', - 'konec_1': '', - 'program_1': '', - 'typ_1': '', - 'garant_1': '', - 'poznamka_1': '', - 'type_name_0': 'WORKSHOP', - 'type_desc_0': 'Workshop', - 'type_color_0': '#0070C0', - } - - schedule = parse_inline_schedule(form_data) - - assert len(schedule) == 1 - assert schedule.iloc[0]['Program'] == 'Program 1' +def test_validation_error_message(): + err = ValidationError("test error") + assert str(err) == "test error" diff --git a/tests/test_docker_integration.py b/tests/test_docker_integration.py deleted file mode 100644 index f835751..0000000 --- a/tests/test_docker_integration.py +++ /dev/null @@ -1,61 +0,0 @@ -import os -import shutil -import subprocess -import time -import urllib.request - -import pytest - - -def podman_available(): - return shutil.which("podman") is not None - - -def wait_for_http(url, timeout=30): - end = time.time() + timeout - last_exc = None - while time.time() < end: - try: - with urllib.request.urlopen(url, timeout=3) as r: - body = r.read().decode('utf-8', errors='ignore') - return r.getcode(), body - except Exception as e: - last_exc = e - time.sleep(0.5) - raise RuntimeError(f"HTTP check failed after {timeout}s: {last_exc}") - - -@pytest.mark.skipif(not podman_available(), reason="Podman is not available on this runner") -@pytest.mark.integration -def test_build_run_and_cleanup_podman(): - image_tag = "scenar-creator:latest" - container_name = "scenar-creator-test" - port = int(os.environ.get('SCENAR_TEST_PORT', '8080')) - - # Ensure podman machine is running (macOS/Windows) - if not subprocess.run(["podman", "machine", "info"], capture_output=True).returncode == 0: - print("Starting podman machine...") - subprocess.run(["podman", "machine", "start"], check=True) - time.sleep(2) - - # Build image - subprocess.run(["podman", "build", "-t", image_tag, "."], check=True) - - # Ensure no leftover container - subprocess.run(["podman", "rm", "-f", container_name], check=False) - - try: - # Run container - subprocess.run([ - "podman", "run", "-d", "--name", container_name, "-p", f"{port}:8080", image_tag - ], check=True) - - # Wait for HTTP and verify content - code, body = wait_for_http(f"http://127.0.0.1:{port}/") - assert code == 200 - assert "Vytvoření Scénáře" in body or "Scenar" in body or "Vytvoření" in body - - finally: - # Cleanup container and image - subprocess.run(["podman", "rm", "-f", container_name], check=False) - subprocess.run(["podman", "rmi", image_tag], check=False) diff --git a/tests/test_http_inline.py b/tests/test_http_inline.py deleted file mode 100644 index 1dcdd00..0000000 --- a/tests/test_http_inline.py +++ /dev/null @@ -1,138 +0,0 @@ -""" -HTTP/CGI integration tests for inline builder workflow. -Tests the real form submission through the CGI endpoint. -""" - -import pytest -import urllib.parse -import subprocess -import json -from io import BytesIO - - -def send_cgi_request(post_data=None, get_params=None): - """ - Send HTTP request to local CGI server and return response. - Assumes server is running on localhost:8000 - """ - import urllib.request - import urllib.error - - url = "http://localhost:8000/cgi-bin/scenar.py" - - if get_params: - url += "?" + urllib.parse.urlencode(get_params) - - if post_data: - data = urllib.parse.urlencode(post_data).encode('utf-8') - else: - data = None - - try: - req = urllib.request.Request(url, data=data, method='POST' if data else 'GET') - with urllib.request.urlopen(req, timeout=10) as response: - return response.read().decode('utf-8'), response.status - except Exception as e: - return str(e), None - - -@pytest.mark.integration -def test_inline_builder_form_submission_valid(): - """Test inline builder with valid form data.""" - form_data = { - 'title': 'Test Event', - 'detail': 'Test Detail', - 'step': 'builder', - - # Schedule row 0 - 'datum_0': '2025-11-13', - 'zacatek_0': '09:00', - 'konec_0': '10:00', - 'program_0': 'Opening', - 'typ_0': 'KEYNOTE', - 'garant_0': 'John Smith', - 'poznamka_0': 'Welcome', - - # Type definition 0 - 'type_name_0': 'KEYNOTE', - 'type_desc_0': 'Main keynote', - 'type_color_0': '#FF0000', - } - - response, status = send_cgi_request(post_data=form_data) - - # Should get 200 OK - assert status == 200, f"Expected 200, got {status}" - - # Response should contain success message - assert '✅ Scénář úspěšně vygenerován' in response or 'Stáhnout scénář' in response, \ - f"Expected success message in response, got: {response[:500]}" - - # Should have download link - assert '/tmp/' in response, "Expected download link in response" - - -@pytest.mark.integration -def test_inline_builder_missing_type(): - """Test that missing type definition is rejected.""" - form_data = { - 'title': 'Test Event', - 'detail': 'Test Detail', - 'step': 'builder', - - # Schedule with type WORKSHOP - 'datum_0': '2025-11-13', - 'zacatek_0': '09:00', - 'konec_0': '10:00', - 'program_0': 'Workshop', - 'typ_0': 'WORKSHOP', # This type is not defined! - 'garant_0': 'John', - 'poznamka_0': '', - - # Only KEYNOTE defined - 'type_name_0': 'KEYNOTE', - 'type_desc_0': 'Keynote', - 'type_color_0': '#FF0000', - } - - response, status = send_cgi_request(post_data=form_data) - - # Should get error response - assert status == 200 # CGI returns 200 with error content - assert 'Chyba' in response or 'Missing type' in response, \ - f"Expected error message, got: {response[:500]}" - - -@pytest.mark.integration -def test_home_page_load(): - """Test that home page loads without errors.""" - response, status = send_cgi_request() - - assert status == 200 - assert '' in response - assert 'Scenar Creator' in response - assert 'Importovat Excel' in response # Tab 1 - assert 'Vytvořit inline' in response # Tab 2 - - -@pytest.mark.integration -def test_inline_editor_tabs_present(): - """Test that inline editor form elements are present.""" - response, status = send_cgi_request() - - assert status == 200 - # Check for key form elements - assert 'id="builderForm"' in response - assert 'id="scheduleTable"' in response - assert 'id="typesContainer"' in response - assert 'id="availableTypes"' in response # datalist - - # Check for JavaScript functions - assert 'function addScheduleRow' in response - assert 'function removeScheduleRow' in response - assert 'function addTypeRow' in response - assert 'function removeTypeRow' in response - - -if __name__ == '__main__': - pytest.main([__file__, '-v']) diff --git a/tests/test_inline_builder.py b/tests/test_inline_builder.py deleted file mode 100644 index 7206a08..0000000 --- a/tests/test_inline_builder.py +++ /dev/null @@ -1,261 +0,0 @@ -""" -End-to-end tests for inline builder (without Excel upload). -Tests the full form submission flow from HTML form to timetable generation. -""" - -import pytest -from datetime import date, time -import pandas as pd -from scenar.core import parse_inline_schedule, parse_inline_types, create_timetable, ValidationError - - -def test_inline_builder_valid_form(): - """Test inline builder with valid schedule and types.""" - # Simulate form data from HTML - form_data = { - # Metadata - 'title': 'Test Conference', - 'detail': 'Testing inline builder', - 'step': 'builder', - - # Schedule rows (2 rows) - 'datum_0': '2025-11-13', - 'zacatek_0': '09:00', - 'konec_0': '10:00', - 'program_0': 'Opening Keynote', - 'typ_0': 'KEYNOTE', - 'garant_0': 'Dr. Smith', - 'poznamka_0': 'Welcome speech', - - 'datum_1': '2025-11-13', - 'zacatek_1': '10:30', - 'konec_1': '11:30', - 'program_1': 'Workshop: Python', - 'typ_1': 'WORKSHOP', - 'garant_1': 'Jane Doe', - 'poznamka_1': 'Hands-on coding', - - # Type definitions - 'type_name_0': 'KEYNOTE', - 'type_desc_0': 'Main Address', - 'type_color_0': '#FF0000', - - 'type_name_1': 'WORKSHOP', - 'type_desc_1': 'Interactive Session', - 'type_color_1': '#0070C0', - } - - # Parse schedule - schedule = parse_inline_schedule(form_data) - assert len(schedule) == 2 - assert schedule.iloc[0]['Program'] == 'Opening Keynote' - assert schedule.iloc[0]['Typ'] == 'KEYNOTE' - - # Parse types - descriptions, colors = parse_inline_types(form_data) - assert len(descriptions) == 2 - assert descriptions['KEYNOTE'] == 'Main Address' - assert colors['KEYNOTE'] == 'FFFF0000' - - # Generate timetable - wb = create_timetable( - schedule, - title=form_data['title'], - detail=form_data['detail'], - program_descriptions=descriptions, - program_colors=colors - ) - - assert wb is not None - ws = wb.active - assert ws['A1'].value == 'Test Conference' - assert ws['A2'].value == 'Testing inline builder' - - -def test_inline_builder_missing_type_definition(): - """Test that undefined type in schedule raises error.""" - form_data = { - 'title': 'Bad Conference', - 'detail': 'Missing type definition', - - # Schedule with UNKNOWN type - 'datum_0': '2025-11-13', - 'zacatek_0': '09:00', - 'konec_0': '10:00', - 'program_0': 'Unknown Program', - 'typ_0': 'UNKNOWN_TYPE', # This type is not defined below! - 'garant_0': 'Someone', - 'poznamka_0': '', - - # Only define LECTURE, not UNKNOWN_TYPE - 'type_name_0': 'LECTURE', - 'type_desc_0': 'Standard Lecture', - 'type_color_0': '#3498db', - } - - schedule = parse_inline_schedule(form_data) - descriptions, colors = parse_inline_types(form_data) - - # Should fail because UNKNOWN_TYPE is not in colors dict - with pytest.raises(Exception): # ScenarsError - create_timetable(schedule, 'Bad', 'Bad', descriptions, colors) - - -def test_inline_builder_empty_type_definition(): - """Test that empty type definitions are skipped.""" - form_data = { - 'title': 'Conference', - 'detail': 'With empty types', - - 'datum_0': '2025-11-13', - 'zacatek_0': '09:00', - 'konec_0': '10:00', - 'program_0': 'Program 1', - 'typ_0': 'WORKSHOP', - 'garant_0': 'John', - 'poznamka_0': '', - - # One empty type definition (should be skipped) - 'type_name_0': '', - 'type_desc_0': 'Empty type', - 'type_color_0': '#FF0000', - - # One valid type - 'type_name_1': 'WORKSHOP', - 'type_desc_1': 'Workshop Type', - 'type_color_1': '#0070C0', - } - - descriptions, colors = parse_inline_types(form_data) - - # Empty type should be skipped - assert 'WORKSHOP' in descriptions - assert '' not in descriptions - - -def test_inline_builder_overlapping_times(): - """Test schedule with overlapping time slots.""" - form_data = { - 'title': 'Conference', - 'detail': 'Overlapping schedule', - - # Two programs at same time - 'datum_0': '2025-11-13', - 'zacatek_0': '09:00', - 'konec_0': '10:00', - 'program_0': 'Program A', - 'typ_0': 'WORKSHOP', - 'garant_0': 'John', - 'poznamka_0': '', - - 'datum_1': '2025-11-13', - 'zacatek_1': '09:30', # Overlaps with Program A - 'konec_1': '10:30', - 'program_1': 'Program B', - 'typ_1': 'WORKSHOP', - 'garant_1': 'Jane', - 'poznamka_1': '', - - 'type_name_0': 'WORKSHOP', - 'type_desc_0': 'Workshop', - 'type_color_0': '#0070C0', - } - - schedule = parse_inline_schedule(form_data) - descriptions, colors = parse_inline_types(form_data) - - # Should allow overlapping times — timetable will show them - assert len(schedule) == 2 - - # Generate timetable (may have rendering issues but should complete) - wb = create_timetable(schedule, 'Conf', 'Test', descriptions, colors) - assert wb is not None - - -def test_inline_builder_multiday(): - """Test schedule spanning multiple days.""" - form_data = { - 'title': 'Multi-day Conference', - 'detail': 'Two-day event', - - # Day 1 - 'datum_0': '2025-11-13', - 'zacatek_0': '09:00', - 'konec_0': '10:00', - 'program_0': 'Day 1 Opening', - 'typ_0': 'KEYNOTE', - 'garant_0': 'Dr. A', - 'poznamka_0': '', - - # Day 2 - 'datum_1': '2025-11-14', - 'zacatek_1': '09:00', - 'konec_1': '10:00', - 'program_1': 'Day 2 Opening', - 'typ_1': 'KEYNOTE', - 'garant_1': 'Dr. B', - 'poznamka_1': '', - - 'type_name_0': 'KEYNOTE', - 'type_desc_0': 'Keynote Speech', - 'type_color_0': '#FF6600', - } - - schedule = parse_inline_schedule(form_data) - descriptions, colors = parse_inline_types(form_data) - - wb = create_timetable(schedule, 'Multi-day', 'Test', descriptions, colors) - - assert wb is not None - ws = wb.active - # Should have multiple date rows - assert ws['A1'].value == 'Multi-day' - - -def test_inline_builder_validation_errors(): - """Test validation of inline form data.""" - # Missing required field - form_data_missing = { - 'datum_0': '2025-11-13', - # Missing zacatek_0, konec_0, program_0, typ_0 - 'garant_0': 'John', - } - - with pytest.raises(ValidationError): - parse_inline_schedule(form_data_missing) - - -def test_inline_builder_with_empty_rows(): - """Test that empty schedule rows are skipped.""" - form_data = { - 'title': 'Test', - 'detail': 'With empty rows', - - # Valid row - 'datum_0': '2025-11-13', - 'zacatek_0': '09:00', - 'konec_0': '10:00', - 'program_0': 'Program 1', - 'typ_0': 'WORKSHOP', - 'garant_0': 'John', - 'poznamka_0': '', - - # Empty row (all fields missing) - 'datum_1': '', - 'zacatek_1': '', - 'konec_1': '', - 'program_1': '', - 'typ_1': '', - 'garant_1': '', - 'poznamka_1': '', - - 'type_name_0': 'WORKSHOP', - 'type_desc_0': 'Workshop', - 'type_color_0': '#0070C0', - } - - schedule = parse_inline_schedule(form_data) - - # Should only have 1 row (empty row skipped) - assert len(schedule) == 1 - assert schedule.iloc[0]['Program'] == 'Program 1' diff --git a/tests/test_pdf.py b/tests/test_pdf.py index 193669a..6919eb1 100644 --- a/tests/test_pdf.py +++ b/tests/test_pdf.py @@ -1,100 +1,95 @@ """ -PDF generation tests. +PDF generation tests for Scenar Creator v3. """ -import pandas as pd import pytest -from datetime import time -from fastapi.testclient import TestClient - from app.core.pdf_generator import generate_pdf from app.core.validator import ScenarsError -from app.main import app +from app.models.event import ScenarioDocument, Block, ProgramType, EventInfo -@pytest.fixture -def client(): - return TestClient(app) +def make_doc(**kwargs): + defaults = { + "version": "1.0", + "event": EventInfo(title="Test PDF", subtitle="Subtitle"), + "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(): - df = pd.DataFrame({ - 'Datum': [pd.Timestamp('2025-11-13').date()], - 'Zacatek': [time(9, 0)], - 'Konec': [time(10, 0)], - 'Program': ['Test Program'], - 'Typ': ['WORKSHOP'], - 'Garant': ['John Doe'], - 'Poznamka': ['Test note'], - }) - - descriptions = {'WORKSHOP': 'Workshop Type'} - colors = {'WORKSHOP': 'FF0070C0'} - - pdf_bytes = generate_pdf(df, "Test PDF", "PDF Detail", descriptions, colors) - + 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_multiday(): - df = pd.DataFrame({ - 'Datum': [pd.Timestamp('2025-11-13').date(), pd.Timestamp('2025-11-14').date()], - 'Zacatek': [time(9, 0), time(14, 0)], - 'Konec': [time(10, 0), time(15, 0)], - 'Program': ['Day 1', 'Day 2'], - 'Typ': ['KEYNOTE', 'WORKSHOP'], - 'Garant': ['Alice', 'Bob'], - 'Poznamka': [None, 'Hands-on'], - }) - - descriptions = {'KEYNOTE': 'Keynote', 'WORKSHOP': 'Workshop'} - colors = {'KEYNOTE': 'FFFF0000', 'WORKSHOP': 'FF0070C0'} - - pdf_bytes = generate_pdf(df, "Multi-day", "Two days", descriptions, colors) - + doc = make_doc( + 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_empty_data(): - df = pd.DataFrame(columns=['Datum', 'Zacatek', 'Konec', 'Program', 'Typ', 'Garant', 'Poznamka']) - +def test_generate_pdf_empty_blocks(): + doc = make_doc(blocks=[]) with pytest.raises(ScenarsError): - generate_pdf(df, "Empty", "Detail", {}, {}) + generate_pdf(doc) def test_generate_pdf_missing_type(): - df = pd.DataFrame({ - 'Datum': [pd.Timestamp('2025-11-13').date()], - 'Zacatek': [time(9, 0)], - 'Konec': [time(10, 0)], - 'Program': ['Test'], - 'Typ': ['UNKNOWN'], - 'Garant': [None], - 'Poznamka': [None], - }) - + 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(df, "Test", "Detail", {}, {}) + generate_pdf(doc) -def test_generate_pdf_api(client): - doc = { - "event": {"title": "PDF Test", "detail": "API PDF"}, - "program_types": [{"code": "WS", "description": "Workshop", "color": "#0070C0"}], - "blocks": [{ - "datum": "2025-11-13", - "zacatek": "09:00:00", - "konec": "10:00:00", - "program": "Opening", - "typ": "WS", - "garant": "John", - "poznamka": "Note" - }] - } - r = client.post("/api/generate-pdf", json=doc) - assert r.status_code == 200 - assert r.headers["content-type"] == "application/pdf" - assert r.content[:5] == b'%PDF-' +def test_generate_pdf_with_event_info(): + doc = make_doc( + event=EventInfo( + title="Full Event", + subtitle="With all fields", + date="2026-03-01", + location="Prague" + ) + ) + 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-' diff --git a/tests/test_read_excel.py b/tests/test_read_excel.py deleted file mode 100644 index b3ed295..0000000 --- a/tests/test_read_excel.py +++ /dev/null @@ -1,361 +0,0 @@ -import io -import pandas as pd -import pytest -from datetime import date, time -from scenar.core import ( - read_excel, create_timetable, get_program_types, ScenarsError, - parse_inline_schedule, parse_inline_types, ValidationError -) - - -def make_excel_bytes(df: pd.DataFrame) -> bytes: - bio = io.BytesIO() - with pd.ExcelWriter(bio, engine='openpyxl') as writer: - df.to_excel(writer, index=False) - return bio.getvalue() - - -def test_read_excel_happy_path(): - df = pd.DataFrame({ - 'Datum': [pd.Timestamp('2025-11-13').date()], - 'Zacatek': ['09:00'], - 'Konec': ['10:00'], - 'Program': ['Test program'], - 'Typ': ['WORKSHOP'], - 'Garant': ['Garant Name'], - 'Poznamka': ['Pozn'] - }) - - content = make_excel_bytes(df) - valid, errors = read_excel(content) - - assert isinstance(valid, pd.DataFrame) - assert len(errors) == 0 - assert len(valid) == 1 - assert valid.iloc[0]['Program'] == 'Test program' - - -def test_read_excel_invalid_time(): - df = pd.DataFrame({ - 'Datum': [pd.Timestamp('2025-11-13').date()], - 'Zacatek': ['not-a-time'], - 'Konec': ['10:00'], - 'Program': ['Bad Time'], - 'Typ': ['LECTURE'], - 'Garant': [None], - 'Poznamka': [None] - }) - - content = make_excel_bytes(df) - valid, errors = read_excel(content) - - assert isinstance(errors, list) - assert len(errors) >= 1 - - -def test_get_program_types(): - """Test form field parsing for program type/color/description.""" - form_data = { - 'type_code_0': 'WORKSHOP', - 'type_code_1': 'LECTURE', - 'desc_0': 'Workshop description', - 'desc_1': 'Lecture description', - 'color_0': '#FF0000', - 'color_1': '#00FF00', - } - - # get_program_types returns (descriptions_dict, colors_dict) tuple - descriptions, colors = get_program_types(form_data) - - assert len(descriptions) == 2 - assert descriptions['WORKSHOP'] == 'Workshop description' - assert descriptions['LECTURE'] == 'Lecture description' - assert colors['WORKSHOP'] == 'FFFF0000' # FF prefix added - assert colors['LECTURE'] == 'FF00FF00' - - -def test_create_timetable(): - """Test Excel timetable generation with properly parsed times.""" - from datetime import time - - # Create test data with time objects (as returned by read_excel) - df = pd.DataFrame({ - 'Datum': [pd.Timestamp('2025-11-13').date()], - 'Zacatek': [time(9, 0)], # Use time object, not string - 'Konec': [time(10, 0)], - 'Program': ['Test Program'], - 'Typ': ['WORKSHOP'], - 'Garant': ['John Doe'], - 'Poznamka': ['Test note'], - }) - - program_descriptions = { - 'WORKSHOP': 'Workshop Type', - } - - program_colors = { - 'WORKSHOP': 'FF0070C0', # AARRGGBB format - } - - # create_timetable returns openpyxl.Workbook - wb = create_timetable( - df, - title="Test Timetable", - detail="Test Detail", - program_descriptions=program_descriptions, - program_colors=program_colors - ) - - assert wb is not None - assert len(wb.sheetnames) > 0 - ws = wb.active - assert ws['A1'].value == "Test Timetable" - assert ws['A2'].value == "Test Detail" - - -def test_create_timetable_with_color_dict(): - """Test timetable generation with separate color dict.""" - from datetime import time - - df = pd.DataFrame({ - 'Datum': [pd.Timestamp('2025-11-13').date()], - 'Zacatek': [time(9, 0)], # Use time object - 'Konec': [time(10, 0)], - 'Program': ['Lecture 101'], - 'Typ': ['LECTURE'], - 'Garant': ['Dr. Smith'], - 'Poznamka': [None], - }) - - program_descriptions = { - 'LECTURE': 'Standard Lecture', - } - - program_colors = { - 'LECTURE': 'FFFF6600', - } - - wb = create_timetable( - df, - title="Advanced Timetable", - detail="With color dict", - program_descriptions=program_descriptions, - program_colors=program_colors - ) - - assert wb is not None - ws = wb.active - assert ws['A1'].value == "Advanced Timetable" - - -def test_parse_inline_schedule(): - """Test parsing inline schedule form data.""" - form_data = { - 'datum_0': '2025-11-13', - 'zacatek_0': '09:00', - 'konec_0': '10:00', - 'program_0': 'Test Program', - 'typ_0': 'WORKSHOP', - 'garant_0': 'John Doe', - 'poznamka_0': 'Test note', - 'datum_1': '2025-11-13', - 'zacatek_1': '10:30', - 'konec_1': '11:30', - 'program_1': 'Another Program', - 'typ_1': 'LECTURE', - 'garant_1': 'Jane Smith', - 'poznamka_1': '', - } - - df = parse_inline_schedule(form_data) - - assert isinstance(df, pd.DataFrame) - assert len(df) == 2 - assert df.iloc[0]['Program'] == 'Test Program' - assert df.iloc[0]['Typ'] == 'WORKSHOP' - assert df.iloc[1]['Program'] == 'Another Program' - assert df.iloc[1]['Typ'] == 'LECTURE' - - -def test_parse_inline_schedule_missing_required(): - """Test that missing required fields raise ValidationError.""" - form_data = { - 'datum_0': '2025-11-13', - 'zacatek_0': '09:00', - # Missing konec_0, program_0, typ_0 - } - - with pytest.raises(ValidationError): - parse_inline_schedule(form_data) - - -def test_parse_inline_types(): - """Test parsing inline type definitions.""" - form_data = { - 'type_name_0': 'WORKSHOP', - 'type_desc_0': 'Workshop Type', - 'type_color_0': '#0070C0', - 'type_name_1': 'LECTURE', - 'type_desc_1': 'Lecture Type', - 'type_color_1': '#FF6600', - } - - descriptions, colors = parse_inline_types(form_data) - - assert len(descriptions) == 2 - assert descriptions['WORKSHOP'] == 'Workshop Type' - assert descriptions['LECTURE'] == 'Lecture Type' - assert colors['WORKSHOP'] == 'FF0070C0' - assert colors['LECTURE'] == 'FFFF6600' - - -def test_inline_workflow_integration(): - """Test end-to-end inline workflow: parse form → create timetable.""" - # Schedule form data - schedule_form = { - 'datum_0': '2025-11-13', - 'zacatek_0': '09:00', - 'konec_0': '10:00', - 'program_0': 'Opening', - 'typ_0': 'KEYNOTE', - 'garant_0': 'Dr. Smith', - 'poznamka_0': 'Start of event', - } - - # Type form data - types_form = { - 'type_name_0': 'KEYNOTE', - 'type_desc_0': 'Keynote Speech', - 'type_color_0': '#FF0000', - } - - # Merge forms - form_data = {**schedule_form, **types_form} - - # Parse - df = parse_inline_schedule(form_data) - descriptions, colors = parse_inline_types(form_data) - - # Generate timetable - wb = create_timetable( - df, - title="Integration Test Event", - detail="Testing inline workflow", - program_descriptions=descriptions, - program_colors=colors - ) - - assert wb is not None - ws = wb.active - assert ws['A1'].value == "Integration Test Event" - assert ws['A2'].value == "Testing inline workflow" - - -def test_excel_import_to_step2_workflow(): - """Test workflow: Excel import -> step 2 (type definition) -> step 3 (generate). - This simulates the user uploading Excel, then filling type definitions. - """ - import base64 - - # Step 1: Create Excel file - df = pd.DataFrame({ - 'Datum': [pd.Timestamp('2025-11-13').date()], - 'Zacatek': ['09:00'], - 'Konec': ['10:00'], - 'Program': ['Opening Keynote'], - 'Typ': ['KEYNOTE'], - 'Garant': ['John Smith'], - 'Poznamka': ['Welcome speech'] - }) - - file_content = make_excel_bytes(df) - - # Step 2: Read Excel (simulating step=2 processing) - valid_data, errors = read_excel(file_content) - assert len(errors) == 0 - assert len(valid_data) == 1 - - # Extract types - program_types = sorted([str(t).strip() for t in valid_data["Typ"].dropna().unique()]) - assert program_types == ['KEYNOTE'] - - # Step 3: User fills type definitions (simulating form submission in step=2) - # This would be base64 encoded in the hidden field - file_content_base64 = base64.b64encode(file_content).decode('utf-8') - - form_data = { - 'title': 'Test Event', - 'detail': 'Test Detail', - 'file_content_base64': file_content_base64, - 'type_code_0': 'KEYNOTE', - 'desc_0': 'Main keynote presentation', - 'color_0': '#FF0000', - 'step': '3' - } - - # Parse types (simulating step=3 processing) - descriptions, colors = get_program_types(form_data) - - assert descriptions['KEYNOTE'] == 'Main keynote presentation' - assert colors['KEYNOTE'] == 'FFFF0000' - - # Step 4: Generate timetable - # Re-read Excel from base64 - file_content_decoded = base64.b64decode(form_data['file_content_base64']) - data, _ = read_excel(file_content_decoded) - - wb = create_timetable( - data, - title=form_data['title'], - detail=form_data['detail'], - program_descriptions=descriptions, - program_colors=colors - ) - - assert wb is not None - ws = wb.active - assert ws['A1'].value == 'Test Event' - assert ws['A2'].value == 'Test Detail' - - -def test_excel_import_to_inline_editor_workflow(): - """Test workflow: Excel import -> step 2 -> step 2b (inline editor). - This simulates clicking 'Upravit v inline editoru' button. - """ - import base64 - - # Create Excel file - df = pd.DataFrame({ - 'Datum': [pd.Timestamp('2025-11-13').date(), pd.Timestamp('2025-11-14').date()], - 'Zacatek': ['09:00', '14:00'], - 'Konec': ['10:00', '15:30'], - 'Program': ['Morning Session', 'Afternoon Workshop'], - 'Typ': ['KEYNOTE', 'WORKSHOP'], - 'Garant': ['Alice', 'Bob'], - 'Poznamka': ['', 'Hands-on'] - }) - - file_content = make_excel_bytes(df) - - # Step 2: Read Excel - valid_data, errors = read_excel(file_content) - assert len(errors) == 0 - assert len(valid_data) == 2 - - # Step 2b: User clicks "Upravit v inline editoru" - # The form would pass file_content_base64 to step=2b - file_content_base64 = base64.b64encode(file_content).decode('utf-8') - - # Simulate step 2b: decode and re-read Excel - file_content_decoded = base64.b64decode(file_content_base64) - data_in_editor, _ = read_excel(file_content_decoded) - - # Verify data loaded correctly - assert len(data_in_editor) == 2 - assert data_in_editor.iloc[0]['Program'] == 'Morning Session' - assert data_in_editor.iloc[1]['Program'] == 'Afternoon Workshop' - - # Extract types for editor - program_types = sorted([str(t).strip() for t in data_in_editor["Typ"].dropna().unique()]) - assert set(program_types) == {'KEYNOTE', 'WORKSHOP'} -