From 31bc80795399b43e6bf743b1eae5e4ad0355ca3e Mon Sep 17 00:00:00 2001 From: Ben Knight Date: Fri, 28 Nov 2025 06:09:15 +0000 Subject: [PATCH 1/3] Update the streamlit configuration to allow for a simpler devcontainer and fix the calling of streamlit. Update the updating of the base image as its not required and slows down container start. If extra packages are required install via features not via post container commands. --- .devcontainer/devcontainer.json | 12 +++++++----- .streamlit/config.toml | 5 +++++ 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 19ff7d1..f43b453 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -2,6 +2,7 @@ "name": "Python 3", // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile "image": "mcr.microsoft.com/devcontainers/python:1-3.11-bookworm", + // If you want packages rather than install as post commands add them as features "customizations": { "codespaces": { "openFiles": [ @@ -17,10 +18,8 @@ ] } }, - "updateContentCommand": "[ -f packages.txt ] && sudo apt update && sudo apt upgrade -y && sudo xargs apt install -y Date: Fri, 28 Nov 2025 06:46:40 +0000 Subject: [PATCH 2/3] Quick pass to remove potential XSS attack points from malicious file uploads. Probably not all of them. --- pages/1_Quarterly_Rankings.py | 14 +++++++------- pages/2_Monthly_Rankings.py | 12 ++++++------ pages/3_Compare_Providers.py | 21 ++++++++++----------- pages/4_Foundation_Group.py | 4 ++-- 4 files changed, 25 insertions(+), 26 deletions(-) diff --git a/pages/1_Quarterly_Rankings.py b/pages/1_Quarterly_Rankings.py index 84ac2f8..0d867be 100644 --- a/pages/1_Quarterly_Rankings.py +++ b/pages/1_Quarterly_Rankings.py @@ -524,8 +524,8 @@ def make_summary_html(df: pd.DataFrame, # Build narrative paragraphs p1 = ( - f"

{prov_name} achieved a {pct_disp} performance rate for " - f"{domain} → {metric} in {quarter} across the {provider_region} region. " + f"

{html.escape(prov_name)} achieved a {pct_disp} performance rate for " + f"{html.escape(domain)} → {html.escape(metric)} in {html.escape(quarter)} across the {html.escape(provider_region)} region. " f"This placed them {rank_val}{'th' if rank_val else ''} nationally out of {nat_n} trusts" ) @@ -603,8 +603,8 @@ def make_summary_html(df: pd.DataFrame, change_txt = f" Regional performance {direction} by {abs(dpp):.1f} percentage points compared to {prev_q}." p1 = ( - f"

The {region_selected} region achieved a weighted average performance of " - f"{reg_pct_disp} for {domain} → {metric} in {quarter}. " + f"

The {html.escape(region_selected)} region achieved a weighted average performance of " + f"{reg_pct_disp} for {html.escape(domain)} → {html.escape(metric)} in {html.escape(quarter)}. " f"This regional view includes {reg_n} trusts out of {nat_n} trusts nationally." f"{comparison_txt}{change_txt}

" ) @@ -952,8 +952,8 @@ def read_svg_file(p: Path) -> str: # ===================== Context banner ====================== context_html = ( - f'Showing {domain}{metric} in {quarter}' - + (f' for {region_selected} region' if region_selected else ' across all regions') + f'Showing {html.escape(domain)}{html.escape(metric)} in {html.escape(quarter)}' + + (f' for {html.escape(region_selected)} region' if region_selected else ' across all regions') + '.' ) st.markdown(f'
{context_html}
', unsafe_allow_html=True) @@ -961,7 +961,7 @@ def read_svg_file(p: Path) -> str: # ===================== Provider heading ==================== if provider_code: prov_name = provider_name_from_code(df_qdmr, provider_code) - st.markdown(f"

{prov_name} ({provider_code})

", unsafe_allow_html=True) + st.markdown(f"

{html.escape(prov_name)} ({html.escape(provider_code)})

", unsafe_allow_html=True) else: st.info("Select a provider to see KPIs and trend.") diff --git a/pages/2_Monthly_Rankings.py b/pages/2_Monthly_Rankings.py index dce07b0..c65310e 100644 --- a/pages/2_Monthly_Rankings.py +++ b/pages/2_Monthly_Rankings.py @@ -17,7 +17,7 @@ import plotly.express as px import plotly.io as pio import streamlit as st -import html as _html +import html from pathlib import Path # add (or keep) this import in the file header @@ -521,8 +521,8 @@ def provider_labels(sub: pd.DataFrame) -> list: # ===================== Context banner ====================== context_html = ( - f'Showing {domain}{metric} in {month_long}' - + (f' for {region_selected} region' if region_selected else ' across all regions') + f'Showing {html.escape(domain)}{html.escape(metric)} in {html.escape(month_long)}' + + (f' for {html.escape(region_selected)} region' if region_selected else ' across all regions') + '.' ) st.markdown(f'
{context_html}
', unsafe_allow_html=True) @@ -530,7 +530,7 @@ def provider_labels(sub: pd.DataFrame) -> list: # ===================== Provider heading ==================== if provider_code: prov_name = provider_name_from_code(df_mdmr, provider_code) # Month+Domain+Metric(+Region) - st.markdown(f"

