477 lines
19 KiB
Python
477 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.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(user, request.POST, request.FILES)
|
|
|
|
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)
|
|
|
|
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(user, request.POST)
|
|
|
|
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)
|
|
|
|
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)
|