Skip to content

Commit 6cf5b29

Browse files
authored
Add Report Generation to PDF (#24)
* [stash] draft * [stash] draft * [test] test! * [format] templating * [dep] dep * [report] add report generation button
1 parent 9c5665c commit 6cf5b29

File tree

4 files changed

+354
-106
lines changed

4 files changed

+354
-106
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ dev = [
1919
"types-pyyaml>=6.0.12.20250915",
2020
"pandas-stubs>=3.0.0.260204",
2121
"types-openpyxl>=3.1.0.20240106",
22+
"playwright>=1.58.0",
2223
]
2324

2425
[tool.pytest.ini_options]

src/epicc/__main__.py

Lines changed: 136 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,80 @@
2323
)
2424
from epicc.utils.section_renderer import render_sections
2525

26+
# ---------------------------------------------------------------------------
27+
# Export / print state helpers (inlined from epicc.utils.export)
28+
# ---------------------------------------------------------------------------
29+
30+
RESULTS_PAYLOAD_KEY = "results_payload"
31+
PRINT_REQUESTED_KEY = "print_requested"
32+
PRINT_TRIGGER_TOKEN_KEY = "print_trigger_token"
33+
34+
35+
def initialize_export_state() -> None:
36+
if RESULTS_PAYLOAD_KEY not in st.session_state:
37+
st.session_state[RESULTS_PAYLOAD_KEY] = None
38+
39+
if PRINT_REQUESTED_KEY not in st.session_state:
40+
st.session_state[PRINT_REQUESTED_KEY] = False
41+
42+
if PRINT_TRIGGER_TOKEN_KEY not in st.session_state:
43+
st.session_state[PRINT_TRIGGER_TOKEN_KEY] = 0
44+
45+
46+
def clear_export_state() -> None:
47+
st.session_state[RESULTS_PAYLOAD_KEY] = None
48+
st.session_state[PRINT_REQUESTED_KEY] = False
49+
st.session_state[PRINT_TRIGGER_TOKEN_KEY] = 0
50+
51+
52+
def has_results() -> bool:
53+
return st.session_state.get(RESULTS_PAYLOAD_KEY) is not None
54+
55+
56+
def get_results_payload() -> dict[str, Any] | None:
57+
payload = st.session_state.get(RESULTS_PAYLOAD_KEY)
58+
if payload is None:
59+
return None
60+
61+
return payload
62+
63+
64+
def set_results_payload(payload: dict[str, Any] | None) -> None:
65+
st.session_state[RESULTS_PAYLOAD_KEY] = payload
66+
67+
68+
def render_export_button() -> None:
69+
export_clicked = st.sidebar.button(
70+
"Export Results as PDF", disabled=not has_results()
71+
)
72+
73+
if export_clicked and has_results():
74+
st.session_state[PRINT_REQUESTED_KEY] = True
75+
st.session_state[PRINT_TRIGGER_TOKEN_KEY] = (
76+
st.session_state.get(PRINT_TRIGGER_TOKEN_KEY, 0) + 1
77+
)
78+
79+
80+
def trigger_print_if_requested() -> None:
81+
if not st.session_state.get(PRINT_REQUESTED_KEY):
82+
return
83+
84+
if not has_results():
85+
st.session_state[PRINT_REQUESTED_KEY] = False
86+
return
87+
88+
trigger_token = st.session_state.get(PRINT_TRIGGER_TOKEN_KEY, 0)
89+
st.html(
90+
(
91+
"<script>"
92+
f"window.__epiccPrintToken = {trigger_token};"
93+
"setTimeout(function(){ window.parent.print(); }, 0);"
94+
"</script>"
95+
),
96+
unsafe_allow_javascript=True,
97+
)
98+
st.session_state[PRINT_REQUESTED_KEY] = False
99+
26100

27101
def _load_styles() -> None:
28102
with importlib.resources.files("epicc").joinpath("web/sidebar.css").open("rb") as f:
@@ -35,13 +109,20 @@ def _sync_active_model(model_key: str) -> dict[str, Any]:
35109
if active_model_key != model_key:
36110
st.session_state.active_model_key = model_key
37111
st.session_state.params = {}
112+
clear_export_state()
38113

39114
if "params" not in st.session_state:
40115
st.session_state.params = {}
41116

42117
return st.session_state.params
43118

44119

