AidAILine / profiles.py
J-Barrert's picture
Upload 14 files
585cfd5 verified
Raw
History Blame Contribute Delete
9.86 kB
"""
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