551 lines
21 KiB
Python
551 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(request.POST, request.FILES, request=request)
|
|
|
|
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(request=request)
|
|
|
|
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")
|
|
|
|
# Calculate progress circle stroke-dashoffset
|
|
# Circle circumference is 326.73 (2 * pi * r, where r=52)
|
|
# When progress is 0%, offset should be 326.73 (empty)
|
|
# When progress is 100%, offset should be 0 (full)
|
|
circumference = 2 * 3.14159 * 52 # ~326.73
|
|
progress = job.progress_percentage
|
|
stroke_dashoffset = circumference * (1 - progress / 100)
|
|
|
|
context = {
|
|
"job": job,
|
|
"progress": progress,
|
|
"stroke_dashoffset": stroke_dashoffset,
|
|
"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)
|