HH/apps/surveys/his_views.py
2026-02-22 08:35:53 +03:00

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)