CoolFace
Apppublic

Samvickid/pubmed-ehr

sourceHugging Faceupdated 3mo agoView on Hugging Face
0likes
app.py619 linesDownload Raw Back to root
1"""PubMed Center — EHR System (System 2 / port 8082)2Patient records, clinical notes, REST API.3"""4import json, os, hashlib, uuid, time, secrets, string, urllib.request, urllib.error5from flask import Flask, render_template, request, jsonify, redirect, url_for, make_response6from functools import wraps7from jinja2 import Undefined8from clinical_normalize import reconcile_record, conditions_from_label9 10 11def _empty(*args, **kwargs):12    """Callable that swallows any args and renders blank — for missing fields."""13    return ""14 15 16class SilentUndefined(Undefined):17    """Render missing patient-record fields as blank instead of raising 500.18 19    Some records (digitized notes, older imports) lack optional fields like20    `prescribed_by` or `start_date`. The templates call `.replace(...)` / slice21    those fields, which raises UndefinedError on the default Undefined. This22    makes such accesses degrade to an empty string.23    """24    def __getattr__(self, name):25        return _empty26 27    def __getitem__(self, key):28        return ""29 30    def __iter__(self):31        return iter(())32 33    def __contains__(self, item):34        return False35 36    def __str__(self):37        return ""38 39 40app = Flask(__name__, template_folder="templates", static_folder="static")41app.jinja_env.undefined = SilentUndefined42app.secret_key = os.environ.get("FLASK_SECRET_KEY") or os.urandom(32).hex()43 44CLINIC_NAME = "PubMed Center"45CLINIC_CODE = "PMC001"46MANAGER_PASSWORD = "1234567"47STAFF_PIN = "SAM001"48 49# Staff can login with their code + PIN (12345)50# Managers login with their code + password (1234567) for API key access51EMPLOYEES = {52    "SAM001": {"name": "Dr. Samuel Alobo", "role": "Chief Physician & Clinical Director", "dept": "Cardiology"},53    "JDO001": {"name": "Dr. Jane Doe", "role": "Attending Physician", "dept": "Neurology"},54    "RMI001": {"name": "Dr. Robert Miller", "role": "Research Coordinator", "dept": "Oncology"},55    "NUR001": {"name": "Sarah Johnson", "role": "Head Nurse", "dept": "Emergency"},56    "ADM001": {"name": "Michael Chen", "role": "Administrator", "dept": "Admin"},57}58# All employees also have a short code: pmc001, pmc002, etc.59STAFF_SHORT_CODES = {}60for i, (code, data) in enumerate(EMPLOYEES.items(), 1):61    short = f"pmc{i:03d}"62    STAFF_SHORT_CODES[short.upper()] = code63 64MANAGER_HASH = hashlib.sha256(MANAGER_PASSWORD.encode()).hexdigest()65PIN_HASH = hashlib.sha256(STAFF_PIN.encode()).hexdigest()66IMAGES = [67    "https://images.unsplash.com/photo-1551076805-e1869033e561?w=1920&q=80",68    "https://images.unsplash.com/photo-1559839734-2b71ea197ec2?w=1920&q=80",69    "https://images.unsplash.com/photo-1576091160399-112ba8d25d1d?w=1920&q=80",70    "https://images.unsplash.com/photo-1511174511562-5f7f185854c8?w=1920&q=80",71    "https://images.unsplash.com/photo-1558494949-ef010cbdcc31?w=1920&q=80",72    "https://images.unsplash.com/photo-1579684385127-1ef15d508118?w=1920&q=80",73    "https://images.unsplash.com/photo-1588776814546-1ffcf47267a5?w=1920&q=80",74    "https://images.unsplash.com/photo-1582750433449-648ed127bb54?w=1920&q=80",75    "https://images.unsplash.com/photo-1631217868264-e5b90bb7e133?w=1920&q=80",76    "https://images.unsplash.com/photo-1559757175-5700dde675bc?w=1920&q=80",77]78SESSIONS = {}79NOTES = {}  # patient_id -> list of notes80 81# ─── Local API Key Management ───82API_KEYS_FILE = os.path.join(os.path.dirname(__file__), "api_keys.json")83 84def load_api_keys():85    if os.path.exists(API_KEYS_FILE):86        try:87            with open(API_KEYS_FILE, "r") as f:88                return json.load(f)89        except:90            return {}91    return {}92 93def save_api_keys(keys):94    with open(API_KEYS_FILE, "w") as f:95        json.dump(keys, f, indent=2)96 97def generate_api_key():98    random_part = ''.join(secrets.choice(string.ascii_letters + string.digits) for _ in range(48))99    return "ehr_" + random_part100 101def get_local_api_keys():102    keys = load_api_keys()103    result = []104    for kid, kdata in keys.items():105        result.append({106            "id": kid,107            "name": kdata.get("name", "Unnamed"),108            "prefix": kdata.get("key", "")[:10] + "...",109            "created": kdata.get("created_at", ""),110            "last_used": kdata.get("last_used", ""),111            "revoked": kdata.get("revoked", False)112        })113    return sorted(result, key=lambda x: x["created"], reverse=True)114 115# ─── Load patient data ───116DATA_DIR = os.path.join(os.path.dirname(__file__), "data")117PATIENTS = []118RICH_DATA = {}119 120def load_patients():121    global PATIENTS, RICH_DATA122    path = os.path.join(DATA_DIR, "sample_1000.json")123    if os.path.exists(path):124        try:125            with open(path, "r") as f:126                PATIENTS = json.load(f)127            for p in PATIENTS:128                p["clinic"] = "PMC001"129        except:130            PATIENTS = []131    # Load rich patient data (medications, procedures, labs, vitals, follow-ups)132    rich_path = os.path.join(os.path.dirname(__file__), "..", "hr_system", "rich_patient_data.json")133    if os.path.exists(rich_path):134        try:135            with open(rich_path, "r") as f:136                RICH_DATA = json.load(f)137        except:138            RICH_DATA = {}139 140load_patients()141 142def require_auth(f):143    @wraps(f)144    def decorated(*args, **kwargs):145        token = request.cookies.get("session_token") or request.headers.get("Authorization", "").replace("Bearer ", "")146        session = SESSIONS.get(token)147        if not session:148            if request.headers.get("X-API-Key") == "pk_test_pubmed_center_2026":149                return f(*args, **kwargs)150            return redirect(url_for("login"))151        return f(*args, **kwargs)152    return decorated153 154def get_patient_by_id(pid):155    for p in PATIENTS:156        if str(p.get("patient_id","")) == str(pid) or str(p.get("patient_uid","")) == str(pid):157            return p158    return None159 160def format_age(ages):161    if not ages:162        return "Unknown"163    val = ages[0][0]164    unit = ages[0][1]165    return f"{int(val)} {unit}{'s' if val > 1 else ''}"166 167@app.route("/")  168@require_auth169def dashboard():170    token = request.cookies.get("session_token", "")171    session = SESSIONS.get(token, {})172    173    # Employees go to restricted patient search, managers see full dashboard174    if not session.get("is_manager"):175        return redirect(url_for("patients"))176    177    total = len(PATIENTS)178    gender_count = {"M": 0, "F": 0, "U": 0}179    for p in PATIENTS:180        g = p.get("gender", "U")181        gender_count[g] = gender_count.get(g, 0) + 1182    age_groups = {"0-18": 0, "19-40": 0, "41-65": 0, "66+": 0}183    for p in PATIENTS:184        ages = p.get("age", [])185        if ages and len(ages) > 0:186            years = ages[0][0] if ages[0][1] == "year" else ages[0][0] / 12187            if years <= 18: age_groups["0-18"] += 1188            elif years <= 40: age_groups["19-40"] += 1189            elif years <= 65: age_groups["41-65"] += 1190            else: age_groups["66+"] += 1191    recent = PATIENTS[:5] if PATIENTS else []192    recent_list = []193    for p in recent:194        recent_list.append({195            "id": p.get("patient_id", ""),196            "title": (p.get("title", "") or "")[:80],197            "gender": p.get("gender", "U"),198            "age": format_age(p.get("age", []))199        })200    return render_template("dashboard.html", total=total, gender_count=gender_count,201        age_groups=age_groups, recent=recent_list, user=session, images=IMAGES)202 203@app.route("/dashboard")204@require_auth205def dashboard_redirect():206    return redirect(url_for("dashboard"))207 208@app.route("/login", methods=["GET", "POST"])209def login():210    if request.method == "POST":211        code_input = request.form.get("emp_code", "").strip().upper()212        password_input = request.form.get("password", "").strip()213        214        # Map short codes (pmc001, pmc002) to employee codes215        emp_code = STAFF_SHORT_CODES.get(code_input, code_input)216        217        emp = EMPLOYEES.get(emp_code)218        if not emp:219            return render_template("login.html", error="Employee not found.")220        221        pw_hash = hashlib.sha256(password_input.encode()).hexdigest()222        223        # Staff: login with PIN 12345224        # Managers: login with password 1234567 (needed for API key access)225        is_manager = (pw_hash == MANAGER_HASH)226        is_staff = (pw_hash == PIN_HASH)227        228        if not is_manager and not is_staff:229            return render_template("login.html", error="Invalid credentials. Staff use password SAM001. Managers use password 1234567.")230        231        session_role = emp["role"]232        if is_manager:233            session_role = f"{emp['role']} (Manager)"234        235        token = hashlib.sha256(f"{emp_code}:{password_input}:{uuid.uuid4()}:{time.time()}".encode()).hexdigest()[:32]236        SESSIONS[token] = {237            "clinic_code": CLINIC_CODE,238            "employee_id": emp_code,239            "name": emp["name"],240            "role": session_role,241            "is_manager": is_manager,242            "login_time": time.time()243        }244        resp = make_response(redirect(url_for("dashboard")))245        resp.set_cookie("session_token", token, max_age=86400, httponly=True, samesite="Lax")246        return resp247    248    # GET — always show fresh login, clear any stale session249    stale_token = request.cookies.get("session_token", "")250    if stale_token in SESSIONS:251        del SESSIONS[stale_token]252    resp = make_response(render_template("login.html"))253    resp.set_cookie("session_token", "", expires=0)254    return resp255 256@app.route("/logout")257def logout():258    """Clear session and redirect to login."""259    token = request.cookies.get("session_token", "")260    if token in SESSIONS:261        del SESSIONS[token]262    resp = make_response(redirect(url_for("login")))263    resp.set_cookie("session_token", "", expires=0)264    return resp265 266# ─── API Key Management (for clinic staff) ───267@app.route("/settings/api-keys")268@require_auth269def api_keys_page():270    token = request.cookies.get("session_token", "")271    session = SESSIONS.get(token, {})272    keys = get_local_api_keys()273    return render_template("api_keys.html", keys=keys, user=session)274 275@app.route("/api/keys/generate", methods=["POST"])276@require_auth277def generate_api_key_route():278    name = request.form.get("name", "EHR API Key").strip()279    keys = load_api_keys()280    kid = hashlib.sha256(f"{uuid.uuid4()}:{time.time()}:{secrets.token_hex(8)}".encode()).hexdigest()[:12]281    raw_key = generate_api_key()282    keys[kid] = {283        "name": name,284        "key": raw_key,285        "created_at": time.strftime("%Y-%m-%d %H:%M:%S"),286        "last_used": "",287        "revoked": False288    }289    save_api_keys(keys)290    resp = make_response(redirect(url_for("api_keys_page")))291    resp.set_cookie("flash_key", raw_key, max_age=30)292    return resp293 294@app.route("/api/keys/revoke/<kid>", methods=["POST"])295@require_auth296def revoke_api_key(kid):297    keys = load_api_keys()298    if kid in keys:299        keys[kid]["revoked"] = True300        save_api_keys(keys)301    return redirect(url_for("api_keys_page"))302 303@app.route("/patients")304@require_auth305def patients():306    token = request.cookies.get("session_token", "")307    session = SESSIONS.get(token, {})308    if not session:309        return redirect(url_for("login"))310    311    page = request.args.get("page", 1, type=int)312    per_page = 20313    search = request.args.get("q", "").strip().lower()314    filtered = PATIENTS315    if search:316        filtered = [p for p in PATIENTS if search in (p.get("patient","") or "").lower() or search in (p.get("title","") or "").lower() or search in str(p.get("patient_id",""))]317    total = len(filtered)318    pages = max(1, (total + per_page - 1) // per_page)319    page = max(1, min(page, pages))320    start = (page-1)*per_page321    end = start+per_page322    page_list = []323    for p in filtered[start:end]:324        page_list.append({325            "id": p.get("patient_id",""),326            "uid": p.get("patient_uid",""),327            "title": (p.get("title","") or "")[:120],328            "gender": p.get("gender","U"),329            "age": format_age(p.get("age",[])),330            "note_preview": (p.get("patient","") or "")[:200],331        })332    333    # Employees see restricted search, managers see full records334    if session.get("is_manager"):335        return render_template("patients.html", patients=page_list, page=page, pages=pages, total=total, q=search, user=session)336    else:337        return render_template("employee_search.html", patients=page_list, page=page, pages=pages, total=total, q=search, user=session)338 339@app.route("/patient/<pid>")340@require_auth341def patient_detail(pid):342    p = get_patient_by_id(pid)343    if not p:344        return "Patient not found", 404345    patient_notes = NOTES.get(str(p.get("patient_id","")), [])346    token = request.cookies.get("session_token", "")347    session = SESSIONS.get(token, {})348    # Get rich patient data (medications, procedures, labs, vitals, follow-ups)349    rich = RICH_DATA.get(str(p.get("patient_number","")), {})350    return render_template("patient_detail.html", patient=p, notes=patient_notes,351        age=format_age(p.get("age",[])), user=session, rich=rich)352 353@app.route("/api/public/patient/<pid>")354def public_patient(pid):355    """Public read-only patient view — serves full rich data including medications, procedures, labs, vitals, follow-ups."""356    p = get_patient_by_id(pid)357    if not p:358        return jsonify({"error":"Patient not found"}), 404359    # Include rich data360    rich = RICH_DATA.get(str(p.get("patient_number","")), {})361    # Consistency guard: structured diagnoses must always be DERIVED from the362    # canonical condition label, so the EHR record can never contradict itself363    # (and RISKA can never cite a disease the patient doesn't have). Clinic-364    # agnostic — see clinical_normalize.reconcile_record.365    condition = p.get("condition", "N/A")366    derived_conditions = conditions_from_label(condition) or rich.get("active_conditions", [])367    return jsonify({368        "patient_id": p.get("patient_id",""),369        "patient_uid": p.get("patient_uid",""),370        "name": p.get("name","Unknown"),371        "age": format_age(p.get("age",[])),372        "gender": p.get("gender","U"),373        "condition": condition,374        "title": (p.get("title","") or "")[:200],375        "notes": (p.get("patient","") or "")[:1000],376        "medications": rich.get("medications", []),377        "procedures": rich.get("procedures", []),378        "lab_results": rich.get("lab_results", []),379        "vitals_history": rich.get("vitals_history", []),380        "follow_ups": rich.get("follow_ups", []),381        "allergies": rich.get("allergies", []),382        "insurance": rich.get("insurance", {}),383        "active_conditions": derived_conditions,384        "diagnoses": derived_conditions,385        "recent_labs": [{"name": k, "value": v} for lab in rich.get("lab_results", [])[:3] for k, v in lab.get("results", {}).items()],386        "appointment_history": rich.get("appointment_history", []),387        "social_factors": rich.get("social_factors", []),388        "risk_tier": rich.get("risk_tier", p.get("risk_tier", "UNASSIGNED")),389        "risk_score": rich.get("risk_score", p.get("risk_score", 0))390    })391 392@app.route("/api/public/risk-distribution")393def public_risk_distribution():394    """Public risk-tier distribution across this clinic's patients.395 396    Clinic-agnostic: RISKA calls the same endpoint for any clinic to learn the397    current risk mix, then uses it to allocate booking slots per tier.398    """399    counts = {}400    # Prefer rich data (same source the public patient API serves), fall back to roster401    source = RICH_DATA.values() if RICH_DATA else PATIENTS402    total = 0403    for rec in source:404        tier = str((rec or {}).get("risk_tier", "UNASSIGNED")).upper()405        counts[tier] = counts.get(tier, 0) + 1406        total += 1407    percentages = {t: round(100 * c / total, 1) for t, c in counts.items()} if total else {}408    return jsonify({409        "clinic": "PMC001",410        "total_patients": total,411        "distribution": counts,412        "percentages": percentages,413    })414 415@app.route("/patient/<pid>/note", methods=["POST"])416@require_auth417def add_note(pid):418    p = get_patient_by_id(pid)419    if not p:420        return jsonify({"error":"Patient not found"}), 404421    content = request.form.get("content","").strip()422    if content:423        pid_str = str(p.get("patient_id",""))424        if pid_str not in NOTES:425            NOTES[pid_str] = []426        token = request.cookies.get("session_token", "")427        session = SESSIONS.get(token, {})428        NOTES[pid_str].append({429            "author": session.get("name","Unknown"),430            "content": content,431            "timestamp": time.strftime("%Y-%m-%d %H:%M:%S")432        })433    return redirect(url_for("patient_detail", pid=pid))434 435# ─── REST API ───436@app.route("/api/patients")437def api_patients():438    if not validate_api_key(request):439        return jsonify({"error":"Unauthorized"}), 401440    page = request.args.get("page", 1, type=int)441    per_page = 20442    q = request.args.get("q","").strip().lower()443    filtered = PATIENTS444    if q:445        filtered = [p for p in PATIENTS if q in (p.get("patient","") or "").lower() or q in (p.get("title","") or "").lower()]446    total = len(filtered)447    pages = max(1, (total+per_page-1)//per_page)448    page = max(1, min(page, pages))449    start = (page-1)*per_page450    end = start+per_page451    result = []452    for p in filtered[start:end]:453        result.append({454            "id": p.get("patient_id",""), "uid": p.get("patient_uid",""),455            "title": (p.get("title","") or "")[:120], "gender": p.get("gender","U"),456            "age": format_age(p.get("age",[])),457            "note_preview": (p.get("patient","") or "")[:200]458        })459    return jsonify({"patients":result, "total":total, "page":page, "pages":pages})460 461@app.route("/api/patient/<pid>")462def api_patient(pid):463    if not validate_api_key(request):464        return jsonify({"error":"Unauthorized"}), 401465    p = get_patient_by_id(pid)466    if not p:467        return jsonify({"error":"Not found"}), 404468    return jsonify({469        "patient": {470            "id": p.get("patient_id",""), "uid": p.get("patient_uid",""),471            "title": p.get("title",""), "gender": p.get("gender","U"),472            "age": format_age(p.get("age",[])),473            "note": p.get("patient",""), "pmid": p.get("PMID","")474        }475    })476 477@app.route("/api/health")478def health():479    return jsonify({"status":"healthy","system":"PubMed Center EHR System","version":"1.0.0","patients":len(PATIENTS)})480 481def validate_api_key(req):482    """Validate API key — checks local EHR keys first, then RISKA portal."""483    api_key = req.headers.get("X-API-Key", "").strip()484    if not api_key:485        return False486    487    # 1. Check local EHR-generated keys488    local_keys = load_api_keys()489    for kdata in local_keys.values():490        if kdata.get("key") == api_key and not kdata.get("revoked"):491            kdata["last_used"] = time.strftime("%Y-%m-%d %H:%M:%S")492            save_api_keys(local_keys)493            return True494    495    # 2. Check against RISKA registration portal496    try:497        payload = json.dumps({"api_key": api_key}).encode()498        validation_req = urllib.request.Request(499            "http://localhost:7071/api/validate-key",500            data=payload,501            headers={"Content-Type": "application/json"}502        )503        resp = urllib.request.urlopen(validation_req, timeout=10)504        result = json.loads(resp.read())505        return result.get("valid", False)506    except (urllib.error.URLError, urllib.error.HTTPError, json.JSONDecodeError, OSError):507        # 3. Fallback: accept the hardcoded demo key508        return api_key == "pk_test_pubmed_center_2026"509 510 511# ─── VERTEX EHR INTEGRATION ───512VERTEX_DATA = {}  # vertex_id -> record513VERTEX_DATA_DIR = os.path.join(os.path.dirname(__file__), "vertex_data")514os.makedirs(VERTEX_DATA_DIR, exist_ok=True)515 516def load_vertex_data():517    """Load all vertex records from disk"""518    records = {}519    if os.path.exists(VERTEX_DATA_DIR):520        for fname in os.listdir(VERTEX_DATA_DIR):521            if fname.endswith(".json"):522                try:523                    with open(os.path.join(VERTEX_DATA_DIR, fname)) as f:524                        rec = json.load(f)525                        records[rec.get("vertex_id", fname)] = rec526                except: pass527    return records528 529def save_vertex_record(vid, data):530    """Save a vertex record to disk"""531    path = os.path.join(VERTEX_DATA_DIR, f"{vid}.json")532    with open(path, "w") as f:533        json.dump(data, f, indent=2)534 535@app.route("/api/vertex/capture", methods=["POST"])536def vertex_capture():537    """Receive captured patient data from Vertex Capture Layer"""538    api_key = request.headers.get("X-API-Key", "")539    if api_key != "pk_test_pubmed_center_2026":540        # Also check local keys541        local_keys = load_api_keys()542        valid = False543        for kdata in local_keys.values():544            if kdata.get("key") == api_key and not kdata.get("revoked"):545                valid = True546                break547        if not valid and not validate_api_key(request):548            return jsonify({"status": "error", "error": "Unauthorized"}), 401549    550    data = request.json or {}551    vertex_record = data.get("vertex_record", data)552    vid = vertex_record.get("vertex_id", f"VTX-{uuid.uuid4().hex[:8].upper()}")553    554    # Add EHR metadata555    vertex_record["ehr_received_at"] = time.strftime("%Y-%m-%d %H:%M:%S")556    vertex_record["ehr_clinic"] = CLINIC_CODE557    vertex_record["ehr_status"] = "active"558    559    # Store in memory + disk560    VERTEX_DATA[vid] = vertex_record561    save_vertex_record(vid, vertex_record)562    563    return jsonify({564        "status": "success",565        "vertex_id": vid,566        "message": "Patient record stored in EHR"567    })568 569@app.route("/vertex")570@require_auth571def vertex_dashboard():572    """Vertex Digitization Dashboard - show captured records"""573    records = load_vertex_data()574    records_list = sorted(records.values(), key=lambda r: r.get("timestamp", ""), reverse=True)575    576    # Calculate pipeline stats577    total_captured = len(records_list)578    total_synced = sum(1 for r in records_list if r.get("ehr_status") == "active")579    580    token = request.cookies.get("session_token", "")581    session = SESSIONS.get(token, {})582    583    return render_template("vertex_dashboard.html", 584        records=records_list[:50],585        total=total_captured,586        synced=total_synced,587        user=session,588        images=IMAGES589    )590 591@app.route("/api/vertex/stats")592def vertex_stats():593    """Get Vertex digitization statistics"""594    records = load_vertex_data()595    records_list = list(records.values())596    total = len(records_list)597    synced = sum(1 for r in records_list if r.get("ehr_status") == "active")598    today = time.strftime("%Y-%m-%d")599    today_count = sum(1 for r in records_list if r.get("timestamp", "").startswith(today))600    601    return jsonify({602        "total_captured": total,603        "synced_to_ehr": synced,604        "captured_today": today_count,605        "phases_completed": {606            "phase1_capture": total,607            "phase2_structure": total,608            "phase3_sync": synced,609            "phase4_accumulation": min(total, 100)  # Progress toward full digitization610        }611    })612 613# ─── End Vertex EHR Integration ───614 615if __name__ == "__main__":616    port = int(os.environ.get("PORT", 8082))617    app.run(host="0.0.0.0", port=port, debug=False)618 619