first commit
This commit is contained in:
0
streamlit_dashboard/page_classes/__init__.py
Normal file
0
streamlit_dashboard/page_classes/__init__.py
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
209
streamlit_dashboard/page_classes/admin_panel_page_class.py
Normal file
209
streamlit_dashboard/page_classes/admin_panel_page_class.py
Normal file
@@ -0,0 +1,209 @@
|
||||
import streamlit as st
|
||||
from streamlit.delta_generator import DeltaGenerator
|
||||
|
||||
from typing import Dict, List, Any
|
||||
import re
|
||||
|
||||
from streamlit_constants import DASHBOARD_CONFIG_OBJECT_KEY
|
||||
from utility_classes.dashboard_config_parser import DashboardConfig, ExportModulePair
|
||||
from utility_classes.base_report_page import BaseReportPage
|
||||
from components.neoserra_export_link_grabber import NeoserraExportLinkGrabber
|
||||
|
||||
class AdminPanelPage(BaseReportPage):
|
||||
"""
|
||||
Provides a centralized administrative interface for configuring dashboard data sources.
|
||||
|
||||
Unlike standard report pages, this class does not generate or render data visualizations.
|
||||
Instead, it hooks into the standard BaseReportPage lifecycle to render a configuration
|
||||
form. It manages the retrieval, validation, and updating of Neoserra API export URLs
|
||||
stored within the global DashboardConfig session state.
|
||||
|
||||
It employs strict regex validation to ensure data pipeline integrity, while also
|
||||
providing a deliberate bypass mechanism to future-proof against external API changes.
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__("Admin Panel")
|
||||
|
||||
self.neoserra_export_link_regex = re.compile(r"https://pasbdc.neoserra.com/api/export\?userid=[0-9]+&appid=[0-9]+&appkey=(([a-z]+)|([0-9]+)|([-]))+")
|
||||
|
||||
@staticmethod
|
||||
def get_page_name():
|
||||
"""
|
||||
Retrieves the human-readable name of the admin panel for UI navigation.
|
||||
|
||||
:return: The static display name "Admin Panel".
|
||||
:rtype: str
|
||||
"""
|
||||
return "Admin Panel"
|
||||
|
||||
def save_settings(self):
|
||||
"""
|
||||
Commits the current configuration state to persistent storage.
|
||||
|
||||
Extracts the DashboardConfig object from the Streamlit session state and triggers
|
||||
its internal save mechanism, providing visual feedback to the user upon completion.
|
||||
"""
|
||||
try:
|
||||
self.app_config.save()
|
||||
st.toast("Settings saved!", icon='✅')
|
||||
except Exception as e:
|
||||
self.logger.exception(f"Error encountered while trying to save the dashboard config. Got {e}")
|
||||
st.toast(f"Failed to save settings, a detailed message has been added to the log. {self.app_config.get_errors_contact_string()}")
|
||||
st.stop()
|
||||
|
||||
def is_valid_neoserra_url(self, url):
|
||||
"""
|
||||
Validates a given URL against the expected Neoserra export API format.
|
||||
|
||||
Used as a gatekeeper during form submission to prevent malformed URLs from breaking
|
||||
the dashboard's data ingestion pipeline.
|
||||
|
||||
:param url: The raw URL string to validate.
|
||||
:type url: str
|
||||
:return: True if the URL matches the expected Neoserra API pattern, False otherwise.
|
||||
:rtype: bool
|
||||
"""
|
||||
if not url:
|
||||
return False
|
||||
|
||||
return bool(re.match(self.neoserra_export_link_regex, url))
|
||||
|
||||
def render_controls(self, container: DeltaGenerator) -> Dict[str, Any]:
|
||||
"""
|
||||
Builds the administrative form and handles the validation/submission pipeline.
|
||||
|
||||
This method constructs a unified Streamlit form containing URL grabber widgets for
|
||||
various data modules (Clients, Trainings, NBS, Funding). By wrapping the inputs
|
||||
in a single form, it ensures that configuration updates are processed as a single
|
||||
batch rather than triggering piecemeal app reruns.
|
||||
|
||||
During submission, it utilizes an "all-or-nothing" validation strategy. If any link
|
||||
fails regex validation (and the manual bypass is not checked), the entire save
|
||||
operation is aborted to prevent partial configuration states.
|
||||
|
||||
:param container: The Streamlit container to draw the administrative form onto.
|
||||
:type container: DeltaGenerator
|
||||
:return: An empty dictionary, as this page does not pass parameters to generate_figures.
|
||||
:rtype: Dict[str, Any]
|
||||
"""
|
||||
|
||||
admin_form = container.form(key=self.get_widget_key("admin_settings_form"))
|
||||
|
||||
client_list_links:ExportModulePair = self.app_config.get_clients_list_urls()
|
||||
client_list_export_module_control = NeoserraExportLinkGrabber("Client List Export Module URLs", prev_fy_initial_value=client_list_links.prev_fy, current_fy_initial_value=client_list_links.current_fy)
|
||||
client_list_export_module_control.render(admin_form)
|
||||
|
||||
trainings_links:ExportModulePair = self.app_config.get_trainings_urls()
|
||||
trainings_export_module_control = NeoserraExportLinkGrabber("Trainings Export Module URLs", prev_fy_initial_value=trainings_links.prev_fy, current_fy_initial_value=trainings_links.current_fy)
|
||||
trainings_export_module_control.render(admin_form)
|
||||
|
||||
nbs_links:ExportModulePair = self.app_config.get_nbs_milestones_urls()
|
||||
nbs_export_module_control = NeoserraExportLinkGrabber("New Business Starts Export Module URLs", prev_fy_initial_value=nbs_links.prev_fy, current_fy_initial_value=nbs_links.current_fy)
|
||||
nbs_export_module_control.render(admin_form)
|
||||
|
||||
funding_links:ExportModulePair = self.app_config.get_funding_milestones_urls()
|
||||
funding_export_module_control = NeoserraExportLinkGrabber("Capital Funding Export Module URLs", prev_fy_initial_value=funding_links.prev_fy, current_fy_initial_value=funding_links.current_fy)
|
||||
funding_export_module_control.render(admin_form)
|
||||
|
||||
api_container = admin_form.container(key=self.get_widget_key("api_container"))
|
||||
|
||||
api_container.write("These settings control the external APIs used by the application. The census year is used for the US census, USDA, and BLS APIs")
|
||||
api_container.write("The census year must be a year for which a census was actually conducted in the US. This cannot be an arbitrary year.")
|
||||
|
||||
census_year = api_container.text_input(label="Census Year", value=self.app_config.get_census_year(), key=self.get_widget_key("census_year"))
|
||||
|
||||
api_container.write("This is the api key for the USDA api which can be found at: https://www.ers.usda.gov/developer/data-apis")
|
||||
|
||||
usda_api_key = api_container.text_input(label="USDA API Key", value=self.app_config.get_usda_api_key(), key=self.get_widget_key("usda_api_key"))
|
||||
|
||||
submitted = admin_form.form_submit_button("Save", type="primary", key=self.get_widget_key("save_settings_form"))
|
||||
|
||||
advanced_settings_expander = container.expander(label="Advanced Settings", key=self.get_widget_key("advanced_settings_expander"))
|
||||
advanced_settings_expander.markdown("Selecting this check box will disable the link validation done on export module URLS on this page. This setting is present so that, in the future, if Neoserra changes the format of export url links, this application can still be used.")
|
||||
advanced_settings_expander.markdown("Be warned that no other module does validation on these values, so only check this if you know what you are doing. Preferably you should modify the regex in the AdminPanelPage class to fit the new link format")
|
||||
bypass_link_validation = advanced_settings_expander.checkbox(label="Bypass export URL validation", value=False, key=self.get_widget_key("bypass_link_validation"))
|
||||
|
||||
if submitted:
|
||||
# Define the links to validate: (URL, Error Message, Config Setter)
|
||||
links_to_process = [
|
||||
(client_list_export_module_control.current_fy_url, "The current fiscal year clients list link",
|
||||
self.app_config.set_clients_list_current_fy_url),
|
||||
(client_list_export_module_control.prev_fy_url, "The previous fiscal year clients list link",
|
||||
self.app_config.set_clients_list_prev_fy_url),
|
||||
(trainings_export_module_control.current_fy_url, "The current fiscal year trainings link",
|
||||
self.app_config.set_trainings_current_fy_url),
|
||||
(trainings_export_module_control.prev_fy_url, "The previous fiscal year trainings link",
|
||||
self.app_config.set_trainings_prev_fy_url),
|
||||
(nbs_export_module_control.current_fy_url, "The current fiscal year NBS milestones link",
|
||||
self.app_config.set_nbs_milestones_current_fy_url),
|
||||
(nbs_export_module_control.prev_fy_url, "The previous fiscal year NBS milestones link",
|
||||
self.app_config.set_nbs_milestones_prev_fy_url),
|
||||
(funding_export_module_control.current_fy_url, "The current fiscal year funding milestones link",
|
||||
self.app_config.set_funding_milestones_current_fy_url),
|
||||
(funding_export_module_control.prev_fy_url, "The previous fiscal year funding milestones link",
|
||||
self.app_config.set_funding_milestones_prev_fy_url),
|
||||
]
|
||||
|
||||
# Validate all links before saving any
|
||||
if not bypass_link_validation:
|
||||
for url, label, setter in links_to_process:
|
||||
if not self.is_valid_neoserra_url(url):
|
||||
st.toast(f"{label} is not a valid Neoserra export module URL.", icon='❌')
|
||||
return {}
|
||||
|
||||
# If all validations pass (or are bypassed), save the values
|
||||
for url, label, setter in links_to_process:
|
||||
setter(url)
|
||||
|
||||
if usda_api_key is None or usda_api_key == "":
|
||||
st.toast(f"USDA API Key cannot be blank!", icon='❌')
|
||||
return {}
|
||||
|
||||
if census_year is None or census_year == "":
|
||||
st.toast(f"Census year cannot be blank!", icon='❌')
|
||||
return {}
|
||||
|
||||
# Set the API configuration values
|
||||
self.app_config.set_usda_api_key(usda_api_key)
|
||||
self.app_config.set_census_year(census_year)
|
||||
|
||||
self.save_settings()
|
||||
return {
|
||||
}
|
||||
|
||||
def generate_figures(self, parameters: Dict[str, Any]):
|
||||
"""
|
||||
Overrides the standard data generation step as a no-op.
|
||||
|
||||
Because this page is strictly for configuration management, it circumvents
|
||||
the data processing phase of the BaseReportPage lifecycle.
|
||||
|
||||
:param parameters: The parameter dictionary (empty from render_controls).
|
||||
:type parameters: Dict[str, Any]
|
||||
:return: An empty dictionary representing no figure data.
|
||||
:rtype: Dict[str, Any]
|
||||
"""
|
||||
return {}
|
||||
|
||||
def render_figures(self, container: DeltaGenerator, output_data: Dict[str, Any]):
|
||||
"""
|
||||
Overrides the standard figure rendering step as a no-op.
|
||||
|
||||
Does nothing, as there are no visual charts or graphs to draw for the Admin Panel.
|
||||
|
||||
:param container: The Streamlit container.
|
||||
:type container: DeltaGenerator
|
||||
:param output_data: The empty output data from generate_figures.
|
||||
:type output_data: Dict[str, Any]
|
||||
"""
|
||||
pass
|
||||
|
||||
def get_syncable_figure_keys(self) -> List[str]:
|
||||
"""
|
||||
Declares that this page has no visual figures requiring axis synchronization.
|
||||
|
||||
:return: An empty list.
|
||||
:rtype: List[str]
|
||||
"""
|
||||
return []
|
||||
@@ -0,0 +1,67 @@
|
||||
import logging
|
||||
|
||||
import streamlit as st
|
||||
from streamlit_authenticator import Authenticate
|
||||
# Imports from this module
|
||||
from page_classes.funding_milestones_page_class import NetworkFundingMilestonesReportPage
|
||||
from utility_classes.base_report_page import BaseReportPage
|
||||
from page_classes.page_class_constants import REPORT_CONFIGS
|
||||
from utility_classes.dashboard_config_parser import DashboardConfig
|
||||
from streamlit_constants import DASHBOARD_CONFIG_OBJECT_KEY
|
||||
|
||||
class AuthenticationPageWrapper:
|
||||
"""
|
||||
Wraps a report page to enforce user authentication before rendering.
|
||||
|
||||
This class acts as a security gateway. It isolates the authentication state logic
|
||||
from the underlying report rendering, ensuring that unauthenticated users
|
||||
cannot instantiate or view protected components, while handling page-level configurations.
|
||||
|
||||
:param inner_report_page: The uninstantiated class of the report to display.
|
||||
:type inner_report_page: type[BaseReportPage]
|
||||
:param report_config: Keyword arguments required to instantiate the report.
|
||||
:type report_config: dict
|
||||
"""
|
||||
def __init__(self, inner_report_page:type[BaseReportPage], report_config:dict):
|
||||
self.inner_report_page = inner_report_page
|
||||
self.report_config = report_config
|
||||
self.app_config:DashboardConfig = st.session_state[DASHBOARD_CONFIG_OBJECT_KEY]
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
def run(self):
|
||||
"""
|
||||
Executes the authentication check and manages the encapsulated report lifecycle.
|
||||
|
||||
Checks the Streamlit session state for valid credentials. If authenticated,
|
||||
it safely constructs and renders the underlying report page, catching and logging
|
||||
any initialization or rendering exceptions to prevent application crashes.
|
||||
"""
|
||||
authenticator: Authenticate = st.session_state.get('authenticator')
|
||||
|
||||
st.set_page_config(layout='wide')
|
||||
|
||||
if st.session_state.get('authentication_status'):
|
||||
st.sidebar.write(f'Welcome *{st.session_state.get("name")}*')
|
||||
authenticator.logout(location="sidebar")
|
||||
|
||||
# Unpack the arguments and construct the page object
|
||||
try:
|
||||
page_class = self.inner_report_page(**self.report_config)
|
||||
except Exception as e:
|
||||
self.logger.exception(f"Failed to construct the report page. Got {e}")
|
||||
st.error(f"Construction of page failed. A detailed error has been added to the logs. {self.app_config.get_errors_contact_string()}")
|
||||
st.stop()
|
||||
|
||||
# Display it
|
||||
try:
|
||||
page_class.render(st.container())
|
||||
except Exception as e:
|
||||
self.logger.exception(f"Failed to render the page. Got {e}")
|
||||
st.error(f"Rendering of page failed. A detailed error has been added to the logs. {self.app_config.get_errors_contact_string()}")
|
||||
st.stop()
|
||||
|
||||
elif st.session_state.get('authentication_status') is False:
|
||||
st.error(f'Username/password is incorrect. {self.app_config.get_errors_contact_string()}')
|
||||
elif st.session_state.get('authentication_status') is None:
|
||||
st.warning('Please enter your username and password')
|
||||
|
||||
254
streamlit_dashboard/page_classes/center_milestones_page_class.py
Normal file
254
streamlit_dashboard/page_classes/center_milestones_page_class.py
Normal file
@@ -0,0 +1,254 @@
|
||||
from typing import Dict, Any, List
|
||||
import logging
|
||||
|
||||
import pandas as pd
|
||||
import streamlit as st
|
||||
|
||||
from utility_classes.base_report_page import BaseReportPage
|
||||
from utility_classes.figure_with_max_y import FigureWithMaxY, find_fig_max_y_and_generate_wrapper, extract_figure_data
|
||||
from cached_function_wrappers.shared import get_df_centers
|
||||
from cached_function_wrappers.funding_milestones_cached_functions import cached_sanitize_funding_data
|
||||
from cached_function_wrappers.nbs_cached_functions import cached_get_nbs_data
|
||||
from streamlit_constants import DASHBOARD_CONFIG_OBJECT_KEY
|
||||
from utility_classes.dashboard_config_parser import DashboardConfig, ExportModulePair
|
||||
from milestone_attribution_graph_library_module import (
|
||||
make_attribution_pie,
|
||||
make_attribution_grouped_chart
|
||||
)
|
||||
|
||||
from constants_module import NEOSERRA_COLUMNS, OUT_COLUMNS
|
||||
|
||||
from streamlit.delta_generator import DeltaGenerator
|
||||
from fiscalyear import *
|
||||
|
||||
class CenterMilestonesReportPage(BaseReportPage):
|
||||
"""
|
||||
Implements a specific report page for visualizing center-level milestones.
|
||||
|
||||
This class manages the lifecycle for displaying either 'Capital Funding' or
|
||||
'New Business Starts' milestones. It calculates current and previous
|
||||
fiscal years to provide temporal context and interacts with the Neoserra
|
||||
export module to fetch and sanitize center-specific datasets.
|
||||
|
||||
:param kwargs: Arbitrary keyword arguments.
|
||||
"""
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__("Center Specific Milestones")
|
||||
|
||||
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:]}'
|
||||
|
||||
self.capital_funding_report_key = "Capital Funding"
|
||||
self.new_business_start_report_key = "New Business Starts"
|
||||
|
||||
|
||||
def get_fiscal_year_export_url(self, selected_fiscal_year:str, is_nbs:bool) -> str:
|
||||
"""
|
||||
Determines the appropriate data export URL based on report type and year.
|
||||
|
||||
Logic branches between New Business Start (NBS) and Funding milestones,
|
||||
then selects the URL corresponding to the current or previous fiscal
|
||||
year stored in the application configuration.
|
||||
|
||||
:param selected_fiscal_year: The fiscal year string (e.g., 'FY26').
|
||||
:type selected_fiscal_year: str
|
||||
:param is_nbs: Flag indicating if the report type is New Business Starts.
|
||||
:type is_nbs: bool
|
||||
:return: The URL for the requested data export.
|
||||
:rtype: str
|
||||
"""
|
||||
if is_nbs:
|
||||
export_urls:ExportModulePair = self.app_config.get_nbs_milestones_urls()
|
||||
else:
|
||||
export_urls:ExportModulePair = self.app_config.get_funding_milestones_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():
|
||||
"""
|
||||
Returns the static display name for the report page.
|
||||
|
||||
:return: "Center Specific Milestones"
|
||||
:rtype: str
|
||||
"""
|
||||
return "Center Specific Milestones"
|
||||
|
||||
def render_controls(self, container: DeltaGenerator) -> Dict[str, Any]:
|
||||
"""
|
||||
Renders UI widgets to capture reporting parameters from the user.
|
||||
|
||||
Displays an expander containing selectors for the target fiscal year,
|
||||
milestone type, and specific center. It validates the connection to
|
||||
the data source before allowing center selection.
|
||||
|
||||
:param container: The Streamlit container for control widgets.
|
||||
:type container: DeltaGenerator
|
||||
:return: Dictionary containing 'selected_fiscal_year', 'selected_center', and 'is_nbs'.
|
||||
: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")
|
||||
)
|
||||
|
||||
report_type = report_settings_expander.selectbox(
|
||||
label="Milestone Type",
|
||||
options=[self.capital_funding_report_key, self.new_business_start_report_key],
|
||||
index=0,
|
||||
key=self.get_widget_key("report_type_selectbox")
|
||||
)
|
||||
|
||||
reportable_only = report_settings_expander.checkbox(label="Reportable only?", value=True, key=self.get_widget_key("reportable_only_checkbox"))
|
||||
|
||||
is_nbs = True if report_type == self.new_business_start_report_key else False
|
||||
|
||||
export_url = self.get_fiscal_year_export_url(selected_fiscal_year, is_nbs)
|
||||
|
||||
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(f"Failed to get the list of all centers for the dataset for this page. A detailed error message has been added to the logs. {self.app_config.get_errors_contact_string()}")
|
||||
st.stop()
|
||||
|
||||
selected_center = report_settings_expander.selectbox(
|
||||
label="Selected Center",
|
||||
options=all_centers,
|
||||
index=0,
|
||||
key=self.get_widget_key("selected_center_selectbox")
|
||||
)
|
||||
|
||||
return {
|
||||
"selected_fiscal_year":selected_fiscal_year,
|
||||
"selected_center":selected_center,
|
||||
"is_nbs":is_nbs,
|
||||
"reportable_only":reportable_only
|
||||
}
|
||||
|
||||
def generate_figures(self, parameters: Dict[str, Any]):
|
||||
"""
|
||||
Fetches raw milestone data and constructs visualization objects.
|
||||
|
||||
Coordinates the data retrieval pipeline using cached wrappers and
|
||||
processes the resulting DataFrames into attribution pie charts and
|
||||
grouped bar charts. Figures are wrapped in utility classes to
|
||||
preserve metadata like Y-axis maximums for potential synchronization.
|
||||
|
||||
:param parameters: User inputs containing center, year, and report type.
|
||||
:type parameters: Dict[str, Any]
|
||||
:return: Dictionary containing 'pie_fig', 'bar_fig', and 'milestone_df'.
|
||||
:rtype: Dict[str, Any]
|
||||
"""
|
||||
selected_fiscal_year = parameters['selected_fiscal_year']
|
||||
selected_center = parameters['selected_center']
|
||||
is_nbs = parameters['is_nbs']
|
||||
reportable_only = parameters['reportable_only']
|
||||
|
||||
try:
|
||||
export_url:str = self.get_fiscal_year_export_url(selected_fiscal_year, is_nbs)
|
||||
if is_nbs:
|
||||
milestone_df = cached_get_nbs_data(export_url, reportable_only=reportable_only, allowed_centers=[selected_center])
|
||||
else:
|
||||
milestone_df = cached_sanitize_funding_data(export_url, reportable_only=reportable_only, allowed_centers=[selected_center])
|
||||
except Exception as e:
|
||||
self.logger.exception(f"Failed to fetch the dataset for this page: {e}")
|
||||
st.error(
|
||||
f"An issue was encountered while fetching the data for this page. A detailed error message has been recorded in the logs. {self.app_config.get_errors_contact_string()}")
|
||||
st.stop()
|
||||
|
||||
title_tag = "New Business Start" if is_nbs else "Funding"
|
||||
try:
|
||||
pie_fig = make_attribution_pie(
|
||||
milestone_df,
|
||||
title=f"{selected_center} Documented vs. Not Documented {title_tag} Milestones {selected_fiscal_year}",
|
||||
date_note=datetime.datetime.now().strftime('%m/%d/%Y'),
|
||||
col_documentation_level=OUT_COLUMNS.milestone_documentation_level
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.exception(f"Failed to generate the pie chart graphic with error: {e}")
|
||||
st.error(f"An issue was encountered while generating the pie chart graphic. A detailed error message has been recorded in the logs. {self.app_config.get_errors_contact_string()}")
|
||||
st.stop()
|
||||
|
||||
try:
|
||||
bar_fig = make_attribution_grouped_chart(
|
||||
milestone_df,
|
||||
title=f"{selected_center} Attribution Source vs. Documentation Level For {title_tag} Milestones {selected_fiscal_year}",
|
||||
)
|
||||
bar_fig.update_layout(height=600)
|
||||
except Exception as e:
|
||||
self.logger.exception(f"Failed to generate the bar chart graphic with error: {e}")
|
||||
st.error(
|
||||
f"An issue was encountered while generating the bar chart graphic. A detailed error message has been recorded in the logs. {self.app_config.get_errors_contact_string()}")
|
||||
st.stop()
|
||||
|
||||
bar_fig_data = extract_figure_data(bar_fig)
|
||||
if bar_fig_data.empty:
|
||||
bar_fig_max_y = 0.0
|
||||
else:
|
||||
bar_fig_max_y = bar_fig_data.groupby('x')['y'].sum().max()
|
||||
|
||||
return {
|
||||
'pie_fig': FigureWithMaxY(figure=pie_fig, max_y=0.0),
|
||||
'bar_fig': FigureWithMaxY(figure=bar_fig, max_y=bar_fig_max_y),
|
||||
'milestone_df':milestone_df
|
||||
}
|
||||
|
||||
def render_figures(self, container: DeltaGenerator, output_data: Dict[str, Any]):
|
||||
"""
|
||||
Displays the generated visualizations and raw data in the UI.
|
||||
|
||||
Renders Plotly charts for documentation levels and attribution
|
||||
sources, followed by an expander containing the underlying
|
||||
pandas DataFrame for auditing.
|
||||
|
||||
:param container: The Streamlit container for output display.
|
||||
:type container: DeltaGenerator
|
||||
:param output_data: The result set from generate_figures.
|
||||
:type output_data: Dict[str, Any]
|
||||
"""
|
||||
milestone_df:pd.DataFrame = output_data['milestone_df']
|
||||
pie_fig = output_data['pie_fig'].get("figure")
|
||||
bar_fig = output_data['bar_fig'].get("figure")
|
||||
|
||||
container.plotly_chart(pie_fig, key=self.get_widget_key("funding_milestone_pie_fig"))
|
||||
container.plotly_chart(bar_fig, key=self.get_widget_key("funding_milestone_bar_fig"))
|
||||
|
||||
dataset_expander = container.expander(
|
||||
label="Source Datasets",
|
||||
expanded=True,
|
||||
key=self.get_widget_key("dataset_expander")
|
||||
)
|
||||
|
||||
dataset_expander.markdown("## Source Data")
|
||||
dataset_expander.markdown("### Neoserra Milestone Dataset")
|
||||
dataset_expander.write(milestone_df)
|
||||
|
||||
def get_syncable_figure_keys(self) -> List[str]:
|
||||
"""
|
||||
Designates specific figures for axis synchronization.
|
||||
|
||||
Identifies the bar chart as a candidate for Y-axis scaling
|
||||
normalization when multiple instances of this report are
|
||||
viewed in a side-by-side comparison.
|
||||
|
||||
:return: List of keys ('bar_fig') available for synchronization.
|
||||
:rtype: List[str]
|
||||
"""
|
||||
return ["bar_fig"]
|
||||
@@ -0,0 +1,265 @@
|
||||
from typing import Dict, Any, List
|
||||
import logging
|
||||
import os
|
||||
|
||||
import pandas as pd
|
||||
import streamlit as st
|
||||
|
||||
from utility_classes.base_report_page import BaseReportPage
|
||||
from utility_classes.figure_with_max_y import FigureWithMaxY
|
||||
from cached_function_wrappers.shared import get_df_centers
|
||||
from cached_function_wrappers.funding_milestones_cached_functions import cached_sanitize_funding_data
|
||||
from streamlit_constants import DASHBOARD_CONFIG_OBJECT_KEY
|
||||
from utility_classes.dashboard_config_parser import DashboardConfig, ExportModulePair
|
||||
|
||||
from section_1_graph_library_module import (
|
||||
make_funding_attribution_network_wide,
|
||||
make_funding_attribution_rate_chart,
|
||||
make_theoretical_funding_attribution_rate_chart
|
||||
)
|
||||
|
||||
from constants_module import NEOSERRA_COLUMNS, OUT_COLUMNS
|
||||
|
||||
from streamlit.delta_generator import DeltaGenerator
|
||||
from fiscalyear import *
|
||||
from plotly.graph_objects import Figure
|
||||
|
||||
class NetworkFundingMilestonesReportPage(BaseReportPage):
|
||||
"""
|
||||
Concrete implementation of a report page for analyzing network-wide funding milestones.
|
||||
|
||||
This class manages the data pipeline and UI rendering for funding metrics. It handles
|
||||
fiscal year temporal states, retrieves specific data export URLs from the global dashboard
|
||||
configuration, and generates targeted attribution charts to evaluate funding documentation rates.
|
||||
|
||||
:param kwargs: Arbitrary keyword arguments passed to the parent constructor.
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""
|
||||
Initializes the report's temporal context and environmental state.
|
||||
|
||||
Extracts current and previous fiscal year boundaries and retrieves the global dashboard
|
||||
configuration from the Streamlit session state to locate the necessary data export URLs.
|
||||
|
||||
:param kwargs: Arbitrary keyword arguments.
|
||||
"""
|
||||
super().__init__("Network Milestones Report")
|
||||
|
||||
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:]}'
|
||||
|
||||
def get_fiscal_year_export_url(self, selected_fiscal_year):
|
||||
"""
|
||||
Resolves the appropriate external dataset URL based on user selection.
|
||||
|
||||
Maps the selected fiscal year string from the UI to the correct data endpoint configured
|
||||
in the application environment.
|
||||
|
||||
:param selected_fiscal_year: The formatted string representing the user's chosen fiscal year.
|
||||
:type selected_fiscal_year: str
|
||||
:return: The URL for the corresponding dataset export.
|
||||
:rtype: str
|
||||
"""
|
||||
export_urls:ExportModulePair = self.app_config.get_funding_milestones_urls()
|
||||
|
||||
if selected_fiscal_year == self.fiscal_year_text:
|
||||
return export_urls.current_fy
|
||||
else:
|
||||
return export_urls.prev_fy
|
||||
return self.current_fy_export_url if selected_fiscal_year == self.fiscal_year_text else self.previous_fy_export_url
|
||||
|
||||
@staticmethod
|
||||
def get_page_name():
|
||||
"""
|
||||
Provides the static human-readable identifier for this report.
|
||||
|
||||
Used by the page comparer to route users to this specific page.
|
||||
|
||||
:return: The display name of the report.
|
||||
:rtype: str
|
||||
"""
|
||||
return "Network Funding Milestones"
|
||||
|
||||
def render_controls(self, container: DeltaGenerator) -> Dict[str, Any]:
|
||||
"""
|
||||
Defines and captures the user input state required to filter the funding report.
|
||||
|
||||
Renders selection widgets for fiscal year and center filtering. It fetches the required
|
||||
list of centers dynamically and halts the Streamlit execution sequence if the dataset
|
||||
fails to load to prevent downstream rendering errors.
|
||||
|
||||
:param container: The Streamlit container to draw the widgets onto.
|
||||
:type container: DeltaGenerator
|
||||
:return: A dictionary containing 'selected_fiscal_year' and 'selected_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"))
|
||||
|
||||
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(
|
||||
f"Failed to get the list of all centers for the dataset for this page. A detailed error message has been added to the logs. {self.app_config.get_errors_contact_string()}")
|
||||
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
|
||||
}
|
||||
|
||||
def generate_figures(self, parameters: Dict[str, Any]):
|
||||
"""
|
||||
Executes the core data processing and visualization pipeline for funding milestones.
|
||||
|
||||
Fetches the targeted dataset, processes the funding metrics, and constructs the Plotly
|
||||
figure objects. It also computes absolute maximums for necessary charts to allow parent
|
||||
orchestrators to synchronize axes across multiple instances.
|
||||
|
||||
:param parameters: The dictionary of user inputs captured from render_controls.
|
||||
:type parameters: Dict[str, Any]
|
||||
:return: A dictionary containing FigureWithMaxY objects for the charts and the raw dataframe.
|
||||
:rtype: Dict[str, Any]
|
||||
"""
|
||||
selected_fiscal_year = parameters['selected_fiscal_year']
|
||||
selected_centers = parameters['selected_centers']
|
||||
reportable_only:bool = parameters['reportable_only']
|
||||
|
||||
export_url = self.get_fiscal_year_export_url(selected_fiscal_year)
|
||||
try:
|
||||
funding_df = cached_sanitize_funding_data(export_url, reportable_only=reportable_only, allowed_centers=selected_centers)
|
||||
except Exception as e:
|
||||
self.logger.exception(f"Failed to fetch the dataset for this page. Got {e}")
|
||||
st.error(f"Failed to fetch the dataset for this page. A detailed error has been added to the logs. {self.app_config.get_errors_contact_string()}")
|
||||
st.stop()
|
||||
|
||||
try:
|
||||
network_wide_funding_fig = make_funding_attribution_network_wide(
|
||||
funding_df,
|
||||
graph_note="",
|
||||
fiscal_year=selected_fiscal_year,
|
||||
title="Capital Funding Attributions Per Center",
|
||||
network_label="PASBDC*",
|
||||
col_neo_center=NEOSERRA_COLUMNS.center,
|
||||
col_documentation_level=OUT_COLUMNS.milestone_documentation_level
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.exception(f"Failed to generate the network wide funding figure for this page. Got {e}")
|
||||
st.error(f"Failed to generate the network wide funding figure for this page. A detailed error has been added to the logs. {self.app_config.get_errors_contact_string()}")
|
||||
st.stop()
|
||||
try:
|
||||
documented_only_fig = make_funding_attribution_rate_chart(
|
||||
funding_df,
|
||||
fiscal_year=selected_fiscal_year,
|
||||
documented_tag=OUT_COLUMNS.val_documented,
|
||||
col_neo_center=NEOSERRA_COLUMNS.center,
|
||||
col_documentation_level=OUT_COLUMNS.milestone_documentation_level
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.exception(f"Failed to generate the documented only figure for this page. Got {e}")
|
||||
st.error(
|
||||
f"Failed to generate the documented only figure for this page. A detailed error has been added to the logs. {self.app_config.get_errors_contact_string()}")
|
||||
st.stop()
|
||||
|
||||
try:
|
||||
theoretical_fig = make_theoretical_funding_attribution_rate_chart(
|
||||
funding_df,
|
||||
title="Documented Percentage if All Funding Milestones With an Attribution Source had an Affirmation",
|
||||
fiscal_year=selected_fiscal_year,
|
||||
documented_tag=OUT_COLUMNS.val_documented,
|
||||
affirmation_missing_tag=OUT_COLUMNS.val_affirmation_missing,
|
||||
col_neo_center=NEOSERRA_COLUMNS.center,
|
||||
col_documentation_level=OUT_COLUMNS.milestone_documentation_level
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.exception(f"Failed to generate the theoretical attribution figure for this page. Got {e}")
|
||||
st.error(
|
||||
f"Failed to generate the theoretical attribution figure for this page. A detailed error has been added to the logs. {self.app_config.get_errors_contact_string()}")
|
||||
st.stop()
|
||||
|
||||
# Obtain the max y value for the chart we care about synchronizing the axis with if this report is in
|
||||
# a page comparer
|
||||
max_bar_y = funding_df[NEOSERRA_COLUMNS.center].value_counts().max()
|
||||
|
||||
return {
|
||||
"network_wide_funding_fig":FigureWithMaxY(figure=network_wide_funding_fig, max_y=max_bar_y),
|
||||
"documented_only_fig":FigureWithMaxY(figure=documented_only_fig, max_y=0.0),
|
||||
"theoretical_fig":FigureWithMaxY(figure=theoretical_fig, max_y=0.0),
|
||||
"funding_df":funding_df
|
||||
}
|
||||
|
||||
def render_figures(self, container: DeltaGenerator, output_data: Dict[str, Any]):
|
||||
"""
|
||||
Maps the generated analytical figures and raw data to Streamlit UI components.
|
||||
|
||||
Draws the instantiated Plotly objects to the container and provides the necessary static
|
||||
context, such as definitions for the documentation levels, to help users interpret the charts.
|
||||
|
||||
:param container: The Streamlit container to draw the visuals onto.
|
||||
:type container: DeltaGenerator
|
||||
:param output_data: The computed figure objects and dataframe returned by generate_figures.
|
||||
:type output_data: Dict[str, Any]
|
||||
"""
|
||||
network_wide_funding_fig:Figure= output_data['network_wide_funding_fig']['figure']
|
||||
documented_only_fig:Figure = output_data['documented_only_fig']['figure']
|
||||
theoretical_fig:Figure = output_data['theoretical_fig']['figure']
|
||||
funding_df:pd.DataFrame = output_data['funding_df']
|
||||
|
||||
container.plotly_chart(network_wide_funding_fig, key=self.get_widget_key("network_wide_funding_fig"))
|
||||
container.markdown(
|
||||
"<b>NOTE: Documentation levels were determined as follows.</b><br>"
|
||||
"<b>Documented: Will be submitted to Nexus as long as 'Director Verified is checked'</b></br>There is a non-blank, non-'Requested on eCenter' attribution source AND Affirmation Statement was non-blank</br>"
|
||||
"<b>Affirmation Statement Missing: Will NOT be submitted to Nexus</b></br>Attribution source is non-blank, non-'Requested on eCenter' BUT affirmation statement was blank.</br>"
|
||||
"<b>Not Documented: Will NOT be submitted to Nexus</b></br> The attribution source is blank or 'Requested on eCenter'.",
|
||||
unsafe_allow_html=True
|
||||
)
|
||||
container.plotly_chart(documented_only_fig, key=self.get_widget_key("documented_only_fig"))
|
||||
container.plotly_chart(theoretical_fig, key=self.get_widget_key("theoretical_fig"))
|
||||
|
||||
dataset_expander = container.expander(
|
||||
label="Source Datasets",
|
||||
expanded=True,
|
||||
key=self.get_widget_key("dataset_expander")
|
||||
)
|
||||
|
||||
dataset_expander.markdown("## Source Data")
|
||||
dataset_expander.markdown("### Neoserra Trainings Dataset")
|
||||
dataset_expander.write(funding_df)
|
||||
|
||||
def get_syncable_figure_keys(self) -> List[str]:
|
||||
"""
|
||||
Declares the figures that support dynamic Y-axis scaling for side-by-side comparisons.
|
||||
|
||||
Restricts synchronization to the network-wide funding chart, as applying external axis limits
|
||||
to percentage-based or non-quantity charts would distort the data representation.
|
||||
|
||||
:return: A list containing the dictionary key for the network-wide funding figure.
|
||||
:rtype: List[str]
|
||||
"""
|
||||
return ["network_wide_funding_fig"]
|
||||
47
streamlit_dashboard/page_classes/home_page_class.py
Normal file
47
streamlit_dashboard/page_classes/home_page_class.py
Normal file
@@ -0,0 +1,47 @@
|
||||
import streamlit as st
|
||||
from streamlit.delta_generator import DeltaGenerator
|
||||
|
||||
from typing import Dict, List, Any
|
||||
import re
|
||||
|
||||
from streamlit_constants import DASHBOARD_CONFIG_OBJECT_KEY
|
||||
from utility_classes.dashboard_config_parser import DashboardConfig, ExportModulePair
|
||||
from utility_classes.base_report_page import BaseReportPage
|
||||
from components.neoserra_export_link_grabber import NeoserraExportLinkGrabber
|
||||
|
||||
class HomePage(BaseReportPage):
|
||||
"""
|
||||
Defines a landing page for the application to guide new users on how it works
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__("Home")
|
||||
|
||||
@staticmethod
|
||||
def get_page_name():
|
||||
"""
|
||||
Retrieves the human-readable name of the admin panel for UI navigation.
|
||||
|
||||
:return: The static display name "Admin Panel".
|
||||
:rtype: str
|
||||
"""
|
||||
return "Home"
|
||||
|
||||
def render_controls(self, container: DeltaGenerator) -> Dict[str, Any]:
|
||||
return {}
|
||||
|
||||
def generate_figures(self, parameters: Dict[str, Any]):
|
||||
return {}
|
||||
|
||||
def render_figures(self, container: DeltaGenerator, output_data: Dict[str, Any]):
|
||||
container.write("This dashboard aggregates data from Neoserra into an easy to understand, and customizable interface."
|
||||
"The available reports are listed on the sidebar on the left-hand side of the screen. If the list is missing, click the arrow icon on the top left of the screen to show the sidebar again.")
|
||||
|
||||
container.write("Each report has a set of options at the top that determine how the data is processed before being turned into visualizations.\n"
|
||||
"Data is pulled directly from the Neoserra Export Module, each report displays its own source data, after user selected filters, at the bottom of the report page.")
|
||||
|
||||
container.write("Client data is protected by using client IDs instead of client names. Simply plug this ID into Neoserra's search bar to determine which client is referenced in the data.")
|
||||
container.write("If you have any questions or suggestions for additional data to add to the dashboard, please submit to support@pasbdc.org")
|
||||
|
||||
def get_syncable_figure_keys(self) -> List[str]:
|
||||
return []
|
||||
298
streamlit_dashboard/page_classes/naics_report_page_class.py
Normal file
298
streamlit_dashboard/page_classes/naics_report_page_class.py
Normal file
@@ -0,0 +1,298 @@
|
||||
# Python modules
|
||||
import datetime
|
||||
from typing import Dict, Any, List
|
||||
import logging
|
||||
|
||||
from pygments.styles import default
|
||||
# Third Party Libraries
|
||||
from streamlit.delta_generator import DeltaGenerator
|
||||
from fiscalyear import *
|
||||
from plotly.graph_objects import Figure
|
||||
import streamlit as st
|
||||
import pandas as pd
|
||||
|
||||
# Imports from this module
|
||||
from cached_function_wrappers.client_list_cached_functions import cached_generate_client_naics_dataset, filter_df_by_naics_codes, cached_get_pa_naics_source_data, cached_get_bls_naics11_data, cached_csv_url_to_dataframe, cached_create_naics_census_percentage_table, cached_get_county_dataset
|
||||
from cached_function_wrappers.shared import get_df_centers
|
||||
from streamlit_constants import USDA_API_KEY, CENSUS_YEAR, DASHBOARD_CONFIG_OBJECT_KEY
|
||||
from utility_classes.base_report_page import BaseReportPage
|
||||
from utility_classes.figure_with_max_y import FigureWithMaxY
|
||||
|
||||
# Imports from the script version of the reports
|
||||
from constants_module import OUT_COLUMNS, NEOSERRA_COLUMNS
|
||||
from section_1_graph_library_module import make_census_naics_chart, make_client_census_comparison_graph, make_county_heatmap
|
||||
from utility_classes.dashboard_config_parser import DashboardConfig, ExportModulePair
|
||||
|
||||
class NaicsReportPage(BaseReportPage):
|
||||
"""
|
||||
Concrete implementation of a report page comparing client NAICS distributions against census baselines.
|
||||
|
||||
This class manages the lifecycle of the NAICS report, bridging internal client demographic
|
||||
data with external USDA Census APIs to evaluate service penetration across industries and counties.
|
||||
|
||||
:param usda_api_key: Authentication key for querying baseline census datasets.
|
||||
:type usda_api_key: str
|
||||
:param census_year: The specific census year to use as the population baseline.
|
||||
:type census_year: str
|
||||
"""
|
||||
def __init__(self):
|
||||
"""
|
||||
Initializes external API context, temporal boundaries, and application configuration.
|
||||
|
||||
Captures authentication credentials and establishes the current/previous fiscal year
|
||||
state to parameterize downstream data fetching protocols.
|
||||
"""
|
||||
super().__init__("NAICS Report")
|
||||
self.usda_api_key = self.app_config.get_usda_api_key()
|
||||
self.census_year = self.app_config.get_census_year()
|
||||
|
||||
# Set to the previous fiscal year
|
||||
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:]}'
|
||||
|
||||
def get_syncable_figure_keys(self) -> List[str]:
|
||||
"""
|
||||
Exposes the primary comparison graph for external dynamic axis scaling.
|
||||
|
||||
Permits a parent orchestrator to synchronize the Y-axis of the client/census
|
||||
comparison chart when multiple instances of this report are rendered side-by-side.
|
||||
|
||||
:return: A list containing the dictionary key for the comparison graph figure.
|
||||
:rtype: List[str]
|
||||
"""
|
||||
return ["comparison_graph_fig"]
|
||||
|
||||
def get_fiscal_year_export_url(self, selected_fiscal_year):
|
||||
"""
|
||||
Resolves the endpoint URL for the client dataset based on the active temporal state.
|
||||
|
||||
:param selected_fiscal_year: The formatted string representing the user's chosen fiscal year.
|
||||
:type selected_fiscal_year: str
|
||||
:return: The URL corresponding to the dataset export for the chosen year.
|
||||
:rtype: str
|
||||
"""
|
||||
export_urls:ExportModulePair = self.app_config.get_clients_list_urls()
|
||||
if selected_fiscal_year == self.fiscal_year_text:
|
||||
return export_urls.current_fy
|
||||
else:
|
||||
return export_urls.prev_fy
|
||||
|
||||
def render_controls(self, container: DeltaGenerator) -> Dict[str, Any]:
|
||||
"""
|
||||
Establishes the report's parameterization boundaries for data querying and visual filtering.
|
||||
|
||||
Captures inputs that mutate the base dataset (Fiscal Year, Centers) and inputs that
|
||||
only modify the presentation layer (Visible NAICS codes). This separation allows for
|
||||
flexible chart manipulation without requiring full data pipeline re-execution.
|
||||
|
||||
:param container: The Streamlit container to draw the widgets onto.
|
||||
:type container: DeltaGenerator
|
||||
:return: A dictionary containing selected fiscal year, centers, and NAICS codes.
|
||||
: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")
|
||||
)
|
||||
|
||||
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(
|
||||
f"Failed to get the list of all centers for the dataset for this page. A detailed error message has been added to the logs. {self.app_config.get_errors_contact_string()}")
|
||||
st.stop()
|
||||
|
||||
selected_centers = report_settings_expander.multiselect(label="Centers", options=all_centers,
|
||||
default=all_centers,
|
||||
key=self.get_widget_key("selected_centers_multiselect"))
|
||||
|
||||
|
||||
|
||||
report_settings_expander.markdown("## View Options")
|
||||
report_settings_expander.markdown(
|
||||
"These settings WILL NOT modify the input dataset, but will show or hide bars on the graph.")
|
||||
|
||||
# Load the dataframe from the export module
|
||||
client_list_df = cached_generate_client_naics_dataset(export_url, self.usda_api_key, self.census_year,
|
||||
selected_centers)
|
||||
|
||||
# Setup the filter that lets users show or hide bars from the graph
|
||||
all_naics_codes = client_list_df[OUT_COLUMNS.naics_2].unique()
|
||||
selected_naics_codes = report_settings_expander.multiselect(label="NAICS Codes",
|
||||
options=all_naics_codes,
|
||||
default=all_naics_codes,
|
||||
format_func=lambda val: f'{val:g}',
|
||||
key=self.get_widget_key(
|
||||
"selected_naics_multiselect")
|
||||
)
|
||||
|
||||
return {
|
||||
"selected_fiscal_year":selected_fiscal_year,
|
||||
"selected_centers":selected_centers,
|
||||
"selected_naics_codes":selected_naics_codes,
|
||||
}
|
||||
|
||||
def generate_figures(self, parameters: Dict[str, Any]):
|
||||
"""
|
||||
Orchestrates the data processing pipeline and constructs the visualization objects.
|
||||
|
||||
Fetches client demographics, external census baselines, and county metrics, applying
|
||||
user-defined filters to generate the final Plotly objects. Computes strict max-Y values
|
||||
for comparison charts to support external axis synchronization.
|
||||
|
||||
:param parameters: The dictionary of user inputs captured from render_controls.
|
||||
:type parameters: Dict[str, Any]
|
||||
:return: A dictionary containing FigureWithMaxY objects for the visuals and raw dataframes.
|
||||
:rtype: Dict[str, Any]
|
||||
"""
|
||||
selected_fiscal_year:str = parameters["selected_fiscal_year"]
|
||||
selected_centers:List[str] = parameters["selected_centers"]
|
||||
selected_naics_codes:List[int] = parameters["selected_naics_codes"]
|
||||
|
||||
export_url = self.get_fiscal_year_export_url(selected_fiscal_year)
|
||||
if len(selected_centers) == 0:
|
||||
st.warning("At least 1 center must be selected!")
|
||||
st.stop()
|
||||
else:
|
||||
try:
|
||||
client_list_df = cached_generate_client_naics_dataset(export_url, self.usda_api_key, self.census_year,
|
||||
selected_centers)
|
||||
|
||||
naics_df = cached_create_naics_census_percentage_table(self.usda_api_key, self.census_year)
|
||||
|
||||
filtered_df = filter_df_by_naics_codes(client_list_df, selected_naics_codes)
|
||||
|
||||
county_df = cached_get_county_dataset(
|
||||
export_url,
|
||||
USDA_API_KEY,
|
||||
CENSUS_YEAR,
|
||||
selected_centers
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.exception(f"Failed to load the dataset for this page, got {e}")
|
||||
st.error(f"Failed to load the dataset for this page. A detailed error has been added to the logs. {self.app_config.get_errors_contact_string()}")
|
||||
st.stop()
|
||||
|
||||
try:
|
||||
comparison_graph_fig = make_client_census_comparison_graph(
|
||||
naics_df,
|
||||
filtered_df,
|
||||
title=f'Comparison between PA Census NAICS code distribution and PASBDC client NAICs distribution {selected_fiscal_year}',
|
||||
naics_df_naics_code_column_name=OUT_COLUMNS.unified_naics,
|
||||
naics_df_naics_label_column_name=OUT_COLUMNS.naics_label,
|
||||
naics_df_census_percentage_column_name=OUT_COLUMNS.census_pct,
|
||||
client_df_naics2_column_name=OUT_COLUMNS.naics_2,
|
||||
client_df_census_percentage=OUT_COLUMNS.pa_naics_pct,
|
||||
client_df_pasbdc_percentage=OUT_COLUMNS.pasbdc_pct
|
||||
)
|
||||
comparison_graph_fig.update_layout(width=799, height=900)
|
||||
comparison_graph_max_y = filtered_df[OUT_COLUMNS.pasbdc_pct].max()
|
||||
if pd.isna(comparison_graph_max_y):
|
||||
comparison_graph_max_y = 0.0
|
||||
except Exception as e:
|
||||
self.logger.exception(f"Failed to generate the SBDC vs Census NAICS comparison figure for this page, got {e}")
|
||||
st.error(f"Failed to generate the SBDC vs Census NAICS comparison figure for this page. A detailed error has been added to the logs. {self.app_config.get_errors_contact_string()}")
|
||||
st.stop()
|
||||
|
||||
try:
|
||||
census_naics_fig = make_census_naics_chart(
|
||||
naics_df,
|
||||
naics_column_name=OUT_COLUMNS.unified_naics,
|
||||
label_column_name=OUT_COLUMNS.naics_label,
|
||||
census_data_column_name=OUT_COLUMNS.census_pct)
|
||||
except Exception as e:
|
||||
self.logger.exception(
|
||||
f"Failed to generate the PA census naics table figure for this page, got {e}")
|
||||
st.error(
|
||||
f"Failed to generate the PA census naics table figure for this page. A detailed error has been added to the logs. {self.app_config.get_errors_contact_string()}")
|
||||
st.stop()
|
||||
|
||||
try:
|
||||
heatmap_fig = make_county_heatmap(
|
||||
county_df,
|
||||
value_column=OUT_COLUMNS.pct_missing_naics,
|
||||
title=f'Missing Client NAICS Codes Per County {selected_fiscal_year}',
|
||||
)
|
||||
heatmap_fig.update_layout(height=799)
|
||||
except Exception as e:
|
||||
self.logger.exception(
|
||||
f"Failed to generate the county missing NAICS heatmap figure for this page, got {e}")
|
||||
st.error(
|
||||
f"Failed to generate the county missing NAICS heatmap figure for this page. A detailed error has been added to the logs. {self.app_config.get_errors_contact_string()}")
|
||||
st.stop()
|
||||
|
||||
return {
|
||||
"comparison_graph_fig":FigureWithMaxY(figure=comparison_graph_fig, max_y=comparison_graph_max_y),
|
||||
"census_naics_fig":FigureWithMaxY(figure=census_naics_fig, max_y=0.0),
|
||||
"heatmap_fig":FigureWithMaxY(figure=heatmap_fig, max_y=0.0),
|
||||
"client_list_df":client_list_df,
|
||||
"naics_df":naics_df,
|
||||
"filtered_df":filtered_df,
|
||||
"county_df":county_df
|
||||
}
|
||||
|
||||
def render_figures(self, container: DeltaGenerator, output_data: Dict[str, Any]):
|
||||
"""
|
||||
Executes the presentation tier, pushing generated artifacts to the Streamlit layout.
|
||||
|
||||
Draws the computed Plotly figures onto the screen and utilizes an expander module
|
||||
to expose the raw, underlying dataframes for auditing and transparency.
|
||||
|
||||
:param container: The Streamlit container to draw the visuals onto.
|
||||
:type container: DeltaGenerator
|
||||
:param output_data: The dictionary of figures and dataframes returned by generate_figures.
|
||||
:type output_data: Dict[str, Any]
|
||||
"""
|
||||
|
||||
comparison_graph_fig:Figure = output_data["comparison_graph_fig"]["figure"]
|
||||
census_naics_fig:Figure = output_data["census_naics_fig"]["figure"]
|
||||
heatmap_fig:Figure = output_data["heatmap_fig"]["figure"]
|
||||
|
||||
client_list_df = output_data["client_list_df"]
|
||||
naics_df = output_data["naics_df"]
|
||||
county_df = output_data["county_df"]
|
||||
|
||||
container.plotly_chart(comparison_graph_fig, key=self.get_widget_key("comparison_graph"))
|
||||
container.plotly_chart(census_naics_fig, key=self.get_widget_key("census_naics_table"))
|
||||
container.plotly_chart(heatmap_fig, key=self.get_widget_key("heatmap_graph"))
|
||||
dataset_expander = container.expander(
|
||||
label="Source Datasets",
|
||||
expanded=True,
|
||||
key=self.get_widget_key("dataset_expander")
|
||||
)
|
||||
|
||||
dataset_expander.markdown("## Source Data")
|
||||
dataset_expander.markdown("### Neoserra Client List")
|
||||
dataset_expander.write(client_list_df)
|
||||
dataset_expander.markdown("### PA Census Data")
|
||||
dataset_expander.write(naics_df)
|
||||
dataset_expander.markdown("### Per County Missing NAICS Data")
|
||||
dataset_expander.write(county_df)
|
||||
|
||||
@staticmethod
|
||||
def get_page_name():
|
||||
"""
|
||||
Provides the static human-readable identifier for this specific report.
|
||||
|
||||
Utilized by the ComparerPage to construct navigation menus.
|
||||
|
||||
:return: The display name of the report.
|
||||
:rtype: str
|
||||
"""
|
||||
return "NAICS Report"
|
||||
256
streamlit_dashboard/page_classes/nbs_milestones_page_class.py
Normal file
256
streamlit_dashboard/page_classes/nbs_milestones_page_class.py
Normal file
@@ -0,0 +1,256 @@
|
||||
from typing import Dict, Any, List
|
||||
import logging
|
||||
|
||||
import pandas as pd
|
||||
from streamlit.delta_generator import DeltaGenerator
|
||||
from fiscalyear import *
|
||||
from plotly.graph_objects import Figure
|
||||
import streamlit as st
|
||||
|
||||
from constants_module import OUT_COLUMNS, NEOSERRA_COLUMNS, TRAINING_COUNT_COLUMNS
|
||||
from streamlit_constants import DASHBOARD_CONFIG_OBJECT_KEY
|
||||
from utility_classes.base_report_page import BaseReportPage
|
||||
from utility_classes.figure_with_max_y import FigureWithMaxY
|
||||
from cached_function_wrappers.shared import get_df_centers
|
||||
from cached_function_wrappers.nbs_cached_functions import cached_get_nbs_data
|
||||
from section_1_graph_library_module import ( # pyright:ignore
|
||||
make_nbs_attribution_network_wide,
|
||||
make_attribution_rate_chart,
|
||||
make_theoretical_attribution_rate_chart,
|
||||
)
|
||||
from utility_classes.dashboard_config_parser import DashboardConfig, ExportModulePair
|
||||
|
||||
|
||||
class NetworkNbsMilestonesReportPage(BaseReportPage):
|
||||
"""
|
||||
Concrete implementation of a report page analyzing New Business Start (NBS) milestones.
|
||||
|
||||
This class manages the data retrieval and rendering lifecycle for NBS metrics. It tracks
|
||||
attribution sources and documentation compliance rates across centers, utilizing temporal
|
||||
state (fiscal years) to route requests to the correct data endpoints.
|
||||
|
||||
:param kwargs: Arbitrary keyword arguments
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""
|
||||
Initializes the temporal boundaries and configuration state for the NBS report.
|
||||
|
||||
Captures the current and previous fiscal years to set up report filtering logic and
|
||||
extracts the global dashboard configuration from the session state to resolve data export URLs.
|
||||
|
||||
:param kwargs: Arbitrary keyword arguments.
|
||||
"""
|
||||
|
||||
super().__init__("Network New Business Start Milestones Report")
|
||||
|
||||
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:]}'
|
||||
|
||||
def get_fiscal_year_export_url(self, selected_fiscal_year) -> str:
|
||||
"""
|
||||
Resolves the external dataset endpoint based on the selected temporal state.
|
||||
|
||||
Maps the user's fiscal year selection to the appropriate configured export URL,
|
||||
ensuring downstream data fetches hit the correct historical or current dataset.
|
||||
|
||||
:param selected_fiscal_year: The string representation of the 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_nbs_milestones_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 the report.
|
||||
|
||||
This value is consumed by dashboard orchestrators to populate navigation menus
|
||||
and UI selectors.
|
||||
|
||||
:return: The human-readable name of the report.
|
||||
:rtype: str
|
||||
"""
|
||||
return "NBS Funding Milestones"
|
||||
|
||||
def render_controls(self, container: DeltaGenerator) -> Dict[str, Any]:
|
||||
"""
|
||||
Defines the input UI and captures the parameter state required for report generation.
|
||||
|
||||
Renders selection widgets for fiscal year and target centers. Enforces a strict halt
|
||||
on Streamlit execution if the base dataset fails to load, preventing cascading errors
|
||||
in downstream processing steps.
|
||||
|
||||
:param container: The layout element 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"))
|
||||
|
||||
export_url:str = 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(
|
||||
f"Failed to get the list of all centers for the dataset for this page. A detailed error message has been added to the logs. {self.app_config.get_errors_contact_string()}")
|
||||
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
|
||||
}
|
||||
|
||||
def generate_figures(self, parameters: Dict[str, Any]):
|
||||
"""
|
||||
Executes the analytical data pipeline and constructs visualization objects.
|
||||
|
||||
Fetches the NBS dataset, applies structural transformations, and generates Plotly charts
|
||||
for attribution and documentation rates. Computes the maximum Y-axis value for quantity-based
|
||||
charts to enable cross-report axis synchronization.
|
||||
|
||||
:param parameters: The parameter 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 = parameters['selected_fiscal_year']
|
||||
selected_centers = parameters['selected_centers']
|
||||
reportable_only = parameters['reportable_only']
|
||||
|
||||
export_url = self.get_fiscal_year_export_url(selected_fiscal_year)
|
||||
|
||||
nbs_df = cached_get_nbs_data(export_url, reportable_only=reportable_only, allowed_centers=selected_centers)
|
||||
|
||||
try:
|
||||
network_wide_funding_fig = make_nbs_attribution_network_wide(
|
||||
nbs_df,
|
||||
# Zero out the graph note, we'll render it manually later
|
||||
graph_note="",
|
||||
title=f"New Business Start Attributions Per Center {selected_fiscal_year}",
|
||||
network_label="PASBDC*",
|
||||
col_neo_center=NEOSERRA_COLUMNS.center,
|
||||
col_documentation_level=OUT_COLUMNS.milestone_documentation_level
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.exception(f"Failed to generate the network wide funding stacked bars chart: {e}")
|
||||
st.error(f"Failed to generate the network wide stacked funding bar chart for this page. A detailed error has been placed in the logs. {self.app_config.get_errors_contact_string()}")
|
||||
st.stop()
|
||||
|
||||
try:
|
||||
documented_only_fig = make_attribution_rate_chart(
|
||||
nbs_df,
|
||||
fiscalyear=selected_fiscal_year,
|
||||
documented_tag=OUT_COLUMNS.val_documented,
|
||||
col_neo_center=NEOSERRA_COLUMNS.center,
|
||||
col_documentation_level=OUT_COLUMNS.milestone_documentation_level
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.exception(f"Failed to generate the network wide documented only bar chart: {e}")
|
||||
st.error(
|
||||
f"Failed to generate the network wide documented only bar chart for this page. A detailed error has been placed in the logs. {self.app_config.get_errors_contact_string()}")
|
||||
st.stop()
|
||||
try:
|
||||
theoretical_fig = make_theoretical_attribution_rate_chart(
|
||||
nbs_df,
|
||||
title=f"Documented Percentage if All Funding Milestones With an Attribution Source had an Affirmation {selected_fiscal_year}",
|
||||
documented_tag=OUT_COLUMNS.val_documented,
|
||||
affirmation_missing_tag=OUT_COLUMNS.val_affirmation_missing,
|
||||
col_neo_center=NEOSERRA_COLUMNS.center,
|
||||
col_documentation_level=OUT_COLUMNS.milestone_documentation_level
|
||||
)
|
||||
|
||||
# Obtain the max y value for the chart we care about synchronizing the axis with if this report is in
|
||||
# a page comparer
|
||||
max_bar_y = nbs_df[NEOSERRA_COLUMNS.center].value_counts().max()
|
||||
except Exception as e:
|
||||
self.logger.exception(f"Failed to generate the network wide theoretical documentation bar chart: {e}")
|
||||
st.error(
|
||||
f"Failed to generate the network wide theoretical documentation bar chart for this page. A detailed error has been placed in the logs. {self.app_config.get_errors_contact_string()}")
|
||||
st.stop()
|
||||
|
||||
return {
|
||||
"network_wide_nbs_fig":FigureWithMaxY(figure=network_wide_funding_fig, max_y=max_bar_y),
|
||||
"documented_only_fig":FigureWithMaxY(figure=documented_only_fig, max_y=0.0),
|
||||
"theoretical_fig":FigureWithMaxY(figure=theoretical_fig, max_y=0.0),
|
||||
"nbs_df":nbs_df
|
||||
}
|
||||
|
||||
def render_figures(self, container: DeltaGenerator, output_data: Dict[str, Any]):
|
||||
"""
|
||||
Binds the computed visualization artifacts and raw data to the Streamlit UI.
|
||||
|
||||
Draws the Plotly figures to the screen and injects static HTML definitions for
|
||||
documentation compliance levels to ensure users can accurately interpret the charts.
|
||||
Exposes the raw dataframe via an expander for data auditing.
|
||||
|
||||
:param container: The layout element to draw the report visuals onto.
|
||||
:type container: DeltaGenerator
|
||||
:param output_data: The computed figures and dataframes from generate_figures.
|
||||
:type output_data: Dict[str, Any]
|
||||
"""
|
||||
network_wide_nbs_fig:Figure= output_data['network_wide_nbs_fig']['figure']
|
||||
documented_only_fig:Figure = output_data['documented_only_fig']['figure']
|
||||
theoretical_fig:Figure = output_data['theoretical_fig']['figure']
|
||||
nbs_df:pd.DataFrame = output_data['nbs_df']
|
||||
|
||||
container.plotly_chart(network_wide_nbs_fig, key=self.get_widget_key("network_wide_funding_fig"))
|
||||
container.markdown(
|
||||
"<b>NOTE:</b>Documentation levels were determined as follows.<br><br>"
|
||||
"<b>Documented: Will be submitted to Nexus as long as 'Director Verified' is checked</b></br> There is a non-blank, non-'Requested on eCenter' attribution source AND Affirmation Statement was non-blank<br>\tNOTE: If the attribution source is eCenter, no affirmation is required.<br>"
|
||||
"<b>Affirmation Statement Missing: Will NOT be submitted to Nexus</b></br> Attribution source is non-blank, non-'Requested on eCenter' BUT affirmation statement was blank.</br>"
|
||||
"<b>Not Documented:Will NOT be submitted to Nexus</b></br> The attribution source is blank or 'Requested on eCenter'",
|
||||
unsafe_allow_html=True
|
||||
)
|
||||
container.plotly_chart(documented_only_fig, key=self.get_widget_key("documented_only_fig"))
|
||||
container.plotly_chart(theoretical_fig, key=self.get_widget_key("theoretical_fig"))
|
||||
|
||||
dataset_expander = container.expander(
|
||||
label="Source Datasets",
|
||||
expanded=True,
|
||||
key=self.get_widget_key("dataset_expander")
|
||||
)
|
||||
dataset_expander.markdown("## Source Data")
|
||||
dataset_expander.markdown("### Neoserra Trainings Dataset")
|
||||
dataset_expander.write(nbs_df)
|
||||
|
||||
def get_syncable_figure_keys(self) -> List[str]:
|
||||
"""
|
||||
Declares the specific figures that permit dynamic external Y-axis scaling.
|
||||
|
||||
Restricts synchronization to the network-wide funding figure, as scaling axes on
|
||||
the percentage-based attribution charts would distort the visual representation.
|
||||
|
||||
:return: A list of dictionary keys corresponding to sync-compatible figures.
|
||||
:rtype: List[str]
|
||||
"""
|
||||
return ["network_wide_nbs_fig"]
|
||||
61
streamlit_dashboard/page_classes/page_class_constants.py
Normal file
61
streamlit_dashboard/page_classes/page_class_constants.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""
|
||||
Central dependency injection registry and configuration mapping for dashboard reports.
|
||||
|
||||
This module defines the `REPORT_CONFIGS` dictionary, which acts as the single source
|
||||
of truth for instantiating concrete report classes. It decouples the application's page
|
||||
routing and security middleware (`AuthenticationPageWrapper`) from the specific initialization
|
||||
requirements of each individual report class.
|
||||
|
||||
By mapping class definitions directly to their required keyword arguments, the application
|
||||
can dynamically construct complex pages at runtime. For example, it securely injects environment
|
||||
constants and API credentials into data-heavy classes like `NaicsReportPage`, while providing
|
||||
empty configuration dictionaries to self-contained classes like `NetworkNbsMilestonesReportPage`
|
||||
and `NetworkFundingMilestonesReportPage` to register them as valid, instantiable routes.
|
||||
|
||||
Crucially, it injects this entire configuration registry into the `ComparerPage`, granting it
|
||||
the necessary architectural context to dynamically instantiate and manage side-by-side versions
|
||||
of any other active report in the system.
|
||||
"""
|
||||
from page_classes.naics_report_page_class import NaicsReportPage
|
||||
from page_classes.nbs_milestones_page_class import NetworkNbsMilestonesReportPage
|
||||
from page_classes.funding_milestones_page_class import NetworkFundingMilestonesReportPage
|
||||
from page_classes.training_count_statistics_page_class import TrainingsCountStatisticsPage
|
||||
from page_classes.training_attendee_ranges_page_class import TrainingAttendeeRanges
|
||||
from page_classes.training_event_count_attendee_comparison_page_class import TrainingEventCountAttendeeComparison
|
||||
from page_classes.training_primary_topics_page_class import TrainingsPrimaryTopicsPage
|
||||
from page_classes.training_attendee_counts_class import TrainingEventAttendeeCounts
|
||||
from page_classes.training_event_count_page_class import TrainingsEventCountsPage
|
||||
from page_classes.report_comparer_page_class import ComparerPage
|
||||
from page_classes.center_milestones_page_class import CenterMilestonesReportPage
|
||||
from page_classes.home_page_class import HomePage
|
||||
from streamlit_constants import CENSUS_YEAR, USDA_API_KEY, CURRENT_FY_NBS_EXPORT_URL, PREV_FY_NBS_EXPORT_URL, CURRENT_FY_FUNDING_EXPORT_URL, PREV_FY_FUNDING_EXPORT_URL, CURRENT_FY_TRAININGS_EXPORT_URL, PREV_FY_TRAININGS_EXPORT_URL
|
||||
|
||||
REPORT_CONFIGS = {
|
||||
NaicsReportPage:{
|
||||
},
|
||||
# These reports do not need any arguments for their constructors, but we still define them as blank so that the app knows what report pages are available in the app
|
||||
NetworkNbsMilestonesReportPage:{
|
||||
},
|
||||
NetworkFundingMilestonesReportPage: {
|
||||
},
|
||||
TrainingsCountStatisticsPage: {
|
||||
},
|
||||
TrainingAttendeeRanges: {
|
||||
},
|
||||
TrainingEventCountAttendeeComparison:{
|
||||
},
|
||||
TrainingsPrimaryTopicsPage: {
|
||||
},
|
||||
TrainingEventAttendeeCounts: {
|
||||
},
|
||||
TrainingsEventCountsPage: {
|
||||
},
|
||||
CenterMilestonesReportPage: {
|
||||
},
|
||||
HomePage: {
|
||||
},
|
||||
}
|
||||
|
||||
REPORT_CONFIGS[ComparerPage] = {
|
||||
'report_configs': REPORT_CONFIGS
|
||||
}
|
||||
159
streamlit_dashboard/page_classes/report_comparer_page_class.py
Normal file
159
streamlit_dashboard/page_classes/report_comparer_page_class.py
Normal file
@@ -0,0 +1,159 @@
|
||||
import copy
|
||||
from typing import List, Dict, Any
|
||||
from copy import deepcopy
|
||||
|
||||
from streamlit.delta_generator import DeltaGenerator
|
||||
from tests.test_init import kwargs
|
||||
|
||||
from utility_classes.base_report_page import BaseReportPage
|
||||
from utility_classes.figure_with_max_y import FigureWithMaxY
|
||||
from page_classes.home_page_class import HomePage
|
||||
|
||||
class ComparerPage(BaseReportPage):
|
||||
"""
|
||||
Orchestrates a dual-column layout to allow dynamic side-by-side report comparison.
|
||||
|
||||
Leverages the BaseReportPage lifecycle to intercept data generation between two
|
||||
child columns. If identical report types are selected, it extracts their max values
|
||||
and synchronizes their Y-axes globally before allowing the final rendering phase,
|
||||
ensuring an accurate visual comparison.
|
||||
|
||||
:param report_configs: Mapping of report page classes to their respective instantiation arguments.
|
||||
:type report_configs: dict
|
||||
"""
|
||||
def __init__(self, report_configs):
|
||||
super().__init__("comparer-page")
|
||||
|
||||
# Ensure that the popping of homepage and comparer page do not affect the instantiation of those two pages
|
||||
self.report_configs = copy.deepcopy(report_configs)
|
||||
self.report_configs.pop(HomePage, None)
|
||||
self.report_configs.pop(ComparerPage, None)
|
||||
|
||||
self.report_name_map = {}
|
||||
for report_page in self.report_configs.keys():
|
||||
self.report_name_map[report_page.get_page_name()] = report_page
|
||||
|
||||
def render(self, container: DeltaGenerator):
|
||||
"""
|
||||
Splits the layout and manages the synchronization pipeline for both child reports.
|
||||
|
||||
:param container: The main Streamlit container allocated for the comparer.
|
||||
:type container: DeltaGenerator
|
||||
"""
|
||||
left_col, right_col = container.columns([0.5, 0.5])
|
||||
|
||||
left_comparer = ComparerColumn(self.report_name_map, self.report_configs, "left")
|
||||
right_comparer = ComparerColumn(self.report_name_map, self.report_configs, "right")
|
||||
|
||||
# Render the controls of each report
|
||||
left_params = left_comparer.render_controls(left_col)
|
||||
right_params = right_comparer.render_controls(right_col)
|
||||
|
||||
# Here we are going to generate the graph figures for the reports, and determine if they
|
||||
# have any graphs for which the y axis's should be matched
|
||||
if left_params and right_params:
|
||||
left_outputs = left_comparer.generate_figures(left_params)
|
||||
right_outputs = right_comparer.generate_figures(right_params)
|
||||
|
||||
if left_outputs and right_outputs:
|
||||
left_instance = left_comparer.selected_report_instance
|
||||
right_instance = right_comparer.selected_report_instance
|
||||
|
||||
# Only sync if the user is comparing two of the same report type
|
||||
if type(left_instance) == type(right_instance):
|
||||
|
||||
# Agnostically ask the report which figures to sync
|
||||
keys_to_sync = left_instance.get_syncable_figure_keys()
|
||||
|
||||
for key in keys_to_sync:
|
||||
left_fig:FigureWithMaxY = left_outputs.get(key)
|
||||
right_fig:FigureWithMaxY = right_outputs.get(key)
|
||||
|
||||
if left_fig and right_fig:
|
||||
# Get the global max
|
||||
global_max = max(left_fig['max_y'], right_fig['max_y'])
|
||||
|
||||
# Apply the sync with a 5% buffer at the top
|
||||
if global_max > 0:
|
||||
y_range = [0, global_max * 1.05]
|
||||
left_fig['figure'].update_layout(yaxis=dict(range=y_range))
|
||||
right_fig['figure'].update_layout(yaxis=dict(range=y_range))
|
||||
|
||||
# Render the outputs, regardless of anything had to be synced
|
||||
left_comparer.render_figures(left_col, left_outputs)
|
||||
right_comparer.render_figures(right_col, right_outputs)
|
||||
|
||||
|
||||
class ComparerColumn(BaseReportPage):
|
||||
"""
|
||||
Acts as an interactive proxy wrapper for a single report within a comparison layout.
|
||||
|
||||
Handles dynamic instantiation of a user-selected report and forcibly overrides its
|
||||
instance ID. This guarantees that Streamlit widget keys remain completely unique
|
||||
even if the user selects the exact same report class in both comparison columns.
|
||||
|
||||
:param report_name_map: Dictionary mapping UI display names to BaseReportPage classes.
|
||||
:type report_name_map: dict
|
||||
:param report_configs: Dictionary mapping classes to their config keyword arguments.
|
||||
:type report_configs: dict
|
||||
:param column_id: An identifier (e.g., 'left', 'right') prepended to prevent widget collisions.
|
||||
:type column_id: str
|
||||
"""
|
||||
def __init__(self, report_name_map: dict, report_configs: dict, column_id: str):
|
||||
super().__init__(f"comparer-column-{column_id}")
|
||||
self.report_name_map = report_name_map
|
||||
self.report_name_map.pop(HomePage, None)
|
||||
self.report_configs = report_configs
|
||||
self.column_id = column_id
|
||||
|
||||
self.selected_report_instance = None
|
||||
|
||||
def render_controls(self, container: DeltaGenerator) -> Dict[str, Any]:
|
||||
"""
|
||||
Draws a selection menu to instantiate the desired report, then renders its specific controls.
|
||||
|
||||
:param container: The Streamlit column container.
|
||||
:type container: DeltaGenerator
|
||||
:return: The parameters generated by the selected report's controls.
|
||||
:rtype: Dict[str, Any]
|
||||
"""
|
||||
selected_report = container.selectbox(
|
||||
label="Select the report to render",
|
||||
options=list(self.report_name_map.keys()),
|
||||
key=self.get_widget_key("report_selectbox")
|
||||
)
|
||||
|
||||
report_page_class = self.report_name_map[selected_report]
|
||||
report_page_parameters = self.report_configs[report_page_class]
|
||||
|
||||
self.selected_report_instance = report_page_class(**report_page_parameters)
|
||||
self.selected_report_instance.instance_id = f'{self.column_id}_{selected_report}'
|
||||
|
||||
return self.selected_report_instance.render_controls(container)
|
||||
|
||||
def generate_figures(self, parameters: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Delegates figure generation to the currently instantiated report.
|
||||
|
||||
:param parameters: User inputs specific to the selected report.
|
||||
:type parameters: Dict[str, Any]
|
||||
:return: The output data payload from the selected report.
|
||||
:rtype: Dict[str, Any]
|
||||
"""
|
||||
if self.selected_report_instance and parameters is not None:
|
||||
return self.selected_report_instance.generate_figures(parameters)
|
||||
|
||||
return None
|
||||
|
||||
def render_figures(self, container: DeltaGenerator, output_data: Dict[str, Any]):
|
||||
"""
|
||||
Delegates the final drawing phase to the currently instantiated report.
|
||||
|
||||
:param container: The Streamlit column container.
|
||||
:type container: DeltaGenerator
|
||||
:param output_data: The display data, potentially mutated by the parent ComparerPage.
|
||||
:type output_data: Dict[str, Any]
|
||||
"""
|
||||
|
||||
if self.selected_report_instance and output_data is not None:
|
||||
self.selected_report_instance.render_figures(container, output_data)
|
||||
@@ -0,0 +1,238 @@
|
||||
from typing import List, Dict, Any
|
||||
import logging
|
||||
|
||||
import pandas as pd
|
||||
from fiscalyear import FiscalYear
|
||||
from streamlit.delta_generator import DeltaGenerator
|
||||
import streamlit as st
|
||||
|
||||
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_attendee_statistics_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 TrainingEventAttendeeCounts(BaseReportPage):
|
||||
"""
|
||||
Concrete implementation of a report page analyzing network-wide training attendance.
|
||||
|
||||
This class manages the pipeline for comparing total event attendance against specific
|
||||
subsets (like first-time and pre-planning attendees). It handles temporal filtering
|
||||
and orchestrates multiple chart variants to provide both absolute and proportional metrics.
|
||||
|
||||
:param kwargs: Arbitrary keyword arguments passed to the parent BaseReportPage constructor.
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""
|
||||
Initializes temporal boundaries and establishes configuration state.
|
||||
|
||||
Captures the current and previous fiscal years for UI parameterization and extracts
|
||||
the global application configuration from the session state to locate correct data endpoints.
|
||||
|
||||
:param kwargs: Arbitrary keyword arguments.
|
||||
"""
|
||||
super().__init__("Network Wide Training Event Attendee 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 specific dataset endpoint URL corresponding to the selected temporal state.
|
||||
|
||||
:param selected_fiscal_year: The formatted string representing the user's chosen fiscal year.
|
||||
:type selected_fiscal_year: Any
|
||||
:return: The URL for the trainings 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 human-readable identifier for this report component.
|
||||
|
||||
Used by the dashboard orchestrator to populate navigation elements.
|
||||
|
||||
:return: The display name of the report.
|
||||
:rtype: str
|
||||
"""
|
||||
|
||||
return "Training Event Attendee Counts"
|
||||
|
||||
def render_controls(self, container: DeltaGenerator) -> Dict[str, Any]:
|
||||
"""
|
||||
Defines the input state parameters and establishes a safe execution boundary.
|
||||
|
||||
Renders selection controls for fiscal year and target centers. Implements a fail-fast
|
||||
pattern that halts the Streamlit execution tree if the underlying dataset fails to load,
|
||||
preventing rendering errors in subsequent pipeline stages.
|
||||
|
||||
:param container: The Streamlit container to draw the input widgets onto.
|
||||
:type container: DeltaGenerator
|
||||
:return: A dictionary containing 'selected_fiscal_year' and 'selected_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 data processing pipeline and constructs the visualization artifacts.
|
||||
|
||||
Fetches the cleaned trainings dataset and generates a suite of chart variants (both
|
||||
absolute counts and percentages). It actively computes the maximum Y-axis boundaries
|
||||
for the quantity-based charts to enable parent orchestrators to synchronize external axes.
|
||||
|
||||
:param parameters: The parameter state dictionary captured from render_controls.
|
||||
: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:
|
||||
attendee_figs = make_center_attendee_statistics_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: {e}")
|
||||
st.error(f"Failed to generate the figures for this page. A detailed error has been added to the logs. {self.app_config.get_errors_contact_string()}")
|
||||
st.stop()
|
||||
|
||||
return {
|
||||
'attendees_total': find_fig_max_y_and_generate_wrapper(attendee_figs[StatChartVariants.TOTAL_COUNT]),
|
||||
'attendees_percent': FigureWithMaxY(figure=attendee_figs[StatChartVariants.TOTAL_PERCENT]),
|
||||
'attendees_first_pre_only': find_fig_max_y_and_generate_wrapper(attendee_figs[StatChartVariants.FIRST_AND_PREPLANNING_ONLY]),
|
||||
'attendees_first_pre_only_percent': FigureWithMaxY(figure=attendee_figs[StatChartVariants.FIRST_AND_PREPLANNING_ONLY_PERCENT], max_y=0.0),
|
||||
'trainings_df':trainings_df
|
||||
}
|
||||
|
||||
def render_figures(self, container: DeltaGenerator, output_data: Dict[str, Any]):
|
||||
"""
|
||||
Maps the generated visual artifacts to a defined spatial layout within the Streamlit UI.
|
||||
|
||||
Arranges the absolute count and percentage charts into a comparative 2x2 grid layout
|
||||
for immediate visual contrast, and exposes the underlying dataset via an expander for auditing.
|
||||
|
||||
: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]
|
||||
"""
|
||||
attendees_total = output_data.get("attendees_total")["figure"]
|
||||
attendees_percent = output_data.get("attendees_percent")["figure"]
|
||||
attendees_first_pre_only = output_data.get("attendees_first_pre_only")["figure"]
|
||||
attendees_first_pre_only_percent = output_data.get("attendees_first_pre_only_percent")["figure"]
|
||||
trainings_df:pd.DataFrame = output_data.get("trainings_df")
|
||||
|
||||
left_col, right_col = container.columns([0.5,0.5])
|
||||
left_col.plotly_chart(attendees_total, key=self.get_widget_key("attendees_total"))
|
||||
right_col.plotly_chart(attendees_percent, key=self.get_widget_key("attendees_percent"))
|
||||
|
||||
left_col, right_col = container.columns([0.5,0.5])
|
||||
left_col.plotly_chart(attendees_first_pre_only, key=self.get_widget_key("attendees_first_pre_only"))
|
||||
right_col.plotly_chart(attendees_first_pre_only_percent, key=self.get_widget_key("attendees_first_pre_only_percent"))
|
||||
|
||||
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 visualization objects that support dynamic external Y-axis scaling.
|
||||
|
||||
Explicitly filters out the percentage-based charts to ensure external synchronization
|
||||
is only applied to absolute quantity charts, preventing visual distortion.
|
||||
|
||||
:return: A list of dictionary keys corresponding to absolute count figures.
|
||||
:rtype: List[str]
|
||||
"""
|
||||
return ["attendees_total", "attendees_first_pre_only"]
|
||||
@@ -0,0 +1,258 @@
|
||||
from typing import List, Dict, Any
|
||||
import logging
|
||||
|
||||
import pandas as pd
|
||||
from fiscalyear import FiscalYear
|
||||
from streamlit.delta_generator import DeltaGenerator
|
||||
import streamlit as st
|
||||
|
||||
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_attendee_bins_statistics_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 TrainingAttendeeRanges(BaseReportPage):
|
||||
"""
|
||||
Concrete implementation of a report page analyzing training attendee size distributions.
|
||||
|
||||
This class manages the pipeline for categorizing training events into attendee size brackets (bins).
|
||||
It isolates specific training types—such as 'First Steps' and 'Preplanning'—to evaluate how
|
||||
introductory courses impact the overall network attendee size distributions.
|
||||
|
||||
:param kwargs: Arbitrary keyword arguments passed to the parent BaseReportPage constructor.
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""
|
||||
Initializes the temporal boundaries and configuration state for the attendee ranges report.
|
||||
|
||||
Captures current and previous fiscal years to manage report filtering and extracts
|
||||
the global application configuration to resolve the appropriate external data endpoints.
|
||||
|
||||
:param kwargs: Arbitrary keyword arguments.
|
||||
"""
|
||||
super().__init__("Network Wide Training Attendee Ranges")
|
||||
|
||||
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 temporal state.
|
||||
|
||||
Maps the user's selected fiscal year to the appropriate data URL, ensuring the pipeline
|
||||
fetches the correct historical or current training records.
|
||||
|
||||
:param selected_fiscal_year: The string representation of the 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 Attendee Ranges"
|
||||
|
||||
def render_controls(self, container: DeltaGenerator) -> Dict[str, Any]:
|
||||
"""
|
||||
Defines the user input interface and establishes a safe execution boundary.
|
||||
|
||||
Renders selection widgets for fiscal year and target centers. Implements a strict fail-fast
|
||||
pattern that halts the Streamlit execution sequence if the baseline dataset fails to load,
|
||||
preventing downstream 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 binned visualization objects.
|
||||
|
||||
Fetches the trainings dataset and generates a suite of chart variants comparing absolute
|
||||
counts against percentages across different training subsets. Computes strict max-Y values
|
||||
for the quantity-based 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
|
||||
)
|
||||
|
||||
self.logger.error(f"{trainings_df.info()}")
|
||||
|
||||
try:
|
||||
bins_figs = make_attendee_bins_statistics_charts(
|
||||
trainings_df,
|
||||
center="Network Wide",
|
||||
network_label="PASBDC*",
|
||||
fiscal_year_tag=selected_fiscal_year,
|
||||
first_steps_vals=['First Steps', 'Next Steps'],
|
||||
preplanning_val=OUT_COLUMNS.val_preplanning,
|
||||
col_neo_attendees_total=NEOSERRA_COLUMNS.attendees_total,
|
||||
col_attendees_range=OUT_COLUMNS.attendees_range,
|
||||
col_neo_primary_topic=NEOSERRA_COLUMNS.primary_training_topic
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.exception(f"Failed to generate figures for this page: {e}")
|
||||
st.error(f"Failed to generate the figures for this page. A detailed error has been added to the logs. {self.app_config.get_errors_contact_string()}")
|
||||
st.stop()
|
||||
|
||||
return {
|
||||
'total_count':find_fig_max_y_and_generate_wrapper(bins_figs[StatChartVariants.TOTAL_COUNT]),
|
||||
'total_percent':FigureWithMaxY(figure=bins_figs[StatChartVariants.TOTAL_PERCENT], max_y=0.0),
|
||||
'no_first_no_pre_count':find_fig_max_y_and_generate_wrapper(bins_figs[StatChartVariants.NO_FIRST_NO_PREPLANNNG_COUNT]),
|
||||
'no_first_no_pre_percent':FigureWithMaxY(figure=bins_figs[StatChartVariants.NO_FIRST_NO_PREPLANNNG_PERCENT], max_y=0.0),
|
||||
'first_pre_only_count':find_fig_max_y_and_generate_wrapper(bins_figs[StatChartVariants.FIRST_AND_PREPLANNING_ONLY]),
|
||||
'first_pre_only_percent':FigureWithMaxY(figure=bins_figs[StatChartVariants.FIRST_AND_PREPLANNING_ONLY_PERCENT], max_y=0.0),
|
||||
'trainings_df':trainings_df
|
||||
}
|
||||
|
||||
def render_figures(self, container: DeltaGenerator, output_data: Dict[str, Any]):
|
||||
"""
|
||||
Iteratively maps the generated paired visual artifacts to the Streamlit layout.
|
||||
|
||||
Arranges the charts sequentially using a repetitive 2-column layout to directly contrast
|
||||
absolute counts with proportional percentages for each training subset. Exposes the raw
|
||||
underlying dataset via an expander module to ensure 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]
|
||||
"""
|
||||
|
||||
chart_pairs = [
|
||||
("All Trainings", "total_count", "total_percent"),
|
||||
("Excluding First Steps & Preplanning", "no_first_no_pre_count", "no_first_no_pre_percent"),
|
||||
("First Steps & Preplanning Only", "first_pre_only_count", "first_pre_only_percent"),
|
||||
]
|
||||
trainings_df:pd.DataFrame = output_data.get("trainings_df")
|
||||
|
||||
for title, count_key, percent_key in chart_pairs:
|
||||
# Add a subheader for the section
|
||||
container.subheader(title)
|
||||
|
||||
# Create a 2-column layout
|
||||
col1, col2 = container.columns(2)
|
||||
|
||||
# Extract and render the count figure
|
||||
count_fig = output_data.get(count_key)['figure']
|
||||
if count_fig:
|
||||
col1.plotly_chart(count_fig, use_container_width=True, key=self.get_widget_key(count_key))
|
||||
|
||||
# Extract and render the percentage figure
|
||||
percent_fig = output_data.get(percent_key)['figure']
|
||||
if percent_fig:
|
||||
col2.plotly_chart(percent_fig, use_container_width=True, key=self.get_widget_key(percent_key))
|
||||
|
||||
# Add a horizontal line to separate sections cleanly
|
||||
container.divider()
|
||||
|
||||
dataset_expander = container.expander(
|
||||
label="Source Datasets",
|
||||
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 count charts for synchronization, filtering out the
|
||||
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 ["total_count", "no_first_no_pre_count", "first_pre_only_count"]
|
||||
@@ -0,0 +1,277 @@
|
||||
from typing import List, Dict, Any
|
||||
import logging
|
||||
|
||||
from fiscalyear import FiscalYear
|
||||
import pandas as pd
|
||||
from streamlit.delta_generator import DeltaGenerator
|
||||
import streamlit as st
|
||||
from constants_module import TRAINING_COUNT_COLUMNS, NEOSERRA_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, cached_generate_center_trainings_count_statistics
|
||||
from section_1_graph_library_module import make_network_trainings_count_statistics_charts, StatChartVariants
|
||||
from utility_classes.figure_with_max_y import FigureWithMaxY, find_fig_max_y_and_generate_wrapper
|
||||
from utility_classes.dashboard_config_parser import DashboardConfig, ExportModulePair
|
||||
|
||||
|
||||
class TrainingsCountStatisticsPage(BaseReportPage):
|
||||
"""
|
||||
Concrete implementation of a report page analyzing zero-attendee training statistics.
|
||||
|
||||
This class manages the data pipeline for evaluating un-attended training events across the network.
|
||||
It categorizes these events into specific subsets (such as 'First Steps', 'Preplanning', and 'On-Demand')
|
||||
to help administrators identify which types of courses historically suffer from zero attendance.
|
||||
|
||||
:param kwargs: Arbitrary keyword arguments passed to the parent BaseReportPage constructor.
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""
|
||||
Initializes temporal filtering boundaries and application configuration state.
|
||||
|
||||
Captures the current and previous fiscal years to parameterize the report and extracts
|
||||
the global dashboard configuration to resolve the necessary Neoserra export endpoints.
|
||||
|
||||
:param kwargs: Arbitrary keyword arguments.
|
||||
"""
|
||||
super().__init__("Network Wide Training 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 appropriate Neoserra Export Module dataset URL based on the active temporal state.
|
||||
|
||||
Maps the user-selected fiscal year string to the specific data endpoint, ensuring
|
||||
subsequent data fetches query the correct historical or current 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 human-readable identifier for this specific report.
|
||||
|
||||
Utilized by dashboard orchestrators to populate navigation menus and routing logic.
|
||||
|
||||
:return: The display name of the report.
|
||||
:rtype: str
|
||||
"""
|
||||
return "Training Counts Statistics"
|
||||
|
||||
def render_controls(self, container: DeltaGenerator) -> Dict[str, Any]:
|
||||
"""
|
||||
Defines the user input interface and establishes a safe execution boundary.
|
||||
|
||||
Renders selection widgets for fiscal year and target centers. Implements a fail-fast
|
||||
pattern that halts the Streamlit execution tree if the underlying center dataset fails
|
||||
to load, preventing cascading errors during the intensive figure generation phase.
|
||||
|
||||
: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 artifacts.
|
||||
|
||||
Fetches the trainings dataset and specifically filters for events with zero attendees.
|
||||
Generates an extensive suite of paired Plotly charts (absolute count vs. percentage) across
|
||||
various course categorizations. Computes strict max-Y bounds for quantity-based charts
|
||||
to permit external 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 dataframes.
|
||||
: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"]
|
||||
|
||||
if len(selected_centers) > 0:
|
||||
export_url = self.get_fiscal_year_export_url(selected_fiscal_year)
|
||||
|
||||
try:
|
||||
stats_df = cached_generate_center_trainings_count_statistics(export_url, reportable_only=reportable_only, allowed_centers=selected_centers, include_future_events=include_future_events, include_on_demand=include_on_demand)
|
||||
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)
|
||||
except Exception as e:
|
||||
self.logger.exception(f"Failed to generate training count statistics for this page: {e}")
|
||||
st.error(f"Failed to generate training count statistics for this page. A detailed error has been added to the logs. {self.app_config.get_errors_contact_string()}")
|
||||
st.stop()
|
||||
|
||||
filter_description = "With 0 Attendees"
|
||||
|
||||
try:
|
||||
collected_figures = make_network_trainings_count_statistics_charts(
|
||||
funding_group_df=stats_df,
|
||||
filter_description_tag=filter_description,
|
||||
fiscal_year_tag=selected_fiscal_year,
|
||||
title_prefix="PASBDC*"
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.exception(f"Failed to build the figures for this report. Got {e}")
|
||||
st.error(f"Failed to build the figures for this report. A detailed error has been added to the logs. {self.app_config.get_errors_contact_string()}")
|
||||
st.stop()
|
||||
|
||||
return {
|
||||
"total_count": find_fig_max_y_and_generate_wrapper(collected_figures[StatChartVariants.TOTAL_COUNT]),
|
||||
"total_percent": find_fig_max_y_and_generate_wrapper(collected_figures[StatChartVariants.TOTAL_PERCENT]),
|
||||
"no_first_steps_count": find_fig_max_y_and_generate_wrapper(collected_figures[StatChartVariants.NO_FIRST_STEPS_COUNT]),
|
||||
"no_first_steps_percent": find_fig_max_y_and_generate_wrapper(collected_figures[StatChartVariants.NO_FIRST_STEPS_PERCENT]),
|
||||
"no_first_no_pre_count": find_fig_max_y_and_generate_wrapper(collected_figures[StatChartVariants.NO_FIRST_NO_PREPLANNNG_COUNT]),
|
||||
"no_first_no_pre_percent": find_fig_max_y_and_generate_wrapper(collected_figures[StatChartVariants.NO_FIRST_NO_PREPLANNNG_PERCENT]),
|
||||
"first_pre_only_count": find_fig_max_y_and_generate_wrapper(collected_figures[StatChartVariants.FIRST_AND_PREPLANNING_ONLY]),
|
||||
"first_pre_only_percent": find_fig_max_y_and_generate_wrapper(collected_figures[StatChartVariants.FIRST_AND_PREPLANNING_ONLY_PERCENT]),
|
||||
"ondemand_count": find_fig_max_y_and_generate_wrapper(collected_figures[StatChartVariants.ON_DEMAND_COUNT]),
|
||||
"ondemand_percent": find_fig_max_y_and_generate_wrapper(collected_figures[StatChartVariants.ON_DEMAND_PERCENT]),
|
||||
"ondemand_no_first_count": find_fig_max_y_and_generate_wrapper(collected_figures[StatChartVariants.ON_DEMAND_NO_FIRST_STEPS_COUNT]),
|
||||
"ondemand_no_first_percent": find_fig_max_y_and_generate_wrapper(collected_figures[StatChartVariants.ON_DEMAND_NO_FIRST_STEPS_PERCENT]),
|
||||
"ondemand_no_first_no_pre_count": find_fig_max_y_and_generate_wrapper(collected_figures[StatChartVariants.ON_DEMAND_NO_FIRST_STEPS_NO_PREPLANNING_COUNT]),
|
||||
"ondemand_no_first_no_pre_percent": find_fig_max_y_and_generate_wrapper(collected_figures[StatChartVariants.ON_DEMAND_NO_FIRST_STEPS_NO_PREPLANNING_PERCENT]),
|
||||
"stats_df": stats_df,
|
||||
"trainings_df": trainings_df
|
||||
}
|
||||
else:
|
||||
st.warning("At least 1 center must be selected!")
|
||||
st.stop()
|
||||
|
||||
|
||||
def get_syncable_figure_keys(self) -> List[str]:
|
||||
"""
|
||||
Declares the specific visualization objects that support dynamic external Y-axis scaling.
|
||||
|
||||
Explicitly isolates the absolute count charts for synchronization, filtering out all
|
||||
percentage-based charts to ensure external axis scaling does not distort proportional views.
|
||||
|
||||
:return: A list of dictionary keys corresponding to absolute count figures.
|
||||
:rtype: List[str]
|
||||
"""
|
||||
return ["total_count", "no_first_steps_count", "no_first_no_pre_count", "first_pre_only_count", "ondemand_count", "ondemand_no_first_count", "ondemand_no_first_no_pre_count"]
|
||||
|
||||
def render_figures(self, container: DeltaGenerator, output_data: Dict[str, Any]):
|
||||
"""
|
||||
Iteratively maps the generated paired visual artifacts to the Streamlit layout.
|
||||
|
||||
Arranges the charts sequentially using a dynamic 2-column layout loop to directly contrast
|
||||
absolute zero-attendance counts with their corresponding proportional percentages. Exposes
|
||||
the underlying filtered datasets via an expander module to ensure data 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]
|
||||
"""
|
||||
chart_pairs = [
|
||||
("Total Trainings", "total_count", "total_percent"),
|
||||
("Excluding First Steps", "no_first_steps_count", "no_first_steps_percent"),
|
||||
("Excluding First Steps & Preplanning", "no_first_no_pre_count", "no_first_no_pre_percent"),
|
||||
("First Steps & Preplanning Only", "first_pre_only_count", "first_pre_only_percent"),
|
||||
("On-Demand Trainings", "ondemand_count", "ondemand_percent"),
|
||||
("On-Demand (Excluding First Steps)", "ondemand_no_first_count", "ondemand_no_first_percent"),
|
||||
("On-Demand (Excluding First Steps & Preplanning)", "ondemand_no_first_no_pre_count",
|
||||
"ondemand_no_first_no_pre_percent"),
|
||||
]
|
||||
|
||||
stats_df:pd.DataFrame = output_data.get("stats_df")
|
||||
trainings_df:pd.DataFrame = output_data.get("trainings_df")
|
||||
|
||||
for title, count_key, percent_key in chart_pairs:
|
||||
# Add a subheader for the section
|
||||
container.subheader(title)
|
||||
|
||||
# Create a 2-column layout
|
||||
col1, col2 = container.columns(2)
|
||||
|
||||
# Extract and render the count figure
|
||||
count_fig = output_data.get(count_key)['figure']
|
||||
if count_fig:
|
||||
col1.plotly_chart(count_fig, use_container_width=True, key=self.get_widget_key(count_key))
|
||||
|
||||
# Extract and render the percentage figure
|
||||
percent_fig = output_data.get(percent_key)['figure']
|
||||
if percent_fig:
|
||||
col2.plotly_chart(percent_fig, use_container_width=True, key=self.get_widget_key(percent_key))
|
||||
|
||||
# Add a horizontal line to separate sections cleanly
|
||||
container.divider()
|
||||
|
||||
dataset_expander = container.expander(
|
||||
label="Source Datasets",
|
||||
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)
|
||||
dataset_expander.markdown("### Calculated Center Statistics dataset")
|
||||
dataset_expander.markdown("Input dataset was filtered to show only 0 attendee events.")
|
||||
dataset_expander.write(stats_df)
|
||||
@@ -0,0 +1,254 @@
|
||||
from typing import List, Dict, Any
|
||||
import logging
|
||||
|
||||
import pandas as pd
|
||||
from fiscalyear import FiscalYear
|
||||
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_attendee_range_charts
|
||||
)
|
||||
from shared_tools_module import StatChartVariants
|
||||
from utility_classes.figure_with_max_y import find_fig_max_y_and_generate_wrapper, FigureWithMaxY, extract_figure_data
|
||||
from utility_classes.dashboard_config_parser import DashboardConfig, ExportModulePair
|
||||
|
||||
|
||||
class TrainingEventCountAttendeeComparison(BaseReportPage):
|
||||
"""
|
||||
Concrete implementation of a report page comparing training event volume against attendee turnout.
|
||||
|
||||
This class manages the data pipeline for evaluating the relationship between the number of
|
||||
events hosted and the resulting attendee counts. It categorizes events to isolate introductory
|
||||
courses (like 'First Steps' and 'Preplanning') to determine their specific impact on overall
|
||||
network attendance metrics.
|
||||
|
||||
:param kwargs: Arbitrary keyword arguments passed to the parent BaseReportPage constructor.
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""
|
||||
Initializes the fiscal boundaries and application configuration state.
|
||||
|
||||
Captures the current and previous fiscal years to parameterize report filtering and extracts
|
||||
the global dashboard configuration to resolve the necessary external data endpoints.
|
||||
|
||||
:param kwargs: Arbitrary keyword arguments.
|
||||
"""
|
||||
super().__init__("Network Wide Event to Attendee Count Comparison")
|
||||
|
||||
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 selected fiscal period.
|
||||
|
||||
Maps the user's fiscal year selection to the appropriate configured data URL, ensuring
|
||||
subsequent data pipelines fetch the correct historical or current 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 populate routing and navigation menus.
|
||||
|
||||
:return: The human-readable name of the report.
|
||||
:rtype: str
|
||||
"""
|
||||
return "Training and Attendee Count Comparison"
|
||||
|
||||
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 year and target centers. Implements a strict
|
||||
fail-fast pattern that halts the Streamlit execution sequence if the baseline center dataset
|
||||
fails to load, preventing downstream 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 the paired comparison charts for total counts
|
||||
versus introductory course subsets. Computes strict max-Y values for these quantity-based
|
||||
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)
|
||||
|
||||
try:
|
||||
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
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.exception(f"Failed to generate dataset for this page. Got {e}")
|
||||
st.error(f"Failed to generate the dataset for this page. A detailed error message has been added to the logs. {self.app_config.get_errors_contact_string()}")
|
||||
st.stop()
|
||||
|
||||
try:
|
||||
range_figs = make_center_attendee_range_charts(
|
||||
trainings_df,
|
||||
title_prefix="PASBDC*",
|
||||
fiscal_year_tag=selected_fiscal_year,
|
||||
col_neo_center=NEOSERRA_COLUMNS.center,
|
||||
col_attendees_range=OUT_COLUMNS.attendees_range,
|
||||
col_neo_training_id=NEOSERRA_COLUMNS.training_id,
|
||||
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 dataset for this page. Got {e}")
|
||||
st.error(f"Failed to generate figures for this page. A detailed error message has been added to the logs. {self.app_config.get_errors_contact_string()}")
|
||||
st.stop()
|
||||
|
||||
# Find the max y value for each graph manually as this can be a challenge for the automated function with stacked bars
|
||||
|
||||
# We have to extract the data first and check it as there is a chance that the data could be empty which .groupby and ['y'] would fail with
|
||||
total_count_data = extract_figure_data(range_figs[StatChartVariants.TOTAL_COUNT])
|
||||
if total_count_data.empty:
|
||||
total_count_max_y = 0.0
|
||||
else:
|
||||
total_count_max_y = total_count_data.groupby('x')['y'].sum().max()
|
||||
|
||||
first_pre_only_data = extract_figure_data(range_figs[StatChartVariants.FIRST_AND_PREPLANNING_ONLY])
|
||||
if first_pre_only_data.empty:
|
||||
first_pre_only_max_y = 0.0
|
||||
else:
|
||||
first_pre_only_max_y = first_pre_only_data.groupby('x')['y'].sum().max()
|
||||
|
||||
return {
|
||||
'total_count':FigureWithMaxY(figure=range_figs[StatChartVariants.TOTAL_COUNT], max_y=total_count_max_y),
|
||||
'first_pre_only':FigureWithMaxY(figure=range_figs[StatChartVariants.FIRST_AND_PREPLANNING_ONLY], max_y=first_pre_only_max_y),
|
||||
'trainings_df':trainings_df
|
||||
}
|
||||
|
||||
def render_figures(self, container: DeltaGenerator, output_data: Dict[str, Any]):
|
||||
"""
|
||||
Maps the generated visual artifacts to the Streamlit layout.
|
||||
|
||||
Arranges the absolute count comparison charts sequentially within the container and
|
||||
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]
|
||||
"""
|
||||
total_count = output_data.get("total_count")["figure"]
|
||||
first_pre_only = output_data.get("first_pre_only")["figure"]
|
||||
trainings_df:pd.DataFrame = output_data.get("trainings_df")
|
||||
|
||||
container.plotly_chart(total_count, key=self.get_widget_key("total_count"))
|
||||
container.plotly_chart(first_pre_only, key=self.get_widget_key("first_pre_only"))
|
||||
|
||||
dataset_expander = container.expander(
|
||||
label="Source Datasets",
|
||||
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.
|
||||
|
||||
Isolates the absolute event and attendee count charts for external synchronization
|
||||
by a parent orchestrator component.
|
||||
|
||||
:return: A list of dictionary keys corresponding to absolute count figures.
|
||||
:rtype: List[str]
|
||||
"""
|
||||
return ["total_count", "first_pre_only"]
|
||||
@@ -0,0 +1,316 @@
|
||||
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"
|
||||
]
|
||||
@@ -0,0 +1,261 @@
|
||||
import sys
|
||||
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_primary_training_topic_pie_charts,
|
||||
make_primary_training_topic_statistics_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 TrainingsPrimaryTopicsPage(BaseReportPage):
|
||||
"""
|
||||
Concrete implementation of a report page analyzing network-wide primary training topics.
|
||||
|
||||
This class manages the data pipeline for evaluating the distribution of course subjects
|
||||
across the network. It employs a dual-visualization strategy: utilizing pie charts to
|
||||
show massive macro-proportions (like the dominance of introductory courses) and bar charts
|
||||
to provide granular visibility into smaller, niche training topics that would otherwise
|
||||
be unreadable in a proportion-based chart.
|
||||
|
||||
: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 the report's time-based
|
||||
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 Primary Topics")
|
||||
|
||||
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 topic 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 Primary Topics"
|
||||
|
||||
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 two distinct sets of figures: pie charts
|
||||
for overall topic proportions and bar charts targeting smaller volume topics. Computes
|
||||
strict max-Y values exclusively for the absolute count bar 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:
|
||||
topic_figs = make_primary_training_topic_statistics_charts(
|
||||
trainings_df,
|
||||
center="Network Wide",
|
||||
network_label="PASBDC*",
|
||||
fiscal_year_tag=selected_fiscal_year,
|
||||
col_neo_primary_topic=NEOSERRA_COLUMNS.primary_training_topic,
|
||||
col_neo_attendees_total=NEOSERRA_COLUMNS.attendees_total
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.exception(f"Failed to generate the topic bar figures for this page. Got {e}")
|
||||
st.error(f"Failed to generate the topic bar figures for this page. A detailed error has been added to the logs. {self.app_config.get_errors_contact_string()}")
|
||||
st.stop()
|
||||
try:
|
||||
topic_pies = make_primary_training_topic_pie_charts(
|
||||
trainings_df,
|
||||
center="Network Wide",
|
||||
network_label="PASBDC*",
|
||||
fiscal_year_tag=selected_fiscal_year,
|
||||
col_neo_attendees_total=NEOSERRA_COLUMNS.attendees_total,
|
||||
col_neo_primary_topic=NEOSERRA_COLUMNS.primary_training_topic
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.exception(f"Failed to generate the topic pie charts for this page. Got {e}.")
|
||||
st.error(f"Failed to generate the topic pie charts for this page. A detailed error has been added to the logs. {self.app_config.get_errors_contact_string()}")
|
||||
st.stop()
|
||||
|
||||
return {
|
||||
'topic_pie_total': FigureWithMaxY(figure=topic_pies[StatChartVariants.TOTAL_PERCENT], max_y=0.0),
|
||||
'topic_pie_no_first': FigureWithMaxY(figure=topic_pies[StatChartVariants.NO_FIRST_STEPS_PERCENT], max_y=0.0),
|
||||
'small_topics_total': find_fig_max_y_and_generate_wrapper(topic_figs[StatChartVariants.SMALL_BARS_TRAININGS]),
|
||||
'small_topics_percent': FigureWithMaxY(figure=topic_figs[StatChartVariants.SMALL_BARS_TRAININGS_PERCENT], max_y=0),
|
||||
'trainings_df':trainings_df
|
||||
}
|
||||
|
||||
def render_figures(self, container: DeltaGenerator, output_data: Dict[str, Any]):
|
||||
"""
|
||||
Maps the generated visual artifacts to a defined spatial layout within the UI.
|
||||
|
||||
Arranges the charts sequentially using a 2x2 grid layout, placing macro-level pie charts
|
||||
on top and granular bar charts on the bottom. This spatial grouping allows users to easily
|
||||
contrast total training volumes with specialized subsets. Exposes the raw underlying dataset
|
||||
via an expander module for data auditing.
|
||||
|
||||
: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]
|
||||
"""
|
||||
|
||||
topic_pie_total = output_data.get("topic_pie_total")["figure"]
|
||||
topic_pie_no_first = output_data.get("topic_pie_no_first")["figure"]
|
||||
small_topics_total = output_data.get("small_topics_total")["figure"]
|
||||
small_topics_percent = output_data.get("small_topics_percent")["figure"]
|
||||
trainings_df:pd.DataFrame = output_data.get("trainings_df")
|
||||
left_col, right_col = container.columns([0.5,0.5])
|
||||
left_col.plotly_chart(topic_pie_total, key=self.get_widget_key("topic_pie_total"))
|
||||
right_col.plotly_chart(topic_pie_no_first, key=self.get_widget_key("topic_pie_no_first"))
|
||||
left_col.plotly_chart(small_topics_total, key=self.get_widget_key("small_topics_total"))
|
||||
right_col.plotly_chart(small_topics_percent, key=self.get_widget_key("small_topics_percent"))
|
||||
|
||||
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 count bar chart ('small_topics_total') for synchronization.
|
||||
It intentionally filters out pie charts and percentage-based bar charts, as applying external
|
||||
axis limits to these would completely break their visual representation.
|
||||
|
||||
:return: A list of dictionary keys corresponding to absolute count figures.
|
||||
:rtype: List[str]
|
||||
"""
|
||||
return ["small_topics_total"]
|
||||
Reference in New Issue
Block a user