""" Simulator views for testing external notification APIs. This module provides API endpoints that: - Simulate external email and SMS services - Receive and process HIS journey events - Create journeys, send surveys, and trigger notifications """ import logging from datetime import datetime, timedelta from django.conf import settings from django.core.mail import send_mail from django.http import JsonResponse from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_http_methods from rest_framework.decorators import api_view from rest_framework.response import Response from rest_framework import status import json from .serializers import HISJourneyEventSerializer, HISJourneyEventListSerializer from .models import HISRequestLog from apps.journeys.models import ( JourneyType, PatientJourneyTemplate, PatientJourneyInstance, PatientJourneyStageInstance, StageStatus ) from apps.organizations.models import Hospital, Department, Patient from apps.surveys.models import SurveyTemplate, SurveyInstance from apps.notifications.services import NotificationService import time def make_json_serializable(data): """ Convert datetime objects to ISO strings for JSON serialization. Recursively processes dictionaries and lists to ensure all datetime objects are converted to ISO format strings for storage in JSON fields. Args: data: Data structure that may contain datetime objects Returns: Data structure with datetime objects converted to ISO strings """ if isinstance(data, dict): return {k: make_json_serializable(v) for k, v in data.items()} elif isinstance(data, list): return [make_json_serializable(item) for item in data] elif isinstance(data, datetime): return data.isoformat() else: return data logger = logging.getLogger(__name__) # Request counter for tracking request_counter = {'email': 0, 'sms': 0} # Request history (last 10 requests) request_history = [] def log_simulator_request(channel, payload, status, response_data=None, error_message=None, processing_time_ms=None, request=None): """ Log simulator request to history, file, and database. Args: channel: Channel type (email, sms, his_event) payload: Request payload data status: Request status (success, failed, partial, sent) response_data: Response data from simulator error_message: Error message if failed processing_time_ms: Processing time in milliseconds request: HTTP request object for IP/user agent """ # Log to in-memory history (keeps last 10) request_id = len(request_history) + 1 entry = { 'id': request_id, 'channel': channel, 'timestamp': datetime.now().isoformat(), 'status': status, 'payload': payload } request_history.append(entry) if len(request_history) > 10: request_history.pop(0) # Log to file logger.info(f"[Simulator] {channel.upper()} Request #{request_id}: {status}") # Log to database try: # Convert datetime objects to ISO strings for JSON serialization serializable_response_data = make_json_serializable(response_data) if response_data else None log_entry = HISRequestLog( channel=channel, payload=payload, status=status, response_data=serializable_response_data, processing_time_ms=processing_time_ms, error_message=error_message, ip_address=request.META.get('REMOTE_ADDR') if request else None, user_agent=request.META.get('HTTP_USER_AGENT') if request else None ) # Extract channel-specific fields if channel == 'email': log_entry.recipient = payload.get('to') log_entry.subject = payload.get('subject') message = payload.get('message', '') log_entry.message_preview = message[:500] if message else None elif channel == 'sms': log_entry.recipient = payload.get('to') message = payload.get('message', '') log_entry.message_preview = message[:500] if message else None elif channel == 'his_event': # Extract from first event if it's a list events = payload.get('events', []) if events and isinstance(events, list) and len(events) > 0: first_event = events[0] log_entry.patient_id = first_event.get('mrn') log_entry.journey_id = first_event.get('encounter_id') log_entry.hospital_code = first_event.get('hospital_code') log_entry.event_type = first_event.get('event_type') log_entry.visit_type = first_event.get('visit_type') log_entry.department = first_event.get('department') log_entry.save() logger.info(f"[Simulator] Logged to database with ID: {log_entry.request_id}") except Exception as e: logger.error(f"[Simulator] Failed to log to database: {str(e)}") @csrf_exempt @require_http_methods(["POST"]) def email_simulator(request): """ Simulate external email API endpoint. Accepts POST request with JSON payload: { "to": "recipient@example.com", "subject": "Email subject", "message": "Plain text message", "html_message": "Optional HTML content" } Sends real email via Django SMTP and returns 200 OK. """ request_counter['email'] += 1 start_time = time.time() try: # Parse request body data = json.loads(request.body) # Validate required fields required_fields = ['to', 'subject', 'message'] missing_fields = [field for field in required_fields if field not in data] if missing_fields: processing_time = int((time.time() - start_time) * 1000) response = { 'success': False, 'error': f'Missing required fields: {", ".join(missing_fields)}' } log_simulator_request('email', data, 'failed', response_data=response, error_message=response['error'], processing_time_ms=processing_time, request=request) return JsonResponse(response, status=400) # Extract fields to_email = data['to'] subject = data['subject'] message = data['message'] html_message = data.get('html_message', None) # Log the request logger.info(f"[Email Simulator] Sending email to {to_email}: {subject}") # Print formatted email to terminal print(f"\n{'╔' + '═'*68 + '╗'}") print(f"║{' ' * 15}📧 EMAIL SIMULATOR{' ' * 34}║") print(f"╠{'═'*68}╣") print(f"║ Request #: {request_counter['email']:<52} ║") print(f"╠{'═'*68}╣") print(f"║ To: {to_email:<52} ║") print(f"║ Subject: {subject[:52]:<52} ║") print(f"╠{'═'*68}╣") print(f"║ Message:{' '*55} ║") # Word wrap message words = message.split() line = "║ " for word in words: if len(line) + len(word) + 1 > 68: print(f"{line:<68} ║") line = "║ " + word else: if line == "║ ": line += word else: line += " " + word # Print last line if not empty if line != "║ ": print(f"{line:<68} ║") # Print HTML section if present if html_message: print(f"╠{'═'*68}╣") print(f"║ HTML Message:{' '*48} ║") html_preview = html_message[:200].replace('\n', ' ') print(f"║ {html_preview:<66} ║") if len(html_message) > 200: print(f"║ ... ({len(html_message)} total characters){' '*30} ║") print(f"╚{'═'*68}╝\n") # Send real email via Django SMTP try: send_mail( subject=subject, message=message, from_email=settings.DEFAULT_FROM_EMAIL, recipient_list=[to_email], html_message=html_message, fail_silently=False ) logger.info(f"[Email Simulator] Email sent via SMTP to {to_email}") email_status = 'sent' except Exception as email_error: logger.error(f"[Email Simulator] SMTP Error: {str(email_error)}") # Log as 'partial' since we displayed it but failed to send email_status = 'partial' processing_time = int((time.time() - start_time) * 1000) # Return error response response = { 'success': False, 'error': f'Email displayed but failed to send via SMTP: {str(email_error)}', 'data': { 'to': to_email, 'subject': subject, 'message_length': len(message), 'has_html': html_message is not None, 'displayed': True, 'sent': False } } log_simulator_request('email', data, email_status, response_data=response, error_message=response['error'], processing_time_ms=processing_time, request=request) return JsonResponse(response, status=500) processing_time = int((time.time() - start_time) * 1000) # Log success logger.info(f"[Email Simulator] Email sent successfully to {to_email}") response = { 'success': True, 'message': 'Email sent successfully', 'data': { 'to': to_email, 'subject': subject, 'message_length': len(message), 'has_html': html_message is not None } } log_simulator_request('email', data, email_status, response_data=response, processing_time_ms=processing_time, request=request) return JsonResponse(response, status=200) except json.JSONDecodeError: processing_time = int((time.time() - start_time) * 1000) response = { 'success': False, 'error': 'Invalid JSON format' } log_simulator_request('email', {}, 'failed', response_data=response, error_message=response['error'], processing_time_ms=processing_time, request=request) return JsonResponse(response, status=400) except Exception as e: processing_time = int((time.time() - start_time) * 1000) logger.error(f"[Email Simulator] Error: {str(e)}") response = { 'success': False, 'error': str(e) } log_simulator_request('email', data if 'data' in locals() else {}, 'failed', response_data=response, error_message=str(e), processing_time_ms=processing_time, request=request) return JsonResponse(response, status=500) @csrf_exempt @require_http_methods(["POST"]) def sms_simulator(request): """ Simulate external SMS API endpoint. Accepts POST request with JSON payload: { "to": "+966501234567", "message": "SMS message text" } Prints SMS to terminal with formatted output and returns 200 OK. """ request_counter['sms'] += 1 start_time = time.time() try: # Parse request body data = json.loads(request.body) # Validate required fields required_fields = ['to', 'message'] missing_fields = [field for field in required_fields if field not in data] if missing_fields: processing_time = int((time.time() - start_time) * 1000) response = { 'success': False, 'error': f'Missing required fields: {", ".join(missing_fields)}' } log_simulator_request('sms', data, 'failed', response_data=response, error_message=response['error'], processing_time_ms=processing_time, request=request) return JsonResponse(response, status=400) # Extract fields to_phone = data['to'] message = data['message'] timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') # Log the request logger.info(f"[SMS Simulator] Sending SMS to {to_phone}") # Print formatted SMS to terminal print(f"\n{'╔' + '═'*68 + '╗'}") print(f"║{' ' * 15}📱 SMS SIMULATOR{' ' * 35}║") print(f"╠{'═'*68}╣") print(f"║ Request #: {request_counter['sms']:<52} ║") print(f"╠{'═'*68}╣") print(f"║ To: {to_phone:<52} ║") print(f"║ Time: {timestamp:<52} ║") print(f"╠{'═'*68}╣") print(f"║ Message:{' '*55} ║") # Word wrap message words = message.split() line = "║ " for word in words: if len(line) + len(word) + 1 > 68: print(f"{line:<68} ║") line = "║ " + word else: if line == "║ ": line += word else: line += " " + word # Print last line if not empty if line != "║ ": print(f"{line:<68} ║") print(f"╚{'═'*68}╝\n") processing_time = int((time.time() - start_time) * 1000) # Log success logger.info(f"[SMS Simulator] SMS sent to {to_phone}: {message[:50]}...") response = { 'success': True, 'message': 'SMS sent successfully', 'data': { 'to': to_phone, 'message_length': len(message) } } log_simulator_request('sms', data, 'sent', response_data=response, processing_time_ms=processing_time, request=request) return JsonResponse(response, status=200) except json.JSONDecodeError: processing_time = int((time.time() - start_time) * 1000) response = { 'success': False, 'error': 'Invalid JSON format' } log_simulator_request('sms', {}, 'failed', response_data=response, error_message=response['error'], processing_time_ms=processing_time, request=request) return JsonResponse(response, status=400) except Exception as e: processing_time = int((time.time() - start_time) * 1000) logger.error(f"[SMS Simulator] Error: {str(e)}") response = { 'success': False, 'error': str(e) } log_simulator_request('sms', data if 'data' in locals() else {}, 'failed', response_data=response, error_message=str(e), processing_time_ms=processing_time, request=request) return JsonResponse(response, status=500) @csrf_exempt @require_http_methods(["GET"]) def health_check(request): """ Health check endpoint for simulator. Returns simulator status and statistics. """ return JsonResponse({ 'status': 'healthy', 'timestamp': datetime.now().isoformat(), 'statistics': { 'total_requests': request_counter['email'] + request_counter['sms'], 'email_requests': request_counter['email'], 'sms_requests': request_counter['sms'] }, 'recent_requests': request_history[-5:] # Last 5 requests }, status=200) @csrf_exempt @require_http_methods(["GET"]) def reset_simulator(request): """ Reset simulator statistics and history. Clears request counter and history. """ global request_counter, request_history request_counter = {'email': 0, 'sms': 0} request_history = [] logger.info("[Simulator] Reset statistics and history") return JsonResponse({ 'success': True, 'message': 'Simulator reset successfully' }, status=200) from rest_framework.permissions import AllowAny from rest_framework.decorators import permission_classes # Import HIS adapter and serializers from apps.integrations.services.his_adapter import HISAdapter from apps.integrations.serializers import HISPatientDataSerializer @api_view(['POST']) @permission_classes([AllowAny]) def his_patient_data_handler(request): """ HIS Patient Data API Endpoint - Real HIS Format Receives patient data from HIS simulator in real HIS format: - Creates or updates patient records - Creates journey instances with stage completions - Sends post-discharge surveys when patient is discharged Expected payload (real HIS format): { "FetchPatientDataTimeStampList": [{ "Type": "Patient Demographic details", "PatientID": "123456", "AdmissionID": "789012", "HospitalID": "1", "HospitalName": "ALH-main", "AdmitDate": "05-Jun-2025 11:06", "DischargeDate": "05-Jun-2025 15:30", "SSN": "1234567890", "PatientName": "Ahmed Mohammed", "MobileNo": "0501234567", ... }], "FetchPatientDataTimeStampVisitDataList": [ {"Type": "Consultation", "BillDate": "05-Jun-2025 11:06"}, {"Type": "Doctor Visited", "BillDate": "05-Jun-2025 11:20"}, ... ], "Code": 200, "Status": "Success" } """ start_time = time.time() try: # Validate request data using HIS serializer # print("---------------------------------------------") serializer = HISPatientDataSerializer(data=request.data) if not serializer.is_valid(): processing_time = int((time.time() - start_time) * 1000) return Response( {'error': 'Invalid HIS data format', 'details': serializer.errors}, status=status.HTTP_400_BAD_REQUEST ) # Process HIS data using adapter result = HISAdapter.process_his_data(serializer.validated_data) processing_time = int((time.time() - start_time) * 1000) if not result['success']: # Log the failed request log_simulator_request('his_event', request.data, 'failed', response_data={'error': result['message']}, error_message=result['message'], processing_time_ms=processing_time, request=request) return Response( {'error': result['message']}, status=status.HTTP_400_BAD_REQUEST ) # Prepare success response - only survey-related data (no journey data) response_data = { 'success': True, 'message': result['message'], 'patient_id': str(result['patient'].id) if result['patient'] else None, 'patient_mrn': result['patient'].mrn if result['patient'] else None, 'patient_name': result['patient'].get_full_name() if result['patient'] else None, 'patient_type': result.get('patient_type'), 'survey_id': str(result['survey'].id) if result['survey'] else None, 'survey_sent': result['survey_sent'], 'survey_url': result['survey_url'] } # Log the successful request log_simulator_request('his_event', request.data, 'success', response_data=response_data, processing_time_ms=processing_time, request=request) return Response(response_data, status=status.HTTP_200_OK) except Exception as e: processing_time = int((time.time() - start_time) * 1000) logger.error(f"[HIS Patient Data Handler] Error: {str(e)}", exc_info=True) # Log the failed request log_simulator_request('his_event', request.data, 'failed', response_data={'error': str(e)}, error_message=str(e), processing_time_ms=processing_time, request=request) return Response( {'error': 'Internal server error', 'details': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) @api_view(['POST']) @permission_classes([AllowAny]) def his_events_handler(request): """ HIS Events API Endpoint - Legacy Format Receives patient journey events from HIS simulator: - Creates or updates patient records - Creates journey instances - Processes stage completions - Sends post-discharge surveys when journey is complete Expected payload: { "events": [ { "encounter_id": "ENC-2024-001", "mrn": "MRN-12345", "national_id": "1234567890", "first_name": "Ahmed", "last_name": "Mohammed", "phone": "+966501234567", "email": "patient@example.com", "event_type": "OPD_STAGE_1_REGISTRATION", "timestamp": "2024-01-20T10:30:00Z", "visit_type": "opd", "department": "Cardiology", "hospital_code": "ALH-main" } ] } NOTE: This endpoint is kept for backward compatibility. New integrations should use /api/simulator/his-patient-data/ """ start_time = time.time() try: # Validate request data serializer = HISJourneyEventListSerializer(data=request.data) if not serializer.is_valid(): return Response( {'error': 'Invalid data', 'details': serializer.errors}, status=status.HTTP_400_BAD_REQUEST ) events_data = serializer.validated_data['events'] # Process each event results = [] survey_invitations_sent = [] for event_data in events_data: result = process_his_event(event_data) results.append(result) # Track if survey was sent if result.get('survey_sent'): survey_invitations_sent.append({ 'encounter_id': event_data['encounter_id'], 'survey_id': result['survey_id'], 'survey_url': result['survey_url'] }) processing_time = int((time.time() - start_time) * 1000) response_data = { 'success': True, 'message': f'Processed {len(events_data)} events successfully', 'results': results, 'surveys_sent': len(survey_invitations_sent), 'survey_details': survey_invitations_sent } # Log the successful request log_simulator_request('his_event', request.data, 'success', response_data=response_data, processing_time_ms=processing_time, request=request) return Response(response_data, status=status.HTTP_200_OK) except Exception as e: processing_time = int((time.time() - start_time) * 1000) logger.error(f"[HIS Events Handler] Error: {str(e)}", exc_info=True) # Log the failed request log_simulator_request('his_event', request.data, 'failed', response_data={'error': str(e)}, error_message=str(e), processing_time_ms=processing_time, request=request) return Response( {'error': 'Internal server error', 'details': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) def process_his_event(event_data): """ Process a single HIS journey event Steps: 1. Get or create patient 2. Get or create journey instance 3. Find and update stage instance 4. Check if journey is complete and send survey if needed """ try: # 1. Get or create patient patient = get_or_create_patient(event_data) # 2. Get or create journey instance journey_instance = get_or_create_journey_instance(event_data, patient) # 3. Find and update stage instance stage_instance = update_stage_instance(journey_instance, event_data) result = { 'encounter_id': event_data['encounter_id'], 'patient_id': str(patient.id) if patient else None, 'journey_id': str(journey_instance.id), 'stage_id': str(stage_instance.id) if stage_instance else None, 'stage_status': stage_instance.status if stage_instance else 'not_found', 'survey_sent': False } # 4. Check if journey is complete and send survey if journey_instance.is_complete(): survey_result = send_post_discharge_survey(journey_instance, patient) if survey_result: result.update(survey_result) return result except Exception as e: logger.error(f"[Process HIS Event] Error for encounter {event_data.get('encounter_id')}: {str(e)}", exc_info=True) return { 'encounter_id': event_data.get('encounter_id'), 'error': str(e), 'success': False } def get_or_create_patient(event_data): """Get or create patient from event data""" try: patient, created = Patient.objects.get_or_create( mrn=event_data['mrn'], defaults={ 'first_name': event_data['first_name'], 'last_name': event_data['last_name'], 'phone': event_data['phone'], 'email': event_data['email'], 'national_id': event_data.get('national_id', ''), } ) logger.info(f"{'Created' if created else 'Found'} patient {patient.mrn}: {patient.get_full_name()}") return patient except Exception as e: logger.error(f"Error creating patient: {str(e)}") raise def get_or_create_journey_instance(event_data, patient): """Get or create journey instance for this encounter""" try: # Get hospital from event data or default to ALH-main hospital_code = event_data.get('hospital_code', 'ALH-main') hospital = Hospital.objects.filter(code=hospital_code).first() if not hospital: raise ValueError(f"Hospital with code '{hospital_code}' not found. Please run seed_journey_surveys command first.") # Map visit_type to JourneyType journey_type_map = { 'ems': JourneyType.EMS, 'inpatient': JourneyType.INPATIENT, 'opd': JourneyType.OPD } journey_type = journey_type_map.get(event_data['visit_type']) if not journey_type: raise ValueError(f"Invalid visit_type: {event_data['visit_type']}") # Get journey template journey_template = PatientJourneyTemplate.objects.filter( hospital=hospital, journey_type=journey_type, is_active=True ).first() if not journey_template: raise ValueError(f"No active journey template found for {journey_type}") # Get or create journey instance journey_instance, created = PatientJourneyInstance.objects.get_or_create( encounter_id=event_data['encounter_id'], defaults={ 'journey_template': journey_template, 'patient': patient, 'hospital': hospital, 'status': 'active' } ) # Create stage instances if this is a new journey if created: for stage_template in journey_template.stages.filter(is_active=True): PatientJourneyStageInstance.objects.create( journey_instance=journey_instance, stage_template=stage_template, status=StageStatus.PENDING ) logger.info(f"Created new journey instance {journey_instance.id} with {journey_template.stages.count()} stages") return journey_instance except Exception as e: logger.error(f"Error creating journey instance: {str(e)}") raise def update_stage_instance(journey_instance, event_data): """Find and update stage instance based on event_type""" try: # Find stage template by trigger_event_code stage_template = journey_instance.journey_template.stages.filter( trigger_event_code=event_data['event_type'], is_active=True ).first() if not stage_template: logger.warning(f"No stage template found for event_type: {event_data['event_type']}") return None # Get or create stage instance stage_instance, created = PatientJourneyStageInstance.objects.get_or_create( journey_instance=journey_instance, stage_template=stage_template, defaults={ 'status': StageStatus.PENDING } ) # Complete the stage if stage_instance.status != StageStatus.COMPLETED: from django.utils import timezone stage_instance.status = StageStatus.COMPLETED stage_instance.completed_at = timezone.now() stage_instance.save() logger.info(f"Completed stage {stage_template.name} for journey {journey_instance.encounter_id}") else: logger.info(f"Stage {stage_template.name} already completed for journey {journey_instance.encounter_id}") return stage_instance except Exception as e: logger.error(f"Error updating stage instance: {str(e)}") raise def send_post_discharge_survey(journey_instance, patient): """ Send post-discharge survey to patient Creates a survey instance and sends invitation via email and SMS """ try: # Check if journey template has post-discharge survey enabled journey_template = journey_instance.journey_template if not journey_template.send_post_discharge_survey: return None # Check if survey already sent for this journey existing_survey = SurveyInstance.objects.filter( journey_instance=journey_instance ).first() if existing_survey: logger.info(f"Survey already sent for journey {journey_instance.encounter_id}") return None # Get survey template from journey template # Use first stage's survey template as the comprehensive survey first_stage = journey_template.stages.filter(is_active=True).order_by('order').first() if not first_stage or not first_stage.survey_template: logger.warning(f"No survey template found for journey {journey_instance.encounter_id}") return None survey_template = first_stage.survey_template # Create survey instance survey_instance = SurveyInstance.objects.create( survey_template=survey_template, patient=patient, journey_instance=journey_instance, hospital=journey_instance.hospital, delivery_channel='email', # Primary channel is email status='pending', recipient_email=patient.email, recipient_phone=patient.phone ) logger.info(f"Created survey instance {survey_instance.id} for journey {journey_instance.encounter_id}") # Send survey invitation via email try: email_log = NotificationService.send_survey_invitation(survey_instance, language='en') logger.info(f"Survey invitation sent via email to {patient.email}") except Exception as e: logger.error(f"Error sending survey email: {str(e)}") # Also send via SMS (as backup) try: sms_log = NotificationService.send_sms( phone=patient.phone, message=f"Your experience survey is ready: {survey_instance.get_survey_url()}", related_object=survey_instance, metadata={'survey_id': str(survey_instance.id)} ) logger.info(f"Survey invitation sent via SMS to {patient.phone}") except Exception as e: logger.error(f"Error sending survey SMS: {str(e)}") # Return survey details return { 'survey_sent': True, 'survey_id': str(survey_instance.id), 'survey_url': survey_instance.get_survey_url(), 'delivery_channel': 'email_and_sms' } except Exception as e: logger.error(f"Error sending post-discharge survey: {str(e)}", exc_info=True) return { 'survey_sent': False, 'error': str(e) }