{prov_name} ({provider_code})

", unsafe_allow_html=True) + st.markdown(f"

{html.escape(prov_name)} ({html.escape(provider_code)})

", unsafe_allow_html=True) else: st.info("Select a provider to see KPIs and trend.") @@ -861,13 +861,13 @@ def provider_labels(sub: pd.DataFrame) -> list: card_html = ( f'
' f'
' - f'
{_html.escape(str(m))}
' + f'
{html.escape(str(m))}
' f'
{pct_txt}
' f'
' f'
' f'
' f' {nat_label}' - f' {_html.escape(reg_label)}' + f' {html.escape(reg_label)}' f'
' f'
' ) diff --git a/pages/3_Compare_Providers.py b/pages/3_Compare_Providers.py index 92ba88c..bef81a4 100644 --- a/pages/3_Compare_Providers.py +++ b/pages/3_Compare_Providers.py @@ -457,17 +457,17 @@ def fmt_pct(val, met): reg_sizeB = int(dataB["Region_Size"]) if pd.notna(dataB.get("Region_Size")) else None reg_rankB = int(dataB["Rank_Region"]) if pd.notna(dataB.get("Rank_Region")) else None - region_txt = f" within the {region_selected} region" if region_selected else " across all regions" + region_txt = f" within the {html.escape(region_selected)} region" if region_selected else " across all regions" p1 = ( - f"

In {period}, {nameA} ({provA}) achieved a {fmt_pct(pctA, metric)} " - f"performance rate for {domain} → {metric}{region_txt}, " + f"

In {html.escape(period)}, {html.escape(nameA)} ({html.escape(provA)}) achieved a {fmt_pct(pctA, metric)} " + f"performance rate for {html.escape(domain)} → {html.escape(metric)}{region_txt}, " f"securing an overall national rank of {rankA} out of {nat_count} trusts" ) if reg_rankA and reg_sizeA: p1 += f" and a regional rank of {reg_rankA} out of {reg_sizeA} trusts" - p1 += f". By comparison, {nameB} ({provB}) recorded {fmt_pct(pctB, metric)}, " + p1 += f". By comparison, {html.escape(nameB)} ({html.escape(provB)}) recorded {fmt_pct(pctB, metric)}, " p1 += f"ranking {rankB} nationally" if reg_rankB and reg_sizeB: @@ -476,8 +476,7 @@ def fmt_pct(val, met): if pd.notna(pctA) and pd.notna(pctB): gap = abs(pctB - pctA) leader = nameB if pctB > pctA else nameA - p1 += f". This represents a {gap:.1f} percentage point gap, with {leader} demonstrating stronger performance" - + p1 += f". This represents a {gap:.1f} percentage point gap, with {html.escape(leader)} demonstrating stronger performance" p1 += "." # Add weighted averages comparison @@ -496,12 +495,12 @@ def fmt_pct(val, met): nat_count = scope0["Provider_Code"].nunique() p1 = ( - f"

In {period}, {nameA} ({provA}) achieved a {fmt_pct(pctA, metric)} " - f"performance rate for {domain} → {metric}, ranking {rankA} out of {nat_count} trusts. " + f"

In {html.escape(period)}, {html.escape(nameA)} ({html.escape(provA)}) achieved a {fmt_pct(pctA, metric)} " + f"performance rate for {html.escape(domain)} → {html.escape(metric)}, ranking {rankA} out of {nat_count} trusts. " f"Provider B has not been selected for comparison.

" ) else: - p1 = f"

No provider data available for the selected filters in {period}.

" + p1 = f"

No provider data available for the selected filters in {html.escape(period)}.

