diff --git "a/app.py" "b/app.py" --- "a/app.py" +++ "b/app.py" @@ -1,1733 +1,1838 @@ -""" -app.py – School Data Fetcher -Progressive disclosure UI: each step appears only when it is needed. -""" - -import gradio as gr -import subprocess -import sys -import os -import re -import json -import tempfile -import pandas as pd -from district_mapper import ( - build_mapper, build_udise_geo_lookup, - apply_geo_decode, apply_district_backmap, - NON_ACTUAL_STATES, -) -from hf_store import ( - push_state_file, pull_all_complete_states, - ALL_REQUIRED_CATEGORIES, - pull_district_reference, pull_mapping_rules, - push_district_reference, push_mapping_rules, - update_district_reference_add, update_district_reference_rename, - delete_district_reference, upsert_mapping_rule, delete_mapping_rule, - get_flagged_districts, - get_hf_credentials, seed_district_reference_from_csv, - seed_mapping_rules_from_excel, -) - -# ── Auto-push helper (called after successful scrape) ───────────────────────── -def _auto_push_to_dataset(state_name: str, categories: list) -> str: - """Automatically push scraped Excel to HF dataset after scrape succeeds.""" - token, repo = get_hf_credentials() - if not token or not repo: - return "⚠️ No HF credentials — raw data not pushed to dataset." - excel_path = get_excel_file(state_name) if state_name else None - if not excel_path or not os.path.exists(excel_path): - return "⚠️ Excel not found — push skipped." - try: - df = pd.read_excel(excel_path) - cat_nums = [c.split(" - ")[0].strip() for c in (categories or [])] - if len(cat_nums) < 7 and "All Categories (1 to 7)" not in categories: - return "⚠️ Partial scrape completed. Excel downloaded, but NOT pushed to dataset. You must select all 7 categories to push." - push_state_file(df=df, state_name=state_name, categories_scraped=cat_nums, token=token, repo=repo) - return f"✅ Auto-pushed raw data for {state_name} to the cloud. Check the Master Sheet tab for any new districts flagged." - except Exception as ex: - return f"❌ Auto-push failed: {ex}" - -# ── State list ──────────────────────────────────────────────────────────────── -STATE_LABEL_TO_ID = { - "ANDAMAN & NICOBAR ISLANDS": 135, "ANDHRA PRADESH": 128, - "ARUNACHAL PRADESH": 112, "ASSAM": 118, "BIHAR": 110, - "CHANDIGARH": 104, "CHHATTISGARH": 122, - "DADRA & NAGAR HAVELI AND DAMAN & DIU": 138, "DELHI": 107, - "GOA": 130, "GUJARAT": 124, "HARYANA": 106, "HIMACHAL PRADESH": 102, - "IAF EC SOCIETY": 163, "JAMMU & KASHMIR": 101, "JHARKHAND": 120, - "KARNATAKA": 129, "KENDRIYA VIDYALAYA SANGHATHAN": 192, - "KERALA": 132, "LADAKH": 137, "LAKSHADWEEP": 131, - "MADHYA PRADESH": 123, "MAHARASHTRA": 127, "MANIPUR": 114, - "MEGHALAYA": 117, "MIZORAM": 115, "MSRVVP": 161, - "NAGALAND": 113, "NAVODAYA VIDYALAYA SAMITI": 193, - "NAVY EDUCATION SOCIETY": 162, "ODISHA": 121, "PUDUCHERRY": 134, - "PUNJAB": 103, "RAJASTHAN": 108, "SIKKIM": 111, "TAMILNADU": 133, - "TELANGANA": 136, "TEST STATE": 199, "TRIPURA": 116, - "UTTAR PRADESH": 109, "UTTARAKHAND": 105, "WEST BENGAL": 119, -} - -ALL_CATEGORIES = { - "3": "3 - Pr. with Up.Pr. sec. and H.Sec.", - "5": "5 - Up. Pr. Secondary and Higher Sec", - "6": "6 - Pr. Up Pr. and Secondary Only", - "7": "7 - Upper Pr. and Secondary", - "8": "8 - Secondary Only", - "10": "10 - Secondary with Higher Secondary", - "11": "11 - Higher Secondary only/Jr. College", -} - -OUTPUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "output") -EXCEL_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "output_excel") -_proc: dict = {"current": None} - -# Analysis results are computed on demand (empty until user uploads files) -_EMPTY_ANALYSIS: dict = {} - - -def _state_prefix(s: str) -> str: - return re.sub(r"[^a-z0-9]+", "_", s.lower()).strip("_") - -def get_output_file(state: str) -> str: - return os.path.join(OUTPUT_DIR, f"{_state_prefix(state)}_schools_by_category.json") - -def get_excel_file(state: str) -> str: - return os.path.join(EXCEL_DIR, f"{_state_prefix(state)}_Schools.xlsx") - -def check_missed_schools(json_path: str): - if not os.path.exists(json_path): - return None - try: - import json - with open(json_path, "r", encoding="utf-8") as f: - data = json.load(f) - if not isinstance(data, list): - return None - def is_invalid(r): - if r.get("status") == "captcha_failed_all_retries": - return True - details = (r.get("response") or {}).get("error", {}).get("errorDetails", {}).get("details", "") - return isinstance(details, str) and "invalid captcha" in details.lower() - return sum(1 for r in data if is_invalid(r)) - except Exception: - return None - - -# ── HTML helpers ────────────────────────────────────────────────────────────── - -def _progress_html(current: int, total: int) -> str: - pct = round(current / total * 100) if total > 0 else 0 - return f""" -
-
- Progress - {current} / {total} ({pct}%) -
-
-
- {"" + str(pct) + "%" if pct > 8 else ""} -
-
-
""" - -def _phase_html(text: str) -> str: - safe = re.sub(r"<[^>]+>", "", re.sub(r"\s*\(id=\d+\)", "", text.split("\n")[0]))[:200] - return f"""
- {safe} -
""" - -def _banner_html(icon: str, title: str, body: str, kind: str) -> str: - """Large status banner shown after a run completes.""" - colors = { - "success": "var(--color-green-500)", - "warning": "var(--color-yellow-500)", - "nodata": "var(--color-blue-500)", - "stopped": "var(--color-red-500)", - } - border_col = colors.get(kind, "var(--border-color-primary)") - return f""" -
-
{icon} {title}
-
{body}
-
""" - -def _stats_html(success: int, no_data: int, failed: int, total: int) -> str: - return f""" -
-
{success}
✓ Retrieved
-
{no_data}
○ Zero Schools
-
{failed}
✗ Incomplete
-
{total}
Total Records
-
""" - -def _result_html(banner: str, stats: str) -> str: - if not banner and not stats: - return "" - return banner + stats - -def _idle_html() -> str: - return "
Ready.
" - -def _stopped_html() -> str: - return "
⏹ Stopped by user.
" - - -# ── Log filter ───────────────────────────────────────────────────────────────── -NOISE = [ - "test session starts", "platform ", "cachedir:", "rootdir:", - "configfile:", "plugins:", "collected ", "live log call", - "PASSED", "FAILED", "=== 1 passed", "=== 1 failed", "no tests ran", - "tests/test_", "====================", "short test summary", - "UserWarning", "warnings.warn", -] -def _is_noise(line: str) -> bool: - return any(m in line for m in NOISE) - -def _clean(line: str) -> str: - return re.sub(r"(INFO|WARNING|ERROR|DEBUG)\s+\S+:\S+:\d+\s+", "", line).strip() - - -# ── Core streamer ───────────────────────────────────────────────────────────── -# Outputs (9 values): -# 0 progress_html -# 1 phase_html -# 2 result_html -# 3 download_btn ← gr.update(value) — raw JSON -# 4 downloads_row ← gr.update(visible) -# 5 excel_dl_btn ← gr.update(value) — auto-generated Excel -# 6 retry_row ← gr.update(visible) -# 7 map_row ← gr.update(visible) — show Map Districts after success -# 8 stop_row ← gr.update(visible) - -def _stream(pytest_args: list, state: str, max_retries: int, mode: str, target_categories: str = None): - out_file = get_output_file(state) - excel_file = get_excel_file(state) - - if mode == "scrape" and os.path.exists(excel_file): - try: os.remove(excel_file) - except Exception: pass - - env = os.environ.copy() - env["KYS_MAX_RETRIES"] = str(int(max_retries)) - if target_categories: - env["KYS_TARGET_CATEGORIES"] = target_categories - - try: - process = subprocess.Popen( - [sys.executable, "-m"] + pytest_args, - stdout=subprocess.PIPE, stderr=subprocess.STDOUT, - encoding="utf-8", errors="replace", text=True, bufsize=1, universal_newlines=True, - cwd=os.path.dirname(os.path.abspath(__file__)), - env=env, - ) - except Exception as e: - import traceback - err_banner = _banner_html("❌", "Error starting process", str(e), "stopped") - yield (_idle_html(), _phase_html(f"❌ Error: {e}"), _result_html(err_banner, ""), traceback.format_exc(), - gr.update(visible=False), gr.update(visible=False), - gr.update(visible=False, value=None), gr.update(visible=False), - gr.update(visible=False), gr.update(visible=False), gr.update(visible=False)) - return - - _proc["current"] = process - current, total = 0, 0 - phase_text = "Initialising…" - log_lines = [] - banner_rendered = "" - stats_rendered = "" - is_done = False - has_failures = False - success_count = 0 - no_data_count = 0 - failed_count = 0 - - re_p1 = re.compile(r"Found (\d+) districts") - re_p2 = re.compile(r"\[(\d+)/(\d+)\] NOW SEARCHING") - re_retry = re.compile(r"\[(\d+)/(\d+)\] RETRY") - re_dc = re.compile(r"District: (.+?)\s*\|\s*Category: (.+?)(?:\s*\(id=\d+\))?$") - re_done_s = re.compile(r"DONE\.\s+Total:\s*(\d+)\s*\|.*?Success:\s*(\d+).*?No Data:\s*(\d+).*?Captcha Failed:\s*(\d+)") - re_done_r = re.compile(r"RETRY DONE\.\s+Attempted:\s*(\d+)\s*\|.*?Resolved:\s*(\d+).*?Still captcha-failed:\s*(\d+)") - - excel_cache = [None] - def _auto_export_excel(): - """Auto-generate Excel from JSON after scrape completes.""" - excel = get_excel_file(state) - if excel_cache[0] == excel and os.path.exists(excel): - return gr.update(value=excel, visible=True) - - try: - proc = subprocess.run( - [sys.executable, "export_to_excel.py", "--state", state], - capture_output=True, encoding="utf-8", errors="replace", text=True, - cwd=os.path.dirname(os.path.abspath(__file__)) - ) - if proc.returncode == 0 and os.path.exists(excel): - excel_cache[0] = excel - return gr.update(value=excel, visible=True) - except Exception: - pass - return gr.update(value=None, visible=False) - - def _emit(): - prog = _progress_html(current, total) if total > 0 else _idle_html() - phase = _phase_html(phase_text) - res = _result_html(banner_rendered, stats_rendered) - stop_vis = gr.update(visible=not is_done) - - if is_done: - if success_count == 0 and no_data_count > 0 and failed_count == 0: - dl_row_vis, retry_vis = gr.update(visible=False), gr.update(visible=False) - elif not has_failures: - # All data complete — show downloads, hide retry - dl_row_vis, retry_vis = gr.update(visible=True), gr.update(visible=False) - else: - # Some failures — show retry, hide downloads - dl_row_vis, retry_vis = gr.update(visible=False), gr.update(visible=True) - else: - dl_row_vis, retry_vis = gr.update(visible=False), gr.update(visible=False) - - dl_val = gr.update(value=out_file) if (is_done and not has_failures and success_count > 0 and os.path.exists(out_file)) else gr.update(value=None) - - # Auto-export excel only when scrape succeeds - if is_done and not has_failures and success_count > 0: - excel_update = _auto_export_excel() - else: - excel_update = gr.update(value=None, visible=False) - - return (prog, phase, res, dl_val, dl_row_vis, excel_update, retry_vis, stop_vis) - - yield _emit() - for raw in iter(process.stdout.readline, ""): - line = raw.rstrip() - if not line: continue - clean = _clean(line) - m = re_p1.search(line) - if m: phase_text = f"📍 {m.group(1)} districts found. Collecting data…"; total = current = 0 - m2 = re_p2.search(line) or re_retry.search(line) - if m2: - current, total = int(m2.group(1)), int(m2.group(2)) - md = re_dc.search(clean) - phase_text = (f"🔍 [{current}/{total}] {md.group(1).strip()} · {md.group(2).strip()}" if md else f"🔍 [{current}/{total}] Processing…") - if "PASS Call" in line or "[PASS" in line: phase_text = phase_text.replace("🔍", "✅") - elif "NO DATA" in line: phase_text = phase_text.replace("🔍", "○") - elif "ALL ROUNDS FAILED" in line: phase_text = phase_text.replace("🔍", "✗") - ms = re_done_s.search(line) - if ms: - t = int(ms.group(1)); success_count = int(ms.group(2)); no_data_count = int(ms.group(3)); failed_count = int(ms.group(4)) - current = total = t; is_done = True; has_failures = failed_count > 0 - stats_rendered = _stats_html(success_count, no_data_count, failed_count, t) - if success_count == 0 and failed_count == 0: - phase_text = "○ No schools found in selected categories."; banner_rendered = _banner_html("○", "No Schools Found", "The selected categories have no schools.", "nodata") - elif not has_failures: - phase_text = "✅ All data retrieved!"; banner_rendered = _banner_html("✅", "All Data Retrieved!", "All school data fetched successfully.", "success") - else: - phase_text = f"⚠️ {failed_count} record(s) incomplete."; banner_rendered = _banner_html("⚠️", "Some Records Are Incomplete", f"{failed_count} record(s) could not be fetched. Please proceed to Step 2 to fetch missing data.", "warning") - mr = re_done_r.search(line) - if mr: - attempted = int(mr.group(1)); resolved = int(mr.group(2)); still_failed = int(mr.group(3)) - success_count = resolved; failed_count = still_failed; no_data_count = 0; current = total = attempted; is_done = True; has_failures = still_failed > 0 - stats_rendered = _stats_html(resolved, 0, still_failed, attempted) - if not has_failures: - phase_text = "✅ All data retrieved!"; banner_rendered = _banner_html("✅", "All Data Retrieved!", "All missing data fetched.", "success") - else: - phase_text = f"⚠️ {still_failed} record(s) still incomplete."; banner_rendered = _banner_html("⚠️", "Some Records Still Incomplete", f"{still_failed} record(s) failed. You can run Step 2 again or proceed to Step 3.", "warning") - yield _emit() - - process.stdout.close(); process.wait(); _proc["current"] = None - if process.returncode not in (0, 1): - is_done = True; phase_text = "Stopped by user."; banner_rendered = _banner_html("⏹", "Process Stopped", "The process was stopped.", "stopped"); yield _emit() - else: - fc = check_missed_schools(out_file) - if fc is not None: - if fc == 0 and success_count > 0: - is_done, has_failures = True, False; phase_text = "✅ All data retrieved!"; banner_rendered = _banner_html("✅", "All Data Retrieved!", "All school data fetched successfully.", "success") - elif fc > 0: - is_done, has_failures = True, True; failed_count = fc; phase_text = f"⚠️ {fc} record(s) incomplete."; banner_rendered = _banner_html("⚠️", "Some Records Are Incomplete", f"{fc} record(s) failed. Please proceed to Step 2 to fetch missing data.", "warning") - yield _emit() - - -# ── Button handlers ─────────────────────────────────────────────────────────── - -def _err_yield(msg: str): - # 8 outputs matching stream_outputs - return (_idle_html(), _phase_html(msg), "", - gr.update(value=None), gr.update(visible=False), - gr.update(value=None, elem_classes=["download-highlight", "hide-file"]), - gr.update(visible=False), gr.update(visible=False)) - -def ui_main_scrape(state, selected_cats, max_retries): - if not state: yield _err_yield("⚠️ Please select a state first."); return - if not selected_cats: yield _err_yield("⚠️ Please select at least one category."); return - cat_ids = [c.split(" - ")[0] for c in selected_cats] - yield from _stream(["pytest", "tests/test_scrape_districts_by_category.py", "-v", "-s", "--state", state], state, max_retries, "scrape", ",".join(cat_ids)) - -def ui_retry(state, max_retries): - if not state: yield _err_yield("⚠️ Please select a state first."); return - out = get_output_file(state) - if not os.path.exists(out): yield _err_yield("⚠️ No data file found."); return - fc = check_missed_schools(out) - if fc == 0: - # Auto-export Excel for complete data - excel_path = get_excel_file(state) - excel_update = gr.update(value=None, elem_classes=["download-highlight", "hide-file"]) - try: - proc = subprocess.run([sys.executable, "export_to_excel.py", "--state", state], - capture_output=True, encoding="utf-8", errors="replace", text=True, - cwd=os.path.dirname(os.path.abspath(__file__))) - if proc.returncode == 0 and os.path.exists(excel_path): - excel_update = gr.update(value=excel_path, visible=True) - except Exception: pass - banner = _banner_html("✅", "All Data Complete!", "Nothing missing — your Excel is ready to download below.", "success") - yield (_idle_html(), _phase_html("✅ All data is complete!"), _result_html(banner, ""), - gr.update(value=out), gr.update(visible=True), - excel_update, - gr.update(visible=False), gr.update(visible=False)) - return - yield from _stream(["pytest", "tests/test_retry_districts_by_category.py", "-v", "-s", "--state", state], state, max_retries, "retry") - -def ui_stop(): - proc = _proc.get("current") - if proc and proc.poll() is None: - proc.terminate() - try: proc.wait(timeout=5) - except subprocess.TimeoutExpired: proc.kill() - _proc["current"] = None - return _stopped_html() - return _phase_html("ℹ️ No process is currently running.") - - -# ── Dashboard helpers ───────────────────────────────────────────────────────── -def _metric_card(label, value, color="#6366f1"): - return f""" -
-
{value}
-
{label}
-
""" - -def _dash_headline_html(): - ns = _ANALYSIS.get("National Summary", pd.DataFrame()) - if ns.empty: - return "

Analysis file not loaded.

" - vals = dict(zip(ns["Metric"], ns["Count"])) - old = f"{vals.get('Total Old Schools', 0):,}" - new = f"{vals.get('Total New Schools', 0):,}" - added= f"{vals.get('Total Schools Added', 0):,}" - # compute deleted - deleted_df = _ANALYSIS.get("All Deleted Schools", pd.DataFrame()) - deleted = f"{len(deleted_df):,}" if not deleted_df.empty else "0" - return f""" -
- {_metric_card('Total Schools (Last Year)', old, '#6366f1')} - {_metric_card('Total Schools (This Year)', new, '#6366f1')} - {_metric_card('✅ Schools Added', added, '#22c55e')} - {_metric_card('❌ Schools Removed', deleted, '#ef4444')} -
""" - -def _truncate_udise(val, n=3): - """Truncate long UDISE lists to first n codes + count.""" - if pd.isna(val) or val == "": - return "" - codes = [c.strip() for c in str(val).split(",") if c.strip()] - if len(codes) <= n: - return ", ".join(codes) - shown = ", ".join(codes[:n]) - return f"{shown} ... (+{len(codes)-n} more)" - -def _full_udise(val): - if pd.isna(val) or val == "": - return "" - return str(val).strip() - -def _filter_state(sheet_name, state_col="State", state=None): - df = _ANALYSIS.get(sheet_name, pd.DataFrame()).copy() - if df.empty: - return df - if state and state != "All States": - df = df[df[state_col] == state] - return df.reset_index(drop=True) - -def _prep_with_udise(df, udise_col): - """Return two versions: one with truncated UDISEs for display, one full.""" - if df.empty or udise_col not in df.columns: - return df - df = df.copy() - df[udise_col] = df[udise_col].apply(_truncate_udise) - return df - -def dash_update(state, analysis): - """Returns all table data when state filter changes.""" - def _fs(sheet, sc="State"): - df = analysis.get(sheet, pd.DataFrame()).copy() - if df.empty: return df - if state and state != "All States": - df = df[df[sc] == state] - return df.reset_index(drop=True) - - dm = _prep_with_udise(_fs("District Mapping"), "List_of_UDISEs") - cs = _prep_with_udise(_fs("Complex Splits"), "List_of_UDISEs") - bm = _prep_with_udise(_fs("Block Mapping"), "List_of_UDISEs") - ct = _prep_with_udise(_fs("Category Transitions"), "List_of_UDISEs") - ad = _fs("All Added Schools", sc="School_State__c") - dl = _fs("All Deleted Schools", sc="School_State__c") - return dm, cs, bm, ct, ad, dl - - -def _get_udise_for_row(sheet_name, row_idx, analysis): - """Look up the full UDISE list for a given row index from live analysis.""" - df = analysis.get(sheet_name, pd.DataFrame()) - if df.empty or row_idx >= len(df) or "List_of_UDISEs" not in df.columns: - return "" - return _full_udise(df.iloc[row_idx]["List_of_UDISEs"]) - - -def on_select_dm(state, last_row, analysis, evt: gr.SelectData): - row = evt.index[0] - if row == last_row: return "", -1 - # filter to current state then get that row's udise - def _fs(sheet): - df = analysis.get(sheet, pd.DataFrame()).copy() - if state and state != "All States": df = df[df["State"] == state] - return df.reset_index(drop=True) - df = _fs("District Mapping") - if df.empty or row >= len(df): return "", row - return _full_udise(df.iloc[row]["List_of_UDISEs"]), row - -def on_select_cs(state, last_row, analysis, evt: gr.SelectData): - row = evt.index[0] - if row == last_row: return "", -1 - def _fs(sheet): - df = analysis.get(sheet, pd.DataFrame()).copy() - if state and state != "All States": df = df[df["State"] == state] - return df.reset_index(drop=True) - df = _fs("Complex Splits") - if df.empty or row >= len(df): return "", row - return _full_udise(df.iloc[row]["List_of_UDISEs"]), row - -def on_select_bm(state, last_row, analysis, evt: gr.SelectData): - row = evt.index[0] - if row == last_row: return "", -1 - def _fs(sheet): - df = analysis.get(sheet, pd.DataFrame()).copy() - if state and state != "All States": df = df[df["State"] == state] - return df.reset_index(drop=True) - df = _fs("Block Mapping") - if df.empty or row >= len(df): return "", row - return _full_udise(df.iloc[row]["List_of_UDISEs"]), row - -def on_select_ct(state, last_row, analysis, evt: gr.SelectData): - row = evt.index[0] - if row == last_row: return "", -1 - def _fs(sheet): - df = analysis.get(sheet, pd.DataFrame()).copy() - if state and state != "All States": df = df[df["State"] == state] - return df.reset_index(drop=True) - df = _fs("Category Transitions") - if df.empty or row >= len(df): return "", row - return _full_udise(df.iloc[row]["List_of_UDISEs"]), row - -# ── Analysis engine (ported from national_analysis.py) ─────────────────────── -def _load_file(path): - if path is None: - raise ValueError("No file uploaded.") - ext = os.path.splitext(path)[-1].lower() - if ext == ".csv": - try: - return pd.read_csv(path, low_memory=False) - except UnicodeDecodeError: - return pd.read_csv(path, encoding="cp1252", low_memory=False) - elif ext in (".xlsx", ".xls"): - return pd.read_excel(path) - else: - raise ValueError(f"Unsupported file type: {ext}. Use .csv or .xlsx") - -def _clean_text(s): - return s.astype(str).str.strip().str.upper() - -def _clean_udise(s): - return s.astype(str).str.replace(r"\.0$", "", regex=True).str.zfill(11) - -def _fmt_udise_list(series): - joined = ", ".join(series.astype(str)) - return joined[:30000] + " ... (+ more)" if len(joined) > 30000 else joined - -def run_analysis(old_path, new_path): - """Run the full comparison. Returns a dict of DataFrames.""" - df_old = _load_file(old_path) - df_new = _load_file(new_path) - - for df in [df_old, df_new]: - df["School_State__c"] = _clean_text(df["School_State__c"]) - df["UDISE"] = _clean_udise(df["School_Udise_Code__c"]) - - df_old = df_old.drop_duplicates(subset=["UDISE"]).set_index("UDISE") - df_new = df_new.drop_duplicates(subset=["UDISE"]).set_index("UDISE") - - old_u = set(df_old.index) - new_u = set(df_new.index) - added_u = new_u - old_u - deleted_u = old_u - new_u - common_u = old_u & new_u - - df_added_raw = df_new.loc[list(added_u)].reset_index()[["UDISE","School_State__c","School_District__c","School_Block__c"]] - df_deleted_raw = df_old.loc[list(deleted_u)].reset_index()[["UDISE","School_State__c","School_District__c","School_Block__c"]] - - all_states = sorted(set(df_old["School_State__c"].unique()) | set(df_new["School_State__c"].unique())) - state_summary = [] - for st in all_states: - so = set(df_old[df_old["School_State__c"] == st].index) - sn = set(df_new[df_new["School_State__c"] == st].index) - state_summary.append({"State": st, "Old Count": len(so), "New Count": len(sn), - "Added": len(sn - so), "Deleted": len(so - sn)}) - df_state_summary = pd.DataFrame(state_summary) - - df_common = df_old.loc[list(common_u)].join(df_new.loc[list(common_u)], lsuffix="_OLD", rsuffix="_NEW").reset_index() - df_common["State"] = _clean_text(df_common["School_State__c_NEW"]) - df_common["Dist_OLD"] = _clean_text(df_common["School_District__c_OLD"]) - df_common["Dist_NEW"] = _clean_text(df_common["School_District__c_NEW"]) - df_common["Block_OLD"]= _clean_text(df_common["School_Block__c_OLD"]) - df_common["Block_NEW"]= _clean_text(df_common["School_Block__c_NEW"]) - df_common["Cat_OLD"] = _clean_text(df_common["schCategoryType__c_OLD"]) - df_common["Cat_NEW"] = _clean_text(df_common["schCategoryType__c_NEW"]) - - dist_chg = df_common[df_common["Dist_OLD"] != df_common["Dist_NEW"]] - district_mapping = pd.DataFrame() - if len(dist_chg): - district_mapping = dist_chg.groupby(["State","Dist_OLD","Dist_NEW"]).agg( - Affected_Schools=("UDISE","count"), List_of_UDISEs=("UDISE", _fmt_udise_list) - ).reset_index() - - # 3. Block to District Mapping (The Exact Matcher for Splits) - # If a new district was formed, we want to know what the old district was for a given block. - # So we group by (State, Dist_NEW, Block_NEW) and find the most common Dist_OLD. - block_to_district = pd.DataFrame() - if not df_common.empty: - # Group by new block and old district - block_dist_counts = df_common.groupby(["State", "Dist_NEW", "Block_NEW", "Dist_OLD"]).agg( - Schools=("UDISE", "count"), - List_of_UDISEs=("UDISE", _fmt_udise_list) - ).reset_index() - - # For each new block, find the old district that had the most schools - # (This establishes the definitive historical mapping for that block) - idx = block_dist_counts.groupby(["State", "Dist_NEW", "Block_NEW"])["Schools"].idxmax() - block_to_district = block_dist_counts.loc[idx].reset_index(drop=True) - - blk_chg = df_common[df_common["Block_OLD"] != df_common["Block_NEW"]] - block_mapping = pd.DataFrame() - if len(blk_chg): - block_mapping = blk_chg.groupby(["State","Dist_OLD","Block_OLD","Block_NEW"]).agg( - Affected_Schools=("UDISE","count"), List_of_UDISEs=("UDISE", _fmt_udise_list) - ).reset_index() - - cat_chg = df_common[df_common["Cat_OLD"] != df_common["Cat_NEW"]] - cat_migrations = pd.DataFrame() - if len(cat_chg): - cat_migrations = cat_chg.groupby(["State","Cat_OLD","Cat_NEW"]).agg( - Count=("UDISE","count"), List_of_UDISEs=("UDISE", _fmt_udise_list) - ).reset_index().sort_values(["State","Count"], ascending=[True,False]) - - ns = pd.DataFrame({"Metric": [ - "Total Old Schools","Total New Schools","Total Schools Added","Total Schools Deleted", - "Common Schools (In Both)","Schools with District Changes","Schools with Block Changes","Schools with Category Changes" - ], "Count": [ - len(df_old), len(df_new), len(added_u), len(deleted_u), - len(common_u), len(dist_chg), len(blk_chg), len(cat_chg) - ]}) - - from district_mapper import build_udise_geo_lookup - geo_lookup = build_udise_geo_lookup(df_new) - rows = [] - for ss, name in geo_lookup["state"].items(): rows.append(["state", ss, name]) - for ssdd, name in geo_lookup["district"].items(): rows.append(["district", ssdd, name]) - for ssddbb, name in geo_lookup["block"].items(): rows.append(["block", ssddbb, name]) - geo_df = pd.DataFrame(rows, columns=["Type", "Code", "Name"]) - - # Find unmapped completely new districts - old_dist_names = set(df_old["School_District__c"].dropna().str.strip().str.upper().unique()) - new_dist_df = df_new[["School_State__c", "School_District__c"]].drop_duplicates().dropna() - new_dist_df["State"] = _clean_text(new_dist_df["School_State__c"]) - new_dist_df["New District Name"] = _clean_text(new_dist_df["School_District__c"]) - - unmapped_new = new_dist_df[~new_dist_df["New District Name"].isin(old_dist_names)].copy() - unmapped_new["Rename To (Type here...)"] = "" - if not unmapped_new.empty: - unmapped_new = unmapped_new[["State", "New District Name", "Rename To (Type here...)"]].sort_values(["State", "New District Name"]).reset_index(drop=True) - else: - unmapped_new = pd.DataFrame(columns=["State", "New District Name", "Rename To (Type here...)"]) - - return { - "National Summary": ns, - "State by State Breakdown": df_state_summary, - "All Added Schools": df_added_raw, - "All Deleted Schools": df_deleted_raw, - "District Mapping": district_mapping, - "Block to District": block_to_district, - "Block Mapping": block_mapping, - "Category Transitions": cat_migrations, - "New Districts": unmapped_new, - "UDISE Geo": geo_df, - } - -# ── CSS ─────────────────────────────────────────────────────────────────────── -css = """ -@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap'); - -* { font-family: 'Inter', sans-serif !important; } - -#title { - text-align: center; - font-size: 2.2em !important; - font-weight: 800 !important; - background: linear-gradient(135deg, #6366f1, #8b5cf6, #a855f7); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; - margin-bottom: 4px !important; - letter-spacing: -0.5px; -} -#subtitle { - text-align: center; - color: var(--body-text-color-subdued); - margin-top: 0; - margin-bottom: 24px; - font-size: .95em; -} - -/* Tab styling */ -.tab-nav button { - font-weight: 600 !important; - font-size: 1em !important; - padding: 10px 20px !important; - border-radius: 10px 10px 0 0 !important; - transition: all 0.2s !important; -} -.tab-nav button.selected { - background: linear-gradient(135deg, #6366f1, #8b5cf6) !important; - color: white !important; -} - -/* Step card styling */ -.step-card { - background: var(--background-fill-secondary); - border-radius: 14px; - padding: 20px; - border: 1px solid var(--border-color-primary); - margin-bottom: 12px; - transition: box-shadow 0.2s; -} -.step-card:hover { box-shadow: 0 4px 20px rgba(99,102,241,0.12); } - -/* Button beautification */ -.btn-primary { background: linear-gradient(135deg, #6366f1, #8b5cf6) !important; border: none !important; } -.btn-primary:hover { transform: translateY(-1px) !important; box-shadow: 0 6px 20px rgba(99,102,241,0.4) !important; } - -/* Download file widget */ -.download-highlight { - border: 2px dashed var(--color-accent) !important; - border-radius: 12px !important; - padding: 0 !important; - position: relative !important; - background: transparent !important; - transition: all 0.25s !important; -} -.download-highlight:hover { - border-style: solid !important; - border-color: var(--color-accent) !important; - background: color-mix(in srgb, var(--color-accent) 5%, transparent) !important; -} -.download-highlight * { position: static !important; } -.download-highlight a { display: flex !important; align-items: center; padding: 14px !important; width: 100%; height: 100%; } -.download-highlight a::after { content: ""; position: absolute !important; inset: 0 !important; z-index: 50 !important; cursor: pointer !important; } -.hide-file { display: none !important; } - -/* Status banners */ -.status-ok { color: #22c55e; font-weight: 700; } -.status-err { color: #ef4444; font-weight: 700; } -.status-warn{ color: #f59e0b; font-weight: 700; } - -/* Accordion styling */ -.gr-accordion { border-radius: 12px !important; border: 1px solid var(--border-color-primary) !important; margin-bottom: 10px !important; } - -/* Section divider label */ -.section-label { - font-size: 0.75em; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 1px; - color: var(--body-text-color-subdued); - margin: 20px 0 8px 0; -} - -/* Metric cards */ -.metric-card { - background: transparent; - border-radius: 14px; - padding: 20px 28px; - text-align: center; - transition: transform 0.2s; -} -.metric-card:hover { transform: translateY(-2px); } - -/* Log box */ -.log-box textarea { font-size:.76em !important; font-family: 'JetBrains Mono', monospace !important; } -""" - -custom_theme = gr.themes.Soft( - primary_hue="violet", - secondary_hue="indigo", - neutral_hue="slate", - font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui", "sans-serif"] -) - -# ── UI ──────────────────────────────────────────────────────────────────────── -with gr.Blocks(title="School Data Fetcher", css=css, theme=custom_theme) as app: - gr.HTML(""" -
-
- 🏫 School Data Fetcher -
-
- Scrape → Auto-push Raw → Map Districts → Build Mapped Master -
-
- """) - - # Holds the live analysis results dict across tabs - analysis_state = gr.State({}) - - with gr.Tabs(): - - # ── Tab 1: Scraper ──────────────────────────────────────────────────── - with gr.Tab("🔍 Scraper"): - - gr.HTML(""" -
-
👋 How it works
-
- Step 1: Choose a state & categories, then click Start Scraping — school data is collected and automatically saved to the cloud.
- Step 2: If anything was missed, click Fix Missing Data to retry those records only.
- Step 3: Repeat for all states. Then go to the 📋 Master Sheet tab to review any newly detected districts and map them if necessary.
- Step 4: Once all states are scraped and mapped, click Build Master Sheet at the bottom of the tab to generate the final mapped excel file. -
-
- """) - - with gr.Row(equal_height=True): - with gr.Column(scale=1): - gr.HTML("
📍 Location
") - state_dd = gr.Dropdown( - choices=sorted(STATE_LABEL_TO_ID.keys()), - label="Select a State or School Type", - info="Includes regular states and pan-India school organisations (KVS, NVS, NAVY, IAF)" - ) - with gr.Column(scale=2): - gr.HTML("
📚 School Types
") - categories_cg = gr.CheckboxGroup( - choices=list(ALL_CATEGORIES.values()), - value=list(ALL_CATEGORIES.values()), - label="Select which school categories to collect" - ) - - gr.HTML("
") - - with gr.Row(): - scrape_btn = gr.Button("▶ Start Scraping", variant="primary", scale=3) - stop_btn_placeholder = gr.HTML("", visible=False) # placeholder - - with gr.Column(visible=False) as confirm_col: - gr.HTML("
⚠️ Warning: This state (and all selected categories) is already present in your dataset. Are you sure you want to scrape it again?
") - with gr.Row(): - confirm_yes_btn = gr.Button("Yes, Scrape Again", variant="stop") - confirm_no_btn = gr.Button("No, Cancel", variant="secondary") - - with gr.Row(visible=False) as stop_row: - stop_btn = gr.Button("⏹ Stop", variant="stop", scale=1) - - should_scrape_state = gr.State(False) - - progress_html = gr.HTML(value=_idle_html()) - phase_html = gr.HTML(value=_phase_html("Select a state and school types above, then click Start Scraping.")) - result_html = gr.HTML(value="") - - with gr.Row(visible=False) as retry_row: - retry_btn = gr.Button("↻ Fix Missing Data (some records failed — click to retry)", variant="secondary", scale=1) - - with gr.Row(visible=False) as downloads_row: - with gr.Column(scale=1): - download_btn = gr.DownloadButton( - label="📄 Download Raw JSON", - value=None, visible=True, - variant="secondary" - ) - with gr.Column(scale=1): - excel_dl_btn = gr.DownloadButton( - label="📊 Download Excel", - value=None, visible=False, - variant="secondary" - ) - - with gr.Accordion("⚙️ Advanced Settings", open=False): - retries_slider = gr.Slider( - minimum=1, maximum=10, value=5, step=1, - label="Retry attempts per failed record (higher = more thorough but slower)" - ) - - - # Auto-push status (shown after scrape succeeds) - auto_push_html = gr.HTML(value="") - - stream_outputs = [ - progress_html, phase_html, result_html, - download_btn, downloads_row, - excel_dl_btn, - retry_row, stop_row, - ] - - def handle_scrape_click(state_name, categories): - from hf_store import check_state_scraped - if not state_name: - return gr.update(), gr.update(), False - if check_state_scraped(state_name, categories): - return gr.update(visible=False), gr.update(visible=True), False - return gr.update(visible=False), gr.update(visible=False), True - - def ui_main_scrape_conditional(should_scrape, state, cats, retries): - if should_scrape: - yield from ui_main_scrape(state, cats, retries) - else: - yield tuple(gr.update() for _ in range(8)) - - def do_auto_push_after_scrape(state, cats): - """Auto-push to dataset after successful scrape and update status.""" - yield "⏳ Pushing data to HuggingFace dataset... please wait." - res = _auto_push_to_dataset(state, cats) - yield res - - scrape_btn.click( - fn=handle_scrape_click, - inputs=[state_dd, categories_cg], - outputs=[scrape_btn, confirm_col, should_scrape_state] - ).then( - fn=ui_main_scrape_conditional, - inputs=[should_scrape_state, state_dd, categories_cg, retries_slider], - outputs=stream_outputs - ).then( - fn=do_auto_push_after_scrape, - inputs=[state_dd, categories_cg], - outputs=[auto_push_html] - ).then( - fn=lambda should: gr.update(visible=True) if should else gr.update(), - inputs=[should_scrape_state], - outputs=[scrape_btn] - ) - - confirm_yes_btn.click( - fn=lambda: (gr.update(visible=False), gr.update(visible=False)), - outputs=[confirm_col, scrape_btn] - ).then( - fn=ui_main_scrape, - inputs=[state_dd, categories_cg, retries_slider], - outputs=stream_outputs - ).then( - fn=do_auto_push_after_scrape, - inputs=[state_dd, categories_cg], - outputs=[auto_push_html] - ).then( - fn=lambda: gr.update(visible=True), - outputs=[scrape_btn] - ) - - confirm_no_btn.click( - fn=lambda: (gr.update(visible=False), gr.update(visible=True)), - outputs=[confirm_col, scrape_btn] - ) - retry_btn.click( fn=ui_retry, inputs=[state_dd, retries_slider], outputs=stream_outputs) - stop_btn.click( fn=ui_stop, inputs=[], outputs=[progress_html]) - - # ── Tab 2: Master Sheet Builder ─────────────────────────────────────── - with gr.Tab("📋 Master Sheet"): - - # ── HF connection status ────────────────────────────────────────── - def _env_status(): - from hf_store import get_hf_credentials - token, repo = get_hf_credentials() - if token and repo: - repo_name = repo.split("/")[-1] if "/" in repo else repo - return f""" -
-
-
-
Connected to Dataset
-
Saving to: {repo}
-
-
""" - return """ -
-
⚠️
-
-
Dataset not set up
-
- Add your HF_TOKEN and HF_REPO to the .env file to enable dataset features. -
-
-
""" - - hf_env_status = gr.HTML(value=_env_status()) - - gr.HTML("
") - gr.HTML(""" -
📡 Dataset Coverage
-
- Only states with all 7 required school categories scraped will be included in the master sheet. -
- """) - - with gr.Row(): - refresh_btn = gr.Button("🔄 Refresh from Dataset", variant="secondary", scale=1) - - coverage_table = gr.Dataframe( - label="State Coverage (Click '🗑️ Delete' in the Action column to remove a state)", - interactive=False, wrap=True, - headers=["State", "Categories Scraped", "Complete?", "Last Scraped", "Action"], - ) - coverage_summary_html = gr.HTML(value="") - - # ── Flagged Districts & Mapping Rules ───────────────────────── - with gr.Accordion("Review New Districts", open=False): - gr.HTML("
Review newly scraped districts and map them to older SF districts or rename.
") - - with gr.Row(): - refresh_flags_btn = gr.Button("🔄 Refresh Flagged Districts", variant="secondary", scale=1) - flagged_table = gr.Dataframe( - label="New districts detected (Type the Old SF name in the last column to rename it)", - headers=["State", "District", "Reason", "Rename District (Optional)"], - interactive=True, wrap=True - ) - flagged_status_html = gr.HTML(value="") - - - - gr.Markdown("---") - build_master_btn = gr.Button("📋 Build Master Sheet", variant="primary") - build_status_html = gr.HTML(value="") - master_dl_btn = gr.DownloadButton( - label="⬇ Download Master Excel", - visible=False, - variant="secondary" - ) - - # ── Callback: Refresh coverage table ───────────────────────────── - coverage_state = gr.State(pd.DataFrame()) - - def ui_refresh_coverage(): - from hf_store import get_hf_credentials - token, repo = get_hf_credentials() - if not token or not repo: - return ( - pd.DataFrame(columns=["State", "Categories Scraped", "Complete?", "Last Scraped", "Action"]), - "

⚠️ HF_TOKEN or HF_REPO not set in environment.

" - ) - try: - from hf_store import pull_all_complete_states - _, partial_info, coverage_df = pull_all_complete_states(token, repo) - if not coverage_df.empty: - coverage_df["Action"] = "🗑️ Delete" - complete_count = int((coverage_df["Complete?"] == "✅ Yes").sum()) if not coverage_df.empty else 0 - total = len(coverage_df) - summary = ( - f"

" - f"✅ {complete_count} states ready to build

" - ) - return coverage_df, summary - except Exception as ex: - return ( - pd.DataFrame(columns=["State", "Categories Scraped", "Complete?", "Last Scraped", "Action"]), - f"

❌ Error: {ex}

" - ) - - refresh_btn.click( - fn=ui_refresh_coverage, - inputs=[], - outputs=[coverage_table, coverage_summary_html], - ).then( - fn=lambda df: df, - inputs=[coverage_table], - outputs=[coverage_state] - ) - - def on_coverage_select(evt: gr.SelectData, current_df): - if evt.value != "🗑️ Delete": - return gr.update(), gr.update() - - row_idx = evt.index[0] - if row_idx >= len(current_df): - return gr.update(), gr.update() - - state_name = current_df.iloc[row_idx]["State"] - from hf_store import get_hf_credentials, delete_state_file - token, repo = get_hf_credentials() - try: - delete_state_file(state_name, token, repo) - except Exception as ex: - return gr.update(), f"

❌ Error deleting: {ex}

" - - new_df, new_summary = ui_refresh_coverage() - msg = f"

✅ Successfully deleted {state_name}.

" + new_summary - return new_df, msg - - coverage_table.select( - fn=on_coverage_select, - inputs=[coverage_state], - outputs=[coverage_table, coverage_summary_html] - ).then( - fn=lambda df: df, - inputs=[coverage_table], - outputs=[coverage_state] - ) - - # ── Callbacks: Flagged Districts & Mapping Rules ────────────────────── - def ui_refresh_flags(): - from hf_store import get_hf_credentials, get_flagged_districts - token, repo = get_hf_credentials() - if not token or not repo: - return pd.DataFrame(columns=["State", "District", "Reason"]), "

⚠️ HF credentials not set.

" - flags = get_flagged_districts(token, repo) - if not flags: - return pd.DataFrame(columns=["State", "District", "Reason"]), "

✅ No flagged districts — all clear!

" - df = pd.DataFrame(flags) - df = df[["state", "district", "reason"]].rename(columns={"state": "State", "district": "District", "reason": "Reason"}) - df["Rename District (Optional)"] = "" - return df, f"

⚠️ {len(flags)} district(s) need review.

" - - refresh_flags_btn.click(fn=ui_refresh_flags, outputs=[flagged_table, flagged_status_html]) - - # ── Callback: Build master sheet ────────────────────────────────── - def ui_build_master(flagged_df): - from district_mapper import build_udise_geo_lookup, apply_geo_decode, build_mapper, apply_district_backmap - from hf_store import get_hf_credentials, pull_all_complete_states, pull_mapping_rules, push_mapped_master, upsert_mapping_rule, rename_district_in_reference - token, repo = get_hf_credentials() - if not token or not repo: - return ( - "

⚠️ HF_TOKEN or HF_REPO not set in environment.

", - gr.update(elem_classes=["download-highlight", "hide-file"]), - ) - try: - # 1. Process inline table mappings - if flagged_df is not None and not flagged_df.empty: - for _, row in flagged_df.iterrows(): - mapped = str(row.get("Rename District (Optional)", "")).strip() - state = str(row.get("State", "")).strip().upper() - dist = str(row.get("District", "")).strip().upper() - if mapped and mapped.upper() != "NAN" and mapped != "None": - upsert_mapping_rule(state, dist, mapped.upper(), 0, token, repo) - rename_district_in_reference(state, dist, mapped.upper(), token, repo) - complete_dfs, _, _ = pull_all_complete_states(token, repo) - - if not complete_dfs: - return ( - "

⚠️ No states with all 7 categories found in dataset.

", - gr.update(elem_classes=["download-highlight", "hide-file"]), - ) - - from hf_store import push_national_analysis_report - master_df = pd.concat(complete_dfs, ignore_index=True) - geo_lookup = build_udise_geo_lookup(master_df) - master_df, geo_changes_df = apply_geo_decode(master_df, geo_lookup) - - # Pull mapping rules automatically - dist_rules, block_rules = pull_mapping_rules(token, repo) - - backmap_changes_df = pd.DataFrame() - if not dist_rules.empty: - analysis_dict = { - "District Mapping": dist_rules, - "Block to District": block_rules if not block_rules.empty else pd.DataFrame(), - } - mapper = build_mapper(analysis_dict) - master_df, backmap_changes_df = apply_district_backmap(master_df, mapper) - - # Combine reports and push - combined_changes = pd.concat([geo_changes_df, backmap_changes_df], ignore_index=True) - if not combined_changes.empty: - push_national_analysis_report(combined_changes, token, repo) - - udise_col = "School_Udise_Code__c" if "School_Udise_Code__c" in master_df.columns else "UDISE" - if udise_col in master_df.columns and "Scraped_Date" in master_df.columns: - master_df = ( - master_df - .sort_values("Scraped_Date", ascending=False) - .drop_duplicates(subset=[udise_col], keep="first") - .reset_index(drop=True) - ) - - # Filter out out-of-scope states (e.g., from Geo-Decoded Navy/KVS schools) - from hf_store import pull_district_reference - ref_df = pull_district_reference(token, repo) - ref_states = set(ref_df["State"].str.strip().str.upper()) if not ref_df.empty else set() - - allowed_states = { - "ARUNACHAL PRADESH", "ASSAM", "BIHAR", "CHHATTISGARH", "JHARKHAND", - "KARNATAKA", "MADHYA PRADESH", "MANIPUR", "MEGHALAYA", "MIZORAM", - "NAGALAND", "ODISHA", "PUDUCHERRY", "RAJASTHAN", "SIKKIM", - "TELANGANA", "TRIPURA", "UTTAR PRADESH", "UTTARAKHAND", "DELHI", - "ANDHRA PRADESH" - } - allowed_states.update(ref_states) - - state_col = "School_State__c" if "School_State__c" in master_df.columns else "State" - if state_col in master_df.columns: - master_df = master_df[master_df[state_col].str.strip().str.upper().isin(allowed_states)] - master_df = master_df.sort_values(state_col, ascending=True).reset_index(drop=True) - - from datetime import datetime, timedelta - ist_now = datetime.utcnow() + timedelta(hours=5, minutes=30) - date_str = ist_now.strftime("%Y_%b_%d_%I_%M_%p").lower() - os.makedirs(EXCEL_DIR, exist_ok=True) - master_path = os.path.join(EXCEL_DIR, f"mapped_master_{date_str}.xlsx") - master_df.to_excel(master_path, index=False) - - push_mapped_master(master_df, token, repo) - - # Auto-sync aliases CSV immediately after building a new master! - try: - from alias_sync import sync_aliases - sync_aliases(token, repo) - except Exception as e: - print(f"Failed to auto-sync aliases: {e}") - - n = len(master_df) - msg = ( - f"

" - f"✅ Mapped master built! {n:,} records from {len(complete_dfs)} complete states. " - f"District mapping applied. Also pushed to dataset." - f"

" - ) - return msg, gr.update(value=master_path, visible=True) - except Exception as ex: - return ( - f"

❌ Error: {ex}

", - gr.update(visible=False), - ) - - build_master_btn.click( - fn=ui_build_master, - inputs=[flagged_table], - outputs=[build_status_html, master_dl_btn], - ) - - # ── Tab 3: Mapping Manager ───────────────────────────────────────────── - with gr.Tab("🗺️ Mapping Manager"): - gr.HTML(""" -
-
🗺️ Mapping Manager
-
- Scholarship Application Reference: The mirror of what Scholarship Application currently knows. When you add a new district to Scholarship Application, find it here and change its status from new districts found to present. -
-
- """) - - gr.HTML("
📁 Scholarship Application District Reference
") - with gr.Row(): - refresh_ref_btn = gr.Button("🔄 Load from Dataset", variant="secondary", scale=1) - - # Filters - with gr.Row(): - ref_filter_state = gr.Dropdown(label="Filter by State", choices=["All"], value="All", scale=1, interactive=True) - ref_filter_status = gr.Dropdown(label="Filter by Status", choices=["All", "present", "new districts found"], value="All", scale=1, interactive=True) - ref_filter_dist = gr.Textbox(label="Search District", placeholder="Type to search...", scale=2, interactive=True) - - # The DataFrame - ref_full_state = gr.State(pd.DataFrame()) - ref_table = gr.Dataframe( - label="Scholarship Application District Reference (Editable)", - interactive=False, wrap=True - ) - - with gr.Accordion("📦 Bulk Excel Import / Export", open=False): - with gr.Row(): - ref_prep_dl_btn = gr.Button("📦 Prepare Current View for Download") - ref_dl_btn = gr.DownloadButton("📥 Download Excel", visible=False) - - with gr.Row(): - ref_upload = gr.File(label="Upload Updated Excel", file_types=[".xlsx"]) - - gr.HTML("
Click any row in the table above to edit its status here:
") - with gr.Row(): - ref_edit_state = gr.Textbox(label="State", interactive=False, scale=2) - ref_edit_dist = gr.Textbox(label="District", interactive=False, scale=2) - ref_edit_status = gr.Dropdown(choices=["present", "new districts found"], label="Status", scale=1) - ref_edit_btn = gr.Button("💾 Update Row", variant="primary", scale=1) - - ref_status_html = gr.HTML(value="") - - gr.HTML("
Add a District
") - with gr.Row(): - ref_state_add = gr.Textbox(label="State", placeholder="e.g. ANDHRA PRADESH", scale=2) - ref_dist_add = gr.Textbox(label="District", placeholder="e.g. ALLURI SITHARAMA RAJU", scale=2) - ref_status_add = gr.Dropdown(choices=["present", "new districts found"], value="present", label="Status", scale=1) - ref_add_btn = gr.Button("➕ Add District", variant="primary", scale=1) - ref_add_status = gr.HTML(value="") - - gr.HTML("
Rename a District (automatically updates mappings)
") - with gr.Row(): - ref_ren_state = gr.Dropdown(label="State", choices=[], allow_custom_value=True, scale=2) - ref_ren_old = gr.Dropdown(label="Old Name", choices=[], allow_custom_value=True, scale=2) - ref_ren_new = gr.Textbox(label="New Name", placeholder="e.g. VISAKHAPATNAM (NEW)", scale=2) - ref_rename_btn = gr.Button("✏️ Rename", variant="secondary", scale=1) - ref_rename_status = gr.HTML(value="") - - gr.HTML("
Delete Actions
") - with gr.Row(): - ref_del_state = gr.Dropdown(label="State", choices=[], allow_custom_value=True, scale=2) - ref_del_dist = gr.Dropdown(label="District", choices=[], allow_custom_value=True, scale=2) - ref_del_dist_btn = gr.Button("🗑️ Delete District", variant="stop", scale=1) - ref_del_state_btn = gr.Button("🚨 Delete Entire State", variant="stop", scale=1) - ref_del_status = gr.HTML(value="") - - def on_ref_table_select(df, evt: gr.SelectData): - row = evt.index[0] - if df is None or df.empty or row >= len(df): - return gr.update(), gr.update(), gr.update(), "" - - state = df.iloc[row]["State"] - dist = df.iloc[row]["District"] - status = df.iloc[row]["Status"] - return gr.update(value=state), gr.update(value=dist), gr.update(value=status), "" - - def ui_ref_edit_row(state, district, new_status): - from hf_store import get_hf_credentials, update_district_reference_add - token, repo = get_hf_credentials() - if not token or not repo or not state or not district: - return "

⚠️ Please select a row from the table first.

" - try: - update_district_reference_add(state, district, new_status, token, repo) - return f"

✅ Successfully updated {district} to '{new_status}'. Refresh the table to see changes.

" - except Exception as ex: - return f"

❌ Error: {ex}

" - - def ui_refresh_ref(): - from hf_store import get_hf_credentials, pull_district_reference - token, repo = get_hf_credentials() - if not token or not repo: - empty = pd.DataFrame() - u = gr.update() - return empty, empty, u, u, u, u, u, "

⚠️ HF credentials not set.

" - df = pull_district_reference(token, repo) - if df.empty: - empty = pd.DataFrame() - u = gr.update() - return empty, empty, u, u, u, u, u, "

⚠️ Scholarship Application Reference empty.

" - states = sorted(list(df["State"].unique())) - dists = sorted(list(df["District"].unique())) - st_up = gr.update(choices=["All"] + states, value="All") - st_dd = gr.update(choices=states) - dist_dd = gr.update(choices=dists) - return df, df, st_up, st_dd, dist_dd, st_dd, dist_dd, f"

✅ Loaded {len(df)} rows.

" - - def filter_ref_table(df, filter_state, filter_status, search_dist): - if df is None or df.empty: return df - res = df.copy() - if filter_state and filter_state != "All": - res = res[res["State"] == filter_state] - if filter_status and filter_status != "All": - res = res[res["Status"] == filter_status] - if search_dist: - res = res[res["District"].str.contains(search_dist.upper(), na=False)] - return res - - def ui_save_table_edits(df, full_df, filter_state, filter_status, search_dist): - from hf_store import get_hf_credentials, push_district_reference - token, repo = get_hf_credentials() - if df is None or df.empty: - return "

⚠️ Table is empty.

", full_df - # The user edited 'df'. We need to merge it back into 'full_df' - # This is a bit complex if they deleted rows or changed keys. - # Actually, if they are filtering, df only contains a subset. - # Let's just overwrite the subset in full_df based on index if we kept index, - # but Gradio df doesn't keep original indices easily. - # Simplest way: They should only save edits if viewing All/All without search. - if filter_state != "All" or filter_status != "All" or search_dist: - return "

⚠️ Please clear all filters (set to 'All', clear search) before saving direct table edits.

", full_df - - try: - df["State"] = df["State"].astype(str).str.strip().str.upper() - df["District"] = df["District"].astype(str).str.strip().str.upper() - df["Status"] = df["Status"].astype(str).str.strip() - - invalid_statuses = df[~df["Status"].isin(["present", "new districts found"])] - if not invalid_statuses.empty: - return "

⚠️ Invalid Status found. You can only use 'present' or 'new districts found'.

", full_df - - df = df.drop_duplicates() - push_district_reference(df, token, repo) - return f"

✅ Saved {len(df)} rows directly to cloud.

", df - except Exception as ex: - return f"

❌ Error: {ex}

", full_df - - def ui_prep_excel_download(df): - if df is None or df.empty: - return gr.update(visible=False), "

⚠️ No data to download.

" - import tempfile - import os - fd, temp_excel = tempfile.mkstemp(suffix=".xlsx") - os.close(fd) - df.to_excel(temp_excel, index=False) - return gr.update(value=temp_excel, visible=True), "

✅ Ready to download!

" - - def ui_upload_excel(file): - if not file: return pd.DataFrame(), "

⚠️ No file uploaded.

" - import pandas as pd - from hf_store import get_hf_credentials, push_district_reference, pull_district_reference - token, repo = get_hf_credentials() - try: - uploaded_df = pd.read_excel(file.name) - if "State" not in uploaded_df.columns or "District" not in uploaded_df.columns or "Status" not in uploaded_df.columns: - return pd.DataFrame(), "

⚠️ Excel must have State, District, and Status columns.

" - - uploaded_df["State"] = uploaded_df["State"].astype(str).str.strip().str.upper() - uploaded_df["District"] = uploaded_df["District"].astype(str).str.strip().str.upper() - uploaded_df["Status"] = uploaded_df["Status"].astype(str).str.strip() - - invalid_statuses = uploaded_df[~uploaded_df["Status"].isin(["present", "new districts found"])] - if not invalid_statuses.empty: - return pd.DataFrame(), "

⚠️ Invalid Status found in Excel. You can only use 'present' or 'new districts found'.

" - - uploaded_df = uploaded_df.drop_duplicates(subset=["State", "District"]) - - full_df = pull_district_reference(token, repo) - if not full_df.empty: - full_df.set_index(["State", "District"], inplace=True) - uploaded_df.set_index(["State", "District"], inplace=True) - full_df.update(uploaded_df) - new_rows = uploaded_df[~uploaded_df.index.isin(full_df.index)] - full_df = pd.concat([full_df, new_rows]) - full_df.reset_index(inplace=True) - df_to_push = full_df - else: - df_to_push = uploaded_df.copy() - - push_district_reference(df_to_push, token, repo) - return df_to_push, f"

✅ Successfully merged {len(uploaded_df)} uploaded rows into the dataset!

" - except Exception as ex: - return pd.DataFrame(), f"

❌ Error: {ex}

" - - def ui_ref_add(state, district, sf_status): - from hf_store import get_hf_credentials, update_district_reference_add - token, repo = get_hf_credentials() - if not token or not repo or not state or not district or not state.strip() or not district.strip(): - return "

⚠️ Fill in all fields and check HF credentials.

" - try: - update_district_reference_add(state.strip().upper(), district.strip().upper(), sf_status, token, repo) - return f"

✅ Added/updated {district.upper()} in {state.upper()}. Refresh table to see.

" - except Exception as ex: - return f"

❌ Error: {ex}

" - - def ui_ref_rename(state, old_name, new_name): - from hf_store import get_hf_credentials, update_district_reference_rename - token, repo = get_hf_credentials() - if not token or not repo or not state or not old_name or not new_name or not state.strip() or not old_name.strip() or not new_name.strip(): - return "

⚠️ Fill in all fields.

" - try: - update_district_reference_rename(state.strip().upper(), old_name.strip().upper(), new_name.strip().upper(), token, repo) - return f"

✅ Renamed {old_name.upper()} → {new_name.upper()} (cascaded to Dataset 2). Refresh table to see.

" - except Exception as ex: - return f"

❌ Error: {ex}

" - - def ui_ref_del_dist(state, district): - from hf_store import get_hf_credentials, delete_district_reference - token, repo = get_hf_credentials() - if not token or not repo or not state or not district or not state.strip() or not district.strip(): - return "

⚠️ Fill in both State and District.

" - try: - delete_district_reference(state.strip().upper(), district.strip().upper(), token, repo) - return f"

✅ Deleted {district.upper()} from {state.upper()}. Refresh table to see.

" - except Exception as ex: - return f"

❌ Error: {ex}

" - - def ui_ref_del_state(state): - from hf_store import get_hf_credentials, delete_state_reference - token, repo = get_hf_credentials() - if not token or not repo or not state: - return "

⚠️ Fill in State.

" - try: - delete_state_reference(state.strip().upper(), token, repo) - return f"

✅ Deleted entire state {state.upper()}. Refresh table to see.

" - except Exception as ex: - return f"

❌ Error: {ex}

" - - # Wirings - refresh_ref_btn.click( - fn=ui_refresh_ref, - outputs=[ref_table, ref_full_state, ref_filter_state, - ref_ren_state, ref_ren_old, - ref_del_state, ref_del_dist, - ref_status_html] - ) - ref_filter_state.change(fn=filter_ref_table, inputs=[ref_full_state, ref_filter_state, ref_filter_status, ref_filter_dist], outputs=[ref_table]) - ref_filter_status.change(fn=filter_ref_table, inputs=[ref_full_state, ref_filter_state, ref_filter_status, ref_filter_dist], outputs=[ref_table]) - ref_filter_dist.change(fn=filter_ref_table, inputs=[ref_full_state, ref_filter_state, ref_filter_status, ref_filter_dist], outputs=[ref_table]) - - ref_table.select(fn=on_ref_table_select, inputs=[ref_table], outputs=[ref_edit_state, ref_edit_dist, ref_edit_status, ref_status_html]) - ref_edit_btn.click(fn=ui_ref_edit_row, inputs=[ref_edit_state, ref_edit_dist, ref_edit_status], outputs=[ref_status_html]) - ref_prep_dl_btn.click(fn=ui_prep_excel_download, inputs=[ref_table], outputs=[ref_dl_btn, ref_status_html]) - ref_upload.upload(fn=ui_upload_excel, inputs=[ref_upload], outputs=[ref_table, ref_status_html]) - - ref_add_btn.click(fn=ui_ref_add, inputs=[ref_state_add, ref_dist_add, ref_status_add], outputs=[ref_add_status]) - ref_rename_btn.click(fn=ui_ref_rename, inputs=[ref_ren_state, ref_ren_old, ref_ren_new], outputs=[ref_rename_status]) - ref_del_dist_btn.click(fn=ui_ref_del_dist, inputs=[ref_del_state, ref_del_dist], outputs=[ref_del_status]) - ref_del_state_btn.click(fn=ui_ref_del_state, inputs=[ref_del_state], outputs=[ref_del_status]) - - # ── Tab 4: Download Master Sheets ────────────────────────────────────── - with gr.Tab("📥 Download History"): - gr.HTML("
Download Previously Built Master Sheets
") - - with gr.Row(): - dl_refresh_btn = gr.Button("🔄 Refresh File List", scale=1) - dl_dropdown = gr.Dropdown(choices=[], label="Select a Master Sheet", scale=3) - - dl_status = gr.HTML() - dl_download_btn = gr.DownloadButton("📥 Download as Excel", visible=False) - - def ui_refresh_dl_list(): - from hf_store import get_hf_credentials, list_mapped_master_files - token, repo = get_hf_credentials() - if not token or not repo: - return gr.update(choices=[]), "

⚠️ HF credentials not set.

" - - files = list_mapped_master_files(token, repo) - if not files: - return gr.update(choices=[]), "

⚠️ No master sheets found in dataset.

" - - filenames = [f.split("/")[-1] for f in files] - filenames.sort(reverse=True) # newest first - return gr.update(choices=filenames), f"

✅ Found {len(filenames)} master sheets.

" - - dl_refresh_btn.click(fn=ui_refresh_dl_list, outputs=[dl_dropdown, dl_status]) - - def ui_prep_download(filename): - if not filename: return gr.update(visible=False), "" - from hf_store import get_hf_credentials - import tempfile - import pandas as pd - from huggingface_hub import hf_hub_download - token, repo = get_hf_credentials() - - try: - local_path = hf_hub_download(repo_id=repo, repo_type="dataset", filename=f"scraped_data/mapped/{filename}", token=token) - df = pd.read_parquet(local_path) - - temp_dir = tempfile.gettempdir() - # Keep the original filename but change extension to .xlsx - excel_filename = filename.replace('.parquet', '.xlsx') - temp_excel = os.path.join(temp_dir, excel_filename) - df.to_excel(temp_excel, index=False) - - return gr.update(value=temp_excel, visible=True), "

✅ Ready to download!

" - except Exception as e: - return gr.update(visible=False), f"

❌ Error preparing download: {e}

" - - dl_dropdown.change(fn=ui_prep_download, inputs=[dl_dropdown], outputs=[dl_download_btn, dl_status]) - - - # ── Tab 5: Master Analytics ──────────────────────────────────────────── - with gr.Tab("📊 Master Analytics"): - gr.HTML(''' -
-
📈 Multi-Year Master Analytics
-
- This tab automatically loads the 2025 master and every - mapped master sheet ever built. It analyzes how government school footprints and overall school counts shift year over year. -
-
- ''') - - with gr.Row(): - analytics_refresh_btn = gr.Button("🔄 Run Analytics", variant="primary", scale=2) - analytics_status = gr.HTML() - - gr.HTML("
📅 Master Timeline
") - gr.HTML("
Year-over-year changes across all master sheets compared to 2025.
") - timeline_table = gr.Dataframe(label="Timeline Metrics", interactive=False, wrap=True) - - gr.HTML("
⚠️ Missing Schools (vs 2025)
") - missing_schools_html = gr.HTML() - missing_schools_table = gr.Dataframe(label="Missing Schools Detailed", interactive=False, wrap=True) - - gr.HTML("
🔄 Management Shifts
") - mgmt_shifts_html = gr.HTML() - mgmt_shifts_table = gr.Dataframe(label="Government → Non-Govt Shifts", interactive=False, wrap=True) - - BASE_CSV_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "master_all_states.csv") - - def _comp_detect_year_label(filepath: str) -> str: - import calendar as _cal - import re as _re - import os as _os - name = _os.path.basename(filepath or "") - m = _re.search(r"(\d{4})[-_](\d{2})", name) - if m: - yr, mo = int(m.group(1)), int(m.group(2)) - if 1 <= mo <= 12: - return f"{_cal.month_abbr[mo]} {yr}" - m2 = _re.search(r"(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[_\-]?(\d{4})", name, _re.IGNORECASE) - if m2: - return f"{m2.group(1).capitalize()} {m2.group(2)}" - m3 = _re.search(r"(\d{4})", name) - return m3.group(1) if m3 else name - - def _comp_find_col(df, *keywords): - for k in keywords: - for c in df.columns: - if k in c.lower(): - return c - return None - - def ui_run_analytics(): - from hf_store import get_hf_credentials, list_mapped_master_files, pull_baseline_master - from huggingface_hub import hf_hub_download - import pandas as pd - import os - - token, repo = get_hf_credentials() - if not token or not repo: - return "

⚠️ HF credentials not set.

", pd.DataFrame(), "", pd.DataFrame(), "", pd.DataFrame() - - masters = [] - # 1. Load baseline - try: - base_df = pull_baseline_master(token, repo) - if not base_df.empty: - masters.append({"label": "2025", "df": base_df}) - except Exception: - pass - - # Fallback to CSV if HF fails - if not masters and os.path.exists(BASE_CSV_PATH): - masters.append({"label": "2025", "df": pd.read_csv(BASE_CSV_PATH, low_memory=False)}) - - # 2. Pull HF mapped masters - hf_files = list_mapped_master_files(token, repo) - for hf_path in sorted(hf_files): - try: - local = hf_hub_download(repo_id=repo, repo_type="dataset", filename=hf_path, token=token, force_download=True) - label = _comp_detect_year_label(hf_path) - masters.append({"label": label, "df": pd.read_parquet(local)}) - except Exception: - pass - - if not masters: - return "

⚠️ No master datasets found.

", pd.DataFrame(), "", pd.DataFrame(), "", pd.DataFrame() - - def _norm_udise(s): - return s.astype(str).str.strip().str.zfill(11) - - def is_govt(s): - val = str(s).lower() - if 'aided' in val or 'private' in val or 'unaided' in val or 'un-aided' in val: - return False - return 'gov' in val or 'dept of edu' in val or 'local body' in val or 'panchayat' in val or 'municipal' in val - - baseline = masters[0] - b_udise = _comp_find_col(baseline["df"], "udise") - b_mgmt = _comp_find_col(baseline["df"], "school_management_type", "management", "mgmt") - if b_udise: baseline["df"]["_U"] = _norm_udise(baseline["df"][b_udise]) - if b_mgmt: baseline["df"]["is_govt"] = baseline["df"][b_mgmt].apply(is_govt) - - timeline_data = [] - baseline_udises = set(baseline["df"]["_U"].dropna()) if b_udise else set() - - missing_df = pd.DataFrame() - changed_mgmt = pd.DataFrame() - - for m in masters: - m_df = m["df"] - m_udise = _comp_find_col(m_df, "udise") - m_mgmt = _comp_find_col(m_df, "school_management_type", "management", "mgmt") - m_name = _comp_find_col(m_df, "school_name", "schoolname") - m_state = _comp_find_col(m_df, "state") - - if m_udise: m_df["_U"] = _norm_udise(m_df[m_udise]) - if m_mgmt: m_df["is_govt"] = m_df[m_mgmt].apply(is_govt) - - tot_schools = len(m_df) - tot_govt = m_df["is_govt"].sum() if m_mgmt else 0 - - missing_count = 0 - shifts_count = 0 - - if m != baseline and b_udise and m_udise: - m_udises = set(m_df["_U"].dropna()) - missing = baseline_udises - m_udises - missing_count = len(missing) - - if m == masters[-1]: - b_name = _comp_find_col(baseline["df"], "school_name", "schoolname") - b_state = _comp_find_col(baseline["df"], "state") - missing_df = baseline["df"][baseline["df"]["_U"].isin(missing)].drop_duplicates(subset=["_U"]).copy() - - cols_to_keep = [] - for c in ["_U", b_name, b_state, b_mgmt]: - if c and c in missing_df.columns: - cols_to_keep.append(c) - missing_df = missing_df[cols_to_keep] if cols_to_keep else missing_df - missing_df.insert(0, "Missing In Master", m["label"]) - - if b_mgmt and m_mgmt: - b_cols = [c for c in ["_U", b_name, b_mgmt, "is_govt"] if c] - m_cols = [c for c in ["_U", m_name, m_mgmt, "is_govt"] if c] - - merged = pd.merge( - baseline["df"][b_cols], - m_df[m_cols], - on="_U", suffixes=("_old", "_new") - ) - changed = merged[(merged["is_govt_old"] == True) & (merged["is_govt_new"] == False)] - shifts_count = len(changed) - - c_name_old = str(b_name) + '_old' if b_name == m_name else b_name - c_mgmt_old = str(b_mgmt) + '_old' if b_mgmt == m_mgmt else b_mgmt - c_mgmt_new = str(m_mgmt) + '_new' if b_mgmt == m_mgmt else m_mgmt - - c_cols = [c for c in ["_U", c_name_old, c_mgmt_old, c_mgmt_new] if c and c in changed.columns] - changed_mgmt = changed[c_cols] if c_cols else changed - changed_mgmt.insert(0, "Shifted In Master", m["label"]) - - timeline_data.append({ - "Master Name": m["label"], - "Total Schools": f"{tot_schools:,}", - "Total Govt Schools": f"{tot_govt:,}", - "Missing (vs 2025)": f"{missing_count:,}" if m != baseline else "-", - "Govt->Non-Govt Shifts": f"{shifts_count:,}" if m != baseline else "-" - }) - - timeline_df = pd.DataFrame(timeline_data) - - status_html = f"

✅ Loaded {len(masters)} master datasets successfully.

" - m_html = f"
{len(missing_df):,} schools from 2025 are completely missing in the newest master.
" - c_html = f"
{len(changed_mgmt):,} schools shifted from Govt to Non-Govt management since 2025.
" - - return status_html, timeline_df, m_html, missing_df, c_html, changed_mgmt - - analytics_refresh_btn.click( - fn=ui_run_analytics, - outputs=[analytics_status, timeline_table, missing_schools_html, missing_schools_table, mgmt_shifts_html, mgmt_shifts_table] - ) - -if __name__ == "__main__": - if os.environ.get("SPACE_ID"): - print("Starting on Dataset Spaces (0.0.0.0:7860) …") - app.queue().launch(server_name="0.0.0.0", server_port=7860) - else: - print("Starting locally …") - app.queue().launch(server_name="127.0.0.1", server_port=7861, inbrowser=True) +""" +app.py – School Data Fetcher +Progressive disclosure UI: each step appears only when it is needed. +""" + +import gradio as gr +import subprocess +import sys +import os +import re +import json +import tempfile +import pandas as pd +from district_mapper import ( + build_mapper, build_udise_geo_lookup, + apply_geo_decode, apply_district_backmap, + NON_ACTUAL_STATES, +) +from hf_store import ( + push_state_file, pull_all_complete_states, + ALL_REQUIRED_CATEGORIES, + pull_district_reference, pull_mapping_rules, + push_district_reference, push_mapping_rules, + update_district_reference_add, update_district_reference_rename, + delete_district_reference, upsert_mapping_rule, delete_mapping_rule, + get_flagged_districts, + get_hf_credentials, seed_district_reference_from_csv, + seed_mapping_rules_from_excel, +) + +# ── Auto-push helper (called after successful scrape) ───────────────────────── +def _auto_push_to_dataset(state_name: str, categories: list) -> str: + """Automatically push scraped Excel to HF dataset after scrape succeeds.""" + token, repo = get_hf_credentials() + if not token or not repo: + return "⚠️ No HF credentials — raw data not pushed to dataset." + excel_path = get_excel_file(state_name) if state_name else None + if not excel_path or not os.path.exists(excel_path): + return "⚠️ Excel not found — push skipped." + try: + df = pd.read_excel(excel_path) + cat_nums = [c.split(" - ")[0].strip() for c in (categories or [])] + if len(cat_nums) < 7 and "All Categories (1 to 7)" not in categories: + return "⚠️ Partial scrape completed. Excel downloaded, but NOT pushed to dataset. You must select all 7 categories to push." + push_state_file(df=df, state_name=state_name, categories_scraped=cat_nums, token=token, repo=repo) + return f"✅ Auto-pushed raw data for {state_name} to the cloud. Check the Master Sheet tab for any new districts flagged." + except Exception as ex: + return f"❌ Auto-push failed: {ex}" + +# ── State list ──────────────────────────────────────────────────────────────── +STATE_LABEL_TO_ID = { + "ANDAMAN & NICOBAR ISLANDS": 135, "ANDHRA PRADESH": 128, + "ARUNACHAL PRADESH": 112, "ASSAM": 118, "BIHAR": 110, + "CHANDIGARH": 104, "CHHATTISGARH": 122, + "DADRA & NAGAR HAVELI AND DAMAN & DIU": 138, "DELHI": 107, + "GOA": 130, "GUJARAT": 124, "HARYANA": 106, "HIMACHAL PRADESH": 102, + "IAF EC SOCIETY": 163, "JAMMU & KASHMIR": 101, "JHARKHAND": 120, + "KARNATAKA": 129, "KENDRIYA VIDYALAYA SANGHATHAN": 192, + "KERALA": 132, "LADAKH": 137, "LAKSHADWEEP": 131, + "MADHYA PRADESH": 123, "MAHARASHTRA": 127, "MANIPUR": 114, + "MEGHALAYA": 117, "MIZORAM": 115, "MSRVVP": 161, + "NAGALAND": 113, "NAVODAYA VIDYALAYA SAMITI": 193, + "NAVY EDUCATION SOCIETY": 162, "ODISHA": 121, "PUDUCHERRY": 134, + "PUNJAB": 103, "RAJASTHAN": 108, "SIKKIM": 111, "TAMILNADU": 133, + "TELANGANA": 136, "TEST STATE": 199, "TRIPURA": 116, + "UTTAR PRADESH": 109, "UTTARAKHAND": 105, "WEST BENGAL": 119, +} + +ALL_CATEGORIES = { + "3": "3 - Pr. with Up.Pr. sec. and H.Sec.", + "5": "5 - Up. Pr. Secondary and Higher Sec", + "6": "6 - Pr. Up Pr. and Secondary Only", + "7": "7 - Upper Pr. and Secondary", + "8": "8 - Secondary Only", + "10": "10 - Secondary with Higher Secondary", + "11": "11 - Higher Secondary only/Jr. College", +} + +OUTPUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "output") +EXCEL_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "output_excel") +_proc: dict = {"current": None} +_batch_abort: dict = {"flag": False} # set by ui_stop() to abort a multi-state batch run + +# Analysis results are computed on demand (empty until user uploads files) +_EMPTY_ANALYSIS: dict = {} + + +def _state_prefix(s: str) -> str: + return re.sub(r"[^a-z0-9]+", "_", s.lower()).strip("_") + +def get_output_file(state: str) -> str: + return os.path.join(OUTPUT_DIR, f"{_state_prefix(state)}_schools_by_category.json") + +def get_excel_file(state: str) -> str: + return os.path.join(EXCEL_DIR, f"{_state_prefix(state)}_Schools.xlsx") + +def check_missed_schools(json_path: str): + if not os.path.exists(json_path): + return None + try: + import json + with open(json_path, "r", encoding="utf-8") as f: + data = json.load(f) + if not isinstance(data, list): + return None + def is_invalid(r): + if r.get("status") == "captcha_failed_all_retries": + return True + details = (r.get("response") or {}).get("error", {}).get("errorDetails", {}).get("details", "") + return isinstance(details, str) and "invalid captcha" in details.lower() + return sum(1 for r in data if is_invalid(r)) + except Exception: + return None + + +# ── HTML helpers ────────────────────────────────────────────────────────────── + +def _progress_html(current: int, total: int) -> str: + pct = round(current / total * 100) if total > 0 else 0 + return f""" +
+
+ Progress + {current} / {total} ({pct}%) +
+
+
+ {"" + str(pct) + "%" if pct > 8 else ""} +
+
+
""" + +def _phase_html(text: str) -> str: + safe = re.sub(r"<[^>]+>", "", re.sub(r"\s*\(id=\d+\)", "", text.split("\n")[0]))[:200] + return f"""
+ {safe} +
""" + +def _banner_html(icon: str, title: str, body: str, kind: str) -> str: + """Large status banner shown after a run completes.""" + colors = { + "success": "var(--color-green-500)", + "warning": "var(--color-yellow-500)", + "nodata": "var(--color-blue-500)", + "stopped": "var(--color-red-500)", + } + border_col = colors.get(kind, "var(--border-color-primary)") + return f""" +
+
{icon} {title}
+
{body}
+
""" + +def _stats_html(success: int, no_data: int, failed: int, total: int) -> str: + return f""" +
+
{success}
✓ Retrieved
+
{no_data}
○ Zero Schools
+
{failed}
✗ Incomplete
+
{total}
Total Records
+
""" + +def _result_html(banner: str, stats: str) -> str: + if not banner and not stats: + return "" + return banner + stats + +def _idle_html() -> str: + return "
Ready.
" + +def _stopped_html() -> str: + return "
⏹ Stopped by user.
" + + +# ── Log filter ───────────────────────────────────────────────────────────────── +NOISE = [ + "test session starts", "platform ", "cachedir:", "rootdir:", + "configfile:", "plugins:", "collected ", "live log call", + "PASSED", "FAILED", "=== 1 passed", "=== 1 failed", "no tests ran", + "tests/test_", "====================", "short test summary", + "UserWarning", "warnings.warn", +] +def _is_noise(line: str) -> bool: + return any(m in line for m in NOISE) + +def _clean(line: str) -> str: + return re.sub(r"(INFO|WARNING|ERROR|DEBUG)\s+\S+:\S+:\d+\s+", "", line).strip() + + +# ── Core streamer ───────────────────────────────────────────────────────────── +# Outputs (9 values): +# 0 progress_html +# 1 phase_html +# 2 result_html +# 3 download_btn ← gr.update(value) — raw JSON +# 4 downloads_row ← gr.update(visible) +# 5 excel_dl_btn ← gr.update(value) — auto-generated Excel +# 6 retry_row ← gr.update(visible) +# 7 map_row ← gr.update(visible) — show Map Districts after success +# 8 stop_row ← gr.update(visible) + +def _stream(pytest_args: list, state: str, max_retries: int, mode: str, target_categories: str = None): + out_file = get_output_file(state) + excel_file = get_excel_file(state) + + if mode == "scrape" and os.path.exists(excel_file): + try: os.remove(excel_file) + except Exception: pass + + env = os.environ.copy() + env["KYS_MAX_RETRIES"] = str(int(max_retries)) + if target_categories: + env["KYS_TARGET_CATEGORIES"] = target_categories + + try: + process = subprocess.Popen( + [sys.executable, "-m"] + pytest_args, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + encoding="utf-8", errors="replace", text=True, bufsize=1, universal_newlines=True, + cwd=os.path.dirname(os.path.abspath(__file__)), + env=env, + ) + except Exception as e: + import traceback + err_banner = _banner_html("❌", "Error starting process", str(e), "stopped") + yield (_idle_html(), _phase_html(f"❌ Error: {e}"), _result_html(err_banner, ""), traceback.format_exc(), + gr.update(visible=False), gr.update(visible=False), + gr.update(visible=False, value=None), gr.update(visible=False), + gr.update(visible=False), gr.update(visible=False), gr.update(visible=False)) + return + + _proc["current"] = process + current, total = 0, 0 + phase_text = "Initialising…" + log_lines = [] + banner_rendered = "" + stats_rendered = "" + is_done = False + has_failures = False + success_count = 0 + no_data_count = 0 + failed_count = 0 + + re_p1 = re.compile(r"Found (\d+) districts") + re_p2 = re.compile(r"\[(\d+)/(\d+)\] NOW SEARCHING") + re_retry = re.compile(r"\[(\d+)/(\d+)\] RETRY") + re_dc = re.compile(r"District: (.+?)\s*\|\s*Category: (.+?)(?:\s*\(id=\d+\))?$") + re_done_s = re.compile(r"DONE\.\s+Total:\s*(\d+)\s*\|.*?Success:\s*(\d+).*?No Data:\s*(\d+).*?Captcha Failed:\s*(\d+)") + re_done_r = re.compile(r"RETRY DONE\.\s+Attempted:\s*(\d+)\s*\|.*?Resolved:\s*(\d+).*?Still captcha-failed:\s*(\d+)") + + excel_cache = [None] + def _auto_export_excel(): + """Auto-generate Excel from JSON after scrape completes.""" + excel = get_excel_file(state) + if excel_cache[0] == excel and os.path.exists(excel): + return gr.update(value=excel, visible=True) + + try: + proc = subprocess.run( + [sys.executable, "export_to_excel.py", "--state", state], + capture_output=True, encoding="utf-8", errors="replace", text=True, + cwd=os.path.dirname(os.path.abspath(__file__)) + ) + if proc.returncode == 0 and os.path.exists(excel): + excel_cache[0] = excel + return gr.update(value=excel, visible=True) + except Exception: + pass + return gr.update(value=None, visible=False) + + def _emit(): + prog = _progress_html(current, total) if total > 0 else _idle_html() + phase = _phase_html(phase_text) + res = _result_html(banner_rendered, stats_rendered) + stop_vis = gr.update(visible=not is_done) + + if is_done: + if success_count == 0 and no_data_count > 0 and failed_count == 0: + dl_row_vis, retry_vis = gr.update(visible=False), gr.update(visible=False) + elif not has_failures: + # All data complete — show downloads, hide retry + dl_row_vis, retry_vis = gr.update(visible=True), gr.update(visible=False) + else: + # Some failures — show retry, hide downloads + dl_row_vis, retry_vis = gr.update(visible=False), gr.update(visible=True) + else: + dl_row_vis, retry_vis = gr.update(visible=False), gr.update(visible=False) + + dl_val = gr.update(value=out_file) if (is_done and not has_failures and success_count > 0 and os.path.exists(out_file)) else gr.update(value=None) + + # Auto-export excel only when scrape succeeds + if is_done and not has_failures and success_count > 0: + excel_update = _auto_export_excel() + else: + excel_update = gr.update(value=None, visible=False) + + return (prog, phase, res, dl_val, dl_row_vis, excel_update, retry_vis, stop_vis) + + yield _emit() + for raw in iter(process.stdout.readline, ""): + line = raw.rstrip() + if not line: continue + clean = _clean(line) + m = re_p1.search(line) + if m: phase_text = f"📍 {m.group(1)} districts found. Collecting data…"; total = current = 0 + m2 = re_p2.search(line) or re_retry.search(line) + if m2: + current, total = int(m2.group(1)), int(m2.group(2)) + md = re_dc.search(clean) + phase_text = (f"🔍 [{current}/{total}] {md.group(1).strip()} · {md.group(2).strip()}" if md else f"🔍 [{current}/{total}] Processing…") + if "PASS Call" in line or "[PASS" in line: phase_text = phase_text.replace("🔍", "✅") + elif "NO DATA" in line: phase_text = phase_text.replace("🔍", "○") + elif "ALL ROUNDS FAILED" in line: phase_text = phase_text.replace("🔍", "✗") + ms = re_done_s.search(line) + if ms: + t = int(ms.group(1)); success_count = int(ms.group(2)); no_data_count = int(ms.group(3)); failed_count = int(ms.group(4)) + current = total = t; is_done = True; has_failures = failed_count > 0 + stats_rendered = _stats_html(success_count, no_data_count, failed_count, t) + if success_count == 0 and failed_count == 0: + phase_text = "○ No schools found in selected categories."; banner_rendered = _banner_html("○", "No Schools Found", "The selected categories have no schools.", "nodata") + elif not has_failures: + phase_text = "✅ All data retrieved!"; banner_rendered = _banner_html("✅", "All Data Retrieved!", "All school data fetched successfully.", "success") + else: + phase_text = f"⚠️ {failed_count} record(s) incomplete."; banner_rendered = _banner_html("⚠️", "Some Records Are Incomplete", f"{failed_count} record(s) could not be fetched. Please proceed to Step 2 to fetch missing data.", "warning") + mr = re_done_r.search(line) + if mr: + attempted = int(mr.group(1)); resolved = int(mr.group(2)); still_failed = int(mr.group(3)) + success_count = resolved; failed_count = still_failed; no_data_count = 0; current = total = attempted; is_done = True; has_failures = still_failed > 0 + stats_rendered = _stats_html(resolved, 0, still_failed, attempted) + if not has_failures: + phase_text = "✅ All data retrieved!"; banner_rendered = _banner_html("✅", "All Data Retrieved!", "All missing data fetched.", "success") + else: + phase_text = f"⚠️ {still_failed} record(s) still incomplete."; banner_rendered = _banner_html("⚠️", "Some Records Still Incomplete", f"{still_failed} record(s) failed. You can run Step 2 again or proceed to Step 3.", "warning") + yield _emit() + + process.stdout.close(); process.wait(); _proc["current"] = None + if process.returncode not in (0, 1): + is_done = True; phase_text = "Stopped by user."; banner_rendered = _banner_html("⏹", "Process Stopped", "The process was stopped.", "stopped"); yield _emit() + else: + fc = check_missed_schools(out_file) + if fc is not None: + if fc == 0 and success_count > 0: + is_done, has_failures = True, False; phase_text = "✅ All data retrieved!"; banner_rendered = _banner_html("✅", "All Data Retrieved!", "All school data fetched successfully.", "success") + elif fc > 0: + is_done, has_failures = True, True; failed_count = fc; phase_text = f"⚠️ {fc} record(s) incomplete."; banner_rendered = _banner_html("⚠️", "Some Records Are Incomplete", f"{fc} record(s) failed. Please proceed to Step 2 to fetch missing data.", "warning") + yield _emit() + + +# ── Button handlers ─────────────────────────────────────────────────────────── + +def _err_yield(msg: str): + # 8 outputs matching stream_outputs + return (_idle_html(), _phase_html(msg), "", + gr.update(value=None), gr.update(visible=False), + gr.update(value=None, elem_classes=["download-highlight", "hide-file"]), + gr.update(visible=False), gr.update(visible=False)) + +def ui_main_scrape(state, selected_cats, max_retries): + if isinstance(state, list): + state = state[-1] if state else None + if not state: yield _err_yield("⚠️ Please select a state first."); return + if not selected_cats: yield _err_yield("⚠️ Please select at least one category."); return + cat_ids = [c.split(" - ")[0] for c in selected_cats] + yield from _stream(["pytest", "tests/test_scrape_districts_by_category.py", "-v", "-s", "--state", state], state, max_retries, "scrape", ",".join(cat_ids)) + +def ui_retry(state, max_retries): + if isinstance(state, list): + state = state[-1] if state else None + if not state: yield _err_yield("⚠️ Please select a state first."); return + out = get_output_file(state) + if not os.path.exists(out): yield _err_yield("⚠️ No data file found."); return + fc = check_missed_schools(out) + if fc == 0: + # Auto-export Excel for complete data + excel_path = get_excel_file(state) + excel_update = gr.update(value=None, elem_classes=["download-highlight", "hide-file"]) + try: + proc = subprocess.run([sys.executable, "export_to_excel.py", "--state", state], + capture_output=True, encoding="utf-8", errors="replace", text=True, + cwd=os.path.dirname(os.path.abspath(__file__))) + if proc.returncode == 0 and os.path.exists(excel_path): + excel_update = gr.update(value=excel_path, visible=True) + except Exception: pass + banner = _banner_html("✅", "All Data Complete!", "Nothing missing — your Excel is ready to download below.", "success") + yield (_idle_html(), _phase_html("✅ All data is complete!"), _result_html(banner, ""), + gr.update(value=out), gr.update(visible=True), + excel_update, + gr.update(visible=False), gr.update(visible=False)) + return + yield from _stream(["pytest", "tests/test_retry_districts_by_category.py", "-v", "-s", "--state", state], state, max_retries, "retry") + +def ui_stop(): + proc = _proc.get("current") + _batch_abort["flag"] = True # also abort any in-progress multi-state batch loop + if proc and proc.poll() is None: + proc.terminate() + try: proc.wait(timeout=5) + except subprocess.TimeoutExpired: proc.kill() + _proc["current"] = None + return _stopped_html() + return _phase_html("ℹ️ No process is currently running.") + + +# ── Multi-state batch scraping ──────────────────────────────────────────────── +AUTO_RETRY_ROUNDS = 3 # automatic "Fix Missing Data" rounds per state before moving on + +def _batch_status_html(idx: int, total: int, state: str, log_lines: list) -> str: + header = "" + if total > 1: + header = f"""
+ 📦 Batch — state {idx} of {total}: {state} +
""" + log_html = "".join( + f"
{line}
" + for line in log_lines + ) + return header + log_html + + +def ui_batch_scrape(states, cats, retries, force=False): + """ + Runs Start Scraping -> auto Fix Missing Data -> auto push for each selected + state in turn, so multiple states can be scraped unattended in one click. + force=True skips the "already scraped" check (used after the user has + explicitly confirmed a re-scrape for a single state). + """ + from hf_store import check_state_scraped + + if isinstance(states, str): + states = [states] + states = [s for s in (states or []) if s] + n = len(states) + noop8 = tuple(gr.update() for _ in range(8)) + + if n == 0: + yield ("",) + _err_yield("⚠️ Please select at least one state first.") + (gr.update(),) + return + + _batch_abort["flag"] = False + log_lines = [] + last_state = states[0] + + for idx, state in enumerate(states, start=1): + last_state = state + if _batch_abort["flag"]: + break + + if not force and check_state_scraped(state, cats): + log_lines.append(f"⏭️ {state} — already fully scraped for these categories, skipped.") + yield (_batch_status_html(idx, n, state, log_lines),) + noop8 + (gr.update(),) + continue + + stopped = False + for out8 in ui_main_scrape(state, cats, retries): + yield (_batch_status_html(idx, n, state, log_lines),) + out8 + (gr.update(),) + if _batch_abort["flag"]: + stopped = True + break + if stopped: + break + + out_file = get_output_file(state) + for attempt in range(1, AUTO_RETRY_ROUNDS + 1): + fc = check_missed_schools(out_file) + if fc in (0, None): + break + log_lines.append(f"↻ {state} — auto-retry round {attempt}/{AUTO_RETRY_ROUNDS} ({fc} missing record(s))…") + yield (_batch_status_html(idx, n, state, log_lines),) + noop8 + (gr.update(),) + for out8 in ui_retry(state, retries): + yield (_batch_status_html(idx, n, state, log_lines),) + out8 + (gr.update(),) + if _batch_abort["flag"]: + stopped = True + break + if stopped: + break + if stopped: + break + + fc_final = check_missed_schools(out_file) + if fc_final: + log_lines.append( + f"⚠️ {state} — {fc_final} record(s) still failing after " + f"{AUTO_RETRY_ROUNDS} auto-retry round(s). Push skipped — use Fix Missing Data manually." + ) + push_update = gr.update() + else: + push_msg = _auto_push_to_dataset(state, cats) + log_lines.append(f"{state}: {push_msg}") + push_update = push_msg + + yield (_batch_status_html(idx, n, state, log_lines),) + noop8 + (push_update,) + + if _batch_abort["flag"]: + log_lines.append("⏹ Batch stopped by user.") + else: + log_lines.append(f"🎉 Batch complete — {n} state(s) processed.") + yield (_batch_status_html(n, n, last_state, log_lines),) + noop8 + (gr.update(),) + _batch_abort["flag"] = False + + +def ui_main_scrape_forced(states, cats, retries): + """Used after the user explicitly confirms re-scraping an already-scraped single state.""" + yield from ui_batch_scrape(states, cats, retries, force=True) + + +# ── Dashboard helpers ───────────────────────────────────────────────────────── +def _metric_card(label, value, color="#6366f1"): + return f""" +
+
{value}
+
{label}
+
""" + +def _dash_headline_html(): + ns = _ANALYSIS.get("National Summary", pd.DataFrame()) + if ns.empty: + return "

Analysis file not loaded.

" + vals = dict(zip(ns["Metric"], ns["Count"])) + old = f"{vals.get('Total Old Schools', 0):,}" + new = f"{vals.get('Total New Schools', 0):,}" + added= f"{vals.get('Total Schools Added', 0):,}" + # compute deleted + deleted_df = _ANALYSIS.get("All Deleted Schools", pd.DataFrame()) + deleted = f"{len(deleted_df):,}" if not deleted_df.empty else "0" + return f""" +
+ {_metric_card('Total Schools (Last Year)', old, '#6366f1')} + {_metric_card('Total Schools (This Year)', new, '#6366f1')} + {_metric_card('✅ Schools Added', added, '#22c55e')} + {_metric_card('❌ Schools Removed', deleted, '#ef4444')} +
""" + +def _truncate_udise(val, n=3): + """Truncate long UDISE lists to first n codes + count.""" + if pd.isna(val) or val == "": + return "" + codes = [c.strip() for c in str(val).split(",") if c.strip()] + if len(codes) <= n: + return ", ".join(codes) + shown = ", ".join(codes[:n]) + return f"{shown} ... (+{len(codes)-n} more)" + +def _full_udise(val): + if pd.isna(val) or val == "": + return "" + return str(val).strip() + +def _filter_state(sheet_name, state_col="State", state=None): + df = _ANALYSIS.get(sheet_name, pd.DataFrame()).copy() + if df.empty: + return df + if state and state != "All States": + df = df[df[state_col] == state] + return df.reset_index(drop=True) + +def _prep_with_udise(df, udise_col): + """Return two versions: one with truncated UDISEs for display, one full.""" + if df.empty or udise_col not in df.columns: + return df + df = df.copy() + df[udise_col] = df[udise_col].apply(_truncate_udise) + return df + +def dash_update(state, analysis): + """Returns all table data when state filter changes.""" + def _fs(sheet, sc="State"): + df = analysis.get(sheet, pd.DataFrame()).copy() + if df.empty: return df + if state and state != "All States": + df = df[df[sc] == state] + return df.reset_index(drop=True) + + dm = _prep_with_udise(_fs("District Mapping"), "List_of_UDISEs") + cs = _prep_with_udise(_fs("Complex Splits"), "List_of_UDISEs") + bm = _prep_with_udise(_fs("Block Mapping"), "List_of_UDISEs") + ct = _prep_with_udise(_fs("Category Transitions"), "List_of_UDISEs") + ad = _fs("All Added Schools", sc="School_State__c") + dl = _fs("All Deleted Schools", sc="School_State__c") + return dm, cs, bm, ct, ad, dl + + +def _get_udise_for_row(sheet_name, row_idx, analysis): + """Look up the full UDISE list for a given row index from live analysis.""" + df = analysis.get(sheet_name, pd.DataFrame()) + if df.empty or row_idx >= len(df) or "List_of_UDISEs" not in df.columns: + return "" + return _full_udise(df.iloc[row_idx]["List_of_UDISEs"]) + + +def on_select_dm(state, last_row, analysis, evt: gr.SelectData): + row = evt.index[0] + if row == last_row: return "", -1 + # filter to current state then get that row's udise + def _fs(sheet): + df = analysis.get(sheet, pd.DataFrame()).copy() + if state and state != "All States": df = df[df["State"] == state] + return df.reset_index(drop=True) + df = _fs("District Mapping") + if df.empty or row >= len(df): return "", row + return _full_udise(df.iloc[row]["List_of_UDISEs"]), row + +def on_select_cs(state, last_row, analysis, evt: gr.SelectData): + row = evt.index[0] + if row == last_row: return "", -1 + def _fs(sheet): + df = analysis.get(sheet, pd.DataFrame()).copy() + if state and state != "All States": df = df[df["State"] == state] + return df.reset_index(drop=True) + df = _fs("Complex Splits") + if df.empty or row >= len(df): return "", row + return _full_udise(df.iloc[row]["List_of_UDISEs"]), row + +def on_select_bm(state, last_row, analysis, evt: gr.SelectData): + row = evt.index[0] + if row == last_row: return "", -1 + def _fs(sheet): + df = analysis.get(sheet, pd.DataFrame()).copy() + if state and state != "All States": df = df[df["State"] == state] + return df.reset_index(drop=True) + df = _fs("Block Mapping") + if df.empty or row >= len(df): return "", row + return _full_udise(df.iloc[row]["List_of_UDISEs"]), row + +def on_select_ct(state, last_row, analysis, evt: gr.SelectData): + row = evt.index[0] + if row == last_row: return "", -1 + def _fs(sheet): + df = analysis.get(sheet, pd.DataFrame()).copy() + if state and state != "All States": df = df[df["State"] == state] + return df.reset_index(drop=True) + df = _fs("Category Transitions") + if df.empty or row >= len(df): return "", row + return _full_udise(df.iloc[row]["List_of_UDISEs"]), row + +# ── Analysis engine (ported from national_analysis.py) ─────────────────────── +def _load_file(path): + if path is None: + raise ValueError("No file uploaded.") + ext = os.path.splitext(path)[-1].lower() + if ext == ".csv": + try: + return pd.read_csv(path, low_memory=False) + except UnicodeDecodeError: + return pd.read_csv(path, encoding="cp1252", low_memory=False) + elif ext in (".xlsx", ".xls"): + return pd.read_excel(path) + else: + raise ValueError(f"Unsupported file type: {ext}. Use .csv or .xlsx") + +def _clean_text(s): + return s.astype(str).str.strip().str.upper() + +def _clean_udise(s): + return s.astype(str).str.replace(r"\.0$", "", regex=True).str.zfill(11) + +def _fmt_udise_list(series): + joined = ", ".join(series.astype(str)) + return joined[:30000] + " ... (+ more)" if len(joined) > 30000 else joined + +def run_analysis(old_path, new_path): + """Run the full comparison. Returns a dict of DataFrames.""" + df_old = _load_file(old_path) + df_new = _load_file(new_path) + + for df in [df_old, df_new]: + df["School_State__c"] = _clean_text(df["School_State__c"]) + df["UDISE"] = _clean_udise(df["School_Udise_Code__c"]) + + df_old = df_old.drop_duplicates(subset=["UDISE"]).set_index("UDISE") + df_new = df_new.drop_duplicates(subset=["UDISE"]).set_index("UDISE") + + old_u = set(df_old.index) + new_u = set(df_new.index) + added_u = new_u - old_u + deleted_u = old_u - new_u + common_u = old_u & new_u + + df_added_raw = df_new.loc[list(added_u)].reset_index()[["UDISE","School_State__c","School_District__c","School_Block__c"]] + df_deleted_raw = df_old.loc[list(deleted_u)].reset_index()[["UDISE","School_State__c","School_District__c","School_Block__c"]] + + all_states = sorted(set(df_old["School_State__c"].unique()) | set(df_new["School_State__c"].unique())) + state_summary = [] + for st in all_states: + so = set(df_old[df_old["School_State__c"] == st].index) + sn = set(df_new[df_new["School_State__c"] == st].index) + state_summary.append({"State": st, "Old Count": len(so), "New Count": len(sn), + "Added": len(sn - so), "Deleted": len(so - sn)}) + df_state_summary = pd.DataFrame(state_summary) + + df_common = df_old.loc[list(common_u)].join(df_new.loc[list(common_u)], lsuffix="_OLD", rsuffix="_NEW").reset_index() + df_common["State"] = _clean_text(df_common["School_State__c_NEW"]) + df_common["Dist_OLD"] = _clean_text(df_common["School_District__c_OLD"]) + df_common["Dist_NEW"] = _clean_text(df_common["School_District__c_NEW"]) + df_common["Block_OLD"]= _clean_text(df_common["School_Block__c_OLD"]) + df_common["Block_NEW"]= _clean_text(df_common["School_Block__c_NEW"]) + df_common["Cat_OLD"] = _clean_text(df_common["schCategoryType__c_OLD"]) + df_common["Cat_NEW"] = _clean_text(df_common["schCategoryType__c_NEW"]) + + dist_chg = df_common[df_common["Dist_OLD"] != df_common["Dist_NEW"]] + district_mapping = pd.DataFrame() + if len(dist_chg): + district_mapping = dist_chg.groupby(["State","Dist_OLD","Dist_NEW"]).agg( + Affected_Schools=("UDISE","count"), List_of_UDISEs=("UDISE", _fmt_udise_list) + ).reset_index() + + # 3. Block to District Mapping (The Exact Matcher for Splits) + # If a new district was formed, we want to know what the old district was for a given block. + # So we group by (State, Dist_NEW, Block_NEW) and find the most common Dist_OLD. + block_to_district = pd.DataFrame() + if not df_common.empty: + # Group by new block and old district + block_dist_counts = df_common.groupby(["State", "Dist_NEW", "Block_NEW", "Dist_OLD"]).agg( + Schools=("UDISE", "count"), + List_of_UDISEs=("UDISE", _fmt_udise_list) + ).reset_index() + + # For each new block, find the old district that had the most schools + # (This establishes the definitive historical mapping for that block) + idx = block_dist_counts.groupby(["State", "Dist_NEW", "Block_NEW"])["Schools"].idxmax() + block_to_district = block_dist_counts.loc[idx].reset_index(drop=True) + + blk_chg = df_common[df_common["Block_OLD"] != df_common["Block_NEW"]] + block_mapping = pd.DataFrame() + if len(blk_chg): + block_mapping = blk_chg.groupby(["State","Dist_OLD","Block_OLD","Block_NEW"]).agg( + Affected_Schools=("UDISE","count"), List_of_UDISEs=("UDISE", _fmt_udise_list) + ).reset_index() + + cat_chg = df_common[df_common["Cat_OLD"] != df_common["Cat_NEW"]] + cat_migrations = pd.DataFrame() + if len(cat_chg): + cat_migrations = cat_chg.groupby(["State","Cat_OLD","Cat_NEW"]).agg( + Count=("UDISE","count"), List_of_UDISEs=("UDISE", _fmt_udise_list) + ).reset_index().sort_values(["State","Count"], ascending=[True,False]) + + ns = pd.DataFrame({"Metric": [ + "Total Old Schools","Total New Schools","Total Schools Added","Total Schools Deleted", + "Common Schools (In Both)","Schools with District Changes","Schools with Block Changes","Schools with Category Changes" + ], "Count": [ + len(df_old), len(df_new), len(added_u), len(deleted_u), + len(common_u), len(dist_chg), len(blk_chg), len(cat_chg) + ]}) + + from district_mapper import build_udise_geo_lookup + geo_lookup = build_udise_geo_lookup(df_new) + rows = [] + for ss, name in geo_lookup["state"].items(): rows.append(["state", ss, name]) + for ssdd, name in geo_lookup["district"].items(): rows.append(["district", ssdd, name]) + for ssddbb, name in geo_lookup["block"].items(): rows.append(["block", ssddbb, name]) + geo_df = pd.DataFrame(rows, columns=["Type", "Code", "Name"]) + + # Find unmapped completely new districts + old_dist_names = set(df_old["School_District__c"].dropna().str.strip().str.upper().unique()) + new_dist_df = df_new[["School_State__c", "School_District__c"]].drop_duplicates().dropna() + new_dist_df["State"] = _clean_text(new_dist_df["School_State__c"]) + new_dist_df["New District Name"] = _clean_text(new_dist_df["School_District__c"]) + + unmapped_new = new_dist_df[~new_dist_df["New District Name"].isin(old_dist_names)].copy() + unmapped_new["Rename To (Type here...)"] = "" + if not unmapped_new.empty: + unmapped_new = unmapped_new[["State", "New District Name", "Rename To (Type here...)"]].sort_values(["State", "New District Name"]).reset_index(drop=True) + else: + unmapped_new = pd.DataFrame(columns=["State", "New District Name", "Rename To (Type here...)"]) + + return { + "National Summary": ns, + "State by State Breakdown": df_state_summary, + "All Added Schools": df_added_raw, + "All Deleted Schools": df_deleted_raw, + "District Mapping": district_mapping, + "Block to District": block_to_district, + "Block Mapping": block_mapping, + "Category Transitions": cat_migrations, + "New Districts": unmapped_new, + "UDISE Geo": geo_df, + } + +# ── CSS ─────────────────────────────────────────────────────────────────────── +css = """ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap'); + +* { font-family: 'Inter', sans-serif !important; } + +#title { + text-align: center; + font-size: 2.2em !important; + font-weight: 800 !important; + background: linear-gradient(135deg, #6366f1, #8b5cf6, #a855f7); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + margin-bottom: 4px !important; + letter-spacing: -0.5px; +} +#subtitle { + text-align: center; + color: var(--body-text-color-subdued); + margin-top: 0; + margin-bottom: 24px; + font-size: .95em; +} + +/* Tab styling */ +.tab-nav button { + font-weight: 600 !important; + font-size: 1em !important; + padding: 10px 20px !important; + border-radius: 10px 10px 0 0 !important; + transition: all 0.2s !important; +} +.tab-nav button.selected { + background: linear-gradient(135deg, #6366f1, #8b5cf6) !important; + color: white !important; +} + +/* Step card styling */ +.step-card { + background: var(--background-fill-secondary); + border-radius: 14px; + padding: 20px; + border: 1px solid var(--border-color-primary); + margin-bottom: 12px; + transition: box-shadow 0.2s; +} +.step-card:hover { box-shadow: 0 4px 20px rgba(99,102,241,0.12); } + +/* Button beautification */ +.btn-primary { background: linear-gradient(135deg, #6366f1, #8b5cf6) !important; border: none !important; } +.btn-primary:hover { transform: translateY(-1px) !important; box-shadow: 0 6px 20px rgba(99,102,241,0.4) !important; } + +/* Download file widget */ +.download-highlight { + border: 2px dashed var(--color-accent) !important; + border-radius: 12px !important; + padding: 0 !important; + position: relative !important; + background: transparent !important; + transition: all 0.25s !important; +} +.download-highlight:hover { + border-style: solid !important; + border-color: var(--color-accent) !important; + background: color-mix(in srgb, var(--color-accent) 5%, transparent) !important; +} +.download-highlight * { position: static !important; } +.download-highlight a { display: flex !important; align-items: center; padding: 14px !important; width: 100%; height: 100%; } +.download-highlight a::after { content: ""; position: absolute !important; inset: 0 !important; z-index: 50 !important; cursor: pointer !important; } +.hide-file { display: none !important; } + +/* Status banners */ +.status-ok { color: #22c55e; font-weight: 700; } +.status-err { color: #ef4444; font-weight: 700; } +.status-warn{ color: #f59e0b; font-weight: 700; } + +/* Accordion styling */ +.gr-accordion { border-radius: 12px !important; border: 1px solid var(--border-color-primary) !important; margin-bottom: 10px !important; } + +/* Section divider label */ +.section-label { + font-size: 0.75em; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--body-text-color-subdued); + margin: 20px 0 8px 0; +} + +/* Metric cards */ +.metric-card { + background: transparent; + border-radius: 14px; + padding: 20px 28px; + text-align: center; + transition: transform 0.2s; +} +.metric-card:hover { transform: translateY(-2px); } + +/* Log box */ +.log-box textarea { font-size:.76em !important; font-family: 'JetBrains Mono', monospace !important; } +""" + +custom_theme = gr.themes.Soft( + primary_hue="violet", + secondary_hue="indigo", + neutral_hue="slate", + font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui", "sans-serif"] +) + +# ── UI ──────────────────────────────────────────────────────────────────────── +with gr.Blocks(title="School Data Fetcher", css=css, theme=custom_theme) as app: + gr.HTML(""" +
+
+ 🏫 School Data Fetcher +
+
+ Scrape → Auto-push Raw → Map Districts → Build Mapped Master +
+
+ """) + + # Holds the live analysis results dict across tabs + analysis_state = gr.State({}) + + with gr.Tabs(): + + # ── Tab 1: Scraper ──────────────────────────────────────────────────── + with gr.Tab("🔍 Scraper"): + + gr.HTML(""" +
+
👋 How it works
+
+ Step 1: Choose one or more states & categories, then click Start Scraping — selected states are scraped one after another automatically, each one retried and pushed to the cloud before moving to the next.
+ Step 2: If a state still has failures after the automatic retries, click Fix Missing Data to retry those records again.
+ Step 3: Once every state is done, go to the 📋 Master Sheet tab to review any newly detected districts and map them if necessary.
+ Step 4: Once all states are scraped and mapped, click Build Master Sheet at the bottom of the tab to generate the final mapped excel file. +
+
+ """) + + with gr.Row(equal_height=True): + with gr.Column(scale=1): + gr.HTML("
📍 Location
") + state_dd = gr.Dropdown( + choices=sorted(STATE_LABEL_TO_ID.keys()), + multiselect=True, + value=[], + label="Select State(s) or School Type(s)", + info="Select one or more. Multiple states are scraped one after another automatically, " + "each retried and pushed to the cloud before moving to the next." + ) + with gr.Column(scale=2): + gr.HTML("
📚 School Types
") + categories_cg = gr.CheckboxGroup( + choices=list(ALL_CATEGORIES.values()), + value=list(ALL_CATEGORIES.values()), + label="Select which school categories to collect" + ) + + gr.HTML("
") + + with gr.Row(): + scrape_btn = gr.Button("▶ Start Scraping", variant="primary", scale=3) + stop_btn_placeholder = gr.HTML("", visible=False) # placeholder + + with gr.Column(visible=False) as confirm_col: + gr.HTML("
⚠️ Warning: This state (and all selected categories) is already present in your dataset. Are you sure you want to scrape it again?
") + with gr.Row(): + confirm_yes_btn = gr.Button("Yes, Scrape Again", variant="stop") + confirm_no_btn = gr.Button("No, Cancel", variant="secondary") + + with gr.Row(visible=False) as stop_row: + stop_btn = gr.Button("⏹ Stop", variant="stop", scale=1) + + should_scrape_state = gr.State(False) + + batch_status_html = gr.HTML(value="") + progress_html = gr.HTML(value=_idle_html()) + phase_html = gr.HTML(value=_phase_html("Select a state and school types above, then click Start Scraping.")) + result_html = gr.HTML(value="") + + with gr.Row(visible=False) as retry_row: + retry_btn = gr.Button("↻ Fix Missing Data (some records failed — click to retry)", variant="secondary", scale=1) + + with gr.Row(visible=False) as downloads_row: + with gr.Column(scale=1): + download_btn = gr.DownloadButton( + label="📄 Download Raw JSON", + value=None, visible=True, + variant="secondary" + ) + with gr.Column(scale=1): + excel_dl_btn = gr.DownloadButton( + label="📊 Download Excel", + value=None, visible=False, + variant="secondary" + ) + + with gr.Accordion("⚙️ Advanced Settings", open=False): + retries_slider = gr.Slider( + minimum=1, maximum=10, value=5, step=1, + label="Retry attempts per failed record (higher = more thorough but slower)" + ) + + + # Auto-push status (shown after scrape succeeds) + auto_push_html = gr.HTML(value="") + + stream_outputs = [ + progress_html, phase_html, result_html, + download_btn, downloads_row, + excel_dl_btn, + retry_row, stop_row, + ] + + def handle_scrape_click(states, categories): + from hf_store import check_state_scraped + states = states or [] + if not states: + return gr.update(), gr.update(), False + if len(states) == 1 and check_state_scraped(states[0], categories): + return gr.update(visible=False), gr.update(visible=True), False + return gr.update(visible=False), gr.update(visible=False), True + + def ui_main_scrape_conditional(should_scrape, states, cats, retries): + if should_scrape: + yield from ui_batch_scrape(states, cats, retries, force=False) + else: + yield tuple(gr.update() for _ in range(10)) + + batch_outputs = [batch_status_html] + stream_outputs + [auto_push_html] + + scrape_btn.click( + fn=handle_scrape_click, + inputs=[state_dd, categories_cg], + outputs=[scrape_btn, confirm_col, should_scrape_state] + ).then( + fn=ui_main_scrape_conditional, + inputs=[should_scrape_state, state_dd, categories_cg, retries_slider], + outputs=batch_outputs + ).then( + fn=lambda should: gr.update(visible=True) if should else gr.update(), + inputs=[should_scrape_state], + outputs=[scrape_btn] + ) + + confirm_yes_btn.click( + fn=lambda: (gr.update(visible=False), gr.update(visible=False)), + outputs=[confirm_col, scrape_btn] + ).then( + fn=ui_main_scrape_forced, + inputs=[state_dd, categories_cg, retries_slider], + outputs=batch_outputs + ).then( + fn=lambda: gr.update(visible=True), + outputs=[scrape_btn] + ) + + confirm_no_btn.click( + fn=lambda: (gr.update(visible=False), gr.update(visible=True)), + outputs=[confirm_col, scrape_btn] + ) + retry_btn.click( fn=ui_retry, inputs=[state_dd, retries_slider], outputs=stream_outputs) + stop_btn.click( fn=ui_stop, inputs=[], outputs=[progress_html]) + + # ── Tab 2: Master Sheet Builder ─────────────────────────────────────── + with gr.Tab("📋 Master Sheet"): + + # ── HF connection status ────────────────────────────────────────── + def _env_status(): + from hf_store import get_hf_credentials + token, repo = get_hf_credentials() + if token and repo: + repo_name = repo.split("/")[-1] if "/" in repo else repo + return f""" +
+
+
+
Connected to Dataset
+
Saving to: {repo}
+
+
""" + return """ +
+
⚠️
+
+
Dataset not set up
+
+ Add your HF_TOKEN and HF_REPO to the .env file to enable dataset features. +
+
+
""" + + hf_env_status = gr.HTML(value=_env_status()) + + gr.HTML("
") + gr.HTML(""" +
📡 Dataset Coverage
+
+ Only states with all 7 required school categories scraped will be included in the master sheet. +
+ """) + + with gr.Row(): + refresh_btn = gr.Button("🔄 Refresh from Dataset", variant="secondary", scale=1) + + coverage_table = gr.Dataframe( + label="State Coverage (Click '🗑️ Delete' in the Action column to remove a state)", + interactive=False, wrap=True, + headers=["State", "Categories Scraped", "Complete?", "Last Scraped", "Action"], + ) + coverage_summary_html = gr.HTML(value="") + + # ── Flagged Districts & Mapping Rules ───────────────────────── + with gr.Accordion("Review New Districts", open=False): + gr.HTML("
Review newly scraped districts and map them to older SF districts or rename.
") + + with gr.Row(): + refresh_flags_btn = gr.Button("🔄 Refresh Flagged Districts", variant="secondary", scale=1) + flagged_table = gr.Dataframe( + label="New districts detected (Type the Old SF name in the last column to rename it)", + headers=["State", "District", "Reason", "Rename District (Optional)"], + interactive=True, wrap=True + ) + flagged_status_html = gr.HTML(value="") + + + + gr.Markdown("---") + build_master_btn = gr.Button("📋 Build Master Sheet", variant="primary") + build_status_html = gr.HTML(value="") + master_dl_btn = gr.DownloadButton( + label="⬇ Download Master Excel", + visible=False, + variant="secondary" + ) + + # ── Callback: Refresh coverage table ───────────────────────────── + coverage_state = gr.State(pd.DataFrame()) + + def ui_refresh_coverage(): + from hf_store import get_hf_credentials + token, repo = get_hf_credentials() + if not token or not repo: + return ( + pd.DataFrame(columns=["State", "Categories Scraped", "Complete?", "Last Scraped", "Action"]), + "

⚠️ HF_TOKEN or HF_REPO not set in environment.

" + ) + try: + from hf_store import pull_all_complete_states + _, partial_info, coverage_df = pull_all_complete_states(token, repo) + if not coverage_df.empty: + coverage_df["Action"] = "🗑️ Delete" + complete_count = int((coverage_df["Complete?"] == "✅ Yes").sum()) if not coverage_df.empty else 0 + total = len(coverage_df) + summary = ( + f"

" + f"✅ {complete_count} states ready to build

" + ) + return coverage_df, summary + except Exception as ex: + return ( + pd.DataFrame(columns=["State", "Categories Scraped", "Complete?", "Last Scraped", "Action"]), + f"

❌ Error: {ex}

" + ) + + refresh_btn.click( + fn=ui_refresh_coverage, + inputs=[], + outputs=[coverage_table, coverage_summary_html], + ).then( + fn=lambda df: df, + inputs=[coverage_table], + outputs=[coverage_state] + ) + + def on_coverage_select(evt: gr.SelectData, current_df): + if evt.value != "🗑️ Delete": + return gr.update(), gr.update() + + row_idx = evt.index[0] + if row_idx >= len(current_df): + return gr.update(), gr.update() + + state_name = current_df.iloc[row_idx]["State"] + from hf_store import get_hf_credentials, delete_state_file + token, repo = get_hf_credentials() + try: + delete_state_file(state_name, token, repo) + except Exception as ex: + return gr.update(), f"

❌ Error deleting: {ex}

" + + new_df, new_summary = ui_refresh_coverage() + msg = f"

✅ Successfully deleted {state_name}.

" + new_summary + return new_df, msg + + coverage_table.select( + fn=on_coverage_select, + inputs=[coverage_state], + outputs=[coverage_table, coverage_summary_html] + ).then( + fn=lambda df: df, + inputs=[coverage_table], + outputs=[coverage_state] + ) + + # ── Callbacks: Flagged Districts & Mapping Rules ────────────────────── + def ui_refresh_flags(): + from hf_store import get_hf_credentials, get_flagged_districts + token, repo = get_hf_credentials() + if not token or not repo: + return pd.DataFrame(columns=["State", "District", "Reason"]), "

⚠️ HF credentials not set.

" + flags = get_flagged_districts(token, repo) + if not flags: + return pd.DataFrame(columns=["State", "District", "Reason"]), "

✅ No flagged districts — all clear!

" + df = pd.DataFrame(flags) + df = df[["state", "district", "reason"]].rename(columns={"state": "State", "district": "District", "reason": "Reason"}) + df["Rename District (Optional)"] = "" + return df, f"

⚠️ {len(flags)} district(s) need review.

" + + refresh_flags_btn.click(fn=ui_refresh_flags, outputs=[flagged_table, flagged_status_html]) + + # ── Callback: Build master sheet ────────────────────────────────── + def ui_build_master(flagged_df): + from district_mapper import build_udise_geo_lookup, apply_geo_decode, build_mapper, apply_district_backmap + from hf_store import get_hf_credentials, pull_all_complete_states, pull_mapping_rules, push_mapped_master, upsert_mapping_rule, rename_district_in_reference + token, repo = get_hf_credentials() + if not token or not repo: + return ( + "

⚠️ HF_TOKEN or HF_REPO not set in environment.

", + gr.update(elem_classes=["download-highlight", "hide-file"]), + ) + try: + # 1. Process inline table mappings + if flagged_df is not None and not flagged_df.empty: + for _, row in flagged_df.iterrows(): + mapped = str(row.get("Rename District (Optional)", "")).strip() + state = str(row.get("State", "")).strip().upper() + dist = str(row.get("District", "")).strip().upper() + if mapped and mapped.upper() != "NAN" and mapped != "None": + upsert_mapping_rule(state, dist, mapped.upper(), 0, token, repo) + rename_district_in_reference(state, dist, mapped.upper(), token, repo) + complete_dfs, _, _ = pull_all_complete_states(token, repo) + + if not complete_dfs: + return ( + "

⚠️ No states with all 7 categories found in dataset.

", + gr.update(elem_classes=["download-highlight", "hide-file"]), + ) + + from hf_store import push_national_analysis_report + master_df = pd.concat(complete_dfs, ignore_index=True) + geo_lookup = build_udise_geo_lookup(master_df) + master_df, geo_changes_df = apply_geo_decode(master_df, geo_lookup) + + # Pull mapping rules automatically + dist_rules, block_rules = pull_mapping_rules(token, repo) + + backmap_changes_df = pd.DataFrame() + if not dist_rules.empty: + analysis_dict = { + "District Mapping": dist_rules, + "Block to District": block_rules if not block_rules.empty else pd.DataFrame(), + } + mapper = build_mapper(analysis_dict) + master_df, backmap_changes_df = apply_district_backmap(master_df, mapper) + + # Combine reports and push + combined_changes = pd.concat([geo_changes_df, backmap_changes_df], ignore_index=True) + if not combined_changes.empty: + push_national_analysis_report(combined_changes, token, repo) + + udise_col = "School_Udise_Code__c" if "School_Udise_Code__c" in master_df.columns else "UDISE" + if udise_col in master_df.columns and "Scraped_Date" in master_df.columns: + master_df = ( + master_df + .sort_values("Scraped_Date", ascending=False) + .drop_duplicates(subset=[udise_col], keep="first") + .reset_index(drop=True) + ) + + # Filter out out-of-scope states (e.g., from Geo-Decoded Navy/KVS schools) + from hf_store import pull_district_reference + ref_df = pull_district_reference(token, repo) + ref_states = set(ref_df["State"].str.strip().str.upper()) if not ref_df.empty else set() + + allowed_states = { + "ARUNACHAL PRADESH", "ASSAM", "BIHAR", "CHHATTISGARH", "JHARKHAND", + "KARNATAKA", "MADHYA PRADESH", "MANIPUR", "MEGHALAYA", "MIZORAM", + "NAGALAND", "ODISHA", "PUDUCHERRY", "RAJASTHAN", "SIKKIM", + "TELANGANA", "TRIPURA", "UTTAR PRADESH", "UTTARAKHAND", "DELHI", + "ANDHRA PRADESH" + } + allowed_states.update(ref_states) + + state_col = "School_State__c" if "School_State__c" in master_df.columns else "State" + if state_col in master_df.columns: + master_df = master_df[master_df[state_col].str.strip().str.upper().isin(allowed_states)] + master_df = master_df.sort_values(state_col, ascending=True).reset_index(drop=True) + + from datetime import datetime, timedelta + ist_now = datetime.utcnow() + timedelta(hours=5, minutes=30) + date_str = ist_now.strftime("%Y_%b_%d_%I_%M_%p").lower() + os.makedirs(EXCEL_DIR, exist_ok=True) + master_path = os.path.join(EXCEL_DIR, f"mapped_master_{date_str}.xlsx") + master_df.to_excel(master_path, index=False) + + push_mapped_master(master_df, token, repo) + + # Auto-sync aliases CSV immediately after building a new master! + try: + from alias_sync import sync_aliases + sync_aliases(token, repo) + except Exception as e: + print(f"Failed to auto-sync aliases: {e}") + + n = len(master_df) + msg = ( + f"

" + f"✅ Mapped master built! {n:,} records from {len(complete_dfs)} complete states. " + f"District mapping applied. Also pushed to dataset." + f"

" + ) + return msg, gr.update(value=master_path, visible=True) + except Exception as ex: + return ( + f"

❌ Error: {ex}

", + gr.update(visible=False), + ) + + build_master_btn.click( + fn=ui_build_master, + inputs=[flagged_table], + outputs=[build_status_html, master_dl_btn], + ) + + # ── Tab 3: Mapping Manager ───────────────────────────────────────────── + with gr.Tab("🗺️ Mapping Manager"): + gr.HTML(""" +
+
🗺️ Mapping Manager
+
+ Scholarship Application Reference: The mirror of what Scholarship Application currently knows. When you add a new district to Scholarship Application, find it here and change its status from new districts found to present. +
+
+ """) + + gr.HTML("
📁 Scholarship Application District Reference
") + with gr.Row(): + refresh_ref_btn = gr.Button("🔄 Load from Dataset", variant="secondary", scale=1) + + # Filters + with gr.Row(): + ref_filter_state = gr.Dropdown(label="Filter by State", choices=["All"], value="All", scale=1, interactive=True) + ref_filter_status = gr.Dropdown(label="Filter by Status", choices=["All", "present", "new districts found"], value="All", scale=1, interactive=True) + ref_filter_dist = gr.Textbox(label="Search District", placeholder="Type to search...", scale=2, interactive=True) + + # The DataFrame + ref_full_state = gr.State(pd.DataFrame()) + ref_table = gr.Dataframe( + label="Scholarship Application District Reference (Editable)", + interactive=False, wrap=True + ) + + with gr.Accordion("📦 Bulk Excel Import / Export", open=False): + with gr.Row(): + ref_prep_dl_btn = gr.Button("📦 Prepare Current View for Download") + ref_dl_btn = gr.DownloadButton("📥 Download Excel", visible=False) + + with gr.Row(): + ref_upload = gr.File(label="Upload Updated Excel", file_types=[".xlsx"]) + + gr.HTML("
Click any row in the table above to edit its status here:
") + with gr.Row(): + ref_edit_state = gr.Textbox(label="State", interactive=False, scale=2) + ref_edit_dist = gr.Textbox(label="District", interactive=False, scale=2) + ref_edit_status = gr.Dropdown(choices=["present", "new districts found"], label="Status", scale=1) + ref_edit_btn = gr.Button("💾 Update Row", variant="primary", scale=1) + + ref_status_html = gr.HTML(value="") + + gr.HTML("
Add a District
") + with gr.Row(): + ref_state_add = gr.Textbox(label="State", placeholder="e.g. ANDHRA PRADESH", scale=2) + ref_dist_add = gr.Textbox(label="District", placeholder="e.g. ALLURI SITHARAMA RAJU", scale=2) + ref_status_add = gr.Dropdown(choices=["present", "new districts found"], value="present", label="Status", scale=1) + ref_add_btn = gr.Button("➕ Add District", variant="primary", scale=1) + ref_add_status = gr.HTML(value="") + + gr.HTML("
Rename a District (automatically updates mappings)
") + with gr.Row(): + ref_ren_state = gr.Dropdown(label="State", choices=[], allow_custom_value=True, scale=2) + ref_ren_old = gr.Dropdown(label="Old Name", choices=[], allow_custom_value=True, scale=2) + ref_ren_new = gr.Textbox(label="New Name", placeholder="e.g. VISAKHAPATNAM (NEW)", scale=2) + ref_rename_btn = gr.Button("✏️ Rename", variant="secondary", scale=1) + ref_rename_status = gr.HTML(value="") + + gr.HTML("
Delete Actions
") + with gr.Row(): + ref_del_state = gr.Dropdown(label="State", choices=[], allow_custom_value=True, scale=2) + ref_del_dist = gr.Dropdown(label="District", choices=[], allow_custom_value=True, scale=2) + ref_del_dist_btn = gr.Button("🗑️ Delete District", variant="stop", scale=1) + ref_del_state_btn = gr.Button("🚨 Delete Entire State", variant="stop", scale=1) + ref_del_status = gr.HTML(value="") + + def on_ref_table_select(df, evt: gr.SelectData): + row = evt.index[0] + if df is None or df.empty or row >= len(df): + return gr.update(), gr.update(), gr.update(), "" + + state = df.iloc[row]["State"] + dist = df.iloc[row]["District"] + status = df.iloc[row]["Status"] + return gr.update(value=state), gr.update(value=dist), gr.update(value=status), "" + + def ui_ref_edit_row(state, district, new_status): + from hf_store import get_hf_credentials, update_district_reference_add + token, repo = get_hf_credentials() + if not token or not repo or not state or not district: + return "

⚠️ Please select a row from the table first.

" + try: + update_district_reference_add(state, district, new_status, token, repo) + return f"

✅ Successfully updated {district} to '{new_status}'. Refresh the table to see changes.

" + except Exception as ex: + return f"

❌ Error: {ex}

" + + def ui_refresh_ref(): + from hf_store import get_hf_credentials, pull_district_reference + token, repo = get_hf_credentials() + if not token or not repo: + empty = pd.DataFrame() + u = gr.update() + return empty, empty, u, u, u, u, u, "

⚠️ HF credentials not set.

" + df = pull_district_reference(token, repo) + if df.empty: + empty = pd.DataFrame() + u = gr.update() + return empty, empty, u, u, u, u, u, "

⚠️ Scholarship Application Reference empty.

" + states = sorted(list(df["State"].unique())) + dists = sorted(list(df["District"].unique())) + st_up = gr.update(choices=["All"] + states, value="All") + st_dd = gr.update(choices=states) + dist_dd = gr.update(choices=dists) + return df, df, st_up, st_dd, dist_dd, st_dd, dist_dd, f"

✅ Loaded {len(df)} rows.

" + + def filter_ref_table(df, filter_state, filter_status, search_dist): + if df is None or df.empty: return df + res = df.copy() + if filter_state and filter_state != "All": + res = res[res["State"] == filter_state] + if filter_status and filter_status != "All": + res = res[res["Status"] == filter_status] + if search_dist: + res = res[res["District"].str.contains(search_dist.upper(), na=False)] + return res + + def ui_save_table_edits(df, full_df, filter_state, filter_status, search_dist): + from hf_store import get_hf_credentials, push_district_reference + token, repo = get_hf_credentials() + if df is None or df.empty: + return "

⚠️ Table is empty.

", full_df + # The user edited 'df'. We need to merge it back into 'full_df' + # This is a bit complex if they deleted rows or changed keys. + # Actually, if they are filtering, df only contains a subset. + # Let's just overwrite the subset in full_df based on index if we kept index, + # but Gradio df doesn't keep original indices easily. + # Simplest way: They should only save edits if viewing All/All without search. + if filter_state != "All" or filter_status != "All" or search_dist: + return "

⚠️ Please clear all filters (set to 'All', clear search) before saving direct table edits.

", full_df + + try: + df["State"] = df["State"].astype(str).str.strip().str.upper() + df["District"] = df["District"].astype(str).str.strip().str.upper() + df["Status"] = df["Status"].astype(str).str.strip() + + invalid_statuses = df[~df["Status"].isin(["present", "new districts found"])] + if not invalid_statuses.empty: + return "

⚠️ Invalid Status found. You can only use 'present' or 'new districts found'.

", full_df + + df = df.drop_duplicates() + push_district_reference(df, token, repo) + return f"

✅ Saved {len(df)} rows directly to cloud.

", df + except Exception as ex: + return f"

❌ Error: {ex}

", full_df + + def ui_prep_excel_download(df): + if df is None or df.empty: + return gr.update(visible=False), "

⚠️ No data to download.

" + import tempfile + import os + fd, temp_excel = tempfile.mkstemp(suffix=".xlsx") + os.close(fd) + df.to_excel(temp_excel, index=False) + return gr.update(value=temp_excel, visible=True), "

✅ Ready to download!

" + + def ui_upload_excel(file): + if not file: return pd.DataFrame(), "

⚠️ No file uploaded.

" + import pandas as pd + from hf_store import get_hf_credentials, push_district_reference, pull_district_reference + token, repo = get_hf_credentials() + try: + uploaded_df = pd.read_excel(file.name) + if "State" not in uploaded_df.columns or "District" not in uploaded_df.columns or "Status" not in uploaded_df.columns: + return pd.DataFrame(), "

⚠️ Excel must have State, District, and Status columns.

" + + uploaded_df["State"] = uploaded_df["State"].astype(str).str.strip().str.upper() + uploaded_df["District"] = uploaded_df["District"].astype(str).str.strip().str.upper() + uploaded_df["Status"] = uploaded_df["Status"].astype(str).str.strip() + + invalid_statuses = uploaded_df[~uploaded_df["Status"].isin(["present", "new districts found"])] + if not invalid_statuses.empty: + return pd.DataFrame(), "

⚠️ Invalid Status found in Excel. You can only use 'present' or 'new districts found'.

" + + uploaded_df = uploaded_df.drop_duplicates(subset=["State", "District"]) + + full_df = pull_district_reference(token, repo) + if not full_df.empty: + full_df.set_index(["State", "District"], inplace=True) + uploaded_df.set_index(["State", "District"], inplace=True) + full_df.update(uploaded_df) + new_rows = uploaded_df[~uploaded_df.index.isin(full_df.index)] + full_df = pd.concat([full_df, new_rows]) + full_df.reset_index(inplace=True) + df_to_push = full_df + else: + df_to_push = uploaded_df.copy() + + push_district_reference(df_to_push, token, repo) + return df_to_push, f"

✅ Successfully merged {len(uploaded_df)} uploaded rows into the dataset!

" + except Exception as ex: + return pd.DataFrame(), f"

❌ Error: {ex}

" + + def ui_ref_add(state, district, sf_status): + from hf_store import get_hf_credentials, update_district_reference_add + token, repo = get_hf_credentials() + if not token or not repo or not state or not district or not state.strip() or not district.strip(): + return "

⚠️ Fill in all fields and check HF credentials.

" + try: + update_district_reference_add(state.strip().upper(), district.strip().upper(), sf_status, token, repo) + return f"

✅ Added/updated {district.upper()} in {state.upper()}. Refresh table to see.

" + except Exception as ex: + return f"

❌ Error: {ex}

" + + def ui_ref_rename(state, old_name, new_name): + from hf_store import get_hf_credentials, update_district_reference_rename + token, repo = get_hf_credentials() + if not token or not repo or not state or not old_name or not new_name or not state.strip() or not old_name.strip() or not new_name.strip(): + return "

⚠️ Fill in all fields.

" + try: + update_district_reference_rename(state.strip().upper(), old_name.strip().upper(), new_name.strip().upper(), token, repo) + return f"

✅ Renamed {old_name.upper()} → {new_name.upper()} (cascaded to Dataset 2). Refresh table to see.

" + except Exception as ex: + return f"

❌ Error: {ex}

" + + def ui_ref_del_dist(state, district): + from hf_store import get_hf_credentials, delete_district_reference + token, repo = get_hf_credentials() + if not token or not repo or not state or not district or not state.strip() or not district.strip(): + return "

⚠️ Fill in both State and District.

" + try: + delete_district_reference(state.strip().upper(), district.strip().upper(), token, repo) + return f"

✅ Deleted {district.upper()} from {state.upper()}. Refresh table to see.

" + except Exception as ex: + return f"

❌ Error: {ex}

" + + def ui_ref_del_state(state): + from hf_store import get_hf_credentials, delete_state_reference + token, repo = get_hf_credentials() + if not token or not repo or not state: + return "

⚠️ Fill in State.

" + try: + delete_state_reference(state.strip().upper(), token, repo) + return f"

✅ Deleted entire state {state.upper()}. Refresh table to see.

" + except Exception as ex: + return f"

❌ Error: {ex}

" + + # Wirings + refresh_ref_btn.click( + fn=ui_refresh_ref, + outputs=[ref_table, ref_full_state, ref_filter_state, + ref_ren_state, ref_ren_old, + ref_del_state, ref_del_dist, + ref_status_html] + ) + ref_filter_state.change(fn=filter_ref_table, inputs=[ref_full_state, ref_filter_state, ref_filter_status, ref_filter_dist], outputs=[ref_table]) + ref_filter_status.change(fn=filter_ref_table, inputs=[ref_full_state, ref_filter_state, ref_filter_status, ref_filter_dist], outputs=[ref_table]) + ref_filter_dist.change(fn=filter_ref_table, inputs=[ref_full_state, ref_filter_state, ref_filter_status, ref_filter_dist], outputs=[ref_table]) + + ref_table.select(fn=on_ref_table_select, inputs=[ref_table], outputs=[ref_edit_state, ref_edit_dist, ref_edit_status, ref_status_html]) + ref_edit_btn.click(fn=ui_ref_edit_row, inputs=[ref_edit_state, ref_edit_dist, ref_edit_status], outputs=[ref_status_html]) + ref_prep_dl_btn.click(fn=ui_prep_excel_download, inputs=[ref_table], outputs=[ref_dl_btn, ref_status_html]) + ref_upload.upload(fn=ui_upload_excel, inputs=[ref_upload], outputs=[ref_table, ref_status_html]) + + ref_add_btn.click(fn=ui_ref_add, inputs=[ref_state_add, ref_dist_add, ref_status_add], outputs=[ref_add_status]) + ref_rename_btn.click(fn=ui_ref_rename, inputs=[ref_ren_state, ref_ren_old, ref_ren_new], outputs=[ref_rename_status]) + ref_del_dist_btn.click(fn=ui_ref_del_dist, inputs=[ref_del_state, ref_del_dist], outputs=[ref_del_status]) + ref_del_state_btn.click(fn=ui_ref_del_state, inputs=[ref_del_state], outputs=[ref_del_status]) + + # ── Tab 4: Download Master Sheets ────────────────────────────────────── + with gr.Tab("📥 Download History"): + gr.HTML("
Download Previously Built Master Sheets
") + + with gr.Row(): + dl_refresh_btn = gr.Button("🔄 Refresh File List", scale=1) + dl_dropdown = gr.Dropdown(choices=[], label="Select a Master Sheet", scale=3) + + dl_status = gr.HTML() + dl_download_btn = gr.DownloadButton("📥 Download as Excel", visible=False) + + def ui_refresh_dl_list(): + from hf_store import get_hf_credentials, list_mapped_master_files + token, repo = get_hf_credentials() + if not token or not repo: + return gr.update(choices=[]), "

⚠️ HF credentials not set.

" + + files = list_mapped_master_files(token, repo) + if not files: + return gr.update(choices=[]), "

⚠️ No master sheets found in dataset.

" + + filenames = [f.split("/")[-1] for f in files] + filenames.sort(reverse=True) # newest first + return gr.update(choices=filenames), f"

✅ Found {len(filenames)} master sheets.

" + + dl_refresh_btn.click(fn=ui_refresh_dl_list, outputs=[dl_dropdown, dl_status]) + + def ui_prep_download(filename): + if not filename: return gr.update(visible=False), "" + from hf_store import get_hf_credentials + import tempfile + import pandas as pd + from huggingface_hub import hf_hub_download + token, repo = get_hf_credentials() + + try: + local_path = hf_hub_download(repo_id=repo, repo_type="dataset", filename=f"scraped_data/mapped/{filename}", token=token) + df = pd.read_parquet(local_path) + + temp_dir = tempfile.gettempdir() + # Keep the original filename but change extension to .xlsx + excel_filename = filename.replace('.parquet', '.xlsx') + temp_excel = os.path.join(temp_dir, excel_filename) + df.to_excel(temp_excel, index=False) + + return gr.update(value=temp_excel, visible=True), "

✅ Ready to download!

" + except Exception as e: + return gr.update(visible=False), f"

❌ Error preparing download: {e}

" + + dl_dropdown.change(fn=ui_prep_download, inputs=[dl_dropdown], outputs=[dl_download_btn, dl_status]) + + + # ── Tab 5: Master Analytics ──────────────────────────────────────────── + with gr.Tab("📊 Master Analytics"): + gr.HTML(''' +
+
📈 Multi-Year Master Analytics
+
+ This tab automatically loads the 2025 master and every + mapped master sheet ever built. It analyzes how government school footprints and overall school counts shift year over year. +
+
+ ''') + + with gr.Row(): + analytics_refresh_btn = gr.Button("🔄 Run Analytics", variant="primary", scale=2) + analytics_status = gr.HTML() + + gr.HTML("
📅 Master Timeline
") + gr.HTML("
Year-over-year changes across all master sheets compared to 2025.
") + timeline_table = gr.Dataframe(label="Timeline Metrics", interactive=False, wrap=True) + + gr.HTML("
⚠️ Missing Schools (vs 2025)
") + missing_schools_html = gr.HTML() + missing_schools_table = gr.Dataframe(label="Missing Schools Detailed", interactive=False, wrap=True) + + gr.HTML("
🔄 Management Shifts
") + mgmt_shifts_html = gr.HTML() + mgmt_shifts_table = gr.Dataframe(label="Government → Non-Govt Shifts", interactive=False, wrap=True) + + BASE_CSV_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "master_all_states.csv") + + def _comp_detect_year_label(filepath: str) -> str: + import calendar as _cal + import re as _re + import os as _os + name = _os.path.basename(filepath or "") + m = _re.search(r"(\d{4})[-_](\d{2})", name) + if m: + yr, mo = int(m.group(1)), int(m.group(2)) + if 1 <= mo <= 12: + return f"{_cal.month_abbr[mo]} {yr}" + m2 = _re.search(r"(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[_\-]?(\d{4})", name, _re.IGNORECASE) + if m2: + return f"{m2.group(1).capitalize()} {m2.group(2)}" + m3 = _re.search(r"(\d{4})", name) + return m3.group(1) if m3 else name + + def _comp_find_col(df, *keywords): + for k in keywords: + for c in df.columns: + if k in c.lower(): + return c + return None + + def ui_run_analytics(): + from hf_store import get_hf_credentials, list_mapped_master_files, pull_baseline_master + from huggingface_hub import hf_hub_download + import pandas as pd + import os + + token, repo = get_hf_credentials() + if not token or not repo: + return "

⚠️ HF credentials not set.

", pd.DataFrame(), "", pd.DataFrame(), "", pd.DataFrame() + + masters = [] + # 1. Load baseline + try: + base_df = pull_baseline_master(token, repo) + if not base_df.empty: + masters.append({"label": "2025", "df": base_df}) + except Exception: + pass + + # Fallback to CSV if HF fails + if not masters and os.path.exists(BASE_CSV_PATH): + masters.append({"label": "2025", "df": pd.read_csv(BASE_CSV_PATH, low_memory=False)}) + + # 2. Pull HF mapped masters + hf_files = list_mapped_master_files(token, repo) + for hf_path in sorted(hf_files): + try: + local = hf_hub_download(repo_id=repo, repo_type="dataset", filename=hf_path, token=token, force_download=True) + label = _comp_detect_year_label(hf_path) + masters.append({"label": label, "df": pd.read_parquet(local)}) + except Exception: + pass + + if not masters: + return "

⚠️ No master datasets found.

", pd.DataFrame(), "", pd.DataFrame(), "", pd.DataFrame() + + def _norm_udise(s): + return s.astype(str).str.strip().str.zfill(11) + + def is_govt(s): + val = str(s).lower() + if 'aided' in val or 'private' in val or 'unaided' in val or 'un-aided' in val: + return False + return 'gov' in val or 'dept of edu' in val or 'local body' in val or 'panchayat' in val or 'municipal' in val + + baseline = masters[0] + b_udise = _comp_find_col(baseline["df"], "udise") + b_mgmt = _comp_find_col(baseline["df"], "school_management_type", "management", "mgmt") + if b_udise: baseline["df"]["_U"] = _norm_udise(baseline["df"][b_udise]) + if b_mgmt: baseline["df"]["is_govt"] = baseline["df"][b_mgmt].apply(is_govt) + + timeline_data = [] + baseline_udises = set(baseline["df"]["_U"].dropna()) if b_udise else set() + + missing_df = pd.DataFrame() + changed_mgmt = pd.DataFrame() + + for m in masters: + m_df = m["df"] + m_udise = _comp_find_col(m_df, "udise") + m_mgmt = _comp_find_col(m_df, "school_management_type", "management", "mgmt") + m_name = _comp_find_col(m_df, "school_name", "schoolname") + m_state = _comp_find_col(m_df, "state") + + if m_udise: m_df["_U"] = _norm_udise(m_df[m_udise]) + if m_mgmt: m_df["is_govt"] = m_df[m_mgmt].apply(is_govt) + + tot_schools = len(m_df) + tot_govt = m_df["is_govt"].sum() if m_mgmt else 0 + + missing_count = 0 + shifts_count = 0 + + if m != baseline and b_udise and m_udise: + m_udises = set(m_df["_U"].dropna()) + missing = baseline_udises - m_udises + missing_count = len(missing) + + if m == masters[-1]: + b_name = _comp_find_col(baseline["df"], "school_name", "schoolname") + b_state = _comp_find_col(baseline["df"], "state") + missing_df = baseline["df"][baseline["df"]["_U"].isin(missing)].drop_duplicates(subset=["_U"]).copy() + + cols_to_keep = [] + for c in ["_U", b_name, b_state, b_mgmt]: + if c and c in missing_df.columns: + cols_to_keep.append(c) + missing_df = missing_df[cols_to_keep] if cols_to_keep else missing_df + missing_df.insert(0, "Missing In Master", m["label"]) + + if b_mgmt and m_mgmt: + b_cols = [c for c in ["_U", b_name, b_mgmt, "is_govt"] if c] + m_cols = [c for c in ["_U", m_name, m_mgmt, "is_govt"] if c] + + merged = pd.merge( + baseline["df"][b_cols], + m_df[m_cols], + on="_U", suffixes=("_old", "_new") + ) + changed = merged[(merged["is_govt_old"] == True) & (merged["is_govt_new"] == False)] + shifts_count = len(changed) + + c_name_old = str(b_name) + '_old' if b_name == m_name else b_name + c_mgmt_old = str(b_mgmt) + '_old' if b_mgmt == m_mgmt else b_mgmt + c_mgmt_new = str(m_mgmt) + '_new' if b_mgmt == m_mgmt else m_mgmt + + c_cols = [c for c in ["_U", c_name_old, c_mgmt_old, c_mgmt_new] if c and c in changed.columns] + changed_mgmt = changed[c_cols] if c_cols else changed + changed_mgmt.insert(0, "Shifted In Master", m["label"]) + + timeline_data.append({ + "Master Name": m["label"], + "Total Schools": f"{tot_schools:,}", + "Total Govt Schools": f"{tot_govt:,}", + "Missing (vs 2025)": f"{missing_count:,}" if m != baseline else "-", + "Govt->Non-Govt Shifts": f"{shifts_count:,}" if m != baseline else "-" + }) + + timeline_df = pd.DataFrame(timeline_data) + + status_html = f"

✅ Loaded {len(masters)} master datasets successfully.

" + m_html = f"
{len(missing_df):,} schools from 2025 are completely missing in the newest master.
" + c_html = f"
{len(changed_mgmt):,} schools shifted from Govt to Non-Govt management since 2025.
" + + return status_html, timeline_df, m_html, missing_df, c_html, changed_mgmt + + analytics_refresh_btn.click( + fn=ui_run_analytics, + outputs=[analytics_status, timeline_table, missing_schools_html, missing_schools_table, mgmt_shifts_html, mgmt_shifts_table] + ) + +if __name__ == "__main__": + if os.environ.get("SPACE_ID"): + print("Starting on Dataset Spaces (0.0.0.0:7860) …") + app.queue().launch(server_name="0.0.0.0", server_port=7860) + else: + print("Starting locally …") + app.queue().launch(server_name="127.0.0.1", server_port=7861, inbrowser=True)