Add configurable locale/display formatting environment variables (`PALLADIUM_CURRENCY_SYMBOL`, `PALLADIUM_THOUSANDS_SEP`, `PALLADIUM_DECIMAL_SEP`) to support regional number formatting in the Streamlit app. Update `.env.example` with documentation for these new variables. Also refresh `00_setup.ipynb` with current execution outputs reflecting a live Athena connection with report templates, a selected client (Global Guardian Insurance, ID=2), and resolved NameError in assumption override cells.
224 lines
7.7 KiB
Python
224 lines
7.7 KiB
Python
"""
|
|
Palladium Streamlit app — TEI data entry, calculation, versioning, export.
|
|
|
|
Run from the project root::
|
|
|
|
streamlit run app/main.py
|
|
|
|
The app picks a TEI tool by ``public_id`` (or creates one from a Report
|
|
template) and exposes Benefits, Costs, Summary, and Versions pages. It is
|
|
study-agnostic — the field set is loaded dynamically from Athena based on
|
|
the linked Report template.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
# Allow `streamlit run app/main.py` from project root without `pip install -e .`
|
|
_ROOT = Path(__file__).resolve().parent.parent
|
|
if str(_ROOT) not in sys.path:
|
|
sys.path.insert(0, str(_ROOT))
|
|
|
|
import streamlit as st
|
|
|
|
from core.tei_client import AthenaAPIError, TEIClient
|
|
from app.utils import icon, inject_icons
|
|
|
|
st.set_page_config(
|
|
page_title="Palladium — TEI Calculator",
|
|
page_icon="🛡️",
|
|
layout="wide",
|
|
)
|
|
|
|
|
|
|
|
@st.cache_resource(show_spinner=False)
|
|
def get_client() -> TEIClient:
|
|
return TEIClient()
|
|
|
|
|
|
def _safe_call(fn, *args, **kwargs):
|
|
"""Run an API call, surfacing errors as Streamlit messages."""
|
|
try:
|
|
return fn(*args, **kwargs)
|
|
except AthenaAPIError as e:
|
|
st.error(f"Athena API error {e.status_code}: {e.detail}")
|
|
except ValueError as e:
|
|
st.error(str(e))
|
|
return None
|
|
|
|
|
|
# CRM lookups, cached briefly so the cascading selects stay snappy.
|
|
@st.cache_data(ttl=120, show_spinner=False)
|
|
def _crm_clients(_client: TEIClient) -> list[dict]:
|
|
try:
|
|
return _client.list_clients()
|
|
except AthenaAPIError:
|
|
return []
|
|
|
|
|
|
@st.cache_data(ttl=120, show_spinner=False)
|
|
def _crm_proposals(_client: TEIClient, client_id: int) -> list[dict]:
|
|
try:
|
|
return _client.proposals_for_client(client_id)
|
|
except AthenaAPIError:
|
|
return []
|
|
|
|
|
|
@st.cache_data(ttl=120, show_spinner=False)
|
|
def _crm_engagements(_client: TEIClient, client_name: str) -> list[dict]:
|
|
try:
|
|
return _client.engagements_for_client(client_name)
|
|
except AthenaAPIError:
|
|
return []
|
|
|
|
|
|
def sidebar_tool_picker(client: TEIClient) -> dict | None:
|
|
"""Sidebar: pick an existing TEI tool or create one from a report template."""
|
|
st.sidebar.markdown(
|
|
f"{icon('shield-fill')} **Palladium**", unsafe_allow_html=True
|
|
)
|
|
st.sidebar.caption("TEI Calculator")
|
|
|
|
tools = _safe_call(client.list_tools) or []
|
|
if tools:
|
|
labels = {
|
|
f"{t.get('name', '(unnamed)')} — {t.get('id', '')[:8]}…": t for t in tools
|
|
}
|
|
choice = st.sidebar.selectbox("TEI Tool", list(labels.keys()))
|
|
tool = labels[choice]
|
|
else:
|
|
st.sidebar.info("No TEI tools yet. Create one below.")
|
|
tool = None
|
|
|
|
with st.sidebar.expander("Create new tool"):
|
|
reports = _safe_call(client.list_reports) or []
|
|
if not reports:
|
|
st.write("No report templates available.")
|
|
else:
|
|
report_labels = {f"{r['name']} ({r['vendor']} {r['version']})": r for r in reports}
|
|
r_choice = st.selectbox("Report template", list(report_labels.keys()))
|
|
|
|
# A TEI tool must attach to a Proposal OR an Engagement.
|
|
# Cascade: client → proposal/engagement, pulled from the CRM.
|
|
clients = _crm_clients(client)
|
|
if not clients:
|
|
st.warning("No CRM clients found — create one in Athena first.")
|
|
return tool
|
|
client_labels = {c["name"]: c for c in clients}
|
|
c_choice = st.selectbox("Client", list(client_labels.keys()))
|
|
crm_client = client_labels[c_choice]
|
|
|
|
attach_kind = st.radio(
|
|
"Attach to", ["Proposal", "Engagement"], horizontal=True
|
|
)
|
|
proposal_id: int | None = None
|
|
engagement_id: int | None = None
|
|
if attach_kind == "Proposal":
|
|
proposals = _crm_proposals(client, crm_client["id"])
|
|
if proposals:
|
|
p_labels = {
|
|
f"{p.get('name')} ({p.get('status')})": p for p in proposals
|
|
}
|
|
p_choice = st.selectbox("Proposal", list(p_labels.keys()))
|
|
proposal_id = p_labels[p_choice]["id"]
|
|
else:
|
|
st.info(
|
|
f"{crm_client['name']} has no proposals. Create one in "
|
|
"Athena (or via 00_provision.ipynb) first."
|
|
)
|
|
else:
|
|
engagements = _crm_engagements(client, crm_client["name"])
|
|
if engagements:
|
|
e_labels = {
|
|
f"{e.get('name')} ({e.get('status')})": e for e in engagements
|
|
}
|
|
e_choice = st.selectbox("Engagement", list(e_labels.keys()))
|
|
engagement_id = e_labels[e_choice]["id"]
|
|
else:
|
|
st.info(f"{crm_client['name']} has no engagements.")
|
|
|
|
default_name = f"{crm_client['name']} — {report_labels[r_choice]['name']}"
|
|
new_name = st.text_input("Tool name", default_name)
|
|
if st.button(
|
|
"Create", disabled=proposal_id is None and engagement_id is None
|
|
):
|
|
report = report_labels[r_choice]
|
|
created = _safe_call(
|
|
client.create_tool,
|
|
report_public_id=report["id"],
|
|
proposal=proposal_id,
|
|
engagement=engagement_id,
|
|
name=new_name or None,
|
|
)
|
|
if created:
|
|
st.success(f"Created tool {created.get('id')}")
|
|
st.cache_data.clear()
|
|
st.rerun()
|
|
|
|
if tool:
|
|
st.sidebar.divider()
|
|
_opp = tool.get("opportunity") or {}
|
|
_client_name = (_opp.get("client") or {}).get("name")
|
|
if _client_name:
|
|
st.sidebar.markdown(f"**Client**: {_client_name}")
|
|
st.sidebar.markdown(f"**Public ID**: `{tool.get('id')}`")
|
|
st.sidebar.markdown(f"**Status**: {tool.get('status', '?')}")
|
|
st.sidebar.markdown(f"**Version**: {tool.get('current_version', 0)}")
|
|
if st.sidebar.button("Recalculate"):
|
|
_safe_call(client.calculate, tool["id"])
|
|
st.toast("Recalculated.", icon=None)
|
|
st.cache_data.clear()
|
|
return tool
|
|
|
|
|
|
def main() -> None:
|
|
inject_icons()
|
|
|
|
st.markdown(
|
|
f"<h1 style='margin-bottom:0'>{icon('shield-fill')} Palladium — TEI Calculator</h1>",
|
|
unsafe_allow_html=True,
|
|
)
|
|
try:
|
|
client = get_client()
|
|
except ValueError as e:
|
|
st.error(str(e))
|
|
st.info("Set ATHENA_BASE_URL and ATHENA_API_KEY in your `.env` file.")
|
|
st.stop()
|
|
return
|
|
|
|
tool = sidebar_tool_picker(client)
|
|
|
|
if tool is None:
|
|
st.info("Pick or create a TEI tool from the sidebar to begin.")
|
|
return
|
|
|
|
# Tab navigation — matches `app/views/*` modules but kept as tabs so all
|
|
# views share the chosen tool/state without re-querying.
|
|
#
|
|
# NOTE: the directory is `app/views/`, NOT `app/pages/`. Streamlit treats a
|
|
# `pages/` directory next to the entrypoint as auto-discovered multipage
|
|
# scripts, which would render blank since these modules only define
|
|
# `render()` and have no top-level output.
|
|
tabs = st.tabs(["Summary", "Benefits", "Costs", "Versions"])
|
|
|
|
from app.views import benefits as benefits_page
|
|
from app.views import costs as costs_page
|
|
from app.views import summary as summary_page
|
|
from app.views import versions as versions_page
|
|
|
|
with tabs[0]:
|
|
summary_page.render(client, tool)
|
|
with tabs[1]:
|
|
benefits_page.render(client, tool)
|
|
with tabs[2]:
|
|
costs_page.render(client, tool)
|
|
with tabs[3]:
|
|
versions_page.render(client, tool)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|