Samvickid/pubmed-ehr
0
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 