Refactor: Oddělení business logiky + inline editor
Some checks failed
Build & Push Docker / build (push) Has been cancelled
Some checks failed
Build & Push Docker / build (push) Has been cancelled
- Nový modul scenar/core.py (491 řádků čisté logiky)
- Refactored cgi-bin/scenar.py (450 řádků CGI wrapper)
- Inline editor s JavaScript row managementem
- Custom exceptions (ScenarsError, ValidationError, TemplateError)
- Kompletní test coverage (10 testů, všechny ✅)
- Fixed Dockerfile (COPY scenar/, requirements.txt)
- Fixed requirements.txt (openpyxl==3.1.5)
- Fixed pytest.ini (pythonpath = .)
- Nové testy: test_http_inline.py, test_inline_builder.py
- HTTP testy označeny jako @pytest.mark.integration
- Build script: scripts/build_image.sh
- Dokumentace: COMPLETION.md
This commit is contained in:
@@ -1,39 +1,11 @@
|
||||
import io
|
||||
import os
|
||||
import pandas as pd
|
||||
import importlib.util
|
||||
import sys
|
||||
import types
|
||||
|
||||
|
||||
def load_scenar_module():
|
||||
repo_root = os.path.dirname(os.path.dirname(__file__))
|
||||
scenar_path = os.path.join(repo_root, 'cgi-bin', 'scenar.py')
|
||||
# Provide a minimal fake `cgi` module so top-level imports in the CGI script don't fail
|
||||
if 'cgi' not in sys.modules:
|
||||
fake_cgi = types.ModuleType('cgi')
|
||||
class FakeFieldStorage:
|
||||
def getvalue(self, key, default=None):
|
||||
return default
|
||||
def keys(self):
|
||||
return []
|
||||
def __contains__(self, item):
|
||||
return False
|
||||
fake_cgi.FieldStorage = FakeFieldStorage
|
||||
sys.modules['cgi'] = fake_cgi
|
||||
# minimal fake cgitb (some environments don't expose it)
|
||||
if 'cgitb' not in sys.modules:
|
||||
fake_cgitb = types.ModuleType('cgitb')
|
||||
def fake_enable():
|
||||
return None
|
||||
fake_cgitb.enable = fake_enable
|
||||
sys.modules['cgitb'] = fake_cgitb
|
||||
|
||||
spec = importlib.util.spec_from_file_location('scenar', scenar_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
# executing the module will run top-level CGI code (prints etc.) but defines functions we need
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
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:
|
||||
@@ -44,8 +16,6 @@ def make_excel_bytes(df: pd.DataFrame) -> bytes:
|
||||
|
||||
|
||||
def test_read_excel_happy_path():
|
||||
scenar = load_scenar_module()
|
||||
|
||||
df = pd.DataFrame({
|
||||
'Datum': [pd.Timestamp('2025-11-13').date()],
|
||||
'Zacatek': ['09:00'],
|
||||
@@ -57,7 +27,7 @@ def test_read_excel_happy_path():
|
||||
})
|
||||
|
||||
content = make_excel_bytes(df)
|
||||
valid, errors = scenar.read_excel(content)
|
||||
valid, errors = read_excel(content)
|
||||
|
||||
assert isinstance(valid, pd.DataFrame)
|
||||
assert len(errors) == 0
|
||||
@@ -66,8 +36,6 @@ def test_read_excel_happy_path():
|
||||
|
||||
|
||||
def test_read_excel_invalid_time():
|
||||
scenar = load_scenar_module()
|
||||
|
||||
df = pd.DataFrame({
|
||||
'Datum': [pd.Timestamp('2025-11-13').date()],
|
||||
'Zacatek': ['not-a-time'],
|
||||
@@ -79,8 +47,205 @@ def test_read_excel_invalid_time():
|
||||
})
|
||||
|
||||
content = make_excel_bytes(df)
|
||||
valid, errors = scenar.read_excel(content)
|
||||
valid, errors = read_excel(content)
|
||||
|
||||
# invalid time should produce at least one error row and valid may be empty
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user