From f476a1009c2038bbc45e9d322c06ba923b43ebb2 Mon Sep 17 00:00:00 2001 From: Martin Sukany Date: Mon, 10 Nov 2025 18:05:37 +0100 Subject: [PATCH] Dockerized --- Dockerfile | 7 +- cgi-bin/scenar.py | 506 ++++++++++++-------------------- tmp/Zazitkovy kurz trabant.xlsx | Bin 6317 -> 9659 bytes 3 files changed, 195 insertions(+), 318 deletions(-) diff --git a/Dockerfile b/Dockerfile index 9138879..86bcd72 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,10 +23,11 @@ COPY templates ./templates # Ensure CGI scripts are executable RUN find /var/www/htdocs/cgi-bin -type f -name "*.py" -exec chmod 0755 {} \; -# Writable tmp for the app +# Writable tmp + kompatibilita s /scripts/tmp (skrypt nic neupravujeme) RUN mkdir -p /var/www/htdocs/tmp \ - && chown -R www-data:www-data /var/www/htdocs/tmp /var/www/htdocs/templates \ - && chmod 0775 /var/www/htdocs/tmp + /var/www/htdocs/scripts/tmp \ + && chown -R www-data:www-data /var/www/htdocs/tmp /var/www/htdocs/scripts \ + && chmod 0775 /var/www/htdocs/tmp /var/www/htdocs/scripts/tmp # --- Python dependencies (add more as needed) --- RUN pip install --no-cache-dir pandas openpyxl diff --git a/cgi-bin/scenar.py b/cgi-bin/scenar.py index 3ebb96b..58ffeaa 100755 --- a/cgi-bin/scenar.py +++ b/cgi-bin/scenar.py @@ -3,15 +3,22 @@ import cgi import cgitb +import html import pandas as pd from openpyxl import Workbook from openpyxl.styles import Alignment, Border, Font, PatternFill, Side from openpyxl.utils import get_column_letter -from datetime import datetime, time +from datetime import datetime from io import BytesIO import base64 import os +# ===== Config ===== +DOCROOT = "/var/www/htdocs" +TMP_DIR = os.path.join(DOCROOT, "tmp") # soubory budou dostupné jako /tmp/ +DEFAULT_COLOR = "#ffffff" # výchozí barva pro +# =================== + cgitb.enable() print("Content-Type: text/html; charset=utf-8") @@ -24,9 +31,7 @@ detail = form.getvalue('detail') show_debug = form.getvalue('debug') == 'on' step = form.getvalue('step', '1') -file_item = None -if 'file' in form: - file_item = form['file'] +file_item = form['file'] if 'file' in form else None def get_program_types(form): program_descriptions = {} @@ -36,9 +41,10 @@ def get_program_types(form): index = key.split('_')[-1] type_code = form.getvalue(f'type_code_{index}') description = form.getvalue(f'desc_{index}', '') - color = 'FF' + form.getvalue(f'color_{index}', 'FFFFFF').lstrip('#') + raw_color = form.getvalue(f'color_{index}', DEFAULT_COLOR) or DEFAULT_COLOR + color_hex = 'FF' + raw_color.lstrip('#') # openpyxl chce AARRGGBB program_descriptions[type_code] = description - program_colors[type_code] = color + program_colors[type_code] = color_hex return program_descriptions, program_colors program_descriptions, program_colors = get_program_types(form) @@ -52,12 +58,11 @@ def normalize_time(time_str): return None def read_excel(file_content): - excel_data = pd.read_excel(BytesIO(file_content), skiprows=0) # Do not skip the header row + excel_data = pd.read_excel(BytesIO(file_content), skiprows=0) excel_data.columns = ["Datum", "Zacatek", "Konec", "Program", "Typ", "Garant", "Poznamka"] if show_debug: - print("
")
-        print("Raw data:")
+        print("
Raw data:\n")
         print(excel_data.head())
         print("
") @@ -76,7 +81,7 @@ def read_excel(file_content): "index": index, "Datum": datum, "Zacatek": zacatek, - "Konec": row["Konec"], # Changed from `konec` to `row["Konec"]` to avoid ValueError in future calculations + "Konec": konec, "Program": row["Program"], "Typ": row["Typ"], "Garant": row["Garant"], @@ -84,55 +89,48 @@ def read_excel(file_content): "row_data": row }) except Exception as e: - error_rows.append({ - "index": index, - "row": row, - "error": str(e) - }) + error_rows.append({"index": index, "row": row, "error": str(e)}) valid_data = pd.DataFrame(valid_data) if show_debug: - print("
")
-        print("Cleaned data:")
+        print("
Cleaned data:\n")
         print(valid_data.head())
         print("
") - print("
")
-        print("Error rows:")
-        for error_row in error_rows:
-            print(f"Index: {error_row['index']}, Error: {error_row['error']}")
-            print(error_row['row'])
+        print("
Error rows:\n")
+        for er in error_rows:
+            print(f"Index: {er['index']}, Error: {er['error']}")
+            print(er['row'])
         print("
") + # Detekce překryvů overlap_errors = [] for date, group in valid_data.groupby('Datum'): sorted_group = group.sort_values(by='Zacatek') previous_end_time = None - - for idx, row in sorted_group.iterrows(): - if previous_end_time and row['Zacatek'] < previous_end_time: + for _, r in sorted_group.iterrows(): + if previous_end_time and r['Zacatek'] < previous_end_time: overlap_errors.append({ - "index": row["index"], - "Datum": row["Datum"], - "Zacatek": row["Zacatek"], - "Konec": row["Konec"], - "Program": row["Program"], - "Typ": row["Typ"], - "Garant": row["Garant"], - "Poznamka": row["Poznamka"], + "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": row["row_data"] + "row_data": r["row_data"] }) - previous_end_time = row['Konec'] + previous_end_time = r['Konec'] if overlap_errors: if show_debug: - print("
")
-            print("Overlap errors:")
-            for error in overlap_errors:
-                print(error)
+            print("
Overlap errors:\n")
+            for e in overlap_errors:
+                print(e)
             print("
") - valid_data = valid_data[~valid_data.index.isin([error['index'] for error in 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 @@ -140,15 +138,17 @@ def read_excel(file_content): def calculate_row_height(cell_value, column_width): if not cell_value: return 15 - max_line_length = column_width * 1.2 - lines = cell_value.split('\n') + lines = str(cell_value).split('\n') line_count = 0 for line in lines: line_count += len(line) // max_line_length + 1 - return line_count * 15 +def calculate_column_width(text): + max_length = max(len(line) for line in str(text).split('\n')) + return max_length * 1.2 + def create_timetable(data, title, detail, program_descriptions, program_colors): if data.empty: print("

Chyba: Načtená data jsou prázdná nebo obsahují neplatné hodnoty. Zkontrolujte vstupní soubor.

") @@ -177,6 +177,10 @@ def create_timetable(data, title, detail, program_descriptions, program_colors): ws['A2'].font = Font(size=16, italic=True) ws['A2'].border = thick_border + # rozumný default šířky prvního sloupce + if ws.column_dimensions[get_column_letter(1)].width is None: + ws.column_dimensions[get_column_letter(1)].width = 40 + title_row_height = calculate_row_height(title, ws.column_dimensions[get_column_letter(1)].width) detail_row_height = calculate_row_height(detail, ws.column_dimensions[get_column_letter(1)].width) ws.row_dimensions[1].height = title_row_height @@ -190,10 +194,9 @@ def create_timetable(data, title, detail, program_descriptions, program_colors): if start_times.isnull().any() or end_times.isnull().any(): print("

Chyba: Načtená data obsahují neplatné hodnoty času. Zkontrolujte vstupní soubor.

") if show_debug: - print("
")
-            print("Start times:")
+            print("
Start times:\n")
             print(start_times)
-            print("End times:")
+            print("\nEnd times:\n")
             print(end_times)
             print("
") return None @@ -202,17 +205,20 @@ def create_timetable(data, title, detail, program_descriptions, program_colors): min_time = min(start_times) max_time = max(end_times) except ValueError as e: - print("

Chyba při zjišťování minimálního a maximálního času: {}

".format(e)) + print(f"

Chyba při zjišťování minimálního a maximálního času: {e}

") if show_debug: - print("
")
-            print("Start times:")
+            print("
Start times:\n")
             print(start_times)
-            print("End times:")
+            print("\nEnd times:\n")
             print(end_times)
             print("
") return None - time_slots = pd.date_range(datetime.combine(datetime.today(), min_time), datetime.combine(datetime.today(), max_time), freq='15min').time + 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) @@ -253,12 +259,9 @@ def create_timetable(data, title, detail, program_descriptions, program_colors): start_index = list(time_slots).index(start_time) + col_offset + 1 end_index = list(time_slots).index(end_time) + col_offset + 1 except ValueError as e: - print("

Chyba při hledání indexu časového slotu: {}

".format(e)) + print(f"

Chyba při hledání indexu časového slotu: {e}

") if show_debug: - print("
")
-                    print("Start time: {}".format(start_time))
-                    print("End time: {}".format(end_time))
-                    print("
") + print("
Start time: {}\nEnd time: {}
".format(start_time, end_time)) continue cell_value = f"{row['Program']}" @@ -274,14 +277,12 @@ def create_timetable(data, title, detail, program_descriptions, program_colors): except AttributeError as e: print(f"

Chyba: {str(e)}. Zkontrolujte vstupní data, která způsobují překrývající se časy bloků.

") if show_debug: - print("
")
-                    print(f"Overlapping block:\n{row}")
-                    print("
") + print("
Overlapping block:\n{}
".format(row)) return None cell.alignment = Alignment(wrap_text=True, horizontal="center", vertical="center") - lines = cell_value.split("\n") - for idx, line in enumerate(lines): + lines = str(cell_value).split("\n") + for idx, _ in enumerate(lines): if idx == 0: cell.font = Font(bold=True) elif idx == 1: @@ -289,7 +290,9 @@ def create_timetable(data, title, detail, program_descriptions, program_colors): 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.fill = PatternFill(start_color=program_colors[row["Typ"]], + end_color=program_colors[row["Typ"]], + fill_type="solid") cell.border = thick_border current_row += 1 @@ -305,8 +308,7 @@ def create_timetable(data, title, detail, program_descriptions, program_colors): legend_max_length = max(legend_max_length, calculate_column_width(legend_text)) legend_row += 1 - ws.column_dimensions[get_column_letter(col_offset)].width = legend_max_length - + 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 @@ -326,119 +328,60 @@ def create_timetable(data, title, detail, program_descriptions, program_colors): return wb -def calculate_column_width(text): - max_length = max(len(line) for line in text.split('\n')) - return max_length * 1.2 - +# ====== HTML flow ====== if step == '1': - print(''' - - - - - Vytvoření Scénáře - Krok 1 - - - -
-

Vytvoření Scénáře - Krok 1

-

Vyplňte níže uvedený formulář k vytvoření nového scénáře. Prosím, vyplňte všechny povinné položky a přiložte požadovaný Excel soubor. Po vyplnění formuláře klikněte na tlačítko "Načíst typy programu".

-

STAHNOUT SABLONU SCENARE ZDE

-
-
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
-
-
- - - - - ''') + print(''' + + + +Vytvoření Scénáře - Krok 1 + + + +
+

Vytvoření Scénáře - Krok 1

+

STÁHNOUT ŠABLONU SCÉNÁŘE ZDE

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +''') elif step == '2' and file_item is not None and file_item.filename: file_content = file_item.file.read() @@ -448,165 +391,98 @@ elif step == '2' and file_item is not None and file_item.filename: print("

Chyba: Načtená data jsou prázdná nebo obsahují neplatné hodnoty. Zkontrolujte vstupní soubor.

") else: program_types = data["Typ"].dropna().unique() - program_types = [typ.strip() for typ in program_types] # Remove any whitespace characters + program_types = [typ.strip() for typ in program_types] - print(''' - - - - - Vytvoření Scénáře - Krok 2 - - - -
-

Vytvoření Scénáře - Krok 2

-

Prosím, vyplňte tituly a barvy pro nalezené typy programu. Po vyplnění formuláře klikněte na tlačítko "Vygenerovat scénář".

-
- - - - - '''.format(title, detail, file_content_base64)) + print(''' + + + +Vytvoření Scénáře - Krok 2 + + + +
+

Vytvoření Scénáře - Krok 2

+

Vyplň tituly a barvy pro nalezené typy programu a odešli.

+ + + + + '''.format( + title=html.escape(title or ""), + detail=html.escape(detail or ""), + fcb64=html.escape(file_content_base64)) + ) for i, typ in enumerate(program_types, start=1): print(''' -
- - - - -
- '''.format(i, i, i, i, typ, i, i, i, i)) +
+ + + + +
'''.format(i=i, typ=html.escape(typ), default_color=DEFAULT_COLOR)) print(''' -
- - -
-
- -
- -
- - - - '''.format(' checked' if show_debug else '')) +
+ + +
+
+ +
+ +
+ + +'''.format(checked=' checked' if show_debug else '')) elif step == '3' and title and detail: file_content_base64 = form.getvalue('file_content_base64') if file_content_base64: file_content = base64.b64decode(file_content_base64) - data, error_rows = read_excel(file_content) if data.empty: print("

Chyba: Načtená data jsou prázdná nebo obsahují neplatné hodnoty. Zkontrolujte vstupní soubor.

") else: + # z POSTu teď přijdou zvolené popisy a barvy + program_descriptions, program_colors = get_program_types(form) wb = create_timetable(data, title, detail, program_descriptions, program_colors) if wb: + os.makedirs(TMP_DIR, exist_ok=True) filename = f"{title}.xlsx" - file_path = os.path.join("/var/www/htdocs/scripts/tmp", filename) + safe_name = "".join(ch if ch.isalnum() or ch in "._- " else "_" for ch in filename) + file_path = os.path.join(TMP_DIR, safe_name) wb.save(file_path) - print(f''' -
-

Výsledky zpracování

-

Stáhnout scénář pro {title} ZDE

-

Název akce: {title}

-

Detail akce: {detail}

-

Data ze souboru:

- {data.to_html(index=False)} -

Stáhnout scénář pro {title} ZDE

-
- ''') - - if error_rows: - print(f''' -
-

Ignorované řádky:

- - - - - - - ''') - for error_row in error_rows: - print(f''' - - - - - - ''') - print(''' -
IndexŘádekDůvod
{error_row["index"]}{error_row["row"].to_dict()}{error_row["error"]}
-
- ''') + print('''
+

Výsledky zpracování

+

Stáhnout scénář pro {title} ZDE

+

Název akce: {title}

+

Detail akce: {detail}

+

Data ze souboru:

+ {table} +

Stáhnout scénář pro {title} ZDE

+
'''.format( + name=html.escape(safe_name), + title=html.escape(title or ""), + detail=html.escape(detail or ""), + table=data.to_html(index=False))) else: print("

Chyba: Soubor nebyl nalezen. Zkontrolujte vstupní data.

") else: diff --git a/tmp/Zazitkovy kurz trabant.xlsx b/tmp/Zazitkovy kurz trabant.xlsx index 0f2e009b270ca2947f36b9557fa0286ca50c3b7d..a9505e79bfe97185feb1ecce4642baa131432975 100644 GIT binary patch delta 6483 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