120+
def _render_results_panel(results_payload: dict[str, Any]) -> None:
121+
st.title(results_payload.get("title", CONFIG.app.title))
122+
st.write(results_payload.get("description", ""))
123+
render_sections(results_payload.get("sections", []))
124+
125+
45126
def _render_excel_parameter_inputs(
46127
params: dict[str, Any],
47128
) -> tuple[dict[str, Any], dict[str, str]]:
@@ -61,6 +142,7 @@ def _render_excel_parameter_inputs(
61142
if st.session_state.get("excel_active_identity") != excel_identity:
62143
st.session_state.excel_active_identity = excel_identity
63144
st.session_state.params = {}
145+
clear_export_state()
64146
params = st.session_state.params
65147
should_refresh_params = True
66148

@@ -135,6 +217,7 @@ def _render_python_parameter_inputs(
135217
if st.session_state.get("active_param_identity") != param_identity:
136218
st.session_state.active_param_identity = param_identity
137219
st.session_state.params = {}
220+
clear_export_state()
138221
params = st.session_state.params
139222
should_refresh_params = True
140223

@@ -281,11 +364,11 @@ def _render_validation_error_details(
281364

282365
def _run_excel_simulation(
283366
params: dict[str, Any], label_overrides: dict[str, str]
284-
) -> None:
367+
) -> dict[str, Any] | None:
285368
uploaded_excel_model = st.session_state.get("excel_model_uploader")
286369
if not uploaded_excel_model:
287370
st.error("Please upload an Excel model file first.")
288-
st.stop()
371+
return None
289372

290373
with st.spinner(f"Running Excel-driven model: {uploaded_excel_model.name}..."):
291374
results = run_excel_driven_model(
@@ -295,22 +378,32 @@ def _run_excel_simulation(
295378
sheet_name=None,
296379
label_overrides=label_overrides,
297380
)
298-
st.title(results.get("model_title", "Excel Driven Model"))
299-
st.write(results.get("model_description", ""))
300-
render_sections(results["sections"])
381+
return {
382+
"title": results.get("model_title", "Excel Driven Model"),
383+
"description": results.get("model_description", ""),
384+
"sections": results.get("sections", []),
385+
}
301386

302387

303388
def _run_python_simulation(
304389
selected_label: str,
305390
model: BaseSimulationModel,
306391
typed_params: BaseModel,
307392
label_overrides: dict[str, str],
308-
) -> None:
393+
) -> dict[str, Any]:
394+
# NOTE: Previously this function rendered results directly with st.* calls and
395+
# returned None implicitly. That meant set_results_payload(None) was always
396+
# called, has_results() was always False, and the PDF export button was
397+
# permanently disabled. It now returns a payload dict stored in session state;
398+
# rendering is deferred to _render_results_panel after st.rerun().
309399
with st.spinner(f"Running {selected_label}..."):
310-
st.title(model.model_title or CONFIG.app.title)
311-
st.write(model.model_description or CONFIG.app.description)
312400
results = model.run(typed_params, label_overrides=label_overrides)
313-
render_sections(model.build_sections(results))
401+
sections = model.build_sections(results)
402+
return {
403+
"title": model.model_title or CONFIG.app.title,
404+
"description": model.model_description or CONFIG.app.description,
405+
"sections": sections,
406+
}
314407

315408

316409
_load_styles()
@@ -328,6 +421,7 @@ def _run_python_simulation(
328421
model_key = selected_label
329422

330423
params = _sync_active_model(model_key)
424+
initialize_export_state()
331425

332426
st.sidebar.subheader("Input Parameters")
333427

@@ -355,20 +449,41 @@ def _run_python_simulation(
355449
_render_validation_error_details(selected_label, exc, sidebar=True)
356450
has_input_errors = True
357451

358-
if not st.sidebar.button("Run Simulation", disabled=has_input_errors):
359-
st.stop()
452+
run_clicked = st.sidebar.button("Run Simulation", disabled=has_input_errors)
453+
render_export_button()
360454

361-
if is_excel_model:
362-
_run_excel_simulation(params, label_overrides)
455+
# For Excel models typed_params is never set (not needed by that path).
456+
# Only block execution for Python models when parameter validation has failed.
457+
if not is_excel_model and typed_params is None:
458+
st.error("Cannot run simulation until parameter validation errors are fixed.")
363459
st.stop()
364460

365-
if typed_params is None:
366-
st.error("Cannot run simulation until parameter validation errors are fixed.")
461+
if run_clicked:
462+
if is_excel_model:
463+
set_results_payload(_run_excel_simulation(params, label_overrides))
464+
else:
465+
assert typed_params is not None # guaranteed by the st.stop() guard above
466+
set_results_payload(
467+
_run_python_simulation(
468+
selected_label,
469+
model_registry[selected_label],
470+
typed_params,
471+
label_overrides,
472+
)
473+
)
474+
475+
# Always rerun after a successful run so the export button reflects the new
476+
# state (has_results() == True) and _render_results_panel is reached below.
477+
if has_results():
478+
st.rerun()
479+
480+
elif not has_results():
481+
# No run was clicked and no stored results exist yet; nothing to display.
367482
st.stop()
368483

369-
_run_python_simulation(
370-
selected_label,
371-
model_registry[selected_label],
372-
typed_params,
373-
label_overrides,
374-
)
484+
485+
results_payload = get_results_payload()
486+
if results_payload:
487+
_render_results_panel(results_payload)
488+
489+
trigger_print_if_requested()

src/epicc/web/sidebar.css

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,55 @@
2626
margin-left: 15px;
2727
}
2828

29+
/* Results export hint shown on screen only */
30+
.export-hint {
31+
margin: 0 0 0.75rem;
32+
padding: 0.5rem 0.75rem;
33+
border-left: 3px solid #0ea5e9;
34+
background: #f1f5f9;
35+
color: #0f172a;
36+
font-size: 0.9rem;
37+
}
38+
39+
/* Print rules to hide controls/chrome and keep the rendered results visible */
40+
@media print {
41+
@page {
42+
margin: 0.5in;
43+
}
44+
45+
[data-testid="stSidebar"],
46+
[data-testid="stToolbar"],
47+
[data-testid="stDecoration"],
48+
[data-testid="stStatusWidget"],
49+
[data-testid="collapsedControl"],
50+
header,
51+
footer,
52+
#MainMenu,
53+
.no-print {
54+
display: none !important;
55+
}
56+
57+
[data-testid="stAppViewContainer"] {
58+
margin: 0 !important;
59+
padding: 0 !important;
60+
}
61+
62+
[data-testid="stMainBlockContainer"] {
63+
max-width: 100% !important;
64+
padding-top: 0 !important;
65+
padding-bottom: 0 !important;
66+
}
67+
68+
.section-divider {
69+
page-break-after: auto;
70+
}
71+
72+
h1,
73+
h2,
74+
h3,
75+
table,
76+
.stMarkdown,
77+
.stTable {
78+
break-inside: avoid;
79+
}
80+
}

0 commit comments

Comments
 (0)