import os import sys import json import time import subprocess import threading from typing import Optional from fastapi import FastAPI, BackgroundTasks, HTTPException from fastapi.responses import HTMLResponse, JSONResponse from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel, Field import pandas as pd import numpy as np from src.utils.paths import data_dir, data_path app = FastAPI(title="Subscription Intelligence Dashboard API") # Enable CORS app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # Project paths BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) DATA_DIR = data_dir() PROCESSED_DIR = os.path.join(DATA_DIR, "processed") RAW_DIR = os.path.join(DATA_DIR, "raw") DOCS_DIR = os.path.join(BASE_DIR, "docs") LOG_FILE_PATH = data_path("pipeline_run.log") # Pipeline status variables pipeline_status = { "status": "IDLE", # IDLE, RUNNING, SUCCESS, FAILED "progress": 0, # 0 to 100 "step": "Not started", "start_time": None, "end_time": None, "elapsed": 0, "records": 50000 } pipeline_lock = threading.Lock() class RevenueSimulationRequest(BaseModel): starting_tenants: int = Field(500, ge=1, le=100_000) monthly_arpu: float = Field(50_000, ge=0, le=10_000_000) monthly_growth_pct: float = Field(4.0, ge=-100, le=200) monthly_churn_pct: float = Field(8.0, ge=0, le=100) months: int = Field(12, ge=1, le=36) @app.get("/health") def health_check(): return {"status": "ok", "service": "subscription-intelligence-dashboard"} def load_parquet(path: str) -> Optional[pd.DataFrame]: normalized_path = path.replace("\\", "/") path_parts = normalized_path.split("/") if path_parts and path_parts[0] == "data": path_parts = path_parts[1:] full_path = data_path(*path_parts) if not os.path.exists(full_path): return None try: return pd.read_parquet(full_path) except Exception as e: print(f"Error loading parquet at {full_path}: {e}") return None def format_inr(val) -> str: """Formats values into Indian Rupees (Lakhs/Crores/Standard)""" if val is None or np.isnan(val): return "INR 0" val = float(val) if val >= 10_000_000: return f"INR {val / 10_000_000:.2f}Cr" elif val >= 100_000: return f"INR {val / 100_000:.2f}L" elif val >= 1_000: return f"INR {val / 1_000:.1f}k" else: return f"INR {val:.0f}" def format_trend(pct) -> dict: """Generates standard trend indicator styling details""" if pct is None or np.isnan(pct): return {"text": "-", "class": "neutral", "up": False} pct = float(pct) if pct > 0: return {"text": f"+{pct:.1f}%", "class": "up", "up": True} elif pct < 0: return {"text": f"-{abs(pct):.1f}%", "class": "dn", "up": False} else: return {"text": "0.0%", "class": "neutral", "up": False} # Background thread runner for pipeline orchestrator def run_pipeline_worker(records: int): global pipeline_status with pipeline_lock: pipeline_status["status"] = "RUNNING" pipeline_status["progress"] = 5 pipeline_status["step"] = "1/5: Generating Data" pipeline_status["start_time"] = time.time() pipeline_status["end_time"] = None pipeline_status["elapsed"] = 0 pipeline_status["records"] = records python_exe = sys.executable # Open the log file os.makedirs(os.path.dirname(LOG_FILE_PATH), exist_ok=True) with open(LOG_FILE_PATH, "w", encoding="utf-8") as log_file: log_file.write(f"=== Subscription Intelligence Pipeline Run Started (Records: {records}) ===\n") log_file.flush() steps = [ ("Generating Data", [python_exe, "src/ingestion/generate_data.py", "--records", str(records)], 25), ("Ingest -> Parquet", [python_exe, "src/ingestion/etl_ingest.py"], 50), ("MRR / ARR / LTV", [python_exe, "src/transforms/mrr_transform.py"], 75), ("Churn + Cohorts", [python_exe, "src/transforms/churn_cohort.py"], 90), ("Benchmark", [python_exe, "src/analytics/benchmark.py"], 100), ] env = os.environ.copy() env["PYTHONIOENCODING"] = "utf-8" success = True for i, (name, cmd, prog) in enumerate(steps): with pipeline_lock: pipeline_status["step"] = f"{i+1}/5: {name}" pipeline_status["progress"] = max(pipeline_status["progress"], int(prog - 15)) log_file.write(f"\n>>> Running Step {i+1}: {name}...\n") log_file.write(f"Command: {' '.join(cmd)}\n") log_file.flush() proc = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, cwd=BASE_DIR, env=env, text=True ) while True: line = proc.stdout.readline() if not line: break log_file.write(line) log_file.flush() # Check elapsed with pipeline_lock: pipeline_status["elapsed"] = round(time.time() - pipeline_status["start_time"], 1) proc.wait() if proc.returncode != 0: success = False log_file.write(f"\n[ERROR] Step '{name}' failed with exit code {proc.returncode}\n") log_file.flush() break with pipeline_lock: pipeline_status["progress"] = prog pipeline_status["elapsed"] = round(time.time() - pipeline_status["start_time"], 1) with pipeline_lock: pipeline_status["end_time"] = time.time() pipeline_status["elapsed"] = round(pipeline_status["end_time"] - pipeline_status["start_time"], 1) if success: pipeline_status["status"] = "SUCCESS" pipeline_status["progress"] = 100 pipeline_status["step"] = "Completed successfully" log_file.write("\n=== Pipeline completed successfully! ===\n") else: pipeline_status["status"] = "FAILED" pipeline_status["step"] = "Pipeline failed" log_file.write("\n=== Pipeline execution failed! ===\n") log_file.flush() @app.post("/api/pipeline/run") def trigger_pipeline(background_tasks: BackgroundTasks, records: int = 50000): global pipeline_status with pipeline_lock: if pipeline_status["status"] == "RUNNING": raise HTTPException(status_code=400, detail="Pipeline is already running.") background_tasks.add_task(run_pipeline_worker, records) return {"message": "Pipeline run triggered", "status": "RUNNING"} @app.get("/api/pipeline/status") def get_pipeline_status(): global pipeline_status with pipeline_lock: if pipeline_status["status"] == "RUNNING": pipeline_status["elapsed"] = round(time.time() - pipeline_status["start_time"], 1) return pipeline_status @app.get("/api/pipeline/logs") def get_pipeline_logs(): if not os.path.exists(LOG_FILE_PATH): return {"logs": "No pipeline run logs available yet. Trigger the pipeline first."} try: with open(LOG_FILE_PATH, "r", encoding="utf-8") as f: return {"logs": f.read()} except Exception as e: return {"logs": f"Error reading logs: {str(e)}"} @app.get("/api/overview") def get_overview(period_filter: str = "1y"): """ Returns KPIs, MRR monthly trend, Revenue by Plan, and Top Tenants """ mrr_summary_df = load_parquet("data/processed/global_mrr_monthly") tenant_ltv_df = load_parquet("data/processed/tenant_ltv") mrr_tenant_df = load_parquet("data/processed/mrr_by_tenant_month") if mrr_summary_df is None or len(mrr_summary_df) == 0: return JSONResponse(status_code=404, content={"detail": "No processed data found. Please run the ETL pipeline."}) mrr_summary_df = mrr_summary_df.sort_values(["event_year", "event_month"]).reset_index(drop=True) # Filter periods based on selection if period_filter == "6m": filtered_summary = mrr_summary_df.tail(6) elif period_filter == "1y": filtered_summary = mrr_summary_df.tail(12) else: filtered_summary = mrr_summary_df # Get latest active values latest_month = mrr_summary_df.iloc[-1] prev_month = mrr_summary_df.iloc[-2] if len(mrr_summary_df) > 1 else latest_month total_mrr = latest_month["total_mrr"] total_arr = latest_month["total_arr"] paying_tenants = latest_month["paying_tenants"] # MoM calculations mrr_pct = ((total_mrr - prev_month["total_mrr"]) / prev_month["total_mrr"] * 100) if prev_month["total_mrr"] > 0 else 0 arr_pct = ((total_arr - prev_month["total_arr"]) / prev_month["total_arr"] * 100) if prev_month["total_arr"] > 0 else 0 tenants_diff = paying_tenants - prev_month["paying_tenants"] # Dynamic Churn churn_df = load_parquet("data/processed/churn_by_plan_month") latest_churn_pct = 0.0 churn_trend = {"text": "-", "class": "neutral"} if churn_df is not None and len(churn_df) > 0: latest_period = latest_month["period"] latest_churn_rows = churn_df[churn_df["period"] == latest_period] if len(latest_churn_rows) > 0: total_active = latest_churn_rows["active_tenants"].sum() total_churned = latest_churn_rows["churned_tenants"].sum() latest_churn_pct = (total_churned / total_active * 100) if total_active > 0 else 0.0 # Get prior month churn prev_period = prev_month["period"] prev_churn_rows = churn_df[churn_df["period"] == prev_period] if len(prev_churn_rows) > 0: prev_active = prev_churn_rows["active_tenants"].sum() prev_churned = prev_churn_rows["churned_tenants"].sum() prev_churn_pct = (prev_churned / prev_active * 100) if prev_active > 0 else 0.0 churn_diff = latest_churn_pct - prev_churn_pct if churn_diff > 0: churn_trend = {"text": f"+{churn_diff:.1f}% vs last mo", "class": "dn"} # Churn going up is BAD (danger) elif churn_diff < 0: churn_trend = {"text": f"-{abs(churn_diff):.1f}% vs last mo", "class": "up"} else: churn_trend = {"text": "No change", "class": "neutral"} # Format KPI blocks kpis = [ { "label": "Total MRR", "val": format_inr(total_mrr), "trend_text": format_trend(mrr_pct)["text"], "trend_class": format_trend(mrr_pct)["class"] }, { "label": "ARR", "val": format_inr(total_arr), "trend_text": format_trend(arr_pct)["text"], "trend_class": format_trend(arr_pct)["class"] }, { "label": "Churn Rate", "val": f"{latest_churn_pct:.1f}%", "trend_text": churn_trend["text"], "trend_class": churn_trend["class"] }, { "label": "Active Tenants", "val": str(int(paying_tenants)), "trend_text": f"+{int(tenants_diff)} this month" if tenants_diff >= 0 else f"-{int(abs(tenants_diff))} this month", "trend_class": "up" if tenants_diff >= 0 else "dn" } ] # 2. Monthly Trend Chart Bars mrr_trend = [] max_mrr = filtered_summary["total_mrr"].max() if len(filtered_summary) > 0 else 1 for _, row in filtered_summary.iterrows(): pct_height = int((row["total_mrr"] / max_mrr) * 90) # Max height 92px # Palette: gradients from light blue to dark blue based on height color = "#B5D4F4" if pct_height > 75: color = "#0C447C" elif pct_height > 50: color = "#185FA5" elif pct_height > 30: color = "#378ADD" elif pct_height > 15: color = "#85B7EB" mrr_trend.append({ "period": row["period"], "month_name": pd.to_datetime(row["period"] + "-01").strftime("%b"), "mrr": float(row["total_mrr"]), "mrr_formatted": format_inr(row["total_mrr"]), "pct_height": pct_height, "color": color }) # 3. Revenue by Plan plan_revenue = [] if mrr_tenant_df is not None and len(mrr_tenant_df) > 0: latest_period = latest_month["period"] latest_tenant_mrr = mrr_tenant_df[mrr_tenant_df["period"] == latest_period] # We need to join with tenant info to get their plans if tenant_ltv_df is not None and len(tenant_ltv_df) > 0: joined = pd.merge(latest_tenant_mrr, tenant_ltv_df[["tenant_id", "plan"]], on="tenant_id", how="inner") plan_grouped = joined.groupby("plan")["mrr"].sum().reset_index() # Map standard plans plan_colors = { "enterprise": "#3C3489", "business": "#185FA5", "growth": "#0F6E56", "starter": "#633806" } total_plan_rev = plan_grouped["mrr"].sum() for _, row in plan_grouped.iterrows(): p = row["plan"] plan_revenue.append({ "plan": p.capitalize(), "raw_mrr": float(row["mrr"]), "mrr": format_inr(row["mrr"]), "pct": int((row["mrr"] / total_plan_rev * 100)) if total_plan_rev > 0 else 0, "color": plan_colors.get(p.lower(), "#185FA5") }) # Sort plans by tier tier_order = {"Enterprise": 4, "Business": 3, "Growth": 2, "Starter": 1} plan_revenue.sort(key=lambda x: tier_order.get(x["plan"], 0), reverse=True) # Default plan revenue if missing if not plan_revenue: plan_revenue = [ {"plan": "Enterprise", "mrr": "INR 0", "pct": 0, "color": "#3C3489"}, {"plan": "Business", "mrr": "INR 0", "pct": 0, "color": "#185FA5"}, {"plan": "Growth", "mrr": "INR 0", "pct": 0, "color": "#0F6E56"}, {"plan": "Starter", "mrr": "INR 0", "pct": 0, "color": "#633806"} ] # 4. Top Tenants by LTV top_tenants = [] if tenant_ltv_df is not None and len(tenant_ltv_df) > 0: sorted_ltv = tenant_ltv_df.sort_values("estimated_ltv", ascending=False).head(5) for _, row in sorted_ltv.iterrows(): # Get latest month MRR tid = row["tenant_id"] latest_mrr = 0.0 mom_growth = 0.0 if mrr_tenant_df is not None: t_history = mrr_tenant_df[mrr_tenant_df["tenant_id"] == tid].sort_values(["event_year", "event_month"]) if len(t_history) > 0: latest_mrr = t_history.iloc[-1]["mrr"] mom_growth = t_history.iloc[-1]["mrr_growth_pct"] top_tenants.append({ "company_name": row["company_name"], "plan": row["plan"].capitalize(), "ltv": format_inr(row["estimated_ltv"]), "mrr": format_inr(latest_mrr), "mom_growth": format_trend(mom_growth)["text"], "mom_class": format_trend(mom_growth)["class"] }) return { "kpis": kpis, "mrr_trend": mrr_trend, "plan_revenue": plan_revenue, "top_tenants": top_tenants } @app.get("/api/tenants") def get_tenants(search: str = "", plan: str = "all", page: int = 1, limit: int = 15): tenant_ltv_df = load_parquet("data/processed/tenant_ltv") mrr_tenant_df = load_parquet("data/processed/mrr_by_tenant_month") if tenant_ltv_df is None or len(tenant_ltv_df) == 0: return {"tenants": [], "total_count": 0, "active_count": 0, "avg_ltv": "INR 0", "arpu": "INR 0"} # Standard metrics total_count = len(tenant_ltv_df) avg_ltv = tenant_ltv_df["estimated_ltv"].mean() # Active/Churned determination # An active tenant had payment in latest period latest_period = "2025-04" if mrr_tenant_df is not None and len(mrr_tenant_df) > 0: latest_period = mrr_tenant_df["period"].max() active_ids = mrr_tenant_df[mrr_tenant_df["period"] == latest_period]["tenant_id"].unique() tenant_ltv_df["status"] = np.where(tenant_ltv_df["tenant_id"].isin(active_ids), "Active", "Churned") else: tenant_ltv_df["status"] = "Active" active_count = len(tenant_ltv_df[tenant_ltv_df["status"] == "Active"]) avg_mrr = tenant_ltv_df["avg_monthly_mrr"].mean() # Apply search filter filtered_df = tenant_ltv_df.copy() if search: filtered_df = filtered_df[ filtered_df["company_name"].str.contains(search, case=False, na=False) | filtered_df["tenant_id"].str.contains(search, case=False, na=False) ] # Apply plan filter if plan != "all": filtered_df = filtered_df[filtered_df["plan"].str.lower() == plan.lower()] # Pagination total_filtered = len(filtered_df) start_idx = (page - 1) * limit end_idx = start_idx + limit paginated_df = filtered_df.sort_values("estimated_ltv", ascending=False).iloc[start_idx:end_idx] tenants_list = [] for _, row in paginated_df.iterrows(): # Look up latest MRR tid = row["tenant_id"] latest_mrr = 0.0 if mrr_tenant_df is not None and len(mrr_tenant_df) > 0: latest_rows = mrr_tenant_df[(mrr_tenant_df["tenant_id"] == tid) & (mrr_tenant_df["period"] == latest_period)] if len(latest_rows) > 0: latest_mrr = latest_rows.iloc[0]["mrr"] tenants_list.append({ "tenant_id": row["tenant_id"], "company_name": row["company_name"], "plan": row["plan"].capitalize(), "mrr": format_inr(latest_mrr if latest_mrr > 0 else row["avg_monthly_mrr"]), "active_months": int(row["active_months"]), "ltv": format_inr(row["estimated_ltv"]), "status": row["status"] }) return { "tenants": tenants_list, "total_count": total_count, "active_count": active_count, "avg_ltv": format_inr(avg_ltv), "arpu": format_inr(avg_mrr), "total_filtered": total_filtered, "page": page, "limit": limit } @app.get("/api/cohorts") def get_cohorts(): cohort_df = load_parquet("data/processed/cohort_retention") cohort_sizes_df = load_parquet("data/processed/cohort_sizes") if cohort_df is None or len(cohort_df) == 0: return {"cohorts": [], "months": []} cohort_df["months_since_start"] = cohort_df["months_since_start"].astype(int) # Pivot cohort matrix pivot = cohort_df.pivot(index="cohort", columns="months_since_start", values="retention_pct").reset_index() pivot = pivot.sort_values("cohort", ascending=True) # Merge with cohort size if cohort_sizes_df is not None: pivot = pd.merge(pivot, cohort_sizes_df, on="cohort", how="left") else: pivot["cohort_size"] = 25 cohorts_list = [] for _, row in pivot.iterrows(): retention_months = [] for m in range(12): val = row.get(m, None) retention_months.append(float(val) if pd.notna(val) else None) cohorts_list.append({ "cohort": row["cohort"], "cohort_size": int(row["cohort_size"]) if pd.notna(row["cohort_size"]) else 25, "retention": retention_months }) return { "cohorts": cohorts_list, "months": [f"M{m}" for m in range(12)] } @app.get("/api/churn") def get_churn(): churn_df = load_parquet("data/processed/churn_by_plan_month") events_df = load_parquet("data/processed/billing_events") tenants_df = load_parquet("data/processed/tenant_ltv") if churn_df is None or len(churn_df) == 0: return {"kpis": [], "plan_churn": [], "churn_risk": []} churn_df = churn_df.copy() churn_df["churned_tenants"] = churn_df["churned_tenants"].fillna(0) churn_df["active_tenants"] = churn_df["active_tenants"].fillna(0) churn_df["churn_rate_pct"] = churn_df["churn_rate_pct"].fillna(0) period_summary = ( churn_df .groupby("period", as_index=False) .agg( active_tenants=("active_tenants", "sum"), churned_tenants=("churned_tenants", "sum") ) .sort_values("period") ) active_churn_periods = period_summary[period_summary["churned_tenants"] > 0] selected_period = ( active_churn_periods.iloc[-1]["period"] if len(active_churn_periods) > 0 else period_summary.iloc[-1]["period"] ) latest_churn = churn_df[churn_df["period"] == selected_period] total_active = latest_churn["active_tenants"].sum() total_churned = latest_churn["churned_tenants"].sum() overall_churn_rate = (total_churned / total_active * 100) if total_active > 0 else 0.0 # Total unique churned tenants across all history total_historical_churned = 0 if events_df is not None: total_historical_churned = events_df[events_df["event_type"] == "subscription_cancelled"]["tenant_id"].nunique() if len(latest_churn) > 0: highest_plan = latest_churn.sort_values("churn_rate_pct", ascending=False).iloc[0] lowest_plan = latest_churn.sort_values("churn_rate_pct", ascending=True).iloc[0] highest_plan_name = str(highest_plan["plan"]).capitalize() lowest_plan_name = str(lowest_plan["plan"]).capitalize() highest_plan_churn = float(highest_plan["churn_rate_pct"]) lowest_plan_churn = float(lowest_plan["churn_rate_pct"]) else: highest_plan_name = lowest_plan_name = "No plan data" highest_plan_churn = lowest_plan_churn = 0.0 kpis = [ {"label": "Observed Churn", "val": f"{overall_churn_rate:.1f}%", "sub": f"{selected_period} latest churn activity"}, {"label": "Highest Plan Churn", "val": f"{highest_plan_churn:.1f}%", "sub": highest_plan_name}, {"label": "Lowest Plan Churn", "val": f"{lowest_plan_churn:.1f}%", "sub": lowest_plan_name}, {"label": "Churned This Period", "val": str(int(total_churned)), "sub": f"{total_historical_churned} all-time cancelled"} ] plan_churn = [] plan_colors = { "starter": "#E24B4A", "growth": "#EF9F27", "business": "#85B7EB", "enterprise": "#1D9E75" } for plan_name in ["starter", "growth", "business", "enterprise"]: p_row = latest_churn[latest_churn["plan"] == plan_name] pct = float(p_row.iloc[0]["churn_rate_pct"]) if len(p_row) > 0 else 0.0 plan_churn.append({ "plan": plan_name.capitalize(), "rate": f"{pct:.1f}%", "bar_width": int(min(pct * 10, 100)), "color": plan_colors.get(plan_name, "#85B7EB") }) # Dynamic billing-risk tenants from recent failed invoices. churn_risk = [] if events_df is not None and tenants_df is not None: events_df = events_df.copy() events_df["event_date"] = pd.to_datetime(events_df["event_date"], errors="coerce") latest_event_date = events_df["event_date"].max() cutoff_date = latest_event_date - pd.Timedelta(days=90) if pd.notna(latest_event_date) else None failed_events = events_df[events_df["event_type"] == "invoice_failed"] if cutoff_date is not None: failed_events = failed_events[failed_events["event_date"] >= cutoff_date] failure_counts = ( failed_events .groupby("tenant_id") .size() .reset_index(name="failure_count") .sort_values("failure_count", ascending=False) .head(4) ) for _, failed_row in failure_counts.iterrows(): tid = failed_row["tenant_id"] t_row = tenants_df[tenants_df["tenant_id"] == tid] if len(t_row) > 0: failure_count = int(failed_row["failure_count"]) reason = ( f"{failure_count} failed invoices in last 90d" if failure_count > 1 else "1 failed invoice in last 90d" ) churn_risk.append({ "company_name": t_row.iloc[0]["company_name"], "reason": reason, "color": "var(--color-text-danger)" if failure_count >= 3 else "var(--color-text-warning)" }) if not churn_risk: churn_risk = [ { "company_name": "No high-risk accounts found", "reason": "No recent failed invoices in processed data", "color": "var(--color-text-tertiary)" } ] return { "kpis": kpis, "plan_churn": plan_churn, "churn_risk": churn_risk, "selected_period": selected_period } @app.post("/api/simulator/revenue") def simulate_revenue(request: RevenueSimulationRequest): tenants = float(request.starting_tenants) arpu = float(request.monthly_arpu) growth_rate = request.monthly_growth_pct / 100 churn_rate = request.monthly_churn_pct / 100 rows = [] starting_mrr = tenants * arpu for month in range(1, request.months + 1): starting_tenants = tenants added_tenants = max(starting_tenants * growth_rate, -starting_tenants) churned_tenants = starting_tenants * churn_rate tenants = max(starting_tenants + added_tenants - churned_tenants, 0) mrr = tenants * arpu rows.append({ "month": month, "starting_tenants": round(starting_tenants, 1), "added_tenants": round(added_tenants, 1), "churned_tenants": round(churned_tenants, 1), "ending_tenants": round(tenants, 1), "mrr": round(mrr, 2), "mrr_formatted": format_inr(mrr), "arr_formatted": format_inr(mrr * 12) }) ending_mrr = rows[-1]["mrr"] if rows else starting_mrr mrr_delta = ending_mrr - starting_mrr request_payload = request.model_dump() if hasattr(request, "model_dump") else request.dict() return { "inputs": request_payload, "summary": { "starting_mrr": format_inr(starting_mrr), "ending_mrr": format_inr(ending_mrr), "ending_arr": format_inr(ending_mrr * 12), "mrr_change": format_inr(mrr_delta), "mrr_change_pct": round((mrr_delta / starting_mrr * 100), 1) if starting_mrr else 0.0, "ending_tenants": round(rows[-1]["ending_tenants"], 1) if rows else round(tenants, 1) }, "projection": rows } @app.get("/api/pipeline") def get_pipeline_details(): events_df = load_parquet("data/processed/billing_events") tenants_df = load_parquet("data/processed/tenant_ltv") cohort_df = load_parquet("data/processed/cohort_retention") rejected_df = load_parquet("data/processed/rejected") total_events = len(events_df) if events_df is not None else 0 paying_tenants = len(tenants_df) if tenants_df is not None else 0 total_cohorts = cohort_df["cohort"].nunique() if cohort_df is not None else 0 rejected_rows = len(rejected_df) if rejected_df is not None else 0 data_status = "Done" if total_events else "Waiting for data" data_status_class = "st-ok" if total_events else "st-idle" jobs = [ { "name": "Job 1 - Ingest + Validate (ETL Core)", "meta": f"{total_events:,} clean events available | {rejected_rows:,} rejected rows | Parquet partitioned by year/month", "bar_width": 100 if total_events else 0, "bar_color": "#1D9E75", "status": data_status_class, "status_text": data_status }, { "name": "Job 2 - MRR / ARR / LTV Computations", "meta": f"Computed dynamic window metrics for {paying_tenants} tenants | Derived ARR and lifetime estimations", "bar_width": 100 if paying_tenants else 0, "bar_color": "#185FA5", "status": "st-ok" if paying_tenants else "st-idle", "status_text": "Done" if paying_tenants else "Waiting for data" }, { "name": "Job 3 - Churn + Cohort Retention Grid", "meta": f"Tracked survival metrics across {total_cohorts} cohort month signups over a rolling 12 month timeframe", "bar_width": 100 if total_cohorts else 0, "bar_color": "#534AB7", "status": "st-ok" if total_cohorts else "st-idle", "status_text": "Done" if total_cohorts else "Waiting for data" }, { "name": "PostgreSQL Schema + Materialized View Design", "meta": "Schema includes tenant-scoped tables, indexes, materialized revenue view, and RLS policies", "bar_width": 100, "bar_color": "#BA7517", "status": "st-ok", "status_text": "Designed" }, { "name": "Kafka Producer - Real-time event streaming topic", "meta": "Producer supports live Kafka mode and dry-run event simulation; not running unless started", "bar_width": 40, "bar_color": "#D85A30", "status": "st-idle", "status_text": "Optional" } ] return jobs @app.get("/api/benchmark") def get_benchmark(): bench_results_path = os.path.join(DOCS_DIR, "benchmark_results.json") if not os.path.exists(bench_results_path): return { "records": [ {"records": "50,000", "pandas": "Not run", "spark": "Not run", "winner": "Run benchmark", "winner_class": "w-pandas", "p_width": 20, "s_width": 20}, {"records": "100,000", "pandas": "Not run", "spark": "Not run", "winner": "Run benchmark", "winner_class": "w-pandas", "p_width": 20, "s_width": 20}, {"records": "200,000", "pandas": "Not run", "spark": "Not run", "winner": "Run benchmark", "winner_class": "w-pandas", "p_width": 20, "s_width": 20} ] } try: with open(bench_results_path, "r", encoding="utf-8") as f: data = json.load(f) formatted_records = [] for row in data: recs = row["records"] p_val = row["pandas_seconds"] s_val = row["spark_seconds"] p_str = f"{p_val:.2f}s" if isinstance(p_val, (int, float)) else str(p_val) s_str = f"{s_val:.2f}s" if isinstance(s_val, (int, float)) else str(s_val) # Compute bar sizes based on standard values p_width = int(min(float(p_val) * 30, 120)) if isinstance(p_val, (int, float)) else 20 s_width = int(min(float(s_val) * 30, 120)) if isinstance(s_val, (int, float)) else 80 winner = row["winner"] w_class = "w-pandas" if "pandas" in winner.lower() else "w-spark" formatted_records.append({ "records": f"{recs:,}", "pandas": p_str, "spark": s_str, "winner": winner, "winner_class": w_class, "p_width": p_width, "s_width": s_width }) return {"records": formatted_records} except Exception as e: print(f"Error loading benchmark: {e}") return { "records": [ {"records": "50,000", "pandas": "Error", "spark": "Error", "winner": "Check benchmark file", "p_width": 20, "s_width": 20, "winner_class": "w-pandas"}, {"records": "100,000", "pandas": "Error", "spark": "Error", "winner": "Check benchmark file", "p_width": 20, "s_width": 20, "winner_class": "w-pandas"}, {"records": "200,000", "pandas": "Error", "spark": "Error", "winner": "Check benchmark file", "p_width": 20, "s_width": 20, "winner_class": "w-pandas"} ] } @app.get("/", response_class=HTMLResponse) def serve_index(): index_path = os.path.join(BASE_DIR, "src", "web", "templates", "index.html") if not os.path.exists(index_path): return HTMLResponse("

Error: src/web/templates/index.html not found!

", status_code=404) with open(index_path, "r", encoding="utf-8") as f: return HTMLResponse(content=f.read()) if __name__ == "__main__": import uvicorn uvicorn.run(app, host="127.0.0.1", port=8000)