Spaces:
Runtime error
Runtime error
| """ | |
| app.py — HF Spaces web app for Gene Function Lab. | |
| CPU only — no model, no torch. | |
| Password protected via APP_PASSWORD secret. | |
| Uses URL token auth (works inside HF Spaces iframe). | |
| """ | |
| import io | |
| import os | |
| import time | |
| import traceback | |
| from flask import Flask, jsonify, request, send_from_directory, Response, redirect | |
| import db as database | |
| import drive_sync | |
| app = Flask(__name__, template_folder="templates") | |
| #Password config | |
| APP_PASSWORD = os.environ.get("APP_PASSWORD", "").strip() | |
| COOKIE_NAME = "gfl_token" | |
| COOKIE_MAXAGE = 60 * 60 * 24 * 7 # 7 days | |
| def _check_auth() -> bool: | |
| if not APP_PASSWORD: | |
| return True | |
| # Check URL token, form token, or cookie | |
| return ( | |
| request.args.get("token", "") == APP_PASSWORD or | |
| request.form.get("token", "") == APP_PASSWORD or | |
| request.cookies.get(COOKIE_NAME, "") == APP_PASSWORD or | |
| request.headers.get("X-Auth-Token", "") == APP_PASSWORD | |
| ) | |
| def require_auth(): | |
| if not _check_auth(): | |
| if request.path.startswith("/api/"): | |
| return jsonify({"error": "Unauthorized"}), 401 | |
| return redirect("/login") | |
| return None | |
| # Startup | |
| print("[Startup] Syncing DB from Drive...") | |
| try: | |
| synced = drive_sync.sync_db_from_drive(force=True) | |
| print(f"[Startup] Sync result: {synced}") | |
| except Exception as e: | |
| print(f"[Startup] Sync error: {e}") | |
| print(traceback.format_exc()) | |
| try: | |
| database.init_db(drive_sync.LOCAL_DB_PATH) | |
| database.DB_PATH = drive_sync.LOCAL_DB_PATH | |
| except Exception as e: | |
| print(f"[Startup] DB init error: {e}") | |
| try: | |
| drive_sync.start_background_sync() | |
| except Exception as e: | |
| print(f"[Startup] Background sync warning: {e}") | |
| def _local_db(): | |
| return drive_sync.LOCAL_DB_PATH | |
| # Force db module to use the same path | |
| database.DB_PATH = drive_sync.LOCAL_DB_PATH | |
| def _ensure_db_schema(): | |
| database.init_db(_local_db()) | |
| def _sort_genes_for_display(genes, summaries, gene_sort): | |
| def functional_pct(g): | |
| summary = summaries.get(g, {}) | |
| total = summary.get("total") or 0 | |
| return (summary.get("functional") or 0) / total if total else 0 | |
| if gene_sort == "gene_asc": | |
| return sorted(genes) | |
| if gene_sort == "functional_desc": | |
| return sorted(genes, key=lambda g: (-(summaries.get(g, {}).get("functional") or 0), g)) | |
| if gene_sort == "functional_pct_desc": | |
| return sorted(genes, key=lambda g: (-functional_pct(g), -(summaries.get(g, {}).get("functional") or 0), g)) | |
| if gene_sort == "total_desc": | |
| return sorted(genes, key=lambda g: (-(summaries.get(g, {}).get("total") or 0), g)) | |
| if gene_sort == "support_desc": | |
| return sorted(genes, key=lambda g: (-(summaries.get(g, {}).get("support_avg") or 0), g)) | |
| return list(genes) | |
| # Login page | |
| LOGIN_HTML = """<!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Gene Function Lab — Login</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> | |
| <style> | |
| *{box-sizing:border-box;margin:0;padding:0;} | |
| body{min-height:100vh;background:#f4f5f7;display:flex;align-items:center;justify-content:center;font-family:'Inter',sans-serif;} | |
| .card{background:#ffffff;border:1px solid #e5e7eb;border-radius:14px;padding:40px;width:100%;max-width:380px;box-shadow:0 2px 16px rgba(0,0,0,.06);} | |
| .logo{font-size:18px;font-weight:700;color:#111827;letter-spacing:-.01em;margin-bottom:4px;} | |
| .logo em{color:#2563eb;font-style:normal;} | |
| .sub{font-size:13px;color:#6b7280;margin-bottom:28px;} | |
| label{font-size:13px;font-weight:500;color:#111827;display:block;margin-bottom:6px;} | |
| input[type=password]{width:100%;background:#f4f5f7;border:1px solid #e5e7eb;color:#111827;font-family:'Inter',sans-serif;font-size:14px;padding:10px 13px;border-radius:8px;outline:none;transition:border-color .15s,box-shadow .15s;} | |
| input[type=password]:focus{border-color:#2563eb;box-shadow:0 0 0 3px rgba(37,99,235,.1);background:#fff;} | |
| input[type=password]::placeholder{color:#9ca3af;} | |
| button{width:100%;margin-top:14px;padding:11px;background:#2563eb;color:#fff;border:none;border-radius:8px;font-family:'Inter',sans-serif;font-size:14px;font-weight:500;cursor:pointer;transition:background .15s;} | |
| button:hover{background:#1d4ed8;} | |
| .err{margin-top:12px;padding:11px 14px;background:#fef2f2;border:1px solid #fca5a5;border-radius:8px;font-size:13px;color:#dc2626;display:none;} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="card"> | |
| <div class="logo">Gene Function <em>Lab</em></div> | |
| <div class="sub">Lab access only — enter the password to continue</div> | |
| <form id="loginForm"> | |
| <label>Password</label> | |
| <input type="password" id="pwd" autofocus placeholder="Enter lab password"> | |
| <button type="submit">Enter Lab</button> | |
| <div class="err" id="err"></div> | |
| </form> | |
| </div> | |
| <script> | |
| document.getElementById('loginForm').addEventListener('submit', async function(e) { | |
| e.preventDefault(); | |
| const pwd = document.getElementById('pwd').value.trim(); | |
| const err = document.getElementById('err'); | |
| err.style.display = 'none'; | |
| try { | |
| const r = await fetch('/api/auth', { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/json'}, | |
| body: JSON.stringify({password: pwd}) | |
| }); | |
| const d = await r.json(); | |
| if (d.ok) { | |
| // Store token in sessionStorage and redirect | |
| sessionStorage.setItem('gfl_token', pwd); | |
| window.location.href = '/?token=' + encodeURIComponent(pwd); | |
| } else { | |
| err.textContent = 'Incorrect password. Try again.'; | |
| err.style.display = 'block'; | |
| } | |
| } catch(ex) { | |
| err.textContent = 'Error connecting. Try again.'; | |
| err.style.display = 'block'; | |
| } | |
| }); | |
| </script> | |
| </body> | |
| </html>""" | |
| def login(): | |
| return LOGIN_HTML | |
| def api_auth(): | |
| """Check password — called by login page JS.""" | |
| body = request.get_json(force=True) or {} | |
| pwd = body.get("password", "").strip() | |
| if not APP_PASSWORD: | |
| return jsonify({"ok": True}) | |
| if pwd == APP_PASSWORD: | |
| return jsonify({"ok": True}) | |
| return jsonify({"ok": False}), 401 | |
| # Routes | |
| def index(): | |
| auth = require_auth() | |
| if auth: return auth | |
| return send_from_directory("templates", "index.html") | |
| def api_stats(): | |
| auth = require_auth() | |
| if auth: return auth | |
| try: | |
| _ensure_db_schema() | |
| stats = database.db_stats(_local_db()) | |
| stats["last_sync"] = time.strftime( | |
| "%Y-%m-%d %H:%M UTC", time.gmtime(drive_sync._last_sync) | |
| ) if drive_sync._last_sync else "never" | |
| return jsonify(stats) | |
| except Exception as e: | |
| return jsonify({ | |
| "total_papers": 0, "total_genes": 0, "functional_papers": 0, | |
| "top_genes": [], "all_genes": [], "last_sync": "error", "error": str(e) | |
| }) | |
| def api_query(): | |
| auth = require_auth() | |
| if auth: return auth | |
| genes_raw = request.args.get("genes", "").strip() | |
| min_conf = float(request.args.get("min_conf", 0.0)) | |
| cancer_type = request.args.get("cancer_type", "all") | |
| functional = request.args.get("functional", "all") | |
| review_status = request.args.get("review_status", "all") | |
| gene_sort = request.args.get("gene_sort", "selected") | |
| paper_sort = request.args.get("paper_sort", "support_desc") | |
| page = max(1, int(request.args.get("page", 1))) | |
| per_page = min(100, int(request.args.get("per_page", 20))) | |
| if not genes_raw: | |
| return jsonify({"error": "genes parameter is required"}), 400 | |
| genes = [g.strip().upper() for g in genes_raw.split(",") if g.strip()] | |
| try: | |
| _ensure_db_schema() | |
| summaries = { | |
| g: database.gene_summary(g, min_conf=min_conf, db_path=_local_db()) | |
| for g in genes | |
| } | |
| sorted_genes = _sort_genes_for_display(genes, summaries, gene_sort) | |
| result = database.query_papers( | |
| genes=sorted_genes, cancer_type=cancer_type, functional=functional, | |
| review_status=review_status, | |
| min_conf=min_conf, page=page, per_page=per_page, | |
| paper_sort=paper_sort, db_path=_local_db(), | |
| ) | |
| result["summaries"] = summaries | |
| result["genes_order"] = sorted_genes | |
| return jsonify(result) | |
| except Exception as e: | |
| return jsonify({"error": str(e), "rows": [], "total": 0, "summaries": {}}), 200 | |
| def api_review(): | |
| auth = require_auth() | |
| if auth: return auth | |
| body = request.get_json(force=True) or {} | |
| gene = (body.get("gene") or "").strip().upper() | |
| pmid = str(body.get("pmid") or "").strip() | |
| if not gene or not pmid: | |
| return jsonify({"error": "gene and pmid are required"}), 400 | |
| try: | |
| _ensure_db_schema() | |
| row = database.update_paper_review( | |
| gene=gene, | |
| pmid=pmid, | |
| review_status=body.get("review_status", "unreviewed"), | |
| review_label=body.get("review_label", ""), | |
| review_notes=body.get("review_notes", ""), | |
| reviewed_by=body.get("reviewed_by", request.remote_addr or ""), | |
| db_path=_local_db(), | |
| ) | |
| if row is None: | |
| return jsonify({"error": "paper not found"}), 404 | |
| uploaded = False | |
| upload_error = "" | |
| try: | |
| uploaded = drive_sync.upload_db_to_drive(_local_db()) | |
| except Exception as e: | |
| upload_error = str(e) | |
| return jsonify({"ok": True, "row": row, "uploaded": uploaded, "upload_error": upload_error}) | |
| except ValueError as e: | |
| return jsonify({"error": str(e)}), 400 | |
| except Exception as e: | |
| return jsonify({"error": str(e)}), 500 | |
| def api_check(): | |
| auth = require_auth() | |
| if auth: return auth | |
| genes_raw = request.args.get("genes", "").strip() | |
| if not genes_raw: | |
| return jsonify({"error": "genes parameter required"}), 400 | |
| genes = [g.strip().upper() for g in genes_raw.split(",") if g.strip()] | |
| result = {} | |
| _ensure_db_schema() | |
| for gene in genes: | |
| in_db = database.gene_is_processed(gene, _local_db()) | |
| q_status = database.get_queue_status(gene, _local_db()) | |
| result[gene] = { | |
| "in_db": in_db, | |
| "queue_status": q_status, | |
| "summary": database.gene_summary(gene, db_path=_local_db()) if in_db else None, | |
| } | |
| return jsonify(result) | |
| def api_request(): | |
| auth = require_auth() | |
| if auth: return auth | |
| body = request.get_json(force=True) or {} | |
| gene = (body.get("gene") or "").strip().upper() | |
| max_papers = int(body.get("max_papers", 300)) | |
| if not gene: | |
| return jsonify({"error": "gene is required"}), 400 | |
| if not gene.replace("-","").replace("_","").isalnum() or len(gene) > 20: | |
| return jsonify({"error": "Invalid gene name"}), 400 | |
| _ensure_db_schema() | |
| if database.gene_is_processed(gene, _local_db()): | |
| return jsonify({ | |
| "status": "already_processed", "gene": gene, | |
| "summary": database.gene_summary(gene, db_path=_local_db()), | |
| "message": f"{gene} is already in the database. Search it to see results." | |
| }) | |
| existing = database.get_queue_status(gene, _local_db()) | |
| if existing and existing["status"] in ("pending", "processing"): | |
| return jsonify({ | |
| "status": existing["status"], "gene": gene, "queue": existing, | |
| "message": f"{gene} is already {existing['status']}. Check back soon." | |
| }) | |
| try: | |
| entry = drive_sync.write_queue_entry_to_drive( | |
| gene=gene, requested_by=request.remote_addr or "", max_papers=max_papers | |
| ) | |
| return jsonify({ | |
| "status": "queued", "gene": gene, "queue": entry, | |
| "message": f"{gene} has been queued. Results will appear once the pipeline runs." | |
| }) | |
| except Exception as e: | |
| return jsonify({"error": f"Could not queue request: {e}"}), 500 | |
| def api_queue(): | |
| auth = require_auth() | |
| if auth: return auth | |
| try: | |
| _ensure_db_schema() | |
| queue = database.get_all_queue(_local_db()) | |
| pending = [q for q in queue if q["status"] == "pending"] | |
| return jsonify({"queue": queue, "pending_count": len(pending)}) | |
| except Exception as e: | |
| return jsonify({"queue": [], "pending_count": 0, "error": str(e)}) | |
| def api_export(): | |
| auth = require_auth() | |
| if auth: return auth | |
| genes_raw = request.args.get("genes", "").strip() | |
| min_conf = float(request.args.get("min_conf", 0.0)) | |
| cancer_type = request.args.get("cancer_type", "all") | |
| functional = request.args.get("functional", "all") | |
| review_status = request.args.get("review_status", "all") | |
| gene_sort = request.args.get("gene_sort", "selected") | |
| paper_sort = request.args.get("paper_sort", "support_desc") | |
| genes = [g.strip().upper() for g in genes_raw.split(",") if g.strip()] if genes_raw else None | |
| try: | |
| _ensure_db_schema() | |
| if genes: | |
| summaries = { | |
| g: database.gene_summary(g, min_conf=min_conf, db_path=_local_db()) | |
| for g in genes | |
| } | |
| genes = _sort_genes_for_display(genes, summaries, gene_sort) | |
| df = database.export_to_df( | |
| genes=genes, cancer_type=cancer_type, | |
| functional=functional, review_status=review_status, | |
| min_conf=min_conf, paper_sort=paper_sort, db_path=_local_db(), | |
| ) | |
| except Exception as e: | |
| return jsonify({"error": str(e)}), 500 | |
| buf = io.StringIO() | |
| df.to_csv(buf, index=False) | |
| buf.seek(0) | |
| fname = f"functional_papers_{'_'.join(genes[:3]) if genes else 'all'}.csv" | |
| return Response( | |
| buf.getvalue(), mimetype="text/csv", | |
| headers={"Content-Disposition": f"attachment; filename={fname}"} | |
| ) | |
| def api_export_gene_summary(): | |
| auth = require_auth() | |
| if auth: return auth | |
| genes_raw = request.args.get("genes", "").strip() | |
| min_conf = float(request.args.get("min_conf", 0.0)) | |
| gene_sort = request.args.get("gene_sort", "selected") | |
| genes = [g.strip().upper() for g in genes_raw.split(",") if g.strip()] if genes_raw else None | |
| try: | |
| _ensure_db_schema() | |
| if genes: | |
| summaries = { | |
| g: database.gene_summary(g, min_conf=min_conf, db_path=_local_db()) | |
| for g in genes | |
| } | |
| genes = _sort_genes_for_display(genes, summaries, gene_sort) | |
| df = database.export_gene_summary_to_df( | |
| genes=genes, | |
| min_conf=min_conf, | |
| db_path=_local_db(), | |
| ) | |
| except Exception as e: | |
| return jsonify({"error": str(e)}), 500 | |
| buf = io.StringIO() | |
| df.to_csv(buf, index=False) | |
| buf.seek(0) | |
| fname = f"gene_summary_{'_'.join(genes[:3]) if genes else 'all'}.csv" | |
| return Response( | |
| buf.getvalue(), mimetype="text/csv", | |
| headers={"Content-Disposition": f"attachment; filename={fname}"} | |
| ) | |
| def api_sync(): | |
| auth = require_auth() | |
| if auth: return auth | |
| try: | |
| drive_sync._drive_file_id = None | |
| synced = drive_sync.sync_db_from_drive(force=True) | |
| _ensure_db_schema() | |
| return jsonify({"synced": synced, "message": "DB refreshed from Drive."}) | |
| except Exception as e: | |
| return jsonify({"synced": False, "error": str(e)}), 500 | |
| if __name__ == "__main__": | |
| port = int(os.environ.get("PORT", 7860)) | |
| app.run(host="0.0.0.0", port=port, debug=False, threaded=True) | |