Files
DentalManagement09/apps/SeleniumService/selenium_UnitedUcard_eligibilityCheckWorker.py
T
GiteadandClaude Sonnet 5 f30016e674 feat: add United Ucard eligibility check with OTP + persistent session
Adds a full login/OTP/search/capture flow for United Ucard (OneHealthcareID
-> UHC Dental provider portal), mirroring the United SCO/DDMA pattern:
persistent Chrome profile, OTP polling, member eligibility search, and PDF
capture saved to the patient's Eligibility folder.

Also fixes a validation bug in the browser-safe patient schema where
email/address/city/zipCode/gender/phone were optional but not nullable,
rejecting patient records with null values on those fields during edits
(e.g. "Expected string, received null").

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 22:44:43 -04:00

633 lines
30 KiB
Python

"""
United Ucard (OneHealthcareID) eligibility check worker.
Scaffolded from selenium_UnitedSCO_eligibilityCheckWorker.py — same shape
(config_driver / login / step1 / step2 / main_workflow) so it plugs into the
same helpers_*_eligibility.py + agent.py session/OTP pattern.
Login flow (confirmed against the live site, 2026-09-13):
0. https://www.uhcdental.com/content/provider/dental.html?state=abc (public site)
span.header-signin-btn ("Sign In") -> a.sign-in-button-link ("Provider Login")
which OIDC-redirects to identity.onehealthcareid.com (or straight back to the
dashboard if there's already a valid SSO session)
1. https://identity.onehealthcareid.com/oneapp/index.html#/login
input#username -> button#btnLogin ("Continue")
2. #/signin/password
input#login-pwd -> button#btnLogin ("Continue")
3. #/rba/options ("Verify Your Identity")
button#textMsg ("Via Text Message")
4. #/rba/options/text ("Access Code")
input#otpBox -> button#continuebtn ("Continue")
5. Lands on the UHC Dental provider dashboard:
https://secure.uhcdental.com/.../postloginhomescreen.html?...
step1()/step2() (the actual eligibility search + PDF capture, once logged in)
are left as TODO stubs — fill these in once we know the exact DOM for the
member search + eligibility result pages on the dashboard.
"""
from selenium.common.exceptions import WebDriverException, TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
import time
import os
import re
import base64
from uniteducard_browser_manager import get_browser_manager
class AutomationUnitedUcardEligibilityCheck:
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.uniteducard_username = self.data.get("uniteducardUsername", "")
self.uniteducard_password = self.data.get("uniteducardPassword", "")
self.payment_group_id = self.data.get("paymentGroupId", "")
# 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 for the One Healthcare ID domain."""
try:
print("[UnitedUcard login] Forcing logout due to credential change...")
browser_manager = get_browser_manager()
try:
self.driver.delete_all_cookies()
print("[UnitedUcard login] Cleared all cookies")
except Exception as e:
print(f"[UnitedUcard login] Error clearing cookies: {e}")
browser_manager.clear_credentials_hash()
print("[UnitedUcard login] Logout complete")
return True
except Exception as e:
print(f"[UnitedUcard login] Error during forced logout: {e}")
return False
def login(self, url):
"""
Log in via https://www.uhcdental.com/content/provider/dental.html (Sign In ->
Provider Login), which OIDC-redirects through identity.onehealthcareid.com.
Returns one of:
"ALREADY_LOGGED_IN" / "SUCCESS" - logged in, caller should proceed to step1
"OTP_REQUIRED" - caller should poll for OTP entry
"ERROR: <msg>" - login failed
"""
wait = WebDriverWait(self.driver, 30)
browser_manager = get_browser_manager()
try:
# Check if credentials have changed - if so, force logout first
if self.uniteducard_username and browser_manager.credentials_changed(self.uniteducard_username):
self._force_logout()
self.driver.get(url)
time.sleep(2)
# Navigate to the public UHC Dental provider portal (NOT directly to
# identity.onehealthcareid.com) — Sign In -> Provider Login triggers
# the OIDC redirect to the identity login page.
self.driver.get(url)
time.sleep(3)
current_url = self.driver.current_url
print(f"[UnitedUcard login] Current URL: {current_url}")
# If we've already landed on the UHC Dental provider dashboard
# (secure.uhcdental.com/.../postloginhomescreen.html), a prior
# session is still valid.
if "secure.uhcdental.com" in current_url.lower():
print("[UnitedUcard login] Already logged in - on UHC Dental dashboard")
return "ALREADY_LOGGED_IN"
# ── Step 0: public site -> Sign In -> Provider Login ────────────
# Confirmed against live site (2026-09-13):
# <span class="header-signin-btn">Sign In</span> (opens dropdown)
# <a class="sign-in-button-link" href="https://identity.onehealthcareid.com/
# oidc/authorize?client_id=DBP92444&response_type=code&scope=openid+profile+email&
# redirect_uri=https://secure.uhcdental.com/content/dental-benefits-provider/
# en/secure/postloginhomescreen.html">Provider Login</a>
if "onehealthcareid.com" not in current_url.lower():
try:
signin_btn = WebDriverWait(self.driver, 10).until(
EC.element_to_be_clickable((By.XPATH,
"//span[contains(@class,'header-signin-btn') or contains(text(),'Sign In')]"))
)
signin_btn.click()
print("[UnitedUcard login] Clicked 'Sign In' to open dropdown")
time.sleep(1)
except TimeoutException:
return "ERROR: 'Sign In' button not found on UHC Dental provider site"
# Match by exact visible text "Provider Login" — the dropdown
# also has an "Administrator Login" link that shares the same
# class AND the same oidc/authorize href pattern (just a
# different client_id), so text is the only reliable
# differentiator. It's also always the first item in the
# dropdown (Provider Login above Administrator Login).
clicked_provider_login = False
try:
provider_login_link = WebDriverWait(self.driver, 10).until(
EC.presence_of_element_located((By.XPATH,
"//a[normalize-space(text())='Provider Login']"))
)
self.driver.execute_script(
"arguments[0].scrollIntoView({block:'center'});", provider_login_link
)
time.sleep(0.3)
try:
provider_login_link.click()
print("[UnitedUcard login] Clicked 'Provider Login' (direct)")
clicked_provider_login = True
except Exception as direct_click_err:
print(f"[UnitedUcard login] Direct click failed ({direct_click_err}); trying JS click")
self.driver.execute_script("arguments[0].click();", provider_login_link)
print("[UnitedUcard login] Clicked 'Provider Login' (JS)")
clicked_provider_login = True
time.sleep(3)
except TimeoutException:
pass
if not clicked_provider_login:
# Fallback: the sign-in dropdown's first link is always
# "Provider Login" (Administrator Login is second) —
# click by position instead of matching text/href.
try:
dropdown_link = WebDriverWait(self.driver, 5).until(
EC.presence_of_element_located((By.XPATH,
"(//a[contains(@class,'sign-in-button-link')])[1]"))
)
self.driver.execute_script("arguments[0].click();", dropdown_link)
print("[UnitedUcard login] Clicked first dropdown link (Provider Login, by position)")
clicked_provider_login = True
time.sleep(3)
except Exception as e:
return f"ERROR: 'Provider Login' link not found in Sign In dropdown: {e}"
current_url = self.driver.current_url
print(f"[UnitedUcard login] After Provider Login click URL: {current_url}")
# SSO may skip straight back to the dashboard if already authenticated
if "secure.uhcdental.com" in current_url.lower():
print("[UnitedUcard login] Already logged in - redirected straight to dashboard")
return "ALREADY_LOGGED_IN"
# Check for OTP input first (in case device left mid-OTP)
try:
WebDriverWait(self.driver, 3).until(
EC.presence_of_element_located((By.XPATH,
"//input[@type='tel' or contains(@placeholder,'code') or contains(@placeholder,'Code') or "
"contains(@aria-label,'Verification') or contains(@id,'otp') or contains(@name,'otp')]"))
)
print("[UnitedUcard login] OTP input found on landing")
return "OTP_REQUIRED"
except TimeoutException:
pass
# ── Step 1: "One Healthcare ID or Email Address" ────────────────
# Confirmed against live site (2026-09-13):
# <input id="username" data-testid="username" ...>
# <button id="btnLogin" data-cy="data-btnLogin-field">Continue</button>
try:
username_field = wait.until(
EC.element_to_be_clickable((By.ID, "username"))
)
username_field.clear()
username_field.send_keys(self.uniteducard_username)
print(f"[UnitedUcard login] Entered username: {self.uniteducard_username}")
except TimeoutException:
return "ERROR: Username field not found on login page"
try:
continue_btn = wait.until(
EC.element_to_be_clickable((By.ID, "btnLogin"))
)
continue_btn.click()
print("[UnitedUcard login] Clicked Continue (btnLogin) after username")
time.sleep(2)
except TimeoutException:
return "ERROR: Continue button (btnLogin) not found after entering username"
# ── Step 2: "Enter Your Password" page (#/signin/password) ─────
# Confirmed against live site (2026-09-13):
# <input id="login-pwd" data-testid="login-pwd" type="password" ...>
# <button id="btnLogin" data-cy="data-btnLogin-field">Continue</button>
# The email field on this page is pre-filled/read-only from step 1.
try:
password_field = wait.until(
EC.presence_of_element_located((By.ID, "login-pwd"))
)
password_field.clear()
password_field.send_keys(self.uniteducard_password)
print("[UnitedUcard login] Entered password")
except TimeoutException:
return "ERROR: Password field not found on login page"
try:
signin_button = wait.until(
EC.element_to_be_clickable((By.ID, "btnLogin"))
)
signin_button.click()
print("[UnitedUcard login] Clicked Continue (btnLogin) on password page")
except TimeoutException:
return "ERROR: Continue button (btnLogin) not found on password page"
if self.uniteducard_username:
browser_manager.save_credentials_hash(self.uniteducard_username)
time.sleep(5) # Wait for login to process
current_url_after_login = self.driver.current_url.lower()
print(f"[UnitedUcard login] After login URL: {current_url_after_login}")
if "secure.uhcdental.com" in current_url_after_login:
print("[UnitedUcard login] Login successful - redirected to UHC Dental dashboard")
return "SUCCESS"
# ── Step 3: "Verify Your Identity" MFA method page (#/rba/options) ──
# Confirmed against live site (2026-09-13):
# <button id="textMsg" data-cy="data-textMsg-field">Via Text Message</button>
if "rba/options" in current_url_after_login:
try:
text_msg_btn = WebDriverWait(self.driver, 10).until(
EC.element_to_be_clickable((By.ID, "textMsg"))
)
text_msg_btn.click()
print("[UnitedUcard login] Clicked 'Via Text Message' on MFA options page")
time.sleep(3)
except TimeoutException:
print("[UnitedUcard login] MFA options page detected but 'Via Text Message' button not found")
current_url_after_login = self.driver.current_url.lower()
print(f"[UnitedUcard login] After MFA method selection URL: {current_url_after_login}")
# Check for OTP input after submitting credentials
try:
WebDriverWait(self.driver, 15).until(
EC.presence_of_element_located((By.XPATH,
"//input[@type='tel' or contains(@placeholder,'code') or contains(@placeholder,'Code') or "
"contains(@aria-label,'Verification') or contains(@aria-label,'verification') or "
"contains(@name,'otp') or contains(@name,'code') or contains(@id,'otp') or contains(@id,'code')]"
))
)
print("[UnitedUcard login] OTP input detected -> OTP_REQUIRED")
return "OTP_REQUIRED"
except TimeoutException:
print("[UnitedUcard login] No OTP input detected")
# Re-check after waiting for OTP check
current_url_after_login = self.driver.current_url.lower()
if "secure.uhcdental.com" in current_url_after_login:
print("[UnitedUcard login] Login successful - redirected to UHC Dental dashboard")
return "SUCCESS"
# Check for error messages on the page
try:
error_elem = self.driver.find_element(By.XPATH,
"//*[contains(@class,'error') or contains(@class,'alert')]")
error_text = error_elem.text
if error_text:
print(f"[UnitedUcard login] Error on page: {error_text}")
return f"ERROR: {error_text}"
except Exception:
pass
return "ERROR: Login did not complete - still on identity domain"
except Exception as e:
print(f"[UnitedUcard login] Exception: {e}")
return f"ERROR:LOGIN FAILED: {e}"
def _format_dob(self, dob_str):
"""Convert DOB from YYYY-MM-DD to MM/DD/YYYY format"""
if dob_str and "-" in dob_str:
dob_parts = dob_str.split("-")
if len(dob_parts) == 3:
# YYYY-MM-DD -> MM/DD/YYYY
return f"{dob_parts[1]}/{dob_parts[2]}/{dob_parts[0]}"
return dob_str
def step1(self):
"""
Fill in the "Eligibility Search" form — it's the default tab already
showing on the dashboard (postloginhomescreen.html) right after login.
Confirmed against live site (2026-09-13):
<input id="serviceDate"> - defaults blank; fill with today's date, MM/DD/YYYY
<input id="eligMemDob"> - Member Date of Birth, MM/DD/YYYY
<input id="eligSubsId"> - Subscriber ID
<input type="button" value="search" ng-click="submit()">
"Search By: Subscriber ID" and "Search For: Individual" are already
the default radio selections, so no extra clicks needed there.
"""
from datetime import date
def _fill_field(field_id, value, label):
"""Wait for field to be clickable, scroll into view, then fill it.
Falls back to a JS value-set (+dispatch input/change events, which
Angular's ng-model listens for) if send_keys hits an
ElementNotInteractable error — the dashboard's Angular app can
take a beat to finish rendering/enabling these fields."""
field = WebDriverWait(self.driver, 20).until(
EC.presence_of_element_located((By.ID, field_id))
)
self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", field)
time.sleep(0.3)
try:
WebDriverWait(self.driver, 10).until(EC.element_to_be_clickable((By.ID, field_id)))
field.clear()
field.send_keys(value)
print(f"[UnitedUcard step1] Entered {label}: {value}")
except Exception as e:
print(f"[UnitedUcard step1] send_keys failed for {label} ({e}); falling back to JS set")
self.driver.execute_script("""
var el = arguments[0];
var val = arguments[1];
var nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
nativeSetter.call(el, val);
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
el.dispatchEvent(new Event('keyup', { bubbles: true }));
""", field, value)
print(f"[UnitedUcard step1] Entered {label} via JS: {value}")
return field
try:
print(f"[UnitedUcard step1] Starting eligibility search for memberId={self.memberId}, DOB={self.dateOfBirth}")
# Give the dashboard's Angular app a moment to finish rendering
# after the redirect from login — filling fields too early was
# throwing "element not interactable".
WebDriverWait(self.driver, 20).until(
lambda d: d.execute_script("return document.readyState") == "complete"
)
time.sleep(2)
# Service Date - default to today.
# NOTE: serviceDate/eligMemDob both use onkeydown="dateFunction(...)",
# a JS input mask that auto-inserts "/" as you type raw digits
# (same as manually typing it). Sending an already-formatted
# "MM/DD/YYYY" string double-processes the slashes through the
# mask and corrupts the stored value — send digits only instead,
# matching what a human typing the field actually does.
try:
today_digits = date.today().strftime("%m%d%Y")
_fill_field("serviceDate", today_digits, "Service Date")
except TimeoutException:
return "ERROR: Service Date field (serviceDate) not found on Eligibility Search page"
except Exception as e:
return f"ERROR: Could not enter Service Date: {e}"
# Member Date of Birth
try:
dob_digits = re.sub(r"\D", "", self._format_dob(self.dateOfBirth))
_fill_field("eligMemDob", dob_digits, "Member DOB")
except Exception as e:
return f"ERROR: Could not enter Member Date of Birth: {e}"
# Subscriber ID
try:
_fill_field("eligSubsId", self.memberId, "Subscriber ID")
except Exception as e:
return f"ERROR: Could not enter Subscriber ID: {e}"
time.sleep(0.5)
# Click SEARCH button
try:
search_btn = WebDriverWait(self.driver, 10).until(
EC.presence_of_element_located((By.XPATH,
"//input[@type='button' and @value='search']"))
)
self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", search_btn)
time.sleep(0.3)
try:
search_btn.click()
except Exception:
self.driver.execute_script("arguments[0].click();", search_btn)
print("[UnitedUcard step1] Clicked SEARCH button")
except Exception as e:
return f"ERROR: Could not click SEARCH button: {e}"
time.sleep(3)
# TODO: confirm what the results look like (same page update? new
# tab? modal?) once we see it, then extend step2() accordingly.
print("[UnitedUcard step1] Eligibility search submitted")
return "Success"
except Exception as e:
print(f"[UnitedUcard step1] Exception: {e}")
return f"ERROR:STEP1 - {e}"
def step2(self):
"""
Extract patient/eligibility info from the "Eligibility Summary" page
(secure.uhcdental.com/.../eligibility-summary.html) that step1()'s
SEARCH click lands on, then capture it as a PDF.
Confirmed against live site (2026-09-13) via screenshot/PDF export:
"Patient" panel -> patient full name (e.g. "WINGHUN KWONG")
"Insurance Information" -> status badges: "In Network",
"Eligible" (or "Not Eligible"/"Ineligible"), "No Essential
Health Benefits", and a MASKED Subscriber ID (e.g. "******444")
— we deliberately do NOT use that masked ID to overwrite the
patient record.
Exact DOM ids/classes for the badges haven't been inspected yet, so
this uses text-based extraction (same approach as UnitedSCO/DDMA)
as a robust starting point.
"""
try:
print("[UnitedUcard step2] Starting eligibility capture")
try:
WebDriverWait(self.driver, 20).until(
lambda d: d.execute_script("return document.readyState") == "complete"
)
except Exception:
pass
time.sleep(2)
current_url = self.driver.current_url
print(f"[UnitedUcard step2] Current URL: {current_url}")
page_text = ""
try:
page_text = self.driver.find_element(By.TAG_NAME, "body").text
except Exception:
pass
# ── Eligibility status ───────────────────────────────────────
eligibilityText = "unknown"
lower_text = page_text.lower()
if "not eligible" in lower_text or "ineligible" in lower_text:
eligibilityText = "inactive"
elif "eligible" in lower_text:
eligibilityText = "active"
print(f"[UnitedUcard step2] Eligibility status: {eligibilityText}")
# ── Patient name ─────────────────────────────────────────────
patientName = f"{self.firstName} {self.lastName}".strip()
name_extracted = False
# Strategy 1: text right after the "Patient" heading, e.g.
# "Patient\nWINGHUN KWONG\nSUBSCRIBER/INSURED"
try:
name_match = re.search(
r'Patient\s*\n\s*([A-Z][A-Za-z\-\']+(?:\s+[A-Z][A-Za-z\-\']+)+)\s*\n',
page_text
)
if name_match:
candidate = name_match.group(1).strip()
if candidate and len(candidate) < 60 and "eligible" not in candidate.lower():
patientName = candidate.title()
name_extracted = True
print(f"[UnitedUcard step2] Extracted patient name from text: {patientName}")
except Exception as e:
print(f"[UnitedUcard step2] Name regex failed: {e}")
# Strategy 2: DOM heuristic fallback — heading/strong element
# following a "Patient" label
if not name_extracted:
try:
elems = self.driver.find_elements(By.XPATH,
"//*[contains(text(),'Patient')]/following::*[self::h1 or self::h2 or self::h3 or self::strong][1]"
)
for elem in elems:
txt = elem.text.strip()
if txt and len(txt.split()) >= 2 and txt[0].isupper() and len(txt) < 60:
patientName = txt.title()
name_extracted = True
print(f"[UnitedUcard step2] Extracted patient name from DOM: {patientName}")
break
except Exception:
pass
if not name_extracted:
print("[UnitedUcard step2] WARNING: Could not extract patient name from page; using form values")
# ── Capture PDF ──────────────────────────────────────────────
pdf_path = self._capture_pdf(self.memberId)
if not pdf_path:
return {"status": "error", "message": "STEP2 FAILED: Could not generate PDF"}
print(f"[UnitedUcard step2] PDF saved: {pdf_path}")
self._hide_browser()
print("[UnitedUcard step2] Eligibility capture complete")
return {
"status": "success",
"eligibility": eligibilityText,
"ss_path": pdf_path,
"pdf_path": pdf_path,
"patientName": patientName,
# NOTE: Subscriber ID shown on this page is masked (e.g.
# "******444"), so we return the ID we searched with rather
# than a scraped-and-truncated value.
"memberId": self.memberId,
}
except Exception as e:
print(f"[UnitedUcard step2] Exception: {e}")
return {"status": "error", "message": f"STEP2 FAILED: {str(e)}"}
def _capture_pdf(self, member_id):
"""Capture the current page as PDF using Chrome DevTools Protocol."""
try:
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,
}
file_identifier = member_id if member_id else f"{self.firstName}_{self.lastName}"
result = self.driver.execute_cdp_cmd("Page.printToPDF", pdf_options)
pdf_data = base64.b64decode(result.get('data', ''))
pdf_path = os.path.join(self.download_dir, f"uniteducard_eligibility_{file_identifier}_{int(time.time())}.pdf")
with open(pdf_path, "wb") as f:
f.write(pdf_data)
return pdf_path
except Exception as e:
print(f"[UnitedUcard _capture_pdf] Error: {e}")
return None
def _hide_browser(self):
"""Hide the browser window after task completion using multiple strategies."""
try:
try:
self.driver.get("about:blank")
time.sleep(0.5)
except Exception:
pass
try:
self.driver.minimize_window()
print("[UnitedUcard step2] Browser window minimized")
return
except Exception:
pass
try:
self.driver.set_window_position(-10000, -10000)
print("[UnitedUcard step2] Browser window moved off-screen")
return
except Exception:
pass
except Exception as e:
print(f"[UnitedUcard step2] Could not hide browser: {e}")
def main_workflow(self, url):
"""Main workflow that runs all steps."""
try:
self.config_driver()
login_result = self.login(url)
print(f"[main_workflow] Login result: {login_result}")
if login_result == "OTP_REQUIRED":
return {"status": "otp_required", "message": "OTP required after login"}
if isinstance(login_result, str) and login_result.startswith("ERROR"):
return {"status": "error", "message": login_result}
step1_result = self.step1()
print(f"[main_workflow] Step1 result: {step1_result}")
if isinstance(step1_result, str) and step1_result.startswith("ERROR"):
return {"status": "error", "message": step1_result}
step2_result = self.step2()
print(f"[main_workflow] Step2 result: {step2_result}")
return step2_result
except Exception as e:
return {"status": "error", "message": str(e)}