From b1f0bafad946612eca9b98d402ea79a6eae9f7cc Mon Sep 17 00:00:00 2001 From: Abdelkouddous LHACHIMI Date: Fri, 13 Feb 2026 00:19:56 +0100 Subject: [PATCH] Complete before first tests --- .gitignore | 25 + config/config.xlsx | Bin 0 -> 65839 bytes eb_script_template.py | 699 ------------- ...=> extract_endoconnect_medical_records.bat | 3 +- extract_endoconnect_medical_records.py | 922 ++++++++++++++++++ 5 files changed, 948 insertions(+), 701 deletions(-) create mode 100644 .gitignore create mode 100644 config/config.xlsx delete mode 100644 eb_script_template.py rename eb_script_template.bat => extract_endoconnect_medical_records.bat (55%) create mode 100644 extract_endoconnect_medical_records.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b7e4351 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +.env +venv/ +ENV/ +NUL diff --git a/config/config.xlsx b/config/config.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..823b556ba93bba35312fc443ddaf9dfa50783cf4 GIT binary patch literal 65839 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/{{total_fmt:<{BAR_TOTAL_FMT_WIDTH}}} " - f"[{{elapsed:<{BAR_TIME_WIDTH}}}<{{remaining:>{BAR_TIME_WIDTH}}}, " - f"{{rate_fmt:>{BAR_RATE_WIDTH}}}]{{postfix}}") - - -# ============================================================================ -# GLOBAL VARIABLES -# ============================================================================ - -# Tokens storage: {app_name: {"access_token": ..., "refresh_token": ...}} -tokens = {} - -# Thread-safe HTTP client pool (one client per thread) -httpx_clients = {} - -# Thread management -threads_list = [] -_threads_list_lock = threading.Lock() -_token_refresh_lock = threading.Lock() - -# Thread pools (initialized in main()) -main_thread_pool = None -subtasks_thread_pool = None - -# User interaction lock -_user_interaction_lock = threading.Lock() - -# Thread-local storage for context -thread_local_storage = threading.local() - -# Rich console for formatted output -console = Console() - - -# ============================================================================ -# UTILITIES -# ============================================================================ - -def get_nested_value(data_structure, path, default=None): - """ - Extract value from nested dict/list structures with wildcard support. - - Args: - data_structure: Nested dict/list to navigate - path: List of keys/indices. Use '*' for list wildcard - default: Value to return if path not found - - Returns: - Value at path, or default if not found - - Examples: - get_nested_value({"a": {"b": 1}}, ["a", "b"]) -> 1 - get_nested_value({"items": [{"x": 1}, {"x": 2}]}, ["items", "*", "x"]) -> [1, 2] - """ - if data_structure is None: - return "$$$$ No Data" - if not path: - return default - - # Handle wildcard in path - if "*" in path: - wildcard_index = path.index("*") - path_before = path[:wildcard_index] - path_after = path[wildcard_index+1:] - - # Helper for non-wildcard path resolution - def _get_simple_nested_value(ds, p, d): - cl = ds - for k in p: - if isinstance(cl, dict): - cl = cl.get(k) - elif isinstance(cl, list): - try: - if isinstance(k, int) and -len(cl) <= k < len(cl): - cl = cl[k] - else: - return d - except (IndexError, TypeError): - return d - else: - return d - if cl is None: - return d - return cl - - base_level = _get_simple_nested_value(data_structure, path_before, default) - - if not isinstance(base_level, list): - return default - - results = [] - for item in base_level: - value = get_nested_value(item, path_after, default) - if value is not default and value != "$$$$ No Data": - results.append(value) - - # Flatten one level for multiple wildcards - final_results = [] - for res in results: - if isinstance(res, list): - final_results.extend(res) - else: - final_results.append(res) - - return final_results - - # No wildcard - standard traversal - current_level = data_structure - for key_or_index in path: - if isinstance(current_level, dict): - current_level = current_level.get(key_or_index) - if current_level is None: - return default - elif isinstance(current_level, list): - try: - if isinstance(key_or_index, int) and -len(current_level) <= key_or_index < len(current_level): - current_level = current_level[key_or_index] - else: - return default - except (IndexError, TypeError): - return default - else: - return default - return current_level - - -def get_httpx_client() -> httpx.Client: - """ - Get or create thread-local HTTP client with keep-alive enabled. - Each thread gets its own client to avoid connection conflicts. - - Returns: - httpx.Client instance for current thread - """ - global httpx_clients - thread_id = threading.get_ident() - if thread_id not in httpx_clients: - httpx_clients[thread_id] = httpx.Client( - headers={"Connection": "keep-alive"}, - limits=httpx.Limits(max_keepalive_connections=20, max_connections=100) - ) - return httpx_clients[thread_id] - - -def get_thread_position(): - """ - Get position of current thread in threads list. - Used for managing progress bar positions in multithreaded environment. - - Returns: - Zero-based index of current thread - """ - global threads_list - thread_id = threading.get_ident() - with _threads_list_lock: - if thread_id not in threads_list: - threads_list.append(thread_id) - return len(threads_list) - 1 - else: - return threads_list.index(thread_id) - - -def clear_httpx_client(): - """ - Clear the thread-local HTTP client to force creation of a new one. - Useful for resetting connections after errors. - """ - global httpx_clients - thread_id = threading.get_ident() - if thread_id in httpx_clients: - try: - httpx_clients[thread_id].close() - except Exception: - pass - del httpx_clients[thread_id] - - -def run_with_context(func, context, *args, **kwargs): - """ - Wrapper to set thread-local context before running a function in a new thread. - Useful for ThreadPoolExecutor where context is lost. - """ - thread_local_storage.current_patient_context = context - return func(*args, **kwargs) - - -# ============================================================================ -# AUTHENTICATION -# ============================================================================ - -def login(): - """ - Authenticate with IAM and configure tokens for all microservices. - - Process: - 1. Prompt for credentials (with defaults) - 2. Login to IAM -> get master_token and user_id - 3. For each microservice (except IAM): call config-token API - 4. Store access_token and refresh_token for each service - - Returns: - "Success": Authentication succeeded for all services - "Error": Authentication failed (can retry) - "Exit": User cancelled login - """ - global tokens - - # Prompt for credentials - user_name = questionary.text("login:", default=DEFAULT_USER_NAME).ask() - password = questionary.password("password:", default=DEFAULT_PASSWORD).ask() - - if not (user_name and password): - return "Exit" - - # Step 1: Login to IAM - try: - client = get_httpx_client() - client.base_url = MICROSERVICES["IAM"]["base_url"] - response = client.post( - MICROSERVICES["IAM"]["endpoints"]["login"].format(**{**globals(),**locals()}), - json={"username": user_name, "password": password}, - timeout=20 - ) - response.raise_for_status() - master_token = response.json()["access_token"] - user_id = response.json()["userId"] - tokens["IAM"] = { - "access_token": master_token, - "refresh_token": response.json()["refresh_token"] - } - except (httpx.RequestError, httpx.HTTPStatusError) as exc: - print(f"Login Error: {exc}") - logging.warning(f"Login Error: {exc}") - return "Error" - - # Step 2: Configure tokens for each microservice - for app_name, app_config in MICROSERVICES.items(): - if app_name == "IAM": - continue # IAM doesn't need config-token - - try: - client = get_httpx_client() - client.base_url = app_config["base_url"] - response = client.post( - app_config["endpoints"]["config_token"].format(**{**globals(),**locals()}), - headers={"Authorization": f"Bearer {master_token}"}, - json={ - "userId": user_id, - "clientId": app_config["app_id"], - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36" - }, - timeout=20 - ) - response.raise_for_status() - tokens[app_name] = { - "access_token": response.json()["access_token"], - "refresh_token": response.json()["refresh_token"] - } - except (httpx.RequestError, httpx.HTTPStatusError) as exc: - print(f"Config-token Error for {app_name}: {exc}") - logging.warning(f"Config-token Error for {app_name}: {exc}") - return "Error" - - print("\nLogin Success") - return "Success" - - -def new_token(app): - """ - Refresh access token for a specific microservice. - - Uses refresh_token to obtain new access_token and refresh_token. - Thread-safe with lock to prevent concurrent refresh attempts. - - Args: - app: Microservice name (e.g., "RC", "GDD") - - Raises: - httpx.RequestError: If refresh fails after all retries - """ - global tokens - - with _token_refresh_lock: - for attempt in range(ERROR_MAX_RETRY): - try: - client = get_httpx_client() - client.base_url = MICROSERVICES[app]["base_url"] - response = client.post( - MICROSERVICES[app]["endpoints"]["refresh"].format(**{**globals(),**locals()}), - headers={"Authorization": f"Bearer {tokens[app]['access_token']}"}, - json={"refresh_token": tokens[app]["refresh_token"]}, - timeout=20 - ) - response.raise_for_status() - tokens[app]["access_token"] = response.json()["access_token"] - tokens[app]["refresh_token"] = response.json()["refresh_token"] - return - except (httpx.RequestError, httpx.HTTPStatusError) as exc: - logging.warning(f"Refresh Token Error for {app} (Attempt {attempt + 1}): {exc}") - if attempt < ERROR_MAX_RETRY - 1: - sleep(WAIT_BEFORE_RETRY) - - logging.critical(f"Persistent error in refresh_token for {app}") - raise httpx.RequestError(message=f"Persistent error in refresh_token for {app}") - - -# ============================================================================ -# DECORATORS -# ============================================================================ - -def api_call_with_retry(func): - """Decorator for API calls with automatic retry and token refresh on 401 errors""" - @functools.wraps(func) - def wrapper(*args, **kwargs): - func_name = func.__name__ - total_attempts = 0 - batch_count = 1 - - while True: - for attempt in range(ERROR_MAX_RETRY): - total_attempts += 1 - try: - return func(*args, **kwargs) - except (httpx.RequestError, httpx.HTTPStatusError) as exc: - logging.warning(f"Error in {func_name} (Attempt {total_attempts}): {exc}") - - # Refresh the thread-local client if an error occurs - # to avoid potential pool corruption or stale connections - clear_httpx_client() - - if isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code == 401: - logging.info(f"Token expired for {func_name}. Refreshing token.") - new_token() - - if attempt < ERROR_MAX_RETRY - 1: - sleep(WAIT_BEFORE_RETRY) - else: - # Max retries reached for this batch - if batch_count < MAX_BATCHS_OF_RETRIES: - logging.warning(f"Batch {batch_count}/{MAX_BATCHS_OF_RETRIES} failed for {func_name}. " - f"Waiting {WAIT_BEFORE_NEW_BATCH_OF_RETRIES}s before automatic retry batch.") - batch_count += 1 - sleep(WAIT_BEFORE_NEW_BATCH_OF_RETRIES) - break # Exit for loop to restart batch in while True - else: - # All automatic batches exhausted, ask the user - with _user_interaction_lock: - console.print(f"\n[bold red]Persistent error in {func_name} after {batch_count} batches ({total_attempts} attempts).[/bold red]") - console.print(f"[red]Exception: {exc}[/red]") - - choice = questionary.select( - f"What would you like to do for {func_name}?", - choices=[ - "Retry (try another batch of retries)", - "Ignore (return None and continue)", - "Stop script (critical error)" - ] - ).ask() - - if choice == "Retry (try another batch of retries)": - logging.info(f"User chose to retry {func_name}. Restarting batch sequence.") - batch_count = 1 # Reset batch counter for the next interactive round - break # Exit for loop to restart batch in while True - elif choice == "Ignore (return None and continue)": - # Retrieve context if available - ctx = getattr(thread_local_storage, "current_patient_context", {"id": "Unknown", "pseudo": "Unknown"}) - logging.warning(f"[IGNORE] User opted to skip {func_name} for Patient {ctx['id']} ({ctx['pseudo']}). Error: {exc}") - return None - else: - logging.critical(f"User chose to stop script after persistent error in {func_name}.") - raise httpx.RequestError(message=f"Persistent error in {func_name} (stopped by user)") - - return wrapper - - -# ============================================================================ -# API TEMPLATES -# ============================================================================ -# Templates for common API patterns. Duplicate and modify for your needs. -# Remember to: -# - Choose appropriate HTTP method (GET/POST/PUT/DELETE) -# - Update endpoint from MICROSERVICES dict -# - Adjust timeout if needed (global API_TIMEOUT or specific value) -# - Always return response.json() - -@api_call_with_retry("RC") -def get_all_organizations(): - """ - Example API call using GET method. - - Returns: - List of organization dictionaries - """ - client = get_httpx_client() - client.base_url = MICROSERVICES["RC"]["base_url"].format(**{**globals(),**locals()}) - response = client.get( - MICROSERVICES["RC"]["endpoints"]["organizations"], - headers={"Authorization": f"Bearer {tokens['RC']['access_token']}"}, - timeout=API_TIMEOUT - ) - response.raise_for_status() - return response.json() - - -@api_call_with_retry("RC") -def search_inclusions(organization_id, limit, page): - """ - Example API call using POST method with query params and JSON body. - - Args: - organization_id: Organization UUID - limit: Max results per page - page: Page number (1-based) - - Returns: - Dict with "data" key containing list of inclusions - """ - client = get_httpx_client() - client.base_url = MICROSERVICES["RC"]["base_url"].format(**{**globals(),**locals()}) - response = client.post( - f"{MICROSERVICES['RC']['endpoints']['search_inclusions']}?limit={limit}&page={page}", - headers={"Authorization": f"Bearer {tokens['RC']['access_token']}"}, - json={ - "protocolId": "3c7bcb4d-91ed-4e9f-b93f-99d8447a276e", # TODO: Configure if needed - "center": organization_id, - "keywords": "" - }, - timeout=API_TIMEOUT - ) - response.raise_for_status() - return response.json() - - -# ============================================================================ -# MAIN PROCESSING -# ============================================================================ - -def main(): - """ - Main processing function. - - Structure: - 1. Authentication - 2. Configuration (thread count) - 3. Initialization (thread pools, timing) - 4. Main processing block (TODO: implement your logic here) - 5. Finalization (elapsed time) - """ - global main_thread_pool, subtasks_thread_pool, thread_local_storage - - # ========== AUTHENTICATION ========== - print() - login_status = login() - while login_status == "Error": - login_status = login() - if login_status == "Exit": - return - - # ========== CONFIGURATION ========== - print() - number_of_threads = int( - questionary.text( - "Number of threads:", - default="12", - validate=lambda x: x.isdigit() and 0 < int(x) <= MAX_THREADS - ).ask() - ) - - # ========== INITIALIZATION ========== - start_time = perf_counter() - - # Initialize thread pools - main_thread_pool = ThreadPoolExecutor(max_workers=number_of_threads) - subtasks_thread_pool = ThreadPoolExecutor(max_workers=SUBTASKS_POOL_SIZE) - - # ========== MAIN PROCESSING BLOCK ========== - print() - console.print("[bold cyan]Starting main processing...[/bold cyan]") - - # TODO: IMPLEMENT YOUR PROCESSING LOGIC HERE - # - # Example pattern with progress bar and multithreading: - # - # items = [...] # Your data to process - # futures = [] - # - # with tqdm(total=len(items), desc="Processing items", - # bar_format=custom_bar_format) as pbar: - # with main_thread_pool as executor: - # - # for item in items: - # - # # Set thread-local context for detailed error logging in decorators - # ctx = {"id": patient_id, "pseudo": pseudo} - # thread_local_storage.current_patient_context = ctx - # - # futures.append(executor.submit(run_with_context, process_item, ctx, item)) - # - # for future in as_completed(futures): - # try: - # result = future.result() - # # Process result here - # pbar.update(1) - # except Exception as exc: - # logging.critical(f"Error in worker: {exc}", exc_info=True) - # print(f"\nCRITICAL ERROR: {exc}") - # executor.shutdown(wait=False, cancel_futures=True) - # raise - # - # Example: Simple test to verify authentication works - # organizations = get_all_organizations() - # console.print(f"[green]Retrieved {len(organizations)} organizations[/green]") - - # ========== FINALIZATION ========== - print() - print(f"Elapsed time: {str(timedelta(seconds=perf_counter() - start_time))}") - - -# ============================================================================ -# ENTRY POINT -# ============================================================================ - -if __name__ == '__main__': - # ========== LOGGING CONFIGURATION ========== - # Auto-generate log filename based on script name - script_name = os.path.splitext(os.path.basename(__file__))[0] - log_file_name = f"{script_name}.log" - - logging.basicConfig( - level=LOG_LEVEL, - format=LOG_FORMAT, - filename=log_file_name, - filemode='w' - ) - - # ========== MAIN EXECUTION ========== - try: - main() - except Exception as e: - logging.critical(f"Script terminated with exception: {e}", exc_info=True) - print(f"\nScript stopped due to error: {e}") - print(traceback.format_exc()) - finally: - # ========== CLEANUP ========== - # Shutdown thread pools gracefully - if 'main_thread_pool' in globals() and main_thread_pool: - main_thread_pool.shutdown(wait=False, cancel_futures=True) - if 'subtasks_thread_pool' in globals() and subtasks_thread_pool: - subtasks_thread_pool.shutdown(wait=False, cancel_futures=True) - - # Pause before exit (prevents console from closing immediately when launched from Windows Explorer) - print('\n') - input("Press Enter to exit...") diff --git a/eb_script_template.bat b/extract_endoconnect_medical_records.bat similarity index 55% rename from eb_script_template.bat rename to extract_endoconnect_medical_records.bat index eb36bb4..bab9356 100644 --- a/eb_script_template.bat +++ b/extract_endoconnect_medical_records.bat @@ -1,4 +1,3 @@ @echo off call C:\PythonProjects\.rcvenv\Scripts\activate.bat -python eb_script_template.py %* - +python extract_endoconnect_medical_records.py %* diff --git a/extract_endoconnect_medical_records.py b/extract_endoconnect_medical_records.py new file mode 100644 index 0000000..8a5ca68 --- /dev/null +++ b/extract_endoconnect_medical_records.py @@ -0,0 +1,922 @@ +""" +Extract Endoconnect Medical Records + +Automated extraction of patient medical records from the Endoconnect platform. + +FEATURES: +- Single-service authentication (Endoconnect) +- Thread-safe HTTP client pool +- Multithreading with progress bars +- Automatic retry on API errors +- Criteria/values configuration from Excel +- JSON export + +QUICK START: +- Run script: python extract_endoconnect_medical_records.py +- Login with credentials +- Confirm/edit professional ID and thread count +- Wait for processing to complete +- Output: JSON file in current directory +""" + +import json +import logging +import os +import sys +import threading +import traceback +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime, timedelta +from time import perf_counter, sleep +import functools + +import httpx +import openpyxl +import questionary +from tqdm import tqdm +from rich.console import Console + + +# ============================================================================ +# CONFIGURATION - CREDENTIALS +# ============================================================================ + +DEFAULT_USER_NAME = "abdel.lhachimi@gmail.com" +DEFAULT_PASSWORD = "GU$y#C#Cv73XFKyT3j6^" + + +# ============================================================================ +# CONFIGURATION - ENDOCONNECT API +# ============================================================================ + +ENDOCONNECT_BASE_URL = "https://api-endo.ziwig.com/api/" + +LOGIN_PATH = "auth/login" +PATIENTS_LIST_PATH = "patients/list" +MEDICAL_RECORD_PATH = "records" +MEDICAL_EVENTS_PATH = "events" + + +# ============================================================================ +# CONFIGURATION - PROFESSIONAL +# ============================================================================ + +DEFAULT_PROFESSIONAL_ID = "99990000005" + + +# ============================================================================ +# CONFIGURATION - PAGINATION +# ============================================================================ + +MAX_PAGE_SIZE = 1000 + + +# ============================================================================ +# CONFIGURATION - THREADING +# ============================================================================ + +MAX_THREADS = 20 + + +# ============================================================================ +# CONFIGURATION - EXCEL CONFIG +# ============================================================================ + +CONFIG_DIR = "config" +CONFIG_WORKBOOK_NAME = "config.xlsx" +CONFIG_CRITERIA_SHEET_NAME = "Criteria" +CONFIG_VALUES_SHEET_NAME = "Criteria_values" + +# Column names - criteria sheet +COL_CRITERIA_ID = "criteria_id" +COL_CRITERIA_LABEL = "criteria_name" +COL_CRITERIA_TYPE = "criteria_type" +COL_CRITERIA_LEVEL1_LABEL = "domaine_name" +COL_CRITERIA_LEVEL2_LABEL = "subdomaine_name" +COL_CRITERIA_ORDER = "criteria_order" + +# Column names - values sheet +COL_VALUE_CRITERIA_ID = "criteria_id" +COL_VALUE_ID = "criteria_value_id" +COL_VALUE_LABEL = "criteria_value" + + +# ============================================================================ +# CONFIGURATION - OUTPUT +# ============================================================================ + +OUTPUT_FILE_NAME = "endoconnect_medical_records" + + +# ============================================================================ +# CONFIGURATION - RETRY & TIMEOUTS +# ============================================================================ + +ERROR_MAX_RETRY = 10 +WAIT_BEFORE_RETRY = 1 +API_TIMEOUT = 600 +MAX_BATCHS_OF_RETRIES = 3 +WAIT_BEFORE_NEW_BATCH_OF_RETRIES = 20 + + +# ============================================================================ +# CONFIGURATION - LOGGING +# ============================================================================ + +LOG_LEVEL = logging.INFO +LOG_FORMAT = '%(asctime)s - %(levelname)s - %(message)s' + + +# ============================================================================ +# CONFIGURATION - PROGRESS BARS +# ============================================================================ + +BAR_N_FMT_WIDTH = 4 +BAR_TOTAL_FMT_WIDTH = 4 +BAR_TIME_WIDTH = 8 +BAR_RATE_WIDTH = 10 + +custom_bar_format = ("{l_bar}{bar}" + f" {{n_fmt:>{BAR_N_FMT_WIDTH}}}/{{total_fmt:<{BAR_TOTAL_FMT_WIDTH}}} " + f"[{{elapsed:<{BAR_TIME_WIDTH}}}<{{remaining:>{BAR_TIME_WIDTH}}}, " + f"{{rate_fmt:>{BAR_RATE_WIDTH}}}]{{postfix}}") + + +# ============================================================================ +# GLOBAL VARIABLES +# ============================================================================ + +token = None + +# Thread-safe HTTP client pool (one client per thread) +httpx_clients = {} + +# Thread management +threads_list = [] +_threads_list_lock = threading.Lock() + +# Thread pool (initialized in main()) +main_thread_pool = None + +# User interaction lock +_user_interaction_lock = threading.Lock() + +# Thread-local storage for context +thread_local_storage = threading.local() + +# Rich console for formatted output +console = Console() + +# Criteria and values configuration (loaded from Excel) +criteria_config = {} +values_config = {} + + +# ============================================================================ +# UTILITIES +# ============================================================================ + +def get_nested_value(data_structure, path, default=None): + """ + Extract value from nested dict/list structures with wildcard support. + + Args: + data_structure: Nested dict/list to navigate + path: List of keys/indices. Use '*' for list wildcard + default: Value to return if path not found + + Returns: + Value at path, or default if not found + + Examples: + get_nested_value({"a": {"b": 1}}, ["a", "b"]) -> 1 + get_nested_value({"items": [{"x": 1}, {"x": 2}]}, ["items", "*", "x"]) -> [1, 2] + """ + if data_structure is None: + return "$$$$ No Data" + if not path: + return default + + # Handle wildcard in path + if "*" in path: + wildcard_index = path.index("*") + path_before = path[:wildcard_index] + path_after = path[wildcard_index+1:] + + # Helper for non-wildcard path resolution + def _get_simple_nested_value(ds, p, d): + cl = ds + for k in p: + if isinstance(cl, dict): + cl = cl.get(k) + elif isinstance(cl, list): + try: + if isinstance(k, int) and -len(cl) <= k < len(cl): + cl = cl[k] + else: + return d + except (IndexError, TypeError): + return d + else: + return d + if cl is None: + return d + return cl + + base_level = _get_simple_nested_value(data_structure, path_before, default) + + if not isinstance(base_level, list): + return default + + results = [] + for item in base_level: + value = get_nested_value(item, path_after, default) + if value is not default and value != "$$$$ No Data": + results.append(value) + + # Flatten one level for multiple wildcards + final_results = [] + for res in results: + if isinstance(res, list): + final_results.extend(res) + else: + final_results.append(res) + + return final_results + + # No wildcard - standard traversal + current_level = data_structure + for key_or_index in path: + if isinstance(current_level, dict): + current_level = current_level.get(key_or_index) + if current_level is None: + return default + elif isinstance(current_level, list): + try: + if isinstance(key_or_index, int) and -len(current_level) <= key_or_index < len(current_level): + current_level = current_level[key_or_index] + else: + return default + except (IndexError, TypeError): + return default + else: + return default + return current_level + + +def get_httpx_client() -> httpx.Client: + """ + Get or create thread-local HTTP client with keep-alive enabled. + Each thread gets its own client to avoid connection conflicts. + + Returns: + httpx.Client instance for current thread + """ + global httpx_clients + thread_id = threading.get_ident() + if thread_id not in httpx_clients: + httpx_clients[thread_id] = httpx.Client( + headers={"Connection": "keep-alive"}, + limits=httpx.Limits(max_keepalive_connections=20, max_connections=100) + ) + return httpx_clients[thread_id] + + +def get_thread_position(): + """ + Get position of current thread in threads list. + Used for managing progress bar positions in multithreaded environment. + + Returns: + Zero-based index of current thread + """ + global threads_list + thread_id = threading.get_ident() + with _threads_list_lock: + if thread_id not in threads_list: + threads_list.append(thread_id) + return len(threads_list) - 1 + else: + return threads_list.index(thread_id) + + +def clear_httpx_client(): + """ + Clear the thread-local HTTP client to force creation of a new one. + Useful for resetting connections after errors. + """ + global httpx_clients + thread_id = threading.get_ident() + if thread_id in httpx_clients: + try: + httpx_clients[thread_id].close() + except Exception: + pass + del httpx_clients[thread_id] + + +def run_with_context(func, context, *args, **kwargs): + """ + Wrapper to set thread-local context before running a function in a new thread. + Useful for ThreadPoolExecutor where context is lost. + """ + thread_local_storage.current_patient_context = context + return func(*args, **kwargs) + + +# ============================================================================ +# CONFIG PATH RESOLUTION (PyInstaller compatible) +# ============================================================================ + +def get_config_path(): + """Resolve path to the config directory, compatible with PyInstaller packaging.""" + if getattr(sys, '_MEIPASS', None): + return os.path.join(sys._MEIPASS, CONFIG_DIR) + return os.path.join(os.path.dirname(os.path.abspath(__file__)), CONFIG_DIR) + + +# ============================================================================ +# EXCEL CONFIGURATION LOADING +# ============================================================================ + +def load_criteria_config(): + """ + Load criteria and values configuration from the Excel workbook. + + Populates global dicts: + - criteria_config: {criteria_id: {label, type, level1, level2, order}} + - values_config: {criteria_id: {value_id: value_label}} + """ + global criteria_config, values_config + + config_path = os.path.join(get_config_path(), CONFIG_WORKBOOK_NAME) + + if not os.path.exists(config_path): + logging.critical(f"Configuration file not found: {config_path}") + raise FileNotFoundError(f"Configuration file not found: {config_path}") + + wb = openpyxl.load_workbook(config_path, read_only=True, data_only=True) + + # --- Load criteria sheet --- + if CONFIG_CRITERIA_SHEET_NAME not in wb.sheetnames: + logging.critical(f"Sheet '{CONFIG_CRITERIA_SHEET_NAME}' not found in {CONFIG_WORKBOOK_NAME}") + raise ValueError(f"Sheet '{CONFIG_CRITERIA_SHEET_NAME}' not found in {CONFIG_WORKBOOK_NAME}") + + ws_criteria = wb[CONFIG_CRITERIA_SHEET_NAME] + rows = list(ws_criteria.iter_rows(values_only=True)) + + if not rows: + logging.critical(f"Sheet '{CONFIG_CRITERIA_SHEET_NAME}' is empty") + raise ValueError(f"Sheet '{CONFIG_CRITERIA_SHEET_NAME}' is empty") + + header = [str(cell).strip() if cell else "" for cell in rows[0]] + + # Find column indices + required_columns = { + COL_CRITERIA_ID: None, COL_CRITERIA_LABEL: None, COL_CRITERIA_TYPE: None, + COL_CRITERIA_LEVEL1_LABEL: None, COL_CRITERIA_LEVEL2_LABEL: None, COL_CRITERIA_ORDER: None, + } + for col_name in required_columns: + if col_name not in header: + logging.critical(f"Missing column '{col_name}' in '{CONFIG_CRITERIA_SHEET_NAME}' sheet. Available columns: {header}") + raise ValueError(f"Missing column '{col_name}' in '{CONFIG_CRITERIA_SHEET_NAME}' sheet") + required_columns[col_name] = header.index(col_name) + + idx_id = required_columns[COL_CRITERIA_ID] + idx_label = required_columns[COL_CRITERIA_LABEL] + idx_type = required_columns[COL_CRITERIA_TYPE] + idx_level1 = required_columns[COL_CRITERIA_LEVEL1_LABEL] + idx_level2 = required_columns[COL_CRITERIA_LEVEL2_LABEL] + idx_order = required_columns[COL_CRITERIA_ORDER] + + criteria_config = {} + for row in rows[1:]: + crit_id = str(row[idx_id]).strip() if row[idx_id] is not None else None + if not crit_id: + continue + criteria_config[crit_id] = { + "label": str(row[idx_label]).strip() if row[idx_label] else crit_id, + "type": str(row[idx_type]).strip().upper() if row[idx_type] else "TEXT", + "level1": str(row[idx_level1]).strip() if row[idx_level1] else None, + "level2": str(row[idx_level2]).strip() if row[idx_level2] else None, + "order": int(row[idx_order]) if row[idx_order] is not None else 9999, + } + + # --- Load values sheet --- + if CONFIG_VALUES_SHEET_NAME not in wb.sheetnames: + logging.critical(f"Sheet '{CONFIG_VALUES_SHEET_NAME}' not found in {CONFIG_WORKBOOK_NAME}") + raise ValueError(f"Sheet '{CONFIG_VALUES_SHEET_NAME}' not found in {CONFIG_WORKBOOK_NAME}") + + ws_values = wb[CONFIG_VALUES_SHEET_NAME] + rows_v = list(ws_values.iter_rows(values_only=True)) + + if not rows_v: + logging.critical(f"Sheet '{CONFIG_VALUES_SHEET_NAME}' is empty") + raise ValueError(f"Sheet '{CONFIG_VALUES_SHEET_NAME}' is empty") + + header_v = [str(cell).strip() if cell else "" for cell in rows_v[0]] + + required_columns_v = {COL_VALUE_CRITERIA_ID: None, COL_VALUE_ID: None, COL_VALUE_LABEL: None} + for col_name in required_columns_v: + if col_name not in header_v: + logging.critical(f"Missing column '{col_name}' in '{CONFIG_VALUES_SHEET_NAME}' sheet. Available columns: {header_v}") + raise ValueError(f"Missing column '{col_name}' in '{CONFIG_VALUES_SHEET_NAME}' sheet") + required_columns_v[col_name] = header_v.index(col_name) + + idx_v_crit_id = required_columns_v[COL_VALUE_CRITERIA_ID] + idx_v_id = required_columns_v[COL_VALUE_ID] + idx_v_label = required_columns_v[COL_VALUE_LABEL] + + values_config = {} + for row in rows_v[1:]: + crit_id = str(row[idx_v_crit_id]).strip() if row[idx_v_crit_id] is not None else None + val_id = str(row[idx_v_id]).strip() if row[idx_v_id] is not None else None + val_label = str(row[idx_v_label]).strip() if row[idx_v_label] is not None else val_id + if not crit_id or not val_id: + continue + if crit_id not in values_config: + values_config[crit_id] = {} + values_config[crit_id][val_id] = val_label + + wb.close() + + logging.info(f"Loaded {len(criteria_config)} criteria and {sum(len(v) for v in values_config.values())} values from config") + console.print(f"[green]Config loaded: {len(criteria_config)} criteria, " + f"{sum(len(v) for v in values_config.values())} values[/green]") + + +# ============================================================================ +# DECORATOR - API CALL WITH RETRY +# ============================================================================ + +def api_call_with_retry(func): + """Decorator for API calls with automatic retry on errors.""" + @functools.wraps(func) + def wrapper(*args, **kwargs): + func_name = func.__name__ + total_attempts = 0 + batch_count = 1 + + while True: + for attempt in range(ERROR_MAX_RETRY): + total_attempts += 1 + try: + return func(*args, **kwargs) + except (httpx.RequestError, httpx.HTTPStatusError) as exc: + ctx = getattr(thread_local_storage, "current_patient_context", {"id": "Unknown", "name": "Unknown"}) + logging.warning(f"Error in {func_name} [Patient {ctx['id']}] ({ctx['name']}) (Attempt {total_attempts}): {exc}") + + clear_httpx_client() + + if attempt < ERROR_MAX_RETRY - 1: + sleep(WAIT_BEFORE_RETRY) + else: + if batch_count < MAX_BATCHS_OF_RETRIES: + logging.warning(f"Batch {batch_count}/{MAX_BATCHS_OF_RETRIES} failed for {func_name} " + f"[Patient {ctx['id']}] ({ctx['name']}). " + f"Waiting {WAIT_BEFORE_NEW_BATCH_OF_RETRIES}s before automatic retry batch.") + batch_count += 1 + sleep(WAIT_BEFORE_NEW_BATCH_OF_RETRIES) + break + else: + with _user_interaction_lock: + console.print(f"\n[bold red]Persistent error in {func_name} [Patient {ctx['id']}] ({ctx['name']}) " + f"after {batch_count} batches ({total_attempts} attempts).[/bold red]") + console.print(f"[red]Exception: {exc}[/red]") + + choice = questionary.select( + f"What would you like to do for {func_name}?", + choices=[ + "Retry (try another batch of retries)", + "Ignore (return None and continue)", + "Stop script (critical error)" + ] + ).ask() + + if choice == "Retry (try another batch of retries)": + logging.info(f"User chose to retry {func_name}. Restarting batch sequence.") + batch_count = 1 + break + elif choice == "Ignore (return None and continue)": + ctx = getattr(thread_local_storage, "current_patient_context", {"id": "Unknown", "name": "Unknown"}) + logging.warning(f"[IGNORE] User opted to skip {func_name} for Patient {ctx['id']} ({ctx['name']}). Error: {exc}") + return None + else: + logging.critical(f"User chose to stop script after persistent error in {func_name}.") + raise httpx.RequestError(message=f"Persistent error in {func_name} (stopped by user)") + + return wrapper + + +# ============================================================================ +# AUTHENTICATION +# ============================================================================ + +def login(): + """ + Authenticate with Endoconnect. + + Returns: + "Success": Authentication succeeded + "Error": Authentication failed (can retry) + "Exit": User cancelled login + """ + global token + + user_name = questionary.text("login:", default=DEFAULT_USER_NAME).ask() + password = questionary.password("password:", default=DEFAULT_PASSWORD).ask() + + if not (user_name and password): + return "Exit" + + try: + client = get_httpx_client() + response = client.post( + f"{ENDOCONNECT_BASE_URL}{LOGIN_PATH}", + json={"email": user_name, "password": password}, + timeout=20 + ) + response.raise_for_status() + token = response.json()["token"] + except (httpx.RequestError, httpx.HTTPStatusError) as exc: + print(f"Login Error: {exc}") + logging.warning(f"Login Error: {exc}") + return "Error" + + print("\nLogin Success") + return "Success" + + +# ============================================================================ +# API FUNCTIONS +# ============================================================================ + +@api_call_with_retry +def get_patients(professional_id): + """Get all patients for a given professional ID (RPPS).""" + client = get_httpx_client() + response = client.post( + f"{ENDOCONNECT_BASE_URL}{PATIENTS_LIST_PATH}", + headers={"Authorization": f"Bearer {token}"}, + json={ + "page": 1, + "pageSize": MAX_PAGE_SIZE, + "RPPS": professional_id, + "search": "" + }, + timeout=API_TIMEOUT + ) + response.raise_for_status() + return response.json() + + +@api_call_with_retry +def get_medical_record(patient_id): + """Get medical record for a patient. Returns docs[0] or None.""" + client = get_httpx_client() + response = client.get( + f"{ENDOCONNECT_BASE_URL}{MEDICAL_RECORD_PATH}?profile={patient_id}", + headers={"Authorization": f"Bearer {token}"}, + timeout=API_TIMEOUT + ) + response.raise_for_status() + data = response.json() + + docs = data.get("docs", []) + if len(docs) != 1: + ctx = getattr(thread_local_storage, "current_patient_context", {"id": patient_id, "name": "Unknown"}) + logging.error(f"Expected exactly 1 doc in medical record for patient {ctx['id']} ({ctx['name']}), got {len(docs)}") + if len(docs) == 0: + return None + return docs[0] + + +@api_call_with_retry +def get_medical_events(patient_id): + """Get medical events for a patient. Returns docs array (may be empty).""" + client = get_httpx_client() + response = client.get( + f"{ENDOCONNECT_BASE_URL}{MEDICAL_EVENTS_PATH}?profile={patient_id}", + headers={"Authorization": f"Bearer {token}"}, + timeout=API_TIMEOUT + ) + response.raise_for_status() + data = response.json() + return data.get("docs", []) + + +# ============================================================================ +# RESULT BUILDING +# ============================================================================ + +def resolve_criteria_value(criteria_id, raw_value, patient_id="Unknown"): + """ + Resolve the display value for a criteria answer. + + For TEXT/NUMERIC/DATE: use raw value directly (join with " | " if array). + For MULTIBOOLEAN/CHECKLIST: lookup value labels from values_config. + """ + config = criteria_config.get(criteria_id) + if not config: + logging.warning(f"[Patient {patient_id}] Unknown criteria_id: {criteria_id}, raw_value: {raw_value}") + if isinstance(raw_value, list): + return " | ".join(str(v) for v in raw_value) + return raw_value + + crit_type = config["type"] + + if crit_type in ("TEXT", "NUMERIC", "DATE"): + if isinstance(raw_value, list): + return " | ".join(str(v) for v in raw_value) + return raw_value + + elif crit_type in ("MULTIBOOLEAN", "CHECKLIST"): + val_lookup = values_config.get(criteria_id, {}) + + if not val_lookup: + logging.warning(f"[Patient {patient_id}] No values configured for MULTIBOOLEAN/CHECKLIST criteria '{criteria_id}' (label: {config['label']})") + + if isinstance(raw_value, list): + labels = [] + for v in raw_value: + v_str = str(v).strip() + label = val_lookup.get(v_str) + if label is None: + logging.warning(f"[Patient {patient_id}] Unknown value_id '{v_str}' for criteria '{criteria_id}' (label: {config['label']})") + labels.append(v_str) + else: + labels.append(label) + return " | ".join(labels) + else: + v_str = str(raw_value).strip() + label = val_lookup.get(v_str) + if label is None: + logging.warning(f"[Patient {patient_id}] Unknown value_id '{v_str}' for criteria '{criteria_id}' (label: {config['label']})") + return v_str + return label + + else: + logging.warning(f"[Patient {patient_id}] Unknown criteria type '{crit_type}' for criteria '{criteria_id}' (label: {config['label']})") + if isinstance(raw_value, list): + return " | ".join(str(v) for v in raw_value) + return raw_value + + +def build_detail(answers, patient_id="Unknown", use_nesting=True): + """ + Build an ordered detail object from answers. + + Attributes are inserted in criteria_order. + If use_nesting=True (record_detail): applies level1/level2 nesting. + If use_nesting=False (event_detail): flat structure, no nesting. + """ + if not answers: + return {} + + # Collect answers with their config, filter unknown criteria + enriched = [] + for answer in answers: + crit_id = str(answer.get("criteria", "")).strip() + raw_value = answer.get("value") + config = criteria_config.get(crit_id) + if not config: + logging.warning(f"[Patient {patient_id}] Skipping unknown criteria_id in answers: {crit_id}, raw_value: {raw_value}") + continue + enriched.append({ + "criteria_id": crit_id, + "label": config["label"], + "type": config["type"], + "level1": config["level1"], + "level2": config["level2"], + "order": config["order"], + "raw_value": raw_value, + }) + + # Sort by criteria_order + enriched.sort(key=lambda x: x["order"]) + + # Build the detail dict + detail = {} + for item in enriched: + label = item["label"] + value = resolve_criteria_value(item["criteria_id"], item["raw_value"], patient_id=patient_id) + level1 = item["level1"] + level2 = item["level2"] + + if use_nesting and level1: + if level1 not in detail: + detail[level1] = {} + if level2: + if level2 not in detail[level1]: + detail[level1][level2] = {} + detail[level1][level2][label] = value + else: + detail[level1][label] = value + else: + detail[label] = value + + return detail + + +def process_patient(patient): + """ + Process a single patient: fetch medical record and events, build result object. + + Args: + patient: Patient dict from get_patients response + + Returns: + Dict with patient_ident, record_metadata, record_detail, events + """ + patient_id = patient["_id"] + patient_name = patient.get("fullName", "Unknown") + + # Fetch data (sequential) + record = get_medical_record(patient_id) + if record is None: + logging.warning(f"[Patient {patient_id}] ({patient_name}) No medical record returned, record_detail will be empty") + + events_data = get_medical_events(patient_id) + if events_data is None: + logging.warning(f"[Patient {patient_id}] ({patient_name}) No medical events returned, events will be empty") + + # Build result + result = { + "patient_ident": { + "_id": patient_id, + "fullName": patient_name, + "birthday": patient.get("birthday", ""), + "email": patient.get("email", ""), + }, + "record_metadata": { + "createdAt": patient.get("createdAt", ""), + "isFinishMedicalRecord": patient.get("isFinishMedicalRecord", False), + "lastUpdate": patient.get("lasUpdate", ""), + "finishOn": patient.get("finishOn", ""), + "confirmedEndo": patient.get("confirmedEndo", False), + }, + "record_detail": build_detail(record.get("answers", []), patient_id=patient_id, use_nesting=True) if record else {}, + "events": [ + { + "event_date": evt.get("date", ""), + "event_type": evt.get("type", ""), + "event_detail": build_detail(evt.get("answers", []), patient_id=patient_id, use_nesting=False), + } + for evt in (events_data or []) + ], + } + + return result + + +# ============================================================================ +# MAIN PROCESSING +# ============================================================================ + +def main(): + """ + Main processing function. + + Flow: + 1. Load Excel config + 2. Login + 3. Confirm professional ID + 4. Confirm thread count + 5. Fetch and filter patients + 6. Process patients in thread pool + 7. Sort and export results to JSON + """ + global main_thread_pool + + # ========== LOAD CONFIG ========== + console.print("[bold cyan]Loading criteria configuration...[/bold cyan]") + load_criteria_config() + + # ========== AUTHENTICATION ========== + print() + login_status = login() + while login_status == "Error": + login_status = login() + if login_status == "Exit": + return + + # ========== PROFESSIONAL ID ========== + print() + professional_id = questionary.text( + "Professional ID (RPPS):", + default=DEFAULT_PROFESSIONAL_ID + ).ask() + if not professional_id: + return + + # ========== THREAD COUNT ========== + print() + number_of_threads = int( + questionary.text( + "Number of threads:", + default="12", + validate=lambda x: x.isdigit() and 0 < int(x) <= MAX_THREADS + ).ask() + ) + + # ========== INITIALIZATION ========== + start_time = perf_counter() + main_thread_pool = ThreadPoolExecutor(max_workers=number_of_threads) + + # ========== FETCH PATIENTS ========== + print() + console.print("[bold cyan]Fetching patients list...[/bold cyan]") + all_patients = get_patients(professional_id) + + if all_patients is None: + logging.critical(f"Failed to fetch patients list for professional_id={professional_id}. Aborting.") + console.print("[bold red]Failed to fetch patients list. Aborting.[/bold red]") + return + + # Filter: keep only patients with finished medical record + patients = [p for p in all_patients if p.get("isFinishMedicalRecord") is True] + + console.print(f"[green]Total patients: {len(all_patients)} | With finished medical record: {len(patients)}[/green]") + + if not patients: + console.print("[yellow]No patients with finished medical records found. Nothing to process.[/yellow]") + return + + # ========== PROCESS PATIENTS ========== + print() + console.print("[bold cyan]Processing patients...[/bold cyan]") + + output = [] + futures = [] + + with tqdm(total=len(patients), desc="Extracting records", + bar_format=custom_bar_format) as pbar: + with main_thread_pool as executor: + for patient in patients: + ctx = {"id": patient["_id"], "name": patient.get("fullName", "Unknown")} + futures.append( + executor.submit(run_with_context, process_patient, ctx, patient) + ) + + for future in as_completed(futures): + try: + result = future.result() + if result is not None: + output.append(result) + pbar.update(1) + except Exception as exc: + logging.critical(f"Error in worker: {exc}", exc_info=True) + print(f"\nCRITICAL ERROR: {exc}") + executor.shutdown(wait=False, cancel_futures=True) + raise + + # ========== SORT RESULTS ========== + output.sort(key=lambda x: ( + x.get("patient_ident", {}).get("fullName", ""), + x.get("patient_ident", {}).get("email", "") + )) + + # ========== EXPORT JSON ========== + timestamp = datetime.now().strftime("%Y%m%d-%H%M") + output_filename = f"{OUTPUT_FILE_NAME}-{timestamp}.json" + + with open(output_filename, "w", encoding="utf-8") as f: + json.dump(output, f, indent=2, ensure_ascii=False) + + # ========== FINALIZATION ========== + print() + console.print(f"[bold green]Export complete: {len(output)} patients -> {output_filename}[/bold green]") + print(f"Elapsed time: {str(timedelta(seconds=perf_counter() - start_time))}") + + +# ============================================================================ +# ENTRY POINT +# ============================================================================ + +if __name__ == '__main__': + # ========== LOGGING CONFIGURATION ========== + script_name = os.path.splitext(os.path.basename(__file__))[0] + log_file_name = f"{script_name}.log" + + logging.basicConfig( + level=LOG_LEVEL, + format=LOG_FORMAT, + filename=log_file_name, + filemode='w' + ) + + # ========== MAIN EXECUTION ========== + try: + main() + except Exception as e: + logging.critical(f"Script terminated with exception: {e}", exc_info=True) + print(f"\nScript stopped due to error: {e}") + print(traceback.format_exc()) + finally: + # ========== CLEANUP ========== + if 'main_thread_pool' in globals() and main_thread_pool: + main_thread_pool.shutdown(wait=False, cancel_futures=True) + + # Pause before exit + print('\n') + input("Press Enter to exit...")