HH/apps/surveys/his_views.py
2026-03-09 16:10:24 +03:00

478 lines
19 KiB
Python

"""
HIS Patient Import Views
Handles importing patient data from HIS/MOH Statistics CSV
and sending surveys to imported patients.
"""
import csv
import io
import logging
from datetime import datetime
from django.contrib import messages
from django.utils import timezone
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db import transaction
from django.db.models import Q
from django.http import JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.views.decorators.http import require_http_methods, require_POST
from apps.core.services import AuditService
from apps.organizations.models import Hospital, Patient
from apps.surveys.forms import HISPatientImportForm, HISSurveySendForm
from apps.surveys.models import SurveyInstance, SurveyStatus, SurveyTemplate, BulkSurveyJob
from apps.surveys.services import SurveyDeliveryService
from apps.surveys.tasks import send_bulk_surveys
logger = logging.getLogger(__name__)
@login_required
def his_patient_import(request):
"""
Import patients from HIS/MOH Statistics CSV.
CSV Format (MOH Statistics):
SNo, Facility Name, Visit Type, Admit Date, Discharge Date,
Patient Name, File Number, SSN, Mobile No, Payment Type,
Gender, Full Age, Nationality, Date of Birth, Diagnosis
"""
user = request.user
# Check permission
if not user.is_px_admin() and not user.is_hospital_admin():
messages.error(request, "You don't have permission to import patient data.")
return redirect('surveys:instance_list')
# Session storage for imported patients
session_key = f'his_import_{user.id}'
if request.method == 'POST':
form = HISPatientImportForm(request.POST, request.FILES, user=user)
if form.is_valid():
try:
hospital = form.cleaned_data['hospital']
csv_file = form.cleaned_data['csv_file']
skip_rows = form.cleaned_data['skip_header_rows']
# Parse CSV
decoded_file = csv_file.read().decode('utf-8-sig')
io_string = io.StringIO(decoded_file)
reader = csv.reader(io_string)
# Skip header/metadata rows
for _ in range(skip_rows):
next(reader, None)
# Read header row
header = next(reader, None)
if not header:
messages.error(request, "CSV file is empty or has no data rows.")
return render(request, 'surveys/his_patient_import.html', {'form': form})
# Find column indices
header = [h.strip().lower() for h in header]
col_map = {
'file_number': _find_column(header, ['file number', 'file_number', 'mrn', 'file no']),
'patient_name': _find_column(header, ['patient name', 'patient_name', 'name']),
'mobile_no': _find_column(header, ['mobile no', 'mobile_no', 'mobile', 'phone']),
'ssn': _find_column(header, ['ssn', 'national id', 'national_id', 'id number']),
'gender': _find_column(header, ['gender', 'sex']),
'visit_type': _find_column(header, ['visit type', 'visit_type', 'type']),
'admit_date': _find_column(header, ['admit date', 'admit_date', 'admission date']),
'discharge_date': _find_column(header, ['discharge date', 'discharge_date']),
'facility': _find_column(header, ['facility name', 'facility', 'hospital']),
'nationality': _find_column(header, ['nationality', 'country']),
'dob': _find_column(header, ['date of birth', 'dob', 'birth date']),
}
# Check required columns
if col_map['file_number'] is None:
messages.error(request, "Could not find 'File Number' column in CSV.")
return render(request, 'surveys/his_patient_import.html', {'form': form})
if col_map['patient_name'] is None:
messages.error(request, "Could not find 'Patient Name' column in CSV.")
return render(request, 'surveys/his_patient_import.html', {'form': form})
# Process data rows
imported_patients = []
errors = []
row_num = skip_rows + 1
for row in reader:
row_num += 1
if not row or not any(row): # Skip empty rows
continue
try:
# Extract data
file_number = _get_cell(row, col_map['file_number'], '').strip()
patient_name = _get_cell(row, col_map['patient_name'], '').strip()
mobile_no = _get_cell(row, col_map['mobile_no'], '').strip()
ssn = _get_cell(row, col_map['ssn'], '').strip()
gender = _get_cell(row, col_map['gender'], '').strip().lower()
visit_type = _get_cell(row, col_map['visit_type'], '').strip()
admit_date = _get_cell(row, col_map['admit_date'], '').strip()
discharge_date = _get_cell(row, col_map['discharge_date'], '').strip()
facility = _get_cell(row, col_map['facility'], '').strip()
nationality = _get_cell(row, col_map['nationality'], '').strip()
dob = _get_cell(row, col_map['dob'], '').strip()
# Skip if missing required fields
if not file_number or not patient_name:
continue
# Clean phone number
if mobile_no:
mobile_no = mobile_no.replace(' ', '').replace('-', '')
if not mobile_no.startswith('+'):
# Assume Saudi number if starts with 0
if mobile_no.startswith('05'):
mobile_no = '+966' + mobile_no[1:]
elif mobile_no.startswith('5'):
mobile_no = '+966' + mobile_no
# Parse name (First Middle Last format)
name_parts = patient_name.split()
first_name = name_parts[0] if name_parts else ''
last_name = name_parts[-1] if len(name_parts) > 1 else ''
# Parse dates
parsed_admit = _parse_date(admit_date)
parsed_discharge = _parse_date(discharge_date)
parsed_dob = _parse_date(dob)
# Normalize gender
if gender in ['male', 'm']:
gender = 'male'
elif gender in ['female', 'f']:
gender = 'female'
else:
gender = ''
imported_patients.append({
'row_num': row_num,
'file_number': file_number,
'patient_name': patient_name,
'first_name': first_name,
'last_name': last_name,
'mobile_no': mobile_no,
'ssn': ssn,
'gender': gender,
'visit_type': visit_type,
'admit_date': parsed_admit.isoformat() if parsed_admit else None,
'discharge_date': parsed_discharge.isoformat() if parsed_discharge else None,
'facility': facility,
'nationality': nationality,
'dob': parsed_dob.isoformat() if parsed_dob else None,
})
except Exception as e:
errors.append(f"Row {row_num}: {str(e)}")
logger.error(f"Error processing row {row_num}: {e}")
# Store in session for review step
request.session[session_key] = {
'hospital_id': str(hospital.id),
'patients': imported_patients,
'errors': errors,
'total_count': len(imported_patients)
}
# Log audit
AuditService.log_event(
event_type='his_patient_import',
description=f"Imported {len(imported_patients)} patients from HIS CSV by {user.get_full_name()}",
user=user,
metadata={
'hospital': hospital.name,
'total_count': len(imported_patients),
'error_count': len(errors)
}
)
if imported_patients:
messages.success(
request,
f"Successfully parsed {len(imported_patients)} patient records. Please review before creating."
)
return redirect('surveys:his_patient_review')
else:
messages.error(request, "No valid patient records found in CSV.")
except Exception as e:
logger.error(f"Error processing HIS CSV: {str(e)}", exc_info=True)
messages.error(request, f"Error processing CSV: {str(e)}")
else:
form = HISPatientImportForm(user=user)
context = {
'form': form,
}
return render(request, 'surveys/his_patient_import.html', context)
@login_required
def his_patient_review(request):
"""
Review imported patients before creating records and sending surveys.
"""
user = request.user
session_key = f'his_import_{user.id}'
import_data = request.session.get(session_key)
if not import_data:
messages.error(request, "No import data found. Please upload CSV first.")
return redirect('surveys:his_patient_import')
hospital = get_object_or_404(Hospital, id=import_data['hospital_id'])
patients = import_data['patients']
errors = import_data.get('errors', [])
# Check for existing patients
for p in patients:
existing = Patient.objects.filter(mrn=p['file_number']).first()
p['exists'] = existing is not None
if request.method == 'POST':
action = request.POST.get('action')
selected_ids = request.POST.getlist('selected_patients')
if action == 'create':
# Create/update patient records
created_count = 0
updated_count = 0
with transaction.atomic():
for p in patients:
if p['file_number'] in selected_ids:
patient, created = Patient.objects.update_or_create(
mrn=p['file_number'],
defaults={
'first_name': p['first_name'],
'last_name': p['last_name'],
'phone': p['mobile_no'],
'national_id': p['ssn'],
'gender': p['gender'] if p['gender'] else '',
'primary_hospital': hospital,
}
)
if created:
created_count += 1
else:
updated_count += 1
# Store only the patient ID to avoid serialization issues
p['patient_id'] = str(patient.id)
# Update session with created patients
request.session[session_key]['patients'] = patients
request.session.modified = True
messages.success(
request,
f"Created {created_count} new patients, updated {updated_count} existing patients."
)
return redirect('surveys:his_patient_survey_send')
elif action == 'cancel':
del request.session[session_key]
messages.info(request, "Import cancelled.")
return redirect('surveys:his_patient_import')
context = {
'hospital': hospital,
'patients': patients,
'errors': errors,
'total_count': len(patients),
}
return render(request, 'surveys/his_patient_review.html', context)
@login_required
def his_patient_survey_send(request):
"""
Send surveys to imported patients - Queues a background task.
"""
user = request.user
session_key = f'his_import_{user.id}'
import_data = request.session.get(session_key)
if not import_data:
messages.error(request, "No import data found. Please upload CSV first.")
return redirect('surveys:his_patient_import')
hospital = get_object_or_404(Hospital, id=import_data['hospital_id'])
all_patients = import_data['patients']
# Filter only patients with records in database
patients = []
for p in all_patients:
if 'patient_id' in p:
patients.append(p)
if not patients:
messages.error(request, "No patients have been created yet. Please create patients first.")
return redirect('surveys:his_patient_review')
if request.method == 'POST':
form = HISSurveySendForm(request.POST, user=user)
if form.is_valid():
survey_template = form.cleaned_data['survey_template']
delivery_channel = form.cleaned_data['delivery_channel']
custom_message = form.cleaned_data.get('custom_message', '')
selected_ids = request.POST.getlist('selected_patients')
# Filter selected patients
selected_patients = [p for p in patients if p['file_number'] in selected_ids]
if not selected_patients:
messages.error(request, "No patients selected.")
return render(request, 'surveys/his_patient_survey_send.html', {
'form': form,
'hospital': hospital,
'patients': patients,
'total_count': len(patients),
})
# Create bulk job
job = BulkSurveyJob.objects.create(
name=f"HIS Import - {hospital.name} - {timezone.now().strftime('%Y-%m-%d %H:%M')}",
status=BulkSurveyJob.JobStatus.PENDING,
source=BulkSurveyJob.JobSource.HIS_IMPORT,
created_by=user,
hospital=hospital,
survey_template=survey_template,
total_patients=len(selected_patients),
delivery_channel=delivery_channel,
custom_message=custom_message,
patient_data=selected_patients
)
# Queue the background task
send_bulk_surveys.delay(str(job.id))
# Log audit
AuditService.log_event(
event_type='his_survey_queued',
description=f"Queued bulk survey job for {len(selected_patients)} patients",
user=user,
metadata={
'job_id': str(job.id),
'hospital': hospital.name,
'survey_template': survey_template.name,
'patient_count': len(selected_patients)
}
)
# Clear session
del request.session[session_key]
messages.success(
request,
f"Survey sending job queued for {len(selected_patients)} patients. "
f"You can check the status in Survey Jobs."
)
return redirect('surveys:bulk_job_status', job_id=job.id)
else:
form = HISSurveySendForm(user=user)
context = {
'form': form,
'hospital': hospital,
'patients': patients,
'total_count': len(patients),
}
return render(request, 'surveys/his_patient_survey_send.html', context)
# Helper functions
def _find_column(header, possible_names):
"""Find column index by possible names"""
for name in possible_names:
for i, h in enumerate(header):
if name.lower() in h.lower():
return i
return None
def _get_cell(row, index, default=''):
"""Safely get cell value"""
if index is None or index >= len(row):
return default
return row[index].strip() if row[index] else default
def _parse_date(date_str):
"""Parse date from various formats"""
if not date_str:
return None
formats = [
'%d-%b-%Y %H:%M:%S',
'%d-%b-%Y',
'%d/%m/%Y %H:%M:%S',
'%d/%m/%Y',
'%Y-%m-%d %H:%M:%S',
'%Y-%m-%d',
]
for fmt in formats:
try:
return datetime.strptime(date_str.strip(), fmt).date()
except ValueError:
continue
return None
@login_required
def bulk_job_status(request, job_id):
"""
View status of a bulk survey job.
"""
user = request.user
job = get_object_or_404(BulkSurveyJob, id=job_id)
# Check permission
if not user.is_px_admin() and job.created_by != user and job.hospital != user.hospital:
messages.error(request, "You don't have permission to view this job.")
return redirect('surveys:instance_list')
context = {
'job': job,
'progress': job.progress_percentage,
'is_complete': job.is_complete,
'results': job.results,
}
return render(request, 'surveys/bulk_job_status.html', context)
@login_required
def bulk_job_list(request):
"""
List all bulk survey jobs for the user.
"""
user = request.user
# Filter jobs
if user.is_px_admin():
jobs = BulkSurveyJob.objects.all()
elif user.hospital:
jobs = BulkSurveyJob.objects.filter(hospital=user.hospital)
else:
jobs = BulkSurveyJob.objects.filter(created_by=user)
jobs = jobs.order_by('-created_at')[:50] # Last 50 jobs
context = {
'jobs': jobs,
}
return render(request, 'surveys/bulk_job_list.html', context)