feat: add Delta Dental Individual (DeltaIndiv) eligibility check flow; fix CCA eligibility false-positive error

CCA step1 was treating the harmless "password set to expire" banner as a
fatal error, closing the browser before the eligibility popup/report
could be captured. Now it short-circuits to success once the patient
popup renders and ignores password-expiration notices.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 15:37:55 -04:00
co-authored by Claude Sonnet 5
parent a5a15d86bc
commit d357edd4a6
15 changed files with 2464 additions and 4 deletions
+1
View File
@@ -4,6 +4,7 @@
.last_credentials
chrome_profile_unitedsco/
chrome_profile_ddma/
chrome_profile_deltaindiv/
chrome_profile_dentaquest/
chrome_profile_masshealth/
chrome_profile_*/
+56
View File
@@ -15,6 +15,7 @@ from selenium_MHBatchPaymentCheckWorker import AutomationMassHealthBatchPaymentC
import os
import time
import helpers_ddma_eligibility as hddma
import helpers_deltaindiv_eligibility as hdeltaindiv
import helpers_deltains_eligibility as hdeltains
import helpers_unitedsco_eligibility as hunitedsco
import helpers_uniteducard_eligibility as huniteducard
@@ -33,6 +34,7 @@ import helpers_bcbs_ma_eligibility as hbcbs_ma
# Import startup session-clear functions
from ddma_browser_manager import clear_ddma_session_on_startup
from deltaindiv_browser_manager import clear_deltaindiv_session_on_startup
from deltains_browser_manager import clear_deltains_session_on_startup
from unitedsco_browser_manager import clear_unitedsco_session_on_startup
from uniteducard_browser_manager import clear_uniteducard_session_on_startup
@@ -47,6 +49,7 @@ print("=" * 50)
print("SELENIUM AGENT STARTING - CLEARING SESSIONS")
print("=" * 50)
clear_ddma_session_on_startup()
clear_deltaindiv_session_on_startup()
clear_deltains_session_on_startup()
clear_unitedsco_session_on_startup()
clear_uniteducard_session_on_startup()
@@ -420,6 +423,45 @@ async def ddma_eligibility(request: Request):
return {"status": "started", "session_id": sid}
async def _deltaindiv_worker_wrapper(sid: str, data: dict, url: str):
"""Background worker for Delta Indiv eligibility — acquires semaphore, updates counters."""
global active_jobs, waiting_jobs
async with semaphore:
async with lock:
waiting_jobs -= 1
active_jobs += 1
try:
await hdeltaindiv.start_deltaindiv_run(sid, data, url)
finally:
async with lock:
active_jobs -= 1
@app.post("/deltaindiv-eligibility")
async def deltaindiv_eligibility(request: Request):
"""
Starts a Delta Indiv eligibility session in the background.
Body: { "data": { ... } }
Returns: { status: "started", session_id: "<uuid>" }
"""
global waiting_jobs
body = await request.json()
data = body.get("data", {})
sid = hdeltaindiv.make_session_entry()
hdeltaindiv.sessions[sid]["type"] = "deltaindiv_eligibility"
hdeltaindiv.sessions[sid]["last_activity"] = time.time()
async with lock:
waiting_jobs += 1
# TODO: replace with the real Delta Indiv sign-in URL
asyncio.create_task(_deltaindiv_worker_wrapper(sid, data, url="https://www.deltadentalma.com/sign-in"))
return {"status": "started", "session_id": sid}
async def _deltains_worker_wrapper(sid: str, data: dict, url: str):
"""Background worker for DeltaIns — acquires semaphore, updates counters."""
global active_jobs, waiting_jobs
@@ -1058,6 +1100,8 @@ async def submit_otp(request: Request):
# Try each session store in order
if sid in hddma.sessions:
res = hddma.submit_otp(sid, otp)
elif sid in hdeltaindiv.sessions:
res = hdeltaindiv.submit_otp(sid, otp)
elif sid in hdeltains.sessions:
res = hdeltains.submit_otp(sid, otp)
elif sid in hunitedsco.sessions:
@@ -1095,6 +1139,8 @@ async def session_status(sid: str):
# Try each session store in order
if sid in hddma.sessions:
s = hddma.get_session_status(sid)
elif sid in hdeltaindiv.sessions:
s = hdeltaindiv.get_session_status(sid)
elif sid in hdeltains.sessions:
s = hdeltains.get_session_status(sid)
elif sid in hunitedsco.sessions:
@@ -1144,6 +1190,16 @@ async def clear_ddma_session_endpoint():
return {"status": "error", "message": str(e)}
@app.post("/clear-deltaindiv-session")
async def clear_deltaindiv_session_endpoint():
"""Clears the Delta Indiv browser session. Call when credentials are deleted or changed."""
try:
clear_deltaindiv_session_on_startup()
return {"status": "success", "message": "Delta Indiv session cleared"}
except Exception as e:
return {"status": "error", "message": str(e)}
@app.post("/clear-deltains-session")
async def clear_deltains_session_endpoint():
"""Clears the DeltaIns browser session. Call when credentials are deleted or changed."""
@@ -0,0 +1,253 @@
"""
Browser manager for Delta Indiv (Delta Dental Indiv) - persistent profile with session management.
- Uses --user-data-dir for persistent profile (device trust tokens)
- Clears session cookies on startup (after PC restart) to force fresh login
- Tracks credentials to detect changes mid-session (triggers logout)
- Anti-detection options to avoid bot detection
"""
import os
import glob
import shutil
import hashlib
import threading
from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from webdriver_manager.chrome import ChromeDriverManager
# Ensure DISPLAY is set for Chrome to work (needed when running from SSH/background)
if not os.environ.get("DISPLAY"):
os.environ["DISPLAY"] = ":0"
class DeltaIndivBrowserManager:
"""
Singleton that manages a persistent Chrome browser instance.
- Uses --user-data-dir for persistent profile (device trust tokens)
- Clears session cookies on startup (after PC restart)
- Tracks credentials to detect changes mid-session
"""
_instance = None
_lock = threading.Lock()
def __new__(cls):
with cls._lock:
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._driver = None
cls._instance.profile_dir = os.path.abspath("chrome_profile_deltaindiv")
cls._instance.download_dir = os.path.abspath("seleniumDownloads")
cls._instance._credentials_file = os.path.join(cls._instance.profile_dir, ".last_credentials")
cls._instance._needs_session_clear = False
os.makedirs(cls._instance.profile_dir, exist_ok=True)
os.makedirs(cls._instance.download_dir, exist_ok=True)
return cls._instance
def clear_session_on_startup(self):
"""
Clear only login cookies on startup to force credential re-entry after restart.
NEVER clears Local Storage or IndexedDB — those hold the Delta Indiv device trust token
that allows the portal to skip OTP for recognised devices.
"""
print("[Delta Indiv BrowserManager] Clearing login cookies on startup (preserving device trust)...")
try:
# Reset credentials tracking so the next login re-saves the hash
if os.path.exists(self._credentials_file):
os.remove(self._credentials_file)
print("[Delta Indiv BrowserManager] Cleared credentials tracking file")
# Only remove cookie / login-data files — these expire the session so the
# user must re-enter credentials, but the device trust token is untouched.
session_files = [
"Cookies",
"Cookies-journal",
"Login Data",
"Login Data-journal",
]
for filename in session_files:
for base in [os.path.join(self.profile_dir, "Default"), self.profile_dir]:
filepath = os.path.join(base, filename)
if os.path.exists(filepath):
try:
os.remove(filepath)
print(f"[Delta Indiv BrowserManager] Removed {filename}")
except Exception as e:
print(f"[Delta Indiv BrowserManager] Could not remove {filename}: {e}")
# Local Storage, IndexedDB, and Session Storage are intentionally
# NOT cleared — they contain the Delta Indiv device trust token that prevents
# OTP from being required on every login.
self._needs_session_clear = True
print("[Delta Indiv BrowserManager] Startup clear done — device trust preserved, OTP not required")
except Exception as e:
print(f"[Delta Indiv BrowserManager] Error clearing session: {e}")
# ── Credential hash tracking ──────────────────────────────────────────────
def _hash_credentials(self, username: str) -> str:
return hashlib.sha256(username.encode()).hexdigest()[:16]
def get_last_credentials_hash(self) -> str | None:
try:
if os.path.exists(self._credentials_file):
with open(self._credentials_file, 'r') as f:
return f.read().strip()
except Exception:
pass
return None
def save_credentials_hash(self, username: str):
try:
cred_hash = self._hash_credentials(username)
with open(self._credentials_file, 'w') as f:
f.write(cred_hash)
except Exception as e:
print(f"[Delta Indiv BrowserManager] Failed to save credentials hash: {e}")
def credentials_changed(self, username: str) -> bool:
last_hash = self.get_last_credentials_hash()
if last_hash is None:
return False # No previous credentials stored — not a change
current_hash = self._hash_credentials(username)
changed = last_hash != current_hash
if changed:
print("[Delta Indiv BrowserManager] Credentials changed — logout required")
return changed
def clear_credentials_hash(self):
try:
if os.path.exists(self._credentials_file):
os.remove(self._credentials_file)
except Exception as e:
print(f"[Delta Indiv BrowserManager] Failed to clear credentials hash: {e}")
# ── Chrome process management ─────────────────────────────────────────────
def _kill_existing_chrome_for_profile(self):
"""Kill any existing Chrome processes using this profile and remove lock files."""
import subprocess
import time as time_module
try:
result = subprocess.run(
["pgrep", "-f", f"user-data-dir={self.profile_dir}"],
capture_output=True, text=True
)
if result.stdout.strip():
for pid in result.stdout.strip().split('\n'):
try:
subprocess.run(["kill", "-9", pid], check=False)
except Exception:
pass
time_module.sleep(1)
except Exception:
pass
for lock_file in ["SingletonLock", "SingletonSocket", "SingletonCookie"]:
lock_path = os.path.join(self.profile_dir, lock_file)
try:
if os.path.islink(lock_path) or os.path.exists(lock_path):
os.remove(lock_path)
except Exception:
pass
# ── Driver lifecycle ──────────────────────────────────────────────────────
def get_driver(self, headless=False):
"""Get or create the persistent browser instance."""
with self._lock:
if self._driver is None:
print("[Delta Indiv BrowserManager] Driver is None, creating new driver")
self._kill_existing_chrome_for_profile()
self._create_driver(headless)
elif not self._is_alive():
print("[Delta Indiv BrowserManager] Driver not alive, recreating")
self._kill_existing_chrome_for_profile()
self._create_driver(headless)
else:
print("[Delta Indiv BrowserManager] Reusing existing driver")
return self._driver
def _is_alive(self):
try:
if self._driver is None:
return False
url = self._driver.current_url
print(f"[Delta Indiv BrowserManager] Driver alive, current URL: {url[:50]}...")
return True
except Exception as e:
print(f"[Delta Indiv BrowserManager] Driver not alive: {e}")
return False
def _create_driver(self, headless=False):
"""Create browser with persistent profile and anti-detection options."""
if self._driver:
try:
self._driver.quit()
except Exception:
pass
options = webdriver.ChromeOptions()
if headless:
options.add_argument("--headless")
# Persistent profile — keeps device trust tokens between runs
options.add_argument(f"--user-data-dir={self.profile_dir}")
options.add_argument("--no-sandbox")
options.add_argument("--disable-dev-shm-usage")
# Anti-detection
options.add_argument("--disable-blink-features=AutomationControlled")
options.add_experimental_option("excludeSwitches", ["enable-automation"])
options.add_experimental_option("useAutomationExtension", False)
options.add_argument("--disable-infobars")
prefs = {
"download.default_directory": self.download_dir,
"plugins.always_open_pdf_externally": True,
"download.prompt_for_download": False,
"download.directory_upgrade": True,
}
options.add_experimental_option("prefs", prefs)
service = Service(ChromeDriverManager().install())
self._driver = webdriver.Chrome(service=service, options=options)
self._driver.maximize_window()
# Remove webdriver property to avoid detection
try:
self._driver.execute_script(
"Object.defineProperty(navigator, 'webdriver', {get: () => undefined})"
)
except Exception:
pass
self._needs_session_clear = False
def quit_driver(self):
"""Quit browser (only call on shutdown — NOT between patients)."""
with self._lock:
if self._driver:
try:
self._driver.quit()
except Exception:
pass
self._driver = None
# ── Singleton accessor ────────────────────────────────────────────────────────
_manager = None
def get_browser_manager() -> DeltaIndivBrowserManager:
global _manager
if _manager is None:
_manager = DeltaIndivBrowserManager()
return _manager
def clear_deltaindiv_session_on_startup():
"""Called by agent.py on startup to clear Delta Indiv session (after PC restart)."""
manager = get_browser_manager()
manager.clear_session_on_startup()
@@ -0,0 +1,297 @@
import os
import time
import asyncio
from typing import Dict, Any
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import WebDriverException, TimeoutException
from selenium_DeltaIndiv_eligibilityCheckWorker import AutomationDeltaIndivEligibilityCheck
# In-memory session store
sessions: Dict[str, Dict[str, Any]] = {}
SESSION_OTP_TIMEOUT = int(os.getenv("SESSION_OTP_TIMEOUT", "120")) # seconds
def make_session_entry() -> str:
"""Create a new session entry and return its ID."""
import uuid
sid = str(uuid.uuid4())
sessions[sid] = {
"status": "created", # created → running → waiting_for_otp → completed / error
"created_at": time.time(),
"last_activity": time.time(),
"bot": None, # AutomationDeltaIndivEligibilityCheck instance
"driver": None, # selenium webdriver
"otp_event": asyncio.Event(),
"otp_value": None, # OTP submitted from the app
"result": None,
"message": None,
"type": None,
}
return sid
async def cleanup_session(sid: str, message: str | None = None):
"""
Wake any OTP waiter, set final state, and remove the session entry.
Safe to call multiple times (idempotent).
NOTE: Does NOT quit the browser driver — the persistent browser stays alive.
"""
s = sessions.get(sid)
if not s:
return
try:
if s.get("status") not in ("completed", "error", "not_found"):
s["status"] = "error"
if message:
s["message"] = message
ev = s.get("otp_event")
if ev and not ev.is_set():
ev.set()
finally:
sessions.pop(sid, None)
print(f"[helpers_deltaindiv] cleaned session {sid}")
async def _remove_session_later(sid: str, delay: int = 20):
await asyncio.sleep(delay)
await cleanup_session(sid)
async def start_deltaindiv_run(sid: str, data: dict, url: str):
"""
Run the full Delta Indiv eligibility workflow for one session.
Called by agent.py inside a wrapper that manages the semaphore/counters.
OTP handling uses two complementary strategies:
1. Accept OTP submitted from the app (via /submit-otp endpoint → otp_value field)
2. Poll the browser URL/DOM directly to detect when the user enters OTP themselves
"""
s = sessions.get(sid)
if not s:
return {"status": "error", "message": "session not found"}
s["status"] = "running"
s["last_activity"] = time.time()
try:
bot = AutomationDeltaIndivEligibilityCheck({"data": data})
bot.config_driver()
s["bot"] = bot
s["driver"] = bot.driver
s["last_activity"] = time.time()
# Navigate to login page
try:
if not url:
raise ValueError("URL not provided for Delta Indiv run")
bot.driver.maximize_window()
bot.driver.get(url)
await asyncio.sleep(1)
except Exception as e:
s["status"] = "error"
s["message"] = f"Navigation failed: {e}"
await cleanup_session(sid)
return {"status": "error", "message": s["message"]}
# Login
try:
login_result = bot.login(url)
except WebDriverException as wde:
s["status"] = "error"
s["message"] = f"Selenium driver error during login: {wde}"
await cleanup_session(sid, s["message"])
return {"status": "error", "message": s["message"]}
except Exception as e:
s["status"] = "error"
s["message"] = f"Unexpected error during login: {e}"
await cleanup_session(sid, s["message"])
return {"status": "error", "message": s["message"]}
# ── Path: already logged in (persistent session) ──────────────────────
if isinstance(login_result, str) and login_result == "ALREADY_LOGGED_IN":
print("[start_deltaindiv_run] Session persisted - skipping OTP")
s["status"] = "running"
s["message"] = "Session persisted"
# ── Path: OTP required ────────────────────────────────────────────────
elif isinstance(login_result, str) and login_result == "OTP_REQUIRED":
s["status"] = "waiting_for_otp"
s["message"] = "OTP required for login - please enter OTP"
s["last_activity"] = time.time()
driver = s["driver"]
# Poll every second for up to SESSION_OTP_TIMEOUT seconds.
# Accept OTP from two sources:
# a) app API (otp_value set by submit_otp())
# b) user entering OTP directly in the browser window
max_polls = SESSION_OTP_TIMEOUT
login_success = False
print(f"[OTP] Polling for OTP completion (up to {SESSION_OTP_TIMEOUT}s)...")
for poll in range(max_polls):
await asyncio.sleep(1)
s["last_activity"] = time.time()
try:
# a) App submitted OTP via /submit-otp endpoint
otp_value = s.get("otp_value")
if otp_value:
print(f"[OTP poll {poll+1}] OTP received from app, typing it in...")
try:
otp_input = driver.find_element(By.XPATH, "//input[@data-test-id='MfaCode']")
otp_input.clear()
otp_input.send_keys(otp_value)
try:
verify_btn = driver.find_element(By.XPATH, "//button[@data-test-id='ConfirmSubmitButton']")
verify_btn.click()
except Exception:
otp_input.send_keys("\n")
print("[OTP] OTP typed and submitted via app")
s["otp_value"] = None # Clear so we don't re-submit
await asyncio.sleep(3)
except Exception as type_err:
print(f"[OTP] Failed to type OTP from app: {type_err}")
# b) Check URL — if we're past OTP page, login succeeded
current_url = driver.current_url.lower()
print(f"[OTP poll {poll+1}/{max_polls}] URL: {current_url[:70]}...")
if "member" in current_url or "dashboard" in current_url or "eligibility" in current_url:
try:
member_search = WebDriverWait(driver, 5).until(
EC.presence_of_element_located((By.XPATH, "//a[contains(@href, 'check-patient-benefits-search')]"))
)
print("[OTP] Member search found — login successful!")
login_success = True
break
except TimeoutException:
print("[OTP] On member page but search input not found, continuing...")
# Check if OTP input is still visible (user hasn't finished)
try:
otp_input_elem = driver.find_element(By.XPATH, "//input[@data-test-id='MfaCode']")
print(f"[OTP poll {poll+1}] OTP input still visible - waiting...")
except Exception:
# OTP input gone — may mean login is completing; try members page
if "sign-in" in current_url:
print("[OTP] OTP input gone, trying to navigate to members page...")
try:
driver.get("https://www.deltadentalma.com/private/provider/dashboard")
await asyncio.sleep(2)
except Exception:
pass
except Exception as poll_err:
print(f"[OTP poll {poll+1}] Error: {poll_err}")
if not login_success:
# Final check — navigate directly to members page
try:
print("[OTP] Final attempt - navigating to members page...")
driver.get("https://www.deltadentalma.com/private/provider/dashboard")
await asyncio.sleep(3)
member_search = WebDriverWait(driver, 10).until(
EC.presence_of_element_located((By.XPATH, "//a[contains(@href, 'check-patient-benefits-search')]"))
)
print("[OTP] Member search found — login successful!")
login_success = True
except TimeoutException:
s["status"] = "error"
s["message"] = "OTP timeout - login not completed"
await cleanup_session(sid)
return {"status": "error", "message": "OTP not completed in time"}
except Exception as final_err:
s["status"] = "error"
s["message"] = f"OTP verification failed: {final_err}"
await cleanup_session(sid)
return {"status": "error", "message": s["message"]}
if login_success:
s["status"] = "running"
s["message"] = "Login successful after OTP"
print("[OTP] Proceeding to step1...")
# ── Path: login succeeded without OTP ─────────────────────────────────
elif isinstance(login_result, str) and login_result == "SUCCESS":
print("[start_deltaindiv_run] Login succeeded without OTP")
s["status"] = "running"
s["message"] = "Login succeeded"
# ── Path: login error ──────────────────────────────────────────────────
elif isinstance(login_result, str) and login_result.startswith("ERROR"):
s["status"] = "error"
s["message"] = login_result
await cleanup_session(sid)
return {"status": "error", "message": login_result}
# ── Step 1: search ────────────────────────────────────────────────────
step1_result = bot.step1()
if isinstance(step1_result, str) and step1_result.startswith("ERROR"):
s["status"] = "error"
s["message"] = step1_result
await cleanup_session(sid)
return {"status": "error", "message": step1_result}
# ── Step 2: PDF generation ────────────────────────────────────────────
step2_result = bot.step2()
if isinstance(step2_result, dict) and step2_result.get("status") == "success":
s["status"] = "completed"
s["result"] = step2_result
s["message"] = "completed"
asyncio.create_task(_remove_session_later(sid, 30))
return step2_result
else:
s["status"] = "error"
if isinstance(step2_result, dict):
s["message"] = step2_result.get("message", "unknown error")
else:
s["message"] = str(step2_result)
await cleanup_session(sid)
return {"status": "error", "message": s["message"]}
except Exception as e:
s["status"] = "error"
s["message"] = f"worker exception: {e}"
await cleanup_session(sid)
return {"status": "error", "message": s["message"]}
def submit_otp(sid: str, otp: str) -> Dict[str, Any]:
"""
Called when the app sends an OTP via POST /submit-otp.
Sets otp_value on the session so the polling loop picks it up.
"""
s = sessions.get(sid)
if not s:
return {"status": "error", "message": "session not found"}
if s.get("status") != "waiting_for_otp":
return {"status": "error", "message": f"session not waiting for OTP (state={s.get('status')})"}
s["otp_value"] = otp
s["last_activity"] = time.time()
try:
s["otp_event"].set()
except Exception:
pass
return {"status": "ok", "message": "otp accepted"}
def get_session_status(sid: str) -> Dict[str, Any]:
s = sessions.get(sid)
if not s:
return {"status": "not_found"}
return {
"session_id": sid,
"status": s.get("status"),
"message": s.get("message"),
"created_at": s.get("created_at"),
"last_activity": s.get("last_activity"),
"result": s.get("result") if s.get("status") == "completed" else None,
}
@@ -466,13 +466,22 @@ class AutomationCCAEligibilityCheck:
if "no results" in body_text.lower() or "not found" in body_text.lower() or "no patient" in body_text.lower():
return "ERROR: No patient found with the provided Subscriber ID and DOB"
# Patient popup already rendered — this is a success regardless of any
# unrelated banner (e.g. a password-expiration notice) also on the page.
if "patient selected" in body_text.lower() or "patient information" in body_text.lower():
return "SUCCESS"
# Check for error alerts
try:
alerts = self.driver.find_elements(By.XPATH,
"//*[@role='alert'] | //*[contains(@class,'alert-danger')]")
for alert in alerts:
if alert.is_displayed() and alert.text.strip():
return f"ERROR: {alert.text.strip()[:200]}"
alert_text = alert.text.strip()
if not alert.is_displayed() or not alert_text:
continue
if "password" in alert_text.lower() and "expire" in alert_text.lower():
continue
return f"ERROR: {alert_text[:200]}"
except Exception:
pass
@@ -0,0 +1,704 @@
from selenium import webdriver
from selenium.common.exceptions import WebDriverException, TimeoutException
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from webdriver_manager.chrome import ChromeDriverManager
import time
import os
import re
import base64
import calendar
from deltaindiv_browser_manager import get_browser_manager
class AutomationDeltaIndivEligibilityCheck:
def __init__(self, data):
self.headless = False
self.driver = None
self.data = data.get("data", {}) if isinstance(data, dict) else {}
# Flatten values for convenience
self.memberId = self.data.get("memberId", "")
self.dateOfBirth = self.data.get("dateOfBirth", "")
self.firstName = self.data.get("firstName", "")
self.lastName = self.data.get("lastName", "")
self.deltaindiv_username = self.data.get("deltaindivUsername", "")
self.deltaindiv_password = self.data.get("deltaindivPassword", "")
self.providerName = self.data.get("providerName", "")
# Use browser manager's download dir
self.download_dir = get_browser_manager().download_dir
os.makedirs(self.download_dir, exist_ok=True)
def config_driver(self):
# Use persistent browser from manager (keeps device trust tokens)
self.driver = get_browser_manager().get_driver(self.headless)
def _force_logout(self):
"""Force logout by clearing cookies when credentials change."""
try:
print("[Delta Indiv login] Forcing logout due to credential change...")
browser_manager = get_browser_manager()
# Try to click logout button if visible
try:
self.driver.get("https://www.deltadentalma.com/")
time.sleep(2)
logout_selectors = [
"//button[contains(text(), 'Log out') or contains(text(), 'Logout') or contains(text(), 'Sign out')]",
"//a[contains(text(), 'Log out') or contains(text(), 'Logout') or contains(text(), 'Sign out')]",
"//button[@aria-label='Log out' or @aria-label='Logout' or @aria-label='Sign out']",
"//*[contains(@class, 'logout') or contains(@class, 'signout')]"
]
for selector in logout_selectors:
try:
logout_btn = WebDriverWait(self.driver, 3).until(
EC.element_to_be_clickable((By.XPATH, selector))
)
logout_btn.click()
print("[Delta Indiv login] Clicked logout button")
time.sleep(2)
break
except TimeoutException:
continue
except Exception as e:
print(f"[Delta Indiv login] Could not click logout button: {e}")
# Clear cookies as backup
try:
self.driver.delete_all_cookies()
print("[Delta Indiv login] Cleared all cookies")
except Exception as e:
print(f"[Delta Indiv login] Error clearing cookies: {e}")
browser_manager.clear_credentials_hash()
print("[Delta Indiv login] Logout complete")
return True
except Exception as e:
print(f"[Delta Indiv login] Error during forced logout: {e}")
return False
def login(self, url):
wait = WebDriverWait(self.driver, 30)
browser_manager = get_browser_manager()
try:
# Check if credentials changed — force logout first
if self.deltaindiv_username and browser_manager.credentials_changed(self.deltaindiv_username):
self._force_logout()
self.driver.get(url)
time.sleep(2)
# Check if already on a logged-in page (persistent session from profile)
try:
current_url = self.driver.current_url
print(f"[login] Current URL: {current_url}")
logged_in_patterns = ["member", "dashboard", "eligibility", "search", "patients", "private/provider"]
is_logged_in_url = any(pattern in current_url.lower() for pattern in logged_in_patterns)
if is_logged_in_url and "sign-in" not in current_url.lower():
print("[login] Already on logged-in page - skipping login entirely")
if "member" not in current_url.lower():
try:
member_search = WebDriverWait(self.driver, 5).until(
EC.presence_of_element_located((By.XPATH, "//a[contains(@href, 'check-patient-benefits-search')]"))
)
print("[login] Found member search input - returning ALREADY_LOGGED_IN")
return "ALREADY_LOGGED_IN"
except TimeoutException:
members_url = "https://www.deltadentalma.com/private/provider/dashboard"
print(f"[login] Navigating to members page: {members_url}")
self.driver.get(members_url)
time.sleep(2)
try:
member_search = WebDriverWait(self.driver, 5).until(
EC.presence_of_element_located((By.XPATH, "//a[contains(@href, 'check-patient-benefits-search')]"))
)
print("[login] Member search found - ALREADY_LOGGED_IN")
return "ALREADY_LOGGED_IN"
except TimeoutException:
print("[login] Could not find member search, will try login")
except Exception as e:
print(f"[login] Error checking current state: {e}")
# Navigate to login URL
self.driver.get(url)
time.sleep(2)
# Check if session redirected us straight to member search
try:
current_url = self.driver.current_url
print(f"[login] URL after navigation: {current_url}")
if "sign-in" not in current_url.lower():
member_search = WebDriverWait(self.driver, 3).until(
EC.presence_of_element_located((By.XPATH, "//a[contains(@href, 'check-patient-benefits-search')]"))
)
if member_search:
print("[login] Session valid - skipping login")
return "ALREADY_LOGGED_IN"
except TimeoutException:
print("[login] Proceeding with login")
# Dismiss any "Authentication flow continued in another tab" modal
modal_dismissed = False
try:
ok_button = WebDriverWait(self.driver, 3).until(
EC.element_to_be_clickable((By.XPATH, "//button[normalize-space(text())='Ok' or normalize-space(text())='OK']"))
)
ok_button.click()
print("[login] Dismissed authentication modal")
modal_dismissed = True
time.sleep(2)
all_windows = self.driver.window_handles
print(f"[login] Windows after modal dismiss: {len(all_windows)}")
if len(all_windows) > 1:
original_window = self.driver.current_window_handle
for window in all_windows:
if window != original_window:
self.driver.switch_to.window(window)
print("[login] Switched to auth popup window")
break
try:
otp_candidate = WebDriverWait(self.driver, 10).until(
EC.presence_of_element_located(
(By.XPATH, "//input[@data-test-id='MfaCode']")
)
)
if otp_candidate:
print("[login] OTP input found in popup -> OTP_REQUIRED")
return "OTP_REQUIRED"
except TimeoutException:
print("[login] No OTP in popup, checking main window")
self.driver.switch_to.window(original_window)
except TimeoutException:
pass # No modal present
if modal_dismissed:
time.sleep(2)
try:
member_search = WebDriverWait(self.driver, 5).until(
EC.presence_of_element_located((By.XPATH, "//a[contains(@href, 'check-patient-benefits-search')]"))
)
if member_search:
print("[login] Already authenticated after modal dismiss")
return "ALREADY_LOGGED_IN"
except TimeoutException:
pass
# Fill login form
try:
email_field = WebDriverWait(self.driver, 10).until(
EC.element_to_be_clickable((By.XPATH, "//input[@data-test-id='Username']"))
)
except TimeoutException:
print("[login] Could not find login form - page may have changed")
return "ERROR: Login form not found"
email_field = wait.until(EC.presence_of_element_located((By.XPATH, "//input[@data-test-id='Username']")))
email_field.clear()
email_field.send_keys(self.deltaindiv_username)
password_field = wait.until(EC.presence_of_element_located((By.XPATH, "//input[@data-test-id='Password']")))
password_field.clear()
password_field.send_keys(self.deltaindiv_password)
login_button = wait.until(EC.element_to_be_clickable((By.XPATH, "//button[@data-test-id='SigninSubmitButton']")))
login_button.click()
# Save credentials hash after login attempt
if self.deltaindiv_username:
browser_manager.save_credentials_hash(self.deltaindiv_username)
# OTP detection — wait up to 30 seconds for OTP input
try:
otp_candidate = WebDriverWait(self.driver, 30).until(
EC.presence_of_element_located(
(By.XPATH, "//input[@data-test-id='MfaCode']")
)
)
if otp_candidate:
print("[login] OTP input detected -> OTP_REQUIRED")
return "OTP_REQUIRED"
except TimeoutException:
print("[login] No OTP input detected in allowed time.")
# Check if we're now on the member search page (login succeeded without OTP)
try:
current_url = self.driver.current_url.lower()
if "member" in current_url or "dashboard" in current_url:
member_search = WebDriverWait(self.driver, 5).until(
EC.presence_of_element_located((By.XPATH, "//a[contains(@href, 'check-patient-benefits-search')]"))
)
print("[login] Login successful - now on member search page")
return "SUCCESS"
except TimeoutException:
pass
# Check for error messages
try:
error_elem = WebDriverWait(self.driver, 3).until(
EC.presence_of_element_located((By.XPATH, "//*[contains(@class,'error') or contains(text(),'invalid') or contains(text(),'failed')]"))
)
print(f"[login] Login failed - error detected: {error_elem.text}")
return f"ERROR:LOGIN FAILED: {error_elem.text}"
except TimeoutException:
pass
if "sign-in" in self.driver.current_url.lower():
print("[login] Login failed - still on login page")
return "ERROR:LOGIN FAILED: Still on login page"
print("[login] Assuming login succeeded (no errors detected)")
return "SUCCESS"
except Exception as e:
print("[login] Exception during login:", e)
return f"ERROR:LOGIN FAILED: {e}"
def step1(self):
"""Open the Group/Health Connector search page and search by Subscriber ID + Birthdate."""
wait = WebDriverWait(self.driver, 30)
try:
fields = []
if self.memberId:
fields.append(f"SubscriberID: {self.memberId}")
if self.dateOfBirth:
fields.append(f"DOB: {self.dateOfBirth}")
print(f"[Delta Indiv step1] Starting search with: {', '.join(fields)}")
search_page_url = "https://www.deltadentalma.com/private/provider/check-patient-benefits-search"
# Try navigating straight to the search page first — avoids any click/visibility issues
print(f"[Delta Indiv step1] Navigating directly to search page: {search_page_url}")
self.driver.get(search_page_url)
time.sleep(2)
if "check-patient-benefits-search" not in self.driver.current_url.lower():
# Direct deep-link didn't stick (e.g. bounced back to sign-in/dashboard) —
# fall back to clicking the Group/Health Connector link from the dashboard.
print(f"[Delta Indiv step1] Direct navigation redirected to: {self.driver.current_url} — falling back to clicking the link")
if "private/provider/dashboard" not in self.driver.current_url.lower():
dashboard_url = "https://www.deltadentalma.com/private/provider/dashboard"
print(f"[Delta Indiv step1] Navigating to dashboard: {dashboard_url}")
self.driver.get(dashboard_url)
time.sleep(2)
link_xpath = "//a[contains(@href, 'check-patient-benefits-search')]"
try:
connector_link = WebDriverWait(self.driver, 20).until(
EC.element_to_be_clickable((By.XPATH, link_xpath))
)
connector_link.click()
except TimeoutException:
# Element may exist but be hidden (e.g. a responsive duplicate) — try any match via JS click
candidates = self.driver.find_elements(By.XPATH, link_xpath)
print(f"[Delta Indiv step1] element_to_be_clickable timed out; found {len(candidates)} matching link(s) in DOM")
if candidates:
try:
self.driver.execute_script("arguments[0].click();", candidates[0])
except Exception as js_err:
print(f"[Delta Indiv step1] JS click failed: {js_err}")
print(f"[Delta Indiv step1] Current URL: {self.driver.current_url}")
return "ERROR: Could not open Group/Health Connector search page"
else:
print(f"[Delta Indiv step1] No Group/Health Connector link found in DOM. Current URL: {self.driver.current_url}")
return "ERROR: Could not open Group/Health Connector search page"
try:
wait.until(lambda d: "check-patient-benefits-search" in d.current_url)
except TimeoutException:
print(f"[Delta Indiv step1] Click did not navigate to search page. Current URL: {self.driver.current_url}")
return "ERROR: Could not open Group/Health Connector search page"
print(f"[Delta Indiv step1] On Group/Health Connector search page: {self.driver.current_url}")
# Fill Birthdate (masked mm/dd/yyyy input — send digits only, mask inserts slashes)
if self.dateOfBirth:
try:
dob_parts = self.dateOfBirth.split("-")
year = dob_parts[0]
month = dob_parts[1].zfill(2)
day = dob_parts[2].zfill(2)
birthdate_input = wait.until(EC.element_to_be_clickable(
(By.XPATH, "//input[@data-test-id='InStateSearchBirthdate']")
))
birthdate_input.click()
birthdate_input.send_keys(Keys.CONTROL, "a")
birthdate_input.send_keys(Keys.BACKSPACE)
birthdate_input.send_keys(f"{month}{day}{year}")
print(f"[Delta Indiv step1] Filled Birthdate: {month}/{day}/{year}")
except Exception as e:
print(f"[Delta Indiv step1] Warning: Could not fill Birthdate: {e}")
# Fill Subscriber ID (== member ID)
if self.memberId:
try:
subscriber_input = wait.until(EC.element_to_be_clickable(
(By.XPATH, "//input[@data-test-id='InStateSearchSubscriberId']")
))
subscriber_input.clear()
subscriber_input.send_keys(self.memberId)
print(f"[Delta Indiv step1] Filled Subscriber ID: {self.memberId}")
except Exception as e:
print(f"[Delta Indiv step1] Warning: Could not fill Subscriber ID: {e}")
time.sleep(0.3)
# Click Search button
search_btn = wait.until(EC.element_to_be_clickable(
(By.XPATH, "//button[@data-test-id='InStateSearchButton']")
))
search_btn.click()
print("[Delta Indiv step1] Clicked Search button")
# Wait for either the Members results table to render, or (when the
# Subscriber ID + DOB match exactly one family member) Delta Indiv to skip
# the results list and navigate straight to the patient detail page.
# Note: the detail page URL is nested UNDER the search page path
# (".../check-patient-benefits-search/check-patient-benefit-details"),
# so "check-patient-benefits-search" alone can't distinguish them —
# only the detail page's own "-benefit-details" segment can.
try:
WebDriverWait(self.driver, 15).until(
lambda d: d.find_elements(By.XPATH, "//table//tbody//tr")
or "check-patient-benefit-details" in d.current_url.lower()
)
time.sleep(2)
except TimeoutException:
print("[Delta Indiv step1] Warning: No results table appeared within timeout")
return "ERROR: INVALID SEARCH CRITERIA"
if "check-patient-benefit-details" in self.driver.current_url.lower():
print(f"[Delta Indiv step1] Search navigated directly to detail page: {self.driver.current_url}")
print("[Delta Indiv step1] Search completed successfully")
return "Success"
except Exception as e:
print(f"[Delta Indiv step1] Exception: {e}")
return f"ERROR:STEP1 - {e}"
def step2(self):
"""Navigate to patient detail page and generate PDF."""
wait = WebDriverWait(self.driver, 90)
try:
import re
eligibilityText = "unknown"
foundMemberId = self.memberId or ""
# If the caller already knows this patient (e.g. an existing patient record
# being rechecked), use that name directly — no need to scrape/parse it off
# the Delta Indiv page, only the eligibility status is actually needed.
patientName = f"{self.firstName} {self.lastName}".strip() if self.firstName and self.lastName else ""
known_patient_name = bool(patientName)
# Delta Indiv now sometimes skips the family-members results table entirely and
# navigates straight to the single-patient detail page (when Subscriber ID +
# DOB match exactly one person). In that case there's no members table to
# parse here — skip straight to the detail-page fallback extraction below.
already_on_detail_page = "check-patient-benefit-details" in self.driver.current_url.lower()
rows = []
target_row = None
if not already_on_detail_page:
# Wait for results table, then pause for full render
try:
WebDriverWait(self.driver, 10).until(
EC.presence_of_element_located((By.XPATH, "//tbody//tr"))
)
time.sleep(2) # Let the row content fully render after table appears
except TimeoutException:
print("[Delta Indiv step2] Warning: Results table not found within timeout")
# The Members table lists the whole family sharing this subscriber ID —
# find the row matching this patient's DOB (falls back to last name, then first row).
rows = self.driver.find_elements(By.XPATH, "//table//tbody//tr")
print(f"[Delta Indiv step2] Found {len(rows)} family member row(s)")
else:
print(f"[Delta Indiv step2] Already on detail page: {self.driver.current_url}")
if self.dateOfBirth and rows:
try:
dob_parts = self.dateOfBirth.split("-")
dob_year, dob_month, dob_day = dob_parts[0], int(dob_parts[1]), int(dob_parts[2])
expected_dob = f"{calendar.month_abbr[dob_month]} {dob_day}, {dob_year}".lower()
for row in rows:
cells = row.find_elements(By.XPATH, "./td")
if len(cells) >= 3 and cells[2].text.strip().lower() == expected_dob:
target_row = row
break
except Exception as e:
print(f"[Delta Indiv step2] DOB matching error (non-fatal): {e}")
if target_row is None and self.lastName and rows:
for row in rows:
if self.lastName.strip().lower() in row.text.lower():
target_row = row
break
if target_row is None and rows:
target_row = rows[0]
print("[Delta Indiv step2] No DOB/name match — defaulting to first family member row")
if target_row is not None:
try:
cells = target_row.find_elements(By.XPATH, "./td")
if cells and not known_patient_name:
patientName = cells[0].text.strip()
print(f"[Delta Indiv step2] Extracted patient name from row: '{patientName}'")
if len(cells) >= 2 and cells[1].text.strip():
foundMemberId = cells[1].text.strip()
if len(cells) >= 7:
status_text = cells[6].text.strip().lower()
if "inactive" in status_text or "termed" in status_text:
eligibilityText = "inactive"
elif "active" in status_text:
eligibilityText = "active"
else:
eligibilityText = status_text
print(f"[Delta Indiv step2] Found eligibility status: {eligibilityText}")
except Exception as e:
print(f"[Delta Indiv step2] Error reading matched row cells: {e}")
# Click into the member's detail page
try:
name_link = target_row.find_element(By.XPATH, ".//a")
name_link.click()
print(f"[Delta Indiv step2] Clicked into detail page for: {patientName}")
except Exception as e:
print(f"[Delta Indiv step2] Warning: Could not click patient name link: {e}")
else:
print("[Delta Indiv step2] Warning: No family member rows found in search results")
# Wait for the detail page to load (URL gains the "-benefit-details" segment)
try:
WebDriverWait(self.driver, 15).until(
lambda d: "check-patient-benefit-details" in d.current_url.lower()
)
print(f"[Delta Indiv step2] Navigated to detail page: {self.driver.current_url}")
except Exception:
print(f"[Delta Indiv step2] Warning: Still on search page: {self.driver.current_url}")
# Wait for meaningful content on the detail page
for selector in [
"//*[contains(text(),'Date of Birth') or contains(text(),'Address') or contains(text(),'Member ID')]",
"//table", "//h1", "//h2",
]:
try:
WebDriverWait(self.driver, 15).until(
EC.presence_of_element_located((By.XPATH, selector))
)
print(f"[Delta Indiv step2] Detail page content loaded: {selector}")
break
except Exception:
continue
time.sleep(3) # Let JavaScript finish rendering all sections
# Fallback: refine patient name from the detail page text if row extraction failed
if not patientName:
try:
page_lines = self.driver.execute_script(
"return document.body.innerText;"
).split('\n')
page_lines = [l.strip() for l in page_lines if l.strip()]
except Exception as e:
print(f"[Delta Indiv step2] Could not get page text: {e}")
page_lines = []
# UI noise words that appear in accessibility/sort-button text or field labels
ui_noise = ['click', 'sort', 'activate', 'direction', 'enter', 'space',
'current', 'use ', 'table', 'column', 'filter', 'search',
'date', 'birth', 'relationship', 'subscriber', 'coverage',
'status', 'period', 'network', 'plan', 'deductible']
def looks_like_name(text):
"""Return True if text is a plausible patient name."""
t = text.strip()
# Must be 2–60 chars, letters/spaces/hyphens/apostrophes only (no periods)
if not t or not (2 <= len(t) <= 60):
return False
if not re.match(r"^[A-Za-z\s\-']+$", t):
return False
# Must not be UI noise
if any(w in t.lower() for w in ui_noise):
return False
return True
# Preferred: the "Benefits for JOSHUA ZHANG" heading names the actual
# patient. "Subscriber name:" is the policy holder (e.g. a parent), so
# it is only used below if this heading is missing.
for line in page_lines:
heading = re.match(r'^benefits\s+for\s+(.+)$', line, re.IGNORECASE)
if heading and looks_like_name(heading.group(1)):
patientName = heading.group(1).strip()
print(f"[Delta Indiv step2] Extracted name from 'Benefits for' heading: '{patientName}'")
break
# Scan lines for the "Name" label. When "Name" appears alone we scan
# forward past accessibility text to the first plausible name value.
for i, line in enumerate(page_lines):
if patientName:
break
# Case 1: "Name : Value" or "Name: Value" on the same line
# (Delta Indiv's detail page labels this "Subscriber name:", so that
# prefix must be accepted rather than excluded)
same_line = re.match(
r'^(?:member\s+|subscriber\s+)?name\s*[:\-]\s*(.+)$', line, re.IGNORECASE
)
if same_line and not re.search(
r'(provider|group|plan)\s+name', line, re.IGNORECASE
):
candidate = same_line.group(1).strip()
if looks_like_name(candidate):
patientName = candidate
print(f"[Delta Indiv step2] Extracted name from 'Name:' label: '{patientName}'")
break
# Case 2: "Name" or "Name:" alone on a line — scan forward for value
elif re.match(r'^(?:member\s+|subscriber\s+)?name\s*:?\s*$', line, re.IGNORECASE):
for j in range(i + 1, min(i + 6, len(page_lines))):
candidate = page_lines[j].strip()
if looks_like_name(candidate):
patientName = candidate
print(f"[Delta Indiv step2] Extracted name from 'Name' label (line +{j-i}): '{patientName}'")
break
if patientName:
break
# Clean patient name — strip any remaining date artifacts
if patientName:
cleaned = re.sub(r'\s*DOB[:\s]*\d{1,2}/\d{1,2}/\d{2,4}\s*', '', patientName, flags=re.IGNORECASE).strip()
cleaned = re.sub(r'\s+\d{1,2}/\d{1,2}/\d{2,4}$', '', cleaned).strip()
patientName = cleaned if cleaned else patientName
if not patientName:
print("[Delta Indiv step2] Could not extract patient name from detail page")
# Fallback: refine eligibility status from the detail page's "Coverage is:"
# label if the family-table row extraction didn't set it (e.g. Delta Indiv
# navigated straight to the detail page, skipping the members table).
if eligibilityText == "unknown":
try:
page_text = self.driver.execute_script("return document.body.innerText;")
coverage_match = re.search(
r'coverage\s+is\s*[:\-]?\s*([A-Za-z]+)', page_text, re.IGNORECASE
)
if coverage_match:
status_text = coverage_match.group(1).strip().lower()
if "inactive" in status_text or "termed" in status_text:
eligibilityText = "inactive"
elif "active" in status_text:
eligibilityText = "active"
else:
eligibilityText = status_text
print(f"[Delta Indiv step2] Extracted eligibility from 'Coverage is:' label: '{eligibilityText}'")
except Exception as e:
print(f"[Delta Indiv step2] Could not extract eligibility from 'Coverage is:' label: {e}")
# Wait for the page to be fully ready before capturing PDF
try:
WebDriverWait(self.driver, 30).until(
lambda d: d.execute_script("return document.readyState") == "complete"
)
except Exception:
pass
# Extra wait for lazy-loaded sections (benefits summary, member history, etc.)
time.sleep(4)
# Generate PDF via Chrome DevTools Protocol
print("[Delta Indiv step2] Generating PDF of patient detail page...")
pdf_options = {
"landscape": False,
"displayHeaderFooter": False,
"printBackground": True,
"preferCSSPageSize": True,
"paperWidth": 8.5,
"paperHeight": 11,
"marginTop": 0.4,
"marginBottom": 0.4,
"marginLeft": 0.4,
"marginRight": 0.4,
"scale": 0.9,
}
result = self.driver.execute_cdp_cmd("Page.printToPDF", pdf_options)
pdf_data = base64.b64decode(result.get('data', ''))
pdf_id = foundMemberId or self.memberId or "unknown"
pdf_path = os.path.join(self.download_dir, f"eligibility_{pdf_id}.pdf")
with open(pdf_path, "wb") as f:
f.write(pdf_data)
print(f"[Delta Indiv step2] PDF saved at: {pdf_path}")
print(f"[Delta Indiv step2] Final — PatientName: '{patientName}', MemberID: '{foundMemberId}'")
return {
"status": "success",
"eligibility": eligibilityText,
"ss_path": pdf_path, # kept for backward compatibility
"pdf_path": pdf_path, # explicit pdf_path
"patientName": patientName,
"memberId": foundMemberId,
"dateOfBirth": self.dateOfBirth,
}
except Exception as e:
print("ERROR in step2:", e)
# Cleanup download dir on error
try:
dl = os.path.abspath(self.download_dir)
if os.path.isdir(dl):
for name in os.listdir(dl):
item = os.path.join(dl, name)
try:
if os.path.isfile(item) or os.path.islink(item):
os.remove(item)
except Exception as rm_err:
print(f"[cleanup] failed to remove {item}: {rm_err}")
except Exception as cleanup_exc:
print(f"[cleanup] unexpected error: {cleanup_exc}")
return {"status": "error", "message": str(e)}
def main_workflow(self, url):
try:
self.config_driver()
self.driver.maximize_window()
time.sleep(3)
login_result = self.login(url)
if login_result.startswith("ERROR"):
return {"status": "error", "message": login_result}
if login_result == "OTP_REQUIRED":
return {"status": "otp_required", "message": "OTP required after login"}
step1_result = self.step1()
if step1_result.startswith("ERROR"):
return {"status": "error", "message": step1_result}
step2_result = self.step2()
if step2_result.get("status") == "error":
return {"status": "error", "message": step2_result.get("message")}
return step2_result
except Exception as e:
return {"status": "error", "message": str(e)}
# NOTE: Do NOT quit driver — keep browser alive for next patient