Files
testing123/streamlit_dashboard/page_classes/training_event_count_page_class.py
2026-05-21 08:40:24 -04:00

316 lines
16 KiB
Python

from typing import List, Dict, Any
import logging
import pandas as pd
from fiscalyear import FiscalYear
from matplotlib.pyplot import figure
from streamlit.delta_generator import DeltaGenerator
import streamlit as st
from plotly.graph_objects import Figure
from constants_module import TRAINING_COUNT_COLUMNS, NEOSERRA_COLUMNS, OUT_COLUMNS
from streamlit_constants import DASHBOARD_CONFIG_OBJECT_KEY
from utility_classes.base_report_page import BaseReportPage
from cached_function_wrappers.shared import get_df_centers
from cached_function_wrappers.trainings_cached_functions import cached_generate_cleaned_trainings_dataset
from section_1_graph_library_module import (
make_center_event_count_charts
)
from shared_tools_module import StatChartVariants
from utility_classes.figure_with_max_y import find_fig_max_y_and_generate_wrapper, FigureWithMaxY
from utility_classes.dashboard_config_parser import DashboardConfig, ExportModulePair
class TrainingsEventCountsPage(BaseReportPage):
"""
Concrete implementation of a report page analyzing network-wide training event volumes.
This class manages the data pipeline for calculating the total number of hosted events
and evaluating how specific course categories (like 'First Steps' or 'Preplanning')
contribute to the overall event schedule. It pairs absolute event counts with their
relative percentages to provide a complete view of event distributions.
:param kwargs: Arbitrary keyword arguments passed to the parent BaseReportPage constructor.
"""
def __init__(self, **kwargs):
"""
Initializes the fiscal period boundaries and application configuration state.
Captures the current and previous fiscal years to manage report filtering logic
and extracts the global dashboard configuration to resolve the necessary external data endpoints.
:param kwargs: Arbitrary keyword arguments.
"""
super().__init__("Network Wide Training Event Counts")
self.fiscal_year = FiscalYear.current()
self.prev_fiscal_year = self.fiscal_year.prev_fiscal_year
self.fiscal_year_text = f'FY{str(self.fiscal_year.fiscal_year)[2:]}'
self.prev_fiscal_year_text = f'FY{str(self.prev_fiscal_year.fiscal_year)[2:]}'
# Grab the app config so we can use it to get the export module urls
self.app_config: DashboardConfig = st.session_state[DASHBOARD_CONFIG_OBJECT_KEY]
self.logger = logging.getLogger(__name__)
def get_fiscal_year_export_url(self, selected_fiscal_year):
"""
Resolves the external dataset endpoint based on the active fiscal period.
Maps the user's selected fiscal year to the appropriate data URL, ensuring the data
pipeline fetches the correct historical or current training event records.
:param selected_fiscal_year: The formatted string representing the user's chosen fiscal year.
:type selected_fiscal_year: Any
:return: The URL for the corresponding dataset export.
:rtype: str
"""
export_urls:ExportModulePair = self.app_config.get_trainings_urls()
if selected_fiscal_year == self.fiscal_year_text:
return export_urls.current_fy
else:
return export_urls.prev_fy
@staticmethod
def get_page_name():
"""
Provides the static display identifier for this report module.
Utilized by dashboard orchestrators to construct routing and navigation menus.
:return: The human-readable name of the report.
:rtype: str
"""
return "Training Event Counts"
def render_controls(self, container: DeltaGenerator) -> Dict[str, Any]:
"""
Defines the user input interface and establishes a safe execution boundary.
Renders selection widgets for the fiscal period and target centers. Implements a strict
fail-fast pattern that halts the Streamlit execution sequence if the baseline dataset
fails to load, preventing downstream visual rendering errors.
:param container: The Streamlit container to attach the input widgets to.
:type container: DeltaGenerator
:return: A dictionary containing the user-selected fiscal year and centers.
:rtype: Dict[str, Any]
"""
report_settings_expander = container.expander(
label="Report Options",
expanded=True,
key=self.get_widget_key("report_settings_expander")
)
report_settings_expander.markdown("## Dataset Options")
report_settings_expander.markdown("These settings will modify the input dataset used to generate the graphs.")
selected_fiscal_year = report_settings_expander.selectbox(
label="Fiscal Year",
options=[self.prev_fiscal_year_text, self.fiscal_year_text],
index=1,
key=self.get_widget_key("selected_fiscal_year_selectbox")
)
reportable_only = report_settings_expander.checkbox(label="Reportable only?", value=True, key=self.get_widget_key("reportable_only_checkbox"))
include_future_events = report_settings_expander.checkbox(label="Include Future Events?", value=False, key=self.get_widget_key("include_future_events_checkbox"))
include_on_demand = report_settings_expander.checkbox(label="Include On-Demand Events?", value=True, key=self.get_widget_key("include_on_demand_checkbox"))
export_url = self.get_fiscal_year_export_url(selected_fiscal_year)
try:
all_centers = get_df_centers(export_url)
except Exception as e:
self.logger.exception(f"Failed to fetch the dataset for this page: {e}")
container.error(
"Failed to get the list of all centers for the dataset for this page. A detailed error message has been added to the logs.")
st.stop()
selected_centers = report_settings_expander.multiselect(label="Centers", options=all_centers,
default=all_centers,
key=self.get_widget_key("selected_centers_multiselect"))
return {
"selected_fiscal_year":selected_fiscal_year,
"selected_centers":selected_centers,
"reportable_only":reportable_only,
"include_future_events":include_future_events,
"include_on_demand":include_on_demand
}
def generate_figures(self, parameters: Dict[str, Any]) -> Dict[str, Any]:
"""
Executes the analytical data pipeline and constructs the visualization objects.
Fetches the trainings dataset and generates paired chart sets representing raw event
counts and their corresponding percentage distributions across various course subsets.
Computes strict max-Y values specifically for the absolute count charts to support
external dynamic axis synchronization.
:param parameters: The parameter state dictionary captured from the render_controls phase.
:type parameters: Dict[str, Any]
:return: A dictionary mapping identifiers to FigureWithMaxY objects and the raw dataframe.
:rtype: Dict[str, Any]
"""
selected_fiscal_year: str = parameters["selected_fiscal_year"]
selected_centers: List[str] = parameters["selected_centers"]
reportable_only:bool = parameters["reportable_only"]
include_future_events:bool = parameters["include_future_events"]
include_on_demand:bool = parameters["include_on_demand"]
export_url = self.get_fiscal_year_export_url(selected_fiscal_year)
trainings_df = cached_generate_cleaned_trainings_dataset(
export_url,
reportable_only=reportable_only,
allowed_centers=selected_centers,
include_future_events=include_future_events,
include_on_demand=include_on_demand
)
try:
event_count_figs = make_center_event_count_charts(
trainings_df,
title_prefix="PASBDC*",
fiscal_year_tag=selected_fiscal_year,
col_neo_center=NEOSERRA_COLUMNS.center,
col_neo_attendees_total=NEOSERRA_COLUMNS.attendees_total,
col_neo_primary_topic=NEOSERRA_COLUMNS.primary_training_topic,
preplanning_val=OUT_COLUMNS.val_preplanning
)
except Exception as e:
self.logger.exception(f"Failed to generate figures for this page. Got {e}")
st.error(f"Failed to generate figures for this page. A detailed error has been added to the logs. {self.app_config.get_errors_contact_string()}")
st.stop()
return {
'events_total': find_fig_max_y_and_generate_wrapper(event_count_figs[StatChartVariants.TOTAL_COUNT]),
'events_percent': FigureWithMaxY(figure=event_count_figs[StatChartVariants.TOTAL_PERCENT], max_y=0.0),
'events_attended': find_fig_max_y_and_generate_wrapper(event_count_figs[StatChartVariants.TOTAL_ATTENDED]),
'events_attended_percent': FigureWithMaxY(figure=event_count_figs[StatChartVariants.PERCENT_ATTENDED]),
'events_no_first': find_fig_max_y_and_generate_wrapper(event_count_figs[StatChartVariants.NO_FIRST_STEPS_COUNT]),
'events_no_first_percent': FigureWithMaxY(figure=event_count_figs[StatChartVariants.NO_FIRST_STEPS_PERCENT], max_y=0.0),
'events_attended_no_first': find_fig_max_y_and_generate_wrapper(event_count_figs[StatChartVariants.NO_FIRST_STEPS_ATTENDED_COUNT]),
'events_attended_no_first_percent': FigureWithMaxY(figure=event_count_figs[StatChartVariants.NO_FIRST_STEPS_ATTENDED_PERCENT]),
'events_no_first_no_pre': find_fig_max_y_and_generate_wrapper(event_count_figs[StatChartVariants.NO_FIRST_NO_PREPLANNNG_COUNT]),
'events_no_first_no_pre_percent': FigureWithMaxY(figure=event_count_figs[StatChartVariants.NO_FIRST_NO_PREPLANNNG_PERCENT]),
'events_first_steps_only': find_fig_max_y_and_generate_wrapper(event_count_figs[StatChartVariants.FIRST_ONLY]),
'events_first_steps_only_percent': FigureWithMaxY(figure=event_count_figs[StatChartVariants.FIRST_ONLY_PERCENT]),
'events_first_pre_only': find_fig_max_y_and_generate_wrapper(event_count_figs[StatChartVariants.FIRST_AND_PREPLANNING_ONLY]),
'events_first_pre_only_percent': FigureWithMaxY(figure=event_count_figs[StatChartVariants.FIRST_AND_PREPLANNING_ONLY_PERCENT]),
'trainings_df':trainings_df
}
def render_figures(self, container: DeltaGenerator, output_data: Dict[str, Any]):
"""
Maps the generated paired visual artifacts to a defined spatial layout within the UI.
Arranges the charts sequentially using a rigid 2-column layout to directly contrast
absolute event counts (left) with proportional percentages (right) for each training subset.
Exposes the raw underlying dataset via an expander module for data auditing and transparency.
:param container: The Streamlit layout container for the visuals.
:type container: DeltaGenerator
:param output_data: The dictionary of computed figures and dataframes from generate_figures.
:type output_data: Dict[str, Any]
"""
# Extract the figures using the correct keys from generate_figures
# Using .figure attribute access since FigureWithMaxY is an object
events_total = output_data.get("events_total")["figure"]
events_percent = output_data.get("events_percent")["figure"]
events_attended = output_data.get("events_attended")["figure"]
events_attended_percent = output_data.get("events_attended_percent")["figure"]
events_no_first = output_data.get("events_no_first")["figure"]
events_no_first_percent = output_data.get("events_no_first_percent")["figure"]
events_attended_no_first = output_data.get("events_attended_no_first")["figure"]
events_attended_no_first_percent = output_data.get("events_attended_no_first_percent")["figure"]
events_no_first_no_pre = output_data.get("events_no_first_no_pre")["figure"]
events_no_first_no_pre_percent = output_data.get("events_no_first_no_pre_percent")["figure"]
events_first_steps_only = output_data.get("events_first_steps_only")["figure"]
events_first_steps_only_percent = output_data.get("events_first_steps_only_percent")["figure"]
events_first_pre_only = output_data.get("events_first_pre_only")["figure"]
events_first_pre_only_percent = output_data.get("events_first_pre_only_percent")["figure"]
trainings_df: pd.DataFrame = output_data.get("trainings_df")
# Keep the 2-column format, pairing raw counts (left) with percentages (right)
left_col, right_col = container.columns([0.5, 0.5])
left_col.plotly_chart(events_total, key=self.get_widget_key("events_total"), use_container_width=True)
right_col.plotly_chart(events_percent, key=self.get_widget_key("events_percent"), use_container_width=True)
left_col, right_col = container.columns([0.5, 0.5])
left_col.plotly_chart(events_attended, key=self.get_widget_key("events_attended"), use_container_width=True)
right_col.plotly_chart(events_attended_percent, key=self.get_widget_key("events_attended_percent"),
use_container_width=True)
left_col, right_col = container.columns([0.5, 0.5])
left_col.plotly_chart(events_no_first, key=self.get_widget_key("events_no_first"), use_container_width=True)
right_col.plotly_chart(events_no_first_percent, key=self.get_widget_key("events_no_first_percent"),
use_container_width=True)
left_col, right_col = container.columns([0.5, 0.5])
left_col.plotly_chart(events_attended_no_first, key=self.get_widget_key("events_attended_no_first"),
use_container_width=True)
right_col.plotly_chart(events_attended_no_first_percent,
key=self.get_widget_key("events_attended_no_first_percent"), use_container_width=True)
left_col, right_col = container.columns([0.5, 0.5])
left_col.plotly_chart(events_no_first_no_pre, key=self.get_widget_key("events_no_first_no_pre"),
use_container_width=True)
right_col.plotly_chart(events_no_first_no_pre_percent,
key=self.get_widget_key("events_no_first_no_pre_percent"), use_container_width=True)
left_col, right_col = container.columns([0.5, 0.5])
left_col.plotly_chart(events_first_steps_only, key=self.get_widget_key("events_first_steps_only"),
use_container_width=True)
right_col.plotly_chart(events_first_steps_only_percent,
key=self.get_widget_key("events_first_steps_only_percent"), use_container_width=True)
left_col, right_col = container.columns([0.5, 0.5])
left_col.plotly_chart(events_first_pre_only, key=self.get_widget_key("events_first_pre_only"),
use_container_width=True)
right_col.plotly_chart(events_first_pre_only_percent, key=self.get_widget_key("events_first_pre_only_percent"),
use_container_width=True)
# Dataset expander matches your original format exactly
dataset_expander = container.expander(
label="Source Dataset",
expanded=True,
key=self.get_widget_key("dataset_expander")
)
dataset_expander.markdown("## Source Data")
dataset_expander.markdown("### Neoserra Trainings Dataset")
dataset_expander.write(trainings_df)
def get_syncable_figure_keys(self) -> List[str]:
"""
Declares the specific figures that permit dynamic external Y-axis scaling.
Explicitly isolates the absolute event count charts for synchronization, filtering out
all percentage-based charts to ensure external axis scaling does not distort proportional data.
:return: A list of dictionary keys corresponding to absolute count figures.
:rtype: List[str]
"""
return [
"events_total",
"events_attended",
"events_no_first",
"events_attended_no_first",
"events_no_first_no_pre",
"events_first_steps_only",
"events_first_pre_only"
]