""" HIS Client Service - Fetches patient data from external HIS systems This service provides a client to fetch patient survey data from Hospital Information Systems (HIS) via their APIs. """ import logging import os from datetime import datetime, timedelta from typing import Dict, List, Optional from urllib.parse import quote import requests from django.utils import timezone from apps.integrations.models import IntegrationConfig, SourceSystem logger = logging.getLogger("apps.integrations") class HISClient: """ Client for fetching patient data from HIS systems. This client connects to external HIS APIs and retrieves patient demographic and visit data for survey processing. """ def __init__(self, config: Optional[IntegrationConfig] = None): """ Initialize HIS client. Args: config: IntegrationConfig instance. If None, will try to load active HIS configuration from database. """ self.config = config or self._get_default_config() self.session = requests.Session() # Load credentials from environment if no config self.username = os.getenv("HIS_API_USERNAME", "") self.password = os.getenv("HIS_API_PASSWORD", "") def _get_default_config(self) -> Optional[IntegrationConfig]: """Get default active HIS configuration from database.""" try: return IntegrationConfig.objects.filter(source_system=SourceSystem.HIS, is_active=True).first() except Exception as e: logger.error(f"Error loading HIS configuration: {e}") return None def _get_headers(self) -> Dict[str, str]: """Get request headers with authentication.""" headers = { "Content-Type": "application/json", "Accept": "application/json", } # Support for API key if configured if self.config and self.config.api_key: headers["X-API-Key"] = self.config.api_key headers["Authorization"] = f"Bearer {self.config.api_key}" return headers def _get_api_url(self) -> Optional[str]: """Get API URL from configuration or environment.""" if not self.config: # Fallback to environment variable return os.getenv("HIS_API_URL", "https://his.alhammadi.med.sa/SSRCE/API/FetchPatientVisitTimeStamps") return self.config.api_url def _get_auth(self) -> Optional[tuple]: """Get Basic Auth credentials.""" if self.username and self.password: return (self.username, self.password) return None def fetch_patient_data(self, since: Optional[datetime] = None, until: Optional[datetime] = None) -> Optional[Dict]: """ Fetch full patient data from HIS system including visit timelines. Returns the complete HIS response dict with all arrays intact: - FetchPatientDataTimeStampList - FetchPatientDataTimeStampVisitEDDataList - FetchPatientDataTimeStampVisitIPDataList - FetchPatientDataTimeStampVisitOPDataList Args: since: Only fetch patients since this datetime until: Only fetch patients until this datetime (optional) Returns: Full HIS response dict or None if error """ api_url = self._get_api_url() if not api_url: logger.error("No HIS API URL configured") return None try: if since: end_time = until if until else timezone.now() else: end_time = timezone.now() since = end_time - timedelta(minutes=5) from_date = self._format_datetime(since) to_date = self._format_datetime(end_time) url = f"{api_url}?FromDate={quote(from_date)}&ToDate={quote(to_date)}&SSN=0&MobileNo=0" logger.info(f"Fetching patient data from HIS: {url}") response = self.session.get( url, headers=self._get_headers(), auth=self._get_auth(), timeout=30, verify=True, ) response.raise_for_status() data = response.json() if isinstance(data, dict): patient_count = len(data.get("FetchPatientDataTimeStampList", [])) logger.info(f"Fetched {patient_count} patients from HIS") return data else: logger.error(f"Unexpected HIS response type: {type(data)}") return None except requests.exceptions.RequestException as e: logger.error(f"Error fetching patient data from HIS: {e}") return None except Exception as e: logger.error(f"Unexpected error fetching patient data from HIS: {e}") return None def fetch_doctor_ratings(self, from_date: datetime, to_date: datetime) -> Optional[Dict]: """ Fetch doctor ratings from HIS FetchDoctorRatingMAPI1 endpoint. Args: from_date: Start date for ratings to_date: End date for ratings Returns: HIS response dict with FetchDoctorRatingMAPI1List or None on error """ api_url = os.getenv("HIS_RATINGS_API_URL") if not api_url: logger.error("HIS_RATINGS_API_URL not configured in environment") return None try: from_date_str = self._format_datetime(from_date) to_date_str = self._format_datetime(to_date) url = f"{api_url}?FromDate={quote(from_date_str)}&ToDate={quote(to_date_str)}&SSN=0&MobileNo=0" logger.info(f"Fetching doctor ratings from HIS: {url}") response = self.session.get( url, headers=self._get_headers(), auth=self._get_auth(), timeout=30, verify=True, ) response.raise_for_status() data = response.json() if isinstance(data, dict): rating_count = len(data.get("FetchDoctorRatingMAPI1List", [])) logger.info(f"Fetched {rating_count} doctor ratings from HIS") return data else: logger.error(f"Unexpected HIS response type: {type(data)}") return None except requests.exceptions.RequestException as e: logger.error(f"Error fetching doctor ratings from HIS: {e}") return None except Exception as e: logger.error(f"Unexpected error fetching doctor ratings from HIS: {e}") return None def fetch_patient_by_identifier(self, ssn: Optional[str] = None, mobile_no: Optional[str] = None) -> Optional[Dict]: """ Search HIS for a patient by SSN or Mobile number. Uses a wide date range (5 years) since we are searching by identifier rather than date. Returns only the patient demographics list from the HIS response. Args: ssn: Patient SSN/National ID mobile_no: Patient mobile number Returns: List of patient demographic dicts from HIS, or None on error """ if not ssn and not mobile_no: logger.warning("fetch_patient_by_identifier called without SSN or MobileNo") return None api_url = self._get_api_url() if not api_url: logger.error("No HIS API URL configured") return None try: end_time = timezone.now() since = end_time - timedelta(days=365 * 5) from_date = self._format_datetime(since) to_date = self._format_datetime(end_time) ssn_param = ssn or "0" mobile_param = mobile_no or "0" url = ( f"{api_url}?FromDate={quote(from_date)}&ToDate={quote(to_date)}" f"&SSN={quote(ssn_param)}&MobileNo={quote(mobile_param)}" ) logger.info(f"Searching HIS by identifier: SSN={ssn_param}, Mobile={mobile_param}") response = self.session.get( url, headers=self._get_headers(), auth=self._get_auth(), timeout=30, verify=True, ) response.raise_for_status() data = response.json() if isinstance(data, dict): patients = data.get("FetchPatientDataTimeStampList", []) logger.info(f"HIS identifier search returned {len(patients)} patients") return patients else: logger.error(f"Unexpected HIS response type: {type(data)}") return None except requests.exceptions.RequestException as e: logger.error(f"Error searching HIS by identifier: {e}") return None except Exception as e: logger.error(f"Unexpected error searching HIS by identifier: {e}") return None def fetch_patient_visits(self, patient_id: str) -> List[Dict]: """ Fetch visit data for a specific patient. Args: patient_id: Patient ID from HIS Returns: List of visit data dictionaries """ api_url = self._get_api_url() if not api_url: return [] visits_endpoint = f"{api_url.rstrip('/')}/{patient_id}/visits" try: response = self.session.get(visits_endpoint, headers=self._get_headers(), timeout=30) response.raise_for_status() data = response.json() if isinstance(data, list): return data elif isinstance(data, dict): return data.get("FetchPatientDataTimeStampVisitDataList", []) return [] except requests.exceptions.RequestException as e: logger.error(f"Error fetching visits for patient {patient_id}: {e}") return [] def test_connection(self) -> Dict: """ Test connectivity to HIS system. Returns: Dict with 'success' boolean and 'message' string """ api_url = self._get_api_url() if not api_url: return {"success": False, "message": "No API URL configured"} try: # Try to fetch patients for last 1 minute as a test end_time = timezone.now() start_time = end_time - timedelta(minutes=1) from_date = self._format_datetime(start_time) to_date = self._format_datetime(end_time) url = f"{api_url}?FromDate={quote(from_date)}&ToDate={quote(to_date)}&SSN=0&MobileNo=0" response = self.session.get( url, headers=self._get_headers(), auth=self._get_auth(), timeout=10, verify=True ) if response.status_code == 200: return { "success": True, "message": "Successfully connected to HIS", "config_name": self.config.name if self.config else "Default", } else: return {"success": False, "message": f"HIS returned status {response.status_code}"} except requests.exceptions.ConnectionError: return {"success": False, "message": "Could not connect to HIS server"} except requests.exceptions.Timeout: return {"success": False, "message": "Connection to HIS timed out"} except Exception as e: return {"success": False, "message": f"Error: {str(e)}"} @staticmethod def _format_datetime(dt: datetime) -> str: """Format datetime for HIS API (DD-Mon-YYYY HH:MM:SS format).""" months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] return f"{dt.day:02d}-{months[dt.month - 1]}-{dt.year} {dt.hour:02d}:{dt.minute:02d}:{dt.second:02d}" class HISClientFactory: """Factory for creating HIS clients.""" @staticmethod def get_client(hospital_id: Optional[str] = None) -> HISClient: """ Get HIS client for a specific hospital or default. Args: hospital_id: Optional hospital ID to get specific config Returns: HISClient instance """ config = None if hospital_id: # Try to find config for specific hospital try: from apps.organizations.models import Hospital hospital = Hospital.objects.filter(id=hospital_id).first() if hospital: config = IntegrationConfig.objects.filter( source_system=SourceSystem.HIS, is_active=True, config_json__hospital_id=str(hospital.id) ).first() except Exception: pass return HISClient(config) @staticmethod def get_all_active_clients() -> List[HISClient]: """Get all active HIS clients for multi-hospital setups.""" configs = IntegrationConfig.objects.filter(source_system=SourceSystem.HIS, is_active=True) if not configs.exists(): # Return default client with no config return [HISClient()] return [HISClient(config) for config in configs]