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

552 lines
21 KiB
Python

"""
Physician Rating Import Views
UI views for manual CSV upload of doctor ratings.
Similar to HIS Patient Import flow.
"""
import csv
import io
import logging
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.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
from .adapter import DoctorRatingAdapter
from .forms import DoctorRatingImportForm
from .models import DoctorRatingImportJob, PhysicianIndividualRating
from .tasks import process_doctor_rating_job
logger = logging.getLogger(__name__)
@login_required
def doctor_rating_import(request):
"""
Import doctor ratings from CSV (Doctor Rating Report format).
CSV Format (Doctor Rating Report):
- Header rows (rows 1-6 contain metadata)
- Column headers in row 7: UHID, Patient Name, Gender, Full Age, Nationality,
Mobile No, Patient Type, Admit Date, Discharge Date, Doctor Name, Rating,
Feed Back, Rating Date
- Department headers appear as rows with only first column filled
- Data rows follow
"""
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 doctor ratings.")
return redirect('physicians:physician_list')
# Session storage for imported ratings
session_key = f'doctor_rating_import_{user.id}'
if request.method == 'POST':
form = DoctorRatingImportForm(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, 'physicians/doctor_rating_import.html', {'form': form})
# Find column indices (handle different possible column names)
header = [h.strip().lower() for h in header]
col_map = {
'uhid': _find_column(header, ['uhid', 'file number', 'file_number', 'mrn', 'patient id']),
'patient_name': _find_column(header, ['patient name', 'patient_name', 'name']),
'gender': _find_column(header, ['gender', 'sex']),
'age': _find_column(header, ['full age', 'age', 'years']),
'nationality': _find_column(header, ['nationality', 'country']),
'mobile_no': _find_column(header, ['mobile no', 'mobile_no', 'mobile', 'phone', 'contact']),
'patient_type': _find_column(header, ['patient type', 'patient_type', 'type', 'visit type']),
'admit_date': _find_column(header, ['admit date', 'admit_date', 'admission date', 'visit date']),
'discharge_date': _find_column(header, ['discharge date', 'discharge_date']),
'doctor_name': _find_column(header, ['doctor name', 'doctor_name', 'physician name', 'physician']),
'rating': _find_column(header, ['rating', 'score', 'rate']),
'feedback': _find_column(header, ['feed back', 'feedback', 'comments', 'comment']),
'rating_date': _find_column(header, ['rating date', 'rating_date', 'date']),
'department': _find_column(header, ['department', 'dept', 'specialty']),
}
# Check required columns
if col_map['uhid'] is None:
messages.error(request, "Could not find 'UHID' column in CSV.")
return render(request, 'physicians/doctor_rating_import.html', {'form': form})
if col_map['doctor_name'] is None:
messages.error(request, "Could not find 'Doctor Name' column in CSV.")
return render(request, 'physicians/doctor_rating_import.html', {'form': form})
if col_map['rating'] is None:
messages.error(request, "Could not find 'Rating' column in CSV.")
return render(request, 'physicians/doctor_rating_import.html', {'form': form})
# Process data rows
imported_ratings = []
errors = []
row_num = skip_rows + 1
current_department = ""
for row in reader:
row_num += 1
if not row or not any(row): # Skip empty rows
continue
try:
# Check if this is a department header row (only first column has value)
if _is_department_header(row, col_map):
current_department = row[0].strip()
continue
# Extract data
uhid = _get_cell(row, col_map['uhid'], '').strip()
patient_name = _get_cell(row, col_map['patient_name'], '').strip()
doctor_name_raw = _get_cell(row, col_map['doctor_name'], '').strip()
rating_str = _get_cell(row, col_map['rating'], '').strip()
# Skip if missing required fields
if not uhid or not doctor_name_raw:
continue
# Validate rating
try:
rating = int(float(rating_str))
if rating < 1 or rating > 5:
errors.append(f"Row {row_num}: Invalid rating {rating}")
continue
except (ValueError, TypeError):
errors.append(f"Row {row_num}: Invalid rating format '{rating_str}'")
continue
# Extract optional fields
gender = _get_cell(row, col_map['gender'], '').strip()
age = _get_cell(row, col_map['age'], '').strip()
nationality = _get_cell(row, col_map['nationality'], '').strip()
mobile_no = _get_cell(row, col_map['mobile_no'], '').strip()
patient_type = _get_cell(row, col_map['patient_type'], '').strip()
admit_date = _get_cell(row, col_map['admit_date'], '').strip()
discharge_date = _get_cell(row, col_map['discharge_date'], '').strip()
feedback = _get_cell(row, col_map['feedback'], '').strip()
rating_date = _get_cell(row, col_map['rating_date'], '').strip()
department = _get_cell(row, col_map['department'], '').strip() or current_department
# Parse doctor name to extract ID
doctor_id, doctor_name_clean = DoctorRatingAdapter.parse_doctor_name(doctor_name_raw)
imported_ratings.append({
'row_num': row_num,
'uhid': uhid,
'patient_name': patient_name,
'doctor_name_raw': doctor_name_raw,
'doctor_id': doctor_id,
'doctor_name': doctor_name_clean,
'rating': rating,
'gender': gender,
'age': age,
'nationality': nationality,
'mobile_no': mobile_no,
'patient_type': patient_type,
'admit_date': admit_date,
'discharge_date': discharge_date,
'feedback': feedback,
'rating_date': rating_date,
'department': department,
})
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),
'ratings': imported_ratings,
'errors': errors,
'total_count': len(imported_ratings)
}
# Log audit
AuditService.log_event(
event_type='doctor_rating_csv_import',
description=f"Parsed {len(imported_ratings)} doctor ratings from CSV by {user.get_full_name()}",
user=user,
metadata={
'hospital': hospital.name,
'total_count': len(imported_ratings),
'error_count': len(errors)
}
)
if imported_ratings:
messages.success(
request,
f"Successfully parsed {len(imported_ratings)} doctor rating records. Please review before importing."
)
return redirect('physicians:doctor_rating_review')
else:
messages.error(request, "No valid doctor rating records found in CSV.")
except Exception as e:
logger.error(f"Error processing Doctor Rating CSV: {str(e)}", exc_info=True)
messages.error(request, f"Error processing CSV: {str(e)}")
else:
form = DoctorRatingImportForm(user)
context = {
'form': form,
}
return render(request, 'physicians/doctor_rating_import.html', context)
@login_required
def doctor_rating_review(request):
"""
Review imported doctor ratings before creating records.
"""
user = request.user
session_key = f'doctor_rating_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('physicians:doctor_rating_import')
hospital = get_object_or_404(Hospital, id=import_data['hospital_id'])
ratings = import_data['ratings']
errors = import_data.get('errors', [])
# Check for staff matches
for r in ratings:
staff = DoctorRatingAdapter.find_staff_by_doctor_id(
r['doctor_id'], hospital, r['doctor_name']
)
r['staff_matched'] = staff is not None
r['staff_name'] = staff.get_full_name() if staff else None
if request.method == 'POST':
action = request.POST.get('action')
if action == 'import':
# Queue bulk import job
job = DoctorRatingImportJob.objects.create(
name=f"CSV Import - {hospital.name} - {len(ratings)} ratings",
status=DoctorRatingImportJob.JobStatus.PENDING,
source=DoctorRatingImportJob.JobSource.CSV_UPLOAD,
created_by=user,
hospital=hospital,
total_records=len(ratings),
raw_data=ratings
)
# Queue the background task
process_doctor_rating_job.delay(str(job.id))
# Log audit
AuditService.log_event(
event_type='doctor_rating_import_queued',
description=f"Queued {len(ratings)} doctor ratings for import",
user=user,
metadata={
'job_id': str(job.id),
'hospital': hospital.name,
'total_records': len(ratings)
}
)
# Clear session
del request.session[session_key]
messages.success(
request,
f"Import job queued for {len(ratings)} ratings. You can check the status below."
)
return redirect('physicians:doctor_rating_job_status', job_id=job.id)
elif action == 'cancel':
del request.session[session_key]
messages.info(request, "Import cancelled.")
return redirect('physicians:doctor_rating_import')
# Pagination
paginator = Paginator(ratings, 50)
page_number = request.GET.get('page')
page_obj = paginator.get_page(page_number)
context = {
'hospital': hospital,
'ratings': ratings,
'page_obj': page_obj,
'errors': errors,
'total_count': len(ratings),
'matched_count': sum(1 for r in ratings if r['staff_matched']),
'unmatched_count': sum(1 for r in ratings if not r['staff_matched']),
}
return render(request, 'physicians/doctor_rating_review.html', context)
@login_required
def doctor_rating_job_status(request, job_id):
"""
View status of a doctor rating import job.
"""
user = request.user
job = get_object_or_404(DoctorRatingImportJob, id=job_id)
# Check permission
if not user.is_px_admin() and job.hospital != user.hospital:
messages.error(request, "You don't have permission to view this job.")
return redirect('physicians:physician_list')
context = {
'job': job,
'progress': job.progress_percentage,
'is_complete': job.is_complete,
'results': job.results,
}
return render(request, 'physicians/doctor_rating_job_status.html', context)
@login_required
def doctor_rating_job_list(request):
"""
List all doctor rating import jobs for the user.
"""
user = request.user
# Filter jobs
if user.is_px_admin():
jobs = DoctorRatingImportJob.objects.all()
elif user.hospital:
jobs = DoctorRatingImportJob.objects.filter(hospital=user.hospital)
else:
jobs = DoctorRatingImportJob.objects.filter(created_by=user)
jobs = jobs.order_by('-created_at')[:50] # Last 50 jobs
context = {
'jobs': jobs,
}
return render(request, 'physicians/doctor_rating_job_list.html', context)
@login_required
def individual_ratings_list(request):
"""
List individual doctor ratings with filtering.
"""
user = request.user
# Base queryset
queryset = PhysicianIndividualRating.objects.select_related(
'hospital', 'staff', 'staff__department'
)
# Apply RBAC
if not user.is_px_admin():
if user.hospital:
queryset = queryset.filter(hospital=user.hospital)
else:
queryset = queryset.none()
# Filters
hospital_id = request.GET.get('hospital')
doctor_id = request.GET.get('doctor_id')
rating_min = request.GET.get('rating_min')
rating_max = request.GET.get('rating_max')
date_from = request.GET.get('date_from')
date_to = request.GET.get('date_to')
source = request.GET.get('source')
if hospital_id:
queryset = queryset.filter(hospital_id=hospital_id)
if doctor_id:
queryset = queryset.filter(doctor_id=doctor_id)
if rating_min:
queryset = queryset.filter(rating__gte=int(rating_min))
if rating_max:
queryset = queryset.filter(rating__lte=int(rating_max))
if date_from:
queryset = queryset.filter(rating_date__date__gte=date_from)
if date_to:
queryset = queryset.filter(rating_date__date__lte=date_to)
if source:
queryset = queryset.filter(source=source)
# Ordering
queryset = queryset.order_by('-rating_date')
# Pagination
paginator = Paginator(queryset, 25)
page_number = request.GET.get('page')
page_obj = paginator.get_page(page_number)
# Get hospitals for filter
from apps.organizations.models import Hospital
if user.is_px_admin():
hospitals = Hospital.objects.filter(status='active')
else:
hospitals = Hospital.objects.filter(id=user.hospital.id) if user.hospital else Hospital.objects.none()
context = {
'page_obj': page_obj,
'hospitals': hospitals,
'sources': PhysicianIndividualRating.RatingSource.choices,
'filters': {
'hospital': hospital_id,
'doctor_id': doctor_id,
'rating_min': rating_min,
'rating_max': rating_max,
'date_from': date_from,
'date_to': date_to,
'source': source,
}
}
return render(request, 'physicians/individual_ratings_list.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 _is_department_header(row, col_map):
"""
Check if a row is a department header row.
Department headers typically have:
- First column has text (department name)
- All other columns are empty
"""
if not row or not row[0]:
return False
# Check if first column has text
first_col = row[0].strip()
if not first_col:
return False
# Check if other important columns are empty
# If UHID, Doctor Name, Rating are all empty, it's likely a header
uhid = _get_cell(row, col_map.get('uhid'), '').strip()
doctor_name = _get_cell(row, col_map.get('doctor_name'), '').strip()
rating = _get_cell(row, col_map.get('rating'), '').strip()
# If these key fields are empty but first column has text, it's a department header
if not uhid and not doctor_name and not rating:
return True
return False
# ============================================================================
# AJAX Endpoints
# ============================================================================
@login_required
def api_job_progress(request, job_id):
"""AJAX endpoint to get job progress."""
user = request.user
job = get_object_or_404(DoctorRatingImportJob, id=job_id)
# Check permission
if not user.is_px_admin() and job.hospital != user.hospital:
return JsonResponse({'error': 'Permission denied'}, status=403)
return JsonResponse({
'job_id': str(job.id),
'status': job.status,
'progress_percentage': job.progress_percentage,
'processed_count': job.processed_count,
'total_records': job.total_records,
'success_count': job.success_count,
'failed_count': job.failed_count,
'is_complete': job.is_complete,
})
@login_required
def api_match_doctor(request):
"""
AJAX endpoint to manually match a doctor to a staff record.
POST data:
- doctor_id: The doctor ID from the rating
- doctor_name: The doctor name
- staff_id: The staff ID to match to
"""
if request.method != 'POST':
return JsonResponse({'error': 'POST required'}, status=405)
user = request.user
doctor_id = request.POST.get('doctor_id')
doctor_name = request.POST.get('doctor_name')
staff_id = request.POST.get('staff_id')
if not staff_id:
return JsonResponse({'error': 'staff_id required'}, status=400)
try:
staff = Staff.objects.get(id=staff_id)
# Check permission
if not user.is_px_admin() and staff.hospital != user.hospital:
return JsonResponse({'error': 'Permission denied'}, status=403)
# Update all unaggregated ratings for this doctor
count = PhysicianIndividualRating.objects.filter(
doctor_id=doctor_id,
is_aggregated=False
).update(staff=staff)
return JsonResponse({
'success': True,
'matched_count': count,
'staff_name': staff.get_full_name()
})
except Staff.DoesNotExist:
return JsonResponse({'error': 'Staff not found'}, status=404)
except Exception as e:
logger.error(f"Error matching doctor: {str(e)}", exc_info=True)
return JsonResponse({'error': str(e)}, status=500)