""" profiles.py — Multi-Patient Care Profile Database Stores individual care profiles (Self, Parent, Child, etc.) Each profile holds legal/clinical baseline data for pre-visit paperwork. """ import json import uuid from copy import deepcopy from pathlib import Path from typing import Optional import config as cfg PROFILES_JSON = cfg.DATA_DIR / "profiles.json" # 50 US states + DC — abbreviations are stored values; labels are "XX — Name" for dropdowns. _US_STATE_NAMES: dict[str, str] = { "AL": "Alabama", "AK": "Alaska", "AZ": "Arizona", "AR": "Arkansas", "CA": "California", "CO": "Colorado", "CT": "Connecticut", "DE": "Delaware", "DC": "District of Columbia", "FL": "Florida", "GA": "Georgia", "HI": "Hawaii", "ID": "Idaho", "IL": "Illinois", "IN": "Indiana", "IA": "Iowa", "KS": "Kansas", "KY": "Kentucky", "LA": "Louisiana", "ME": "Maine", "MD": "Maryland", "MA": "Massachusetts", "MI": "Michigan", "MN": "Minnesota", "MS": "Mississippi", "MO": "Missouri", "MT": "Montana", "NE": "Nebraska", "NV": "Nevada", "NH": "New Hampshire", "NJ": "New Jersey", "NM": "New Mexico", "NY": "New York", "NC": "North Carolina", "ND": "North Dakota", "OH": "Ohio", "OK": "Oklahoma", "OR": "Oregon", "PA": "Pennsylvania", "RI": "Rhode Island", "SC": "South Carolina", "SD": "South Dakota", "TN": "Tennessee", "TX": "Texas", "UT": "Utah", "VT": "Vermont", "VA": "Virginia", "WA": "Washington", "WV": "West Virginia", "WI": "Wisconsin", "WY": "Wyoming", } _US_NAME_TO_ABBR = {name.lower(): abbr for abbr, name in _US_STATE_NAMES.items()} US_STATE_CHOICES: list[tuple[str, str]] = [ (f"{abbr} — {name}", abbr) for abbr, name in sorted(_US_STATE_NAMES.items(), key=lambda item: item[1]) ] def normalize_us_state(state: str) -> Optional[str]: """Return a 2-letter US state code, or None if empty/unrecognized.""" s = (state or "").strip() if not s: return None if len(s) == 2: abbr = s.upper() return abbr if abbr in _US_STATE_NAMES else None return _US_NAME_TO_ABBR.get(s.lower()) PROFILE_TEMPLATE = { "id": "", # UUID, auto-generated "label": "", # User-friendly label (e.g. "Mom", "Self") "full_name": "", # Full Legal Name "dob": "", # Date of Birth (YYYY-MM-DD) "street_address": "", # Composed single-line (legacy + export) "address_line1": "", "address_line2": "", "address_city": "", "address_state": "", "address_zip": "", "phone_number": "", # Patient primary phone number "email": "", # Patient email address "insurance_provider": "", "policy_id": "", "group_id": "", "pcp_name": "", # Primary Care Doctor name "pcp_clinic": "", # Clinic / office name "pcp_address": "", # Composed clinic address (legacy + export) "pcp_address_line1": "", "pcp_address_line2": "", "pcp_address_city": "", "pcp_address_state": "", "pcp_address_zip": "", "pcp_phone": "", # PCP office phone # Care Mode lives on the profile, not the appointment. # "Managing someone else's care" → brief shows a Caregiver header line # "Managing my own care" → brief suppresses the Caregiver line "care_mode": "Managing someone else's care", "caregiver_name": "", # only used when care_mode == "Managing someone else's care" "caregiver_phone": "", # caregiver contact phone (Side B, caregiver mode) "caregiver_email": "", # caregiver contact email (Side B, caregiver mode) # Multiple emergency contacts: list of {"name", "phone", "relationship"} dicts. # The form saves this list; the brief generator renders all non-empty entries. "emergency_contacts": [], # Legacy single-contact fields — kept on disk for backward compat with # profiles saved before the multi-contact refactor. New saves write to # `emergency_contacts` instead. The brief generator falls back to these # if the list is empty. "emergency_contact_name": "", "emergency_contact_phone": "", # Ongoing symptoms tracked on the profile; suggested on Doctor Visit tab. "tracked_symptoms": [], } def get_tracked_symptoms(profile_id: str) -> list[str]: """Return cleaned tracked symptom labels for a profile.""" if not profile_id: return [] p = get_profile(profile_id) or {} raw = p.get("tracked_symptoms") or [] if not isinstance(raw, list): return [] return [str(s).strip() for s in raw if str(s).strip()] def _load_all() -> list[dict]: """Load all profiles from disk.""" if PROFILES_JSON.exists(): try: with open(PROFILES_JSON, "r", encoding="utf-8") as f: return json.load(f) except Exception: pass return [] def _save_all(profiles: list[dict]): """Persist all profiles to disk.""" cfg.DATA_DIR.mkdir(parents=True, exist_ok=True) with open(PROFILES_JSON, "w", encoding="utf-8") as f: json.dump(profiles, f, indent=2) # ── Public API ──────────────────────────────────────────────────────────────── def create_profile(label: str = "", template: Optional[dict] = None) -> dict: """Create a new profile. Returns the created profile dict.""" profiles = _load_all() profile = deepcopy(PROFILE_TEMPLATE) profile["id"] = str(uuid.uuid4()) profile["label"] = label if template: # Merge provided fields without replacing id tid = template.pop("id", None) profile.update(template) if tid: profile["id"] = tid profiles.append(profile) _save_all(profiles) return profile def get_profiles() -> list[dict]: """Return all profiles.""" return _load_all() def get_profile(profile_id: str) -> Optional[dict]: """Return a single profile by id, or None.""" for p in _load_all(): if p["id"] == profile_id: return p return None def update_profile(profile_id: str, updates: dict) -> Optional[dict]: """Update fields on an existing profile. Returns updated profile or None.""" profiles = _load_all() for i, p in enumerate(profiles): if p["id"] == profile_id: # Don't let caller change the id updates.pop("id", None) profiles[i].update(updates) _save_all(profiles) return profiles[i] return None def delete_profile(profile_id: str) -> bool: """Delete a profile by id. Returns True if deleted.""" profiles = _load_all() before = len(profiles) profiles = [p for p in profiles if p["id"] != profile_id] if len(profiles) < before: _save_all(profiles) return True return False def delete_profile_data(profile_id: str): """Remove all tracker data scoped to profile_id.""" import med_tracker import appointment_tracker import food_tracker med_tracker.delete_for_profile(profile_id) appointment_tracker.delete_for_profile(profile_id) food_tracker.delete_for_profile(profile_id) try: import rag_engine rag_engine.delete_for_profile(profile_id) except Exception: pass def profile_display_name(profile: dict) -> str: """Human-readable name for dropdowns and session banner.""" full = (profile.get("full_name") or "").strip() if full: return full return (profile.get("label") or "").strip() or "Unnamed" def get_profile_choices() -> list[tuple[str, str]]: """Return (display name, id) pairs for dropdown population.""" return [(profile_display_name(p), p["id"]) for p in _load_all()] def compose_address( line1: str = "", line2: str = "", city: str = "", state: str = "", zip_code: str = "", legacy: str = "", ) -> str: """Build a multi-line mailing address from structured parts.""" line1 = (line1 or "").strip() line2 = (line2 or "").strip() city = (city or "").strip() state = (state or "").strip() zip_code = (zip_code or "").strip() if not any((line1, line2, city, state, zip_code)) and legacy: return legacy.strip() lines = [] if line1: lines.append(line1) if line2: lines.append(line2) tail = [] if state: tail.append(state.upper() if len(state) == 2 else state) if zip_code: tail.append(zip_code) if city and tail: lines.append(f"{city}, {' '.join(tail)}") elif city: lines.append(city) elif tail: lines.append(" ".join(tail)) return "\n".join(lines) def address_parts(profile: dict, *, pcp: bool = False) -> tuple[str, str, str, str, str]: """Return (line1, line2, city, state, zip) with legacy single-field fallback.""" if pcp: line1 = profile.get("pcp_address_line1", "") line2 = profile.get("pcp_address_line2", "") city = profile.get("pcp_address_city", "") state = profile.get("pcp_address_state", "") zip_code = profile.get("pcp_address_zip", "") legacy = profile.get("pcp_address", "") else: line1 = profile.get("address_line1", "") line2 = profile.get("address_line2", "") city = profile.get("address_city", "") state = profile.get("address_state", "") zip_code = profile.get("address_zip", "") legacy = profile.get("street_address", "") line1, line2, city, state, zip_code = map( lambda v: (v or "").strip(), (line1, line2, city, state, zip_code) ) if not any((line1, line2, city, state, zip_code)) and legacy: return legacy.strip(), "", "", "", "" return line1, line2, city, state, zip_code