feat: add setup notebook and update env example for Athena

This commit is contained in:
2026-06-10 07:02:34 -04:00
parent a2420ed692
commit faa7d20b3e
27 changed files with 2483 additions and 151 deletions

View File

@@ -27,7 +27,14 @@ def value_editor(
a notes column. Returns the edited DataFrame; the caller is responsible
for converting it back to value-row dicts and PUTting to Athena.
"""
fields = [f for f in fields if f.get("table") == table]
fields = [
f
for f in fields
if f.get("table") == table
# Companion "<key>_initial" fields are edited via the Initial column
# on their parent cost row, not as standalone rows.
and not str(f.get("field_key", "")).endswith("_initial")
]
fields.sort(key=lambda f: int(f.get("sort_order") or 0))
by_key = {v.get("field_key"): v for v in values}

View File

@@ -48,6 +48,31 @@ def _safe_call(fn, *args, **kwargs):
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.title("🛡️ Palladium")
@@ -71,24 +96,70 @@ def sidebar_tool_picker(client: TEIClient) -> dict | None:
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()))
new_name = st.text_input("Tool name (optional)", "")
proposal_id = st.number_input(
"Proposal ID (optional)", min_value=0, value=0, step=1
# 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
)
if st.button("Create"):
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=int(proposal_id) or None,
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)}")

View File

@@ -27,9 +27,14 @@ def render(client: TEIClient, tool: dict) -> None:
st.error(f"Athena API error: {e.detail}")
return
npv = float(summary.get("npv") or 0)
roi = float(summary.get("roi") or summary.get("roi_pct") or 0)
payback = summary.get("payback_months")
npv = float(summary.get("net_present_value") or summary.get("npv") or 0)
roi = float(
summary.get("roi_percentage")
or summary.get("roi")
or summary.get("roi_pct")
or 0
)
payback = summary.get("payback_period_months", summary.get("payback_months"))
bpv = float(summary.get("total_benefits_pv") or 0)
cpv = float(summary.get("total_costs_pv") or 0)
@@ -45,7 +50,16 @@ def render(client: TEIClient, tool: dict) -> None:
st.divider()
# Build the yearly breakdown from the documented per-year summary keys
# (benefits_year_N / costs_year_N) when no pre-built breakdown exists.
yb = summary.get("yearly_breakdown") or []
if not yb:
n = 1
while f"benefits_year_{n}" in summary or f"costs_year_{n}" in summary:
b = float(summary.get(f"benefits_year_{n}") or 0)
c = float(summary.get(f"costs_year_{n}") or 0)
yb.append({"year": n, "benefits": b, "costs": c, "net": b - c})
n += 1
initial = float(summary.get("initial_costs") or 0)
if yb:
charts.cashflow(yb, initial_cost=initial)

View File

@@ -17,11 +17,24 @@ def _diff_rows(a: dict[str, dict], b: dict[str, dict]) -> list[dict]:
"""Return one row per field with side-by-side year values."""
keys = sorted(set(a.keys()) | set(b.keys()))
rows: list[dict] = []
def _years_of(v: dict) -> dict:
"""Accept both friendly (year_values) and wire (nested years) shapes."""
if isinstance(v.get("year_values"), dict):
return {str(k): val for k, val in v["year_values"].items()}
if isinstance(v.get("years"), dict):
return {
str(k): (cell or {}).get("value")
for k, cell in v["years"].items()
}
if v.get("value") is not None:
return {"1": v["value"]}
return {}
for k in keys:
av = a.get(k, {}) or {}
bv = b.get(k, {}) or {}
ay = av.get("year_values") or {}
by = bv.get("year_values") or {}
ay = _years_of(av)
by = _years_of(bv)
years = sorted(set(ay.keys()) | set(by.keys()), key=lambda x: int(x))
for y in years:
a_val = float(ay.get(y) or 0)
@@ -79,8 +92,13 @@ def render(client: TEIClient, tool: dict) -> None:
{
"Version": v.get("version_number"),
"Date": v.get("created_at") or v.get("date"),
"NPV": float(snap.get("npv") or 0),
"ROI %": float(snap.get("roi") or snap.get("roi_pct") or 0),
"NPV": float(snap.get("net_present_value") or snap.get("npv") or 0),
"ROI %": float(
snap.get("roi_percentage")
or snap.get("roi")
or snap.get("roi_pct")
or 0
),
"Note": v.get("note", ""),
}
)