feat: add setup notebook and update env example for Athena
This commit is contained in:
@@ -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}
|
||||
|
||||
81
app/main.py
81
app/main.py
@@ -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)}")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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", ""),
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user