552 lines
21 KiB
Python
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)
|