" # Paragraph 2: Trend analysis period_col = "Month_dt" if mode == "Monthly" else "Quarter" @@ -527,7 +526,7 @@ def fmt_pct(val, met): min_pct = pcts.min() max_pct = pcts.max() p2_parts.append( - f"{nameA} ({provA}) has shown performance ranging from " + f"{html.escape(nameA)} ({html.escape(provA)}) has shown performance ranging from " f"{fmt_pct(min_pct, metric)} to {fmt_pct(max_pct, metric)} over the preceding {lookback} {period_label}" ) @@ -539,7 +538,7 @@ def fmt_pct(val, met): min_pct = pcts.min() max_pct = pcts.max() p2_parts.append( - f"{nameB} ({provB}) has demonstrated performance between " + f"{html.escape(nameB)} ({html.escape(provB)}) has demonstrated performance between " f"{fmt_pct(min_pct, metric)} and {fmt_pct(max_pct, metric)} over the same period" ) diff --git a/pages/4_Foundation_Group.py b/pages/4_Foundation_Group.py index bede908..6f022f1 100644 --- a/pages/4_Foundation_Group.py +++ b/pages/4_Foundation_Group.py @@ -635,9 +635,9 @@ def render_empty_state(): unsafe_allow_html=True, ) -ctx_bits = [f"{domain}", f"{metric}", f"{period}"] +ctx_bits = [f"{html.escape(domain)}", f"{html.escape(metric)}", f"{html.escape(period)}"] if region != "(All Regions)": - ctx_bits.append(f"{region}") + ctx_bits.append(f"{html.escape(region)}") st.markdown( f"
Viewing {' → '.join(ctx_bits)}.
", unsafe_allow_html=True, From 53b8dcc6253a62fa891a0d27467a7bd88ce88069 Mon Sep 17 00:00:00 2001 From: Ben Knight Date: Fri, 28 Nov 2025 07:17:34 +0000 Subject: [PATCH 3/3] Add powershell script to setup streamlit to run on a server so it can be hosted locally. --- setup_service.ps1 | 117 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 setup_service.ps1 diff --git a/setup_service.ps1 b/setup_service.ps1 new file mode 100644 index 0000000..ce745f2 --- /dev/null +++ b/setup_service.ps1 @@ -0,0 +1,117 @@ +<# +.SYNOPSIS + Sets up the Streamlit application as a Windows Service using NSSM. + +.DESCRIPTION + This script installs Chocolatey (if not present), installs NSSM via Chocolatey, + and configures a Windows Service to run the Streamlit application. + +.NOTES + Run this script as Administrator. +#> + +# Ensure the script is run as Administrator +if (!([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator")) { + Write-Warning "You do not have Administrator rights to run this script!`nPlease re-run this script as an Administrator!" + Break +} + +# --- Configuration --- +$ServiceName = "NOF_Dashboards" +$DisplayName = "NOF Dashboards Streamlit App" +$Description = "Runs the NOF Dashboards Streamlit application." +$AppScript = "Homepage.py" +$AppPort = 8501 + +# Get the script's directory (assumed to be repo root) +$RepoRoot = $PSScriptRoot + +# Path to Python executable in the virtual environment +$PythonPath = Join-Path $RepoRoot ".venv\Scripts\python.exe" + +# check if venv exists +if (-not (Test-Path $PythonPath)) { + Write-Warning "Virtual environment python not found at: $PythonPath" + $PythonPath = Read-Host "Please enter the full path to your Python executable" + if (-not (Test-Path $PythonPath)) { + Write-Error "Python executable not found. Please create a .venv or provide a valid path." + Exit 1 + } +} + +Write-Host "Using Python: $PythonPath" +Write-Host "Repo Root: $RepoRoot" + +# --- Install Chocolatey (if not installed) --- +if (-not (Get-Command choco -ErrorAction SilentlyContinue)) { + Write-Host "Chocolatey not found. Installing..." + Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; iex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1')) +} else { + Write-Host "Chocolatey is already installed." +} + +# --- Install NSSM --- +if (-not (Get-Command nssm -ErrorAction SilentlyContinue)) { + Write-Host "NSSM not found. Installing via Chocolatey..." + choco install nssm -y + # Refresh env vars so nssm is available in current session + $env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User") +} else { + Write-Host "NSSM is already installed." +} + +# Check if nssm is now available +if (-not (Get-Command nssm -ErrorAction SilentlyContinue)) { + Write-Error "NSSM installation failed or not found in PATH. Please restart the shell or install manually." + Exit 1 +} + +# --- Configure Service --- + +# Check if service already exists +$ServiceStatus = Get-Service $ServiceName -ErrorAction SilentlyContinue +if ($ServiceStatus) { + Write-Host "Service '$ServiceName' already exists. Removing it..." + nssm stop $ServiceName + nssm remove $ServiceName confirm +} + +Write-Host "Installing Service '$ServiceName'..." + +# Arguments for Streamlit +# Note: absolute paths for script +$ScriptPath = Join-Path $RepoRoot $AppScript +$Arguments = "-m streamlit run `"$ScriptPath`" --server.port $AppPort --server.headless true" + +# Install service +nssm install $ServiceName "$PythonPath" $Arguments + +# Set additional parameters +nssm set $ServiceName DisplayName "$DisplayName" +nssm set $ServiceName Description "$Description" +nssm set $ServiceName AppDirectory "$RepoRoot" +nssm set $ServiceName Start SERVICE_AUTO_START + +# Logging (Redirect stdout/stderr) +$LogDir = Join-Path $RepoRoot "logs" +if (-not (Test-Path $LogDir)) { New-Item -ItemType Directory -Path $LogDir | Out-Null } + +$StdoutLog = Join-Path $LogDir "service_stdout.log" +$StderrLog = Join-Path $LogDir "service_stderr.log" + +nssm set $ServiceName AppStdout "$StdoutLog" +nssm set $ServiceName AppStderr "$StderrLog" +# Enable log rotation (optional but recommended) +nssm set $ServiceName AppRotateFiles 1 +nssm set $ServiceName AppRotateOnline 1 +nssm set $ServiceName AppRotateSeconds 86400 +nssm set $ServiceName AppRotateBytes 5242880 + +# --- Start Service --- +Write-Host "Starting Service '$ServiceName'..." +nssm start $ServiceName + +Write-Host "Service setup complete!" +Write-Host "You can access the app at http://localhost:$AppPort" + +Read-Host -Prompt "Press Enter to exit" \ No newline at end of file