2885 lines
105 KiB
Python
2885 lines
105 KiB
Python
from django.conf import settings
|
|
from django.contrib.auth.decorators import login_required
|
|
from django.core.paginator import Paginator
|
|
from django.db.models import Q
|
|
from django.shortcuts import render, redirect, get_object_or_404
|
|
from django.contrib import messages
|
|
from django.views.decorators.http import require_POST, require_http_methods
|
|
from django.views.decorators.csrf import csrf_exempt
|
|
|
|
from django.utils import timezone
|
|
from django.utils.translation import activate, get_language, gettext as _
|
|
from apps.core.decorators import block_source_user, hospital_admin_required
|
|
|
|
from .models import Department, Hospital, Organization, Patient, Staff, StaffSection, StaffSubsection
|
|
from apps.accounts.models import User
|
|
from .forms import StaffForm, PatientForm
|
|
|
|
|
|
@block_source_user
|
|
@login_required
|
|
def hospital_list(request):
|
|
"""Hospitals list view"""
|
|
queryset = Hospital.objects.all()
|
|
|
|
# Apply RBAC filters
|
|
user = request.user
|
|
if user.is_px_admin():
|
|
# PX Admins see only their selected tenant hospital
|
|
if request.tenant_hospital:
|
|
queryset = queryset.filter(hospital=request.tenant_hospital)
|
|
else:
|
|
queryset = queryset.none()
|
|
elif user.hospital:
|
|
queryset = queryset.filter(hospital=user.hospital)
|
|
|
|
# Apply filters
|
|
hospital_filter = request.GET.get("hospital")
|
|
if hospital_filter:
|
|
queryset = queryset.filter(hospital_id=hospital_filter)
|
|
|
|
status_filter = request.GET.get("status")
|
|
if status_filter:
|
|
queryset = queryset.filter(status=status_filter)
|
|
|
|
# Search
|
|
search_query = request.GET.get("search")
|
|
if search_query:
|
|
queryset = queryset.filter(
|
|
Q(name__icontains=search_query) | Q(name_ar__icontains=search_query) | Q(code__icontains=search_query)
|
|
)
|
|
|
|
# Ordering
|
|
queryset = queryset.order_by("hospital", "name")
|
|
|
|
# Pagination
|
|
page_size = int(request.GET.get("page_size", 25))
|
|
paginator = Paginator(queryset, page_size)
|
|
page_number = request.GET.get("page", 1)
|
|
page_obj = paginator.get_page(page_number)
|
|
|
|
# Get hospitals for filter
|
|
hospitals = Hospital.objects.filter(status="active")
|
|
if not user.is_px_admin() and user.hospital:
|
|
hospitals = hospitals.filter(id=user.hospital.id)
|
|
|
|
context = {
|
|
"page_obj": page_obj,
|
|
"departments": page_obj.object_list,
|
|
"hospitals": hospitals,
|
|
"filters": request.GET,
|
|
}
|
|
|
|
return render(request, "organizations/department_list.html", context)
|
|
|
|
|
|
@block_source_user
|
|
@login_required
|
|
def staff_list(request):
|
|
"""Staff list view - filtered by tenant hospital"""
|
|
lang = request.session.get("django_language", get_language())
|
|
activate(lang)
|
|
queryset = Staff.objects.select_related("hospital", "department", "user")
|
|
|
|
# Always filter by tenant_hospital (set by TenantMiddleware)
|
|
# This handles both PX admins (with selected hospital) and regular users
|
|
if request.tenant_hospital:
|
|
queryset = queryset.filter(hospital=request.tenant_hospital)
|
|
else:
|
|
queryset = queryset.none() # No access without tenant hospital context
|
|
|
|
# Apply filters
|
|
department_filter = request.GET.get("department")
|
|
if department_filter:
|
|
queryset = queryset.filter(department_id=department_filter)
|
|
|
|
status_filter = request.GET.get("status")
|
|
if status_filter:
|
|
queryset = queryset.filter(status=status_filter)
|
|
|
|
staff_type_filter = request.GET.get("department_type")
|
|
if staff_type_filter:
|
|
queryset = queryset.filter(department_type=staff_type_filter)
|
|
|
|
# is_head filter
|
|
is_head_filter = request.GET.get("is_head")
|
|
if is_head_filter:
|
|
if is_head_filter.lower() == "true":
|
|
queryset = queryset.filter(is_head=True)
|
|
elif is_head_filter.lower() == "false":
|
|
queryset = queryset.filter(is_head=False)
|
|
|
|
# Filter by department ForeignKey
|
|
department_filter = request.GET.get("department")
|
|
if department_filter:
|
|
queryset = queryset.filter(department_id=department_filter)
|
|
|
|
section_filter = request.GET.get("section")
|
|
if section_filter:
|
|
queryset = queryset.filter(section__icontains=section_filter)
|
|
|
|
subsection_filter = request.GET.get("subsection")
|
|
if subsection_filter:
|
|
queryset = queryset.filter(subsection__icontains=subsection_filter)
|
|
|
|
# Search
|
|
search_query = request.GET.get("search")
|
|
if search_query:
|
|
queryset = queryset.filter(
|
|
Q(first_name__icontains=search_query)
|
|
| Q(last_name__icontains=search_query)
|
|
| Q(employee_id__icontains=search_query)
|
|
| Q(license_number__icontains=search_query)
|
|
| Q(specialization__icontains=search_query)
|
|
| Q(job_title__icontains=search_query)
|
|
)
|
|
|
|
# Ordering
|
|
queryset = queryset.order_by("last_name", "first_name")
|
|
|
|
# Pagination
|
|
page_size = int(request.GET.get("page_size", 25))
|
|
paginator = Paginator(queryset, page_size)
|
|
page_number = request.GET.get("page", 1)
|
|
page_obj = paginator.get_page(page_number)
|
|
|
|
# Get departments for filter dropdown (from current hospital context)
|
|
if request.tenant_hospital:
|
|
departments = Department.objects.filter(hospital=request.tenant_hospital, status="active").order_by("name")
|
|
else:
|
|
departments = Department.objects.filter(status="active").order_by("name")
|
|
|
|
# Get unique values for section/subsection filters
|
|
base_queryset = Staff.objects.select_related("hospital", "department", "user")
|
|
if request.tenant_hospital:
|
|
base_queryset = base_queryset.filter(hospital=request.tenant_hospital)
|
|
|
|
sections = list(
|
|
base_queryset.exclude(section="").values_list("section", "section_ar").distinct().order_by("section")
|
|
)
|
|
sections = [(s[0], s[1] if lang == "ar" and s[1] else s[0]) for s in sections]
|
|
subsections = (
|
|
base_queryset.exclude(subsection="")
|
|
.values_list("subsection", "subsection_ar")
|
|
.distinct()
|
|
.order_by("subsection")
|
|
)
|
|
subsections = [(s[0], s[1] if lang == "ar" and s[1] else s[0]) for s in subsections]
|
|
|
|
context = {
|
|
"staff": page_obj,
|
|
"filters": request.GET,
|
|
"departments": departments,
|
|
"sections": sections,
|
|
"subsections": subsections,
|
|
}
|
|
|
|
return render(request, "organizations/staff_list.html", context)
|
|
|
|
|
|
@block_source_user
|
|
@login_required
|
|
def organization_list(request):
|
|
"""Organizations list view"""
|
|
queryset = Organization.objects.all()
|
|
|
|
# Apply RBAC filters
|
|
user = request.user
|
|
if not user.is_px_admin() and user.hospital and user.hospital.organization:
|
|
queryset = queryset.filter(id=user.hospital.organization.id)
|
|
|
|
# Apply filters
|
|
status_filter = request.GET.get("status")
|
|
if status_filter:
|
|
queryset = queryset.filter(status=status_filter)
|
|
|
|
city_filter = request.GET.get("city")
|
|
if city_filter:
|
|
queryset = queryset.filter(city__icontains=city_filter)
|
|
|
|
# Search
|
|
search_query = request.GET.get("search")
|
|
if search_query:
|
|
queryset = queryset.filter(
|
|
Q(name__icontains=search_query)
|
|
| Q(name_ar__icontains=search_query)
|
|
| Q(code__icontains=search_query)
|
|
| Q(license_number__icontains=search_query)
|
|
)
|
|
|
|
# Ordering
|
|
queryset = queryset.order_by("name")
|
|
|
|
# Pagination
|
|
page_size = int(request.GET.get("page_size", 25))
|
|
paginator = Paginator(queryset, page_size)
|
|
page_number = request.GET.get("page", 1)
|
|
page_obj = paginator.get_page(page_number)
|
|
|
|
context = {
|
|
"page_obj": page_obj,
|
|
"organizations": page_obj.object_list,
|
|
"filters": request.GET,
|
|
}
|
|
|
|
return render(request, "organizations/organization_list.html", context)
|
|
|
|
|
|
@block_source_user
|
|
@login_required
|
|
def organization_detail(request, pk):
|
|
"""Organization detail view"""
|
|
organization = Organization.objects.get(pk=pk)
|
|
|
|
# Apply RBAC filters
|
|
user = request.user
|
|
if not user.is_px_admin():
|
|
if user.hospital and user.hospital.organization:
|
|
if organization.id != user.hospital.organization.id:
|
|
# User doesn't have access to this organization
|
|
from django.http import HttpResponseForbidden
|
|
|
|
return HttpResponseForbidden("You don't have permission to view this organization")
|
|
else:
|
|
from django.http import HttpResponseForbidden
|
|
|
|
return HttpResponseForbidden("You don't have permission to view this organization")
|
|
|
|
hospitals = organization.hospitals.all()
|
|
|
|
context = {
|
|
"organization": organization,
|
|
"hospitals": hospitals,
|
|
}
|
|
|
|
return render(request, "organizations/organization_detail.html", context)
|
|
|
|
|
|
@block_source_user
|
|
@login_required
|
|
def organization_create(request):
|
|
"""Create organization view"""
|
|
# Only PX Admins can create organizations
|
|
user = request.user
|
|
if not user.is_px_admin():
|
|
from django.http import HttpResponseForbidden
|
|
|
|
return HttpResponseForbidden("Only PX Admins can create organizations")
|
|
|
|
if request.method == "POST":
|
|
name = request.POST.get("name")
|
|
name_ar = request.POST.get("name_ar")
|
|
code = request.POST.get("code")
|
|
address = request.POST.get("address", "")
|
|
city = request.POST.get("city", "")
|
|
phone = request.POST.get("phone", "")
|
|
email = request.POST.get("email", "")
|
|
website = request.POST.get("website", "")
|
|
license_number = request.POST.get("license_number", "")
|
|
status = request.POST.get("status", "active")
|
|
|
|
if name and code:
|
|
organization = Organization.objects.create(
|
|
name=name,
|
|
name_ar=name_ar or name,
|
|
code=code,
|
|
address=address,
|
|
city=city,
|
|
phone=phone,
|
|
email=email,
|
|
website=website,
|
|
license_number=license_number,
|
|
status=status,
|
|
)
|
|
|
|
# Redirect to organization detail
|
|
from django.shortcuts import redirect
|
|
|
|
return redirect("organizations:organization_detail", pk=organization.id)
|
|
|
|
return render(request, "organizations/organization_form.html")
|
|
|
|
|
|
@block_source_user
|
|
@login_required
|
|
def patient_list(request):
|
|
"""Patients list view"""
|
|
queryset = Patient.objects.select_related("primary_hospital")
|
|
|
|
# Filter by current hospital context
|
|
user = request.user
|
|
if request.tenant_hospital:
|
|
queryset = queryset.filter(primary_hospital=request.tenant_hospital)
|
|
|
|
status_filter = request.GET.get("status")
|
|
if status_filter:
|
|
queryset = queryset.filter(status=status_filter)
|
|
|
|
gender_filter = request.GET.get("gender")
|
|
if gender_filter:
|
|
queryset = queryset.filter(gender=gender_filter)
|
|
|
|
nationality_filter = request.GET.get("nationality")
|
|
if nationality_filter:
|
|
queryset = queryset.filter(nationality=nationality_filter)
|
|
|
|
# Search
|
|
search_query = request.GET.get("search")
|
|
if search_query:
|
|
from apps.core.encryption import compute_national_id_hash
|
|
|
|
nid_hash = compute_national_id_hash(search_query)
|
|
queryset = queryset.filter(
|
|
Q(mrn__icontains=search_query)
|
|
| Q(first_name__icontains=search_query)
|
|
| Q(last_name__icontains=search_query)
|
|
| Q(national_id_hash=nid_hash)
|
|
| Q(phone__icontains=search_query)
|
|
)
|
|
|
|
# Ordering
|
|
order_by = request.GET.get("order_by", "last_name")
|
|
if order_by not in ["last_name", "-last_name", "-created_at", "created_at"]:
|
|
order_by = "last_name"
|
|
queryset = queryset.order_by(order_by, "first_name")
|
|
|
|
# Stats (computed from filtered queryset)
|
|
stats = {
|
|
"total": queryset.count(),
|
|
"active": queryset.filter(status="active").count(),
|
|
"hospitals": queryset.values("primary_hospital").distinct().count(),
|
|
"visits": 0,
|
|
}
|
|
|
|
# Export CSV
|
|
if request.GET.get("export") == "csv":
|
|
import csv
|
|
from django.http import HttpResponse
|
|
|
|
response = HttpResponse(content_type="text/csv")
|
|
response["Content-Disposition"] = 'attachment; filename="patients.csv"'
|
|
writer = csv.writer(response)
|
|
writer.writerow(
|
|
[
|
|
"MRN",
|
|
"First Name",
|
|
"Last Name",
|
|
"National ID",
|
|
"Gender",
|
|
"Nationality",
|
|
"Phone",
|
|
"Email",
|
|
"Hospital",
|
|
"Status",
|
|
"Created At",
|
|
]
|
|
)
|
|
for p in queryset[:10000]:
|
|
writer.writerow(
|
|
[
|
|
p.mrn,
|
|
p.first_name,
|
|
p.last_name,
|
|
p.get_masked_national_id(),
|
|
p.get_gender_display(),
|
|
p.nationality,
|
|
p.phone,
|
|
p.email,
|
|
p.primary_hospital.name if p.primary_hospital else "",
|
|
p.get_status_display(),
|
|
p.created_at.strftime("%Y-%m-%d %H:%M"),
|
|
]
|
|
)
|
|
return response
|
|
|
|
# Pagination
|
|
page_size = int(request.GET.get("page_size", 25))
|
|
paginator = Paginator(queryset, page_size)
|
|
page_number = request.GET.get("page", 1)
|
|
page_obj = paginator.get_page(page_number)
|
|
|
|
# Get nationalities for filter
|
|
nationalities = (
|
|
Patient.objects.exclude(nationality="").values_list("nationality", flat=True).order_by("nationality").distinct()
|
|
)
|
|
|
|
context = {
|
|
"page_obj": page_obj,
|
|
"patients": page_obj.object_list,
|
|
"nationalities": nationalities,
|
|
"filters": request.GET,
|
|
"stats": stats,
|
|
}
|
|
|
|
return render(request, "organizations/patient_list.html", context)
|
|
|
|
|
|
@block_source_user
|
|
@login_required
|
|
def staff_detail(request, pk):
|
|
"""Staff detail view"""
|
|
staff = get_object_or_404(Staff.objects.select_related("user", "hospital", "department"), pk=pk)
|
|
|
|
# Apply RBAC filters
|
|
user = request.user
|
|
if not user.is_px_admin() and staff.hospital != user.hospital:
|
|
from django.http import HttpResponseForbidden
|
|
|
|
return HttpResponseForbidden("You don't have permission to view this staff member")
|
|
|
|
from apps.complaints.models import Complaint, ComplaintInvolvedStaff
|
|
|
|
direct_complaints = Complaint.objects.filter(staff=staff).select_related(
|
|
"patient", "hospital", "department", "assigned_to"
|
|
)
|
|
|
|
involved_records = ComplaintInvolvedStaff.objects.filter(staff=staff).select_related(
|
|
"complaint__patient", "complaint__hospital", "complaint__department", "complaint__assigned_to"
|
|
)
|
|
|
|
complaint_rows = []
|
|
seen_ids = set()
|
|
|
|
for c in direct_complaints:
|
|
complaint_rows.append({"complaint": c, "role": _("Primary")})
|
|
seen_ids.add(c.id)
|
|
|
|
for inv in involved_records:
|
|
if inv.complaint_id not in seen_ids:
|
|
complaint_rows.append({"complaint": inv.complaint, "role": inv.get_role_display()})
|
|
seen_ids.add(inv.complaint_id)
|
|
|
|
complaint_rows.sort(key=lambda r: r["complaint"].created_at, reverse=True)
|
|
|
|
all_complaints = [r["complaint"] for r in complaint_rows]
|
|
complaint_counts = {
|
|
"total": len(all_complaints),
|
|
"open": sum(1 for c in all_complaints if c.status == "open"),
|
|
"in_progress": sum(1 for c in all_complaints if c.status == "in_progress"),
|
|
"resolved": sum(1 for c in all_complaints if c.status in ("resolved", "partially_resolved")),
|
|
"closed": sum(1 for c in all_complaints if c.status == "closed"),
|
|
}
|
|
|
|
context = {
|
|
"staff": staff,
|
|
"complaint_rows": complaint_rows,
|
|
"complaint_counts": complaint_counts,
|
|
}
|
|
|
|
if staff.user and (user.is_px_admin() or user.is_hospital_admin()):
|
|
from apps.accounts.models import StaffActivityLog
|
|
|
|
staff_activities = (
|
|
StaffActivityLog.objects.filter(user=staff.user)
|
|
.select_related("content_type")
|
|
.order_by("-created_at")[:50]
|
|
)
|
|
staff_login_count = StaffActivityLog.objects.filter(user=staff.user, activity_type="login").count()
|
|
staff_action_count = (
|
|
StaffActivityLog.objects.filter(user=staff.user).exclude(activity_type__in=["login", "logout"]).count()
|
|
)
|
|
context["staff_activities"] = staff_activities
|
|
context["staff_login_count"] = staff_login_count
|
|
context["staff_action_count"] = staff_action_count
|
|
|
|
return render(request, "organizations/staff_detail.html", context)
|
|
|
|
|
|
@block_source_user
|
|
@login_required
|
|
def staff_complaints_export(request, pk):
|
|
"""Export complaints related to a staff member as Excel"""
|
|
from django.http import HttpResponseForbidden
|
|
|
|
from openpyxl import Workbook
|
|
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
|
from openpyxl.utils import get_column_letter
|
|
|
|
from apps.complaints.models import Complaint, ComplaintInvolvedStaff
|
|
|
|
staff = get_object_or_404(Staff, pk=pk)
|
|
|
|
user = request.user
|
|
if not user.is_px_admin() and staff.hospital != user.hospital:
|
|
return HttpResponseForbidden("You don't have permission")
|
|
|
|
direct_complaints = Complaint.objects.filter(staff=staff).select_related(
|
|
"patient", "hospital", "department"
|
|
)
|
|
involved_records = ComplaintInvolvedStaff.objects.filter(staff=staff).select_related(
|
|
"complaint__patient", "complaint__hospital", "complaint__department"
|
|
)
|
|
|
|
rows = []
|
|
seen_ids = set()
|
|
for c in direct_complaints:
|
|
rows.append((c, _("Primary")))
|
|
seen_ids.add(c.id)
|
|
for inv in involved_records:
|
|
if inv.complaint_id not in seen_ids:
|
|
rows.append((inv.complaint, inv.get_role_display()))
|
|
seen_ids.add(inv.complaint_id)
|
|
|
|
wb = Workbook()
|
|
ws = wb.active
|
|
ws.title = "Staff Complaints"
|
|
|
|
header_font = Font(bold=True, color="FFFFFF")
|
|
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
|
|
header_alignment = Alignment(horizontal="center", vertical="center")
|
|
thin_border = Border(
|
|
left=Side(style="thin"),
|
|
right=Side(style="thin"),
|
|
top=Side(style="thin"),
|
|
bottom=Side(style="thin"),
|
|
)
|
|
|
|
headers = [
|
|
"Reference #",
|
|
"Title",
|
|
"Patient Name",
|
|
"Hospital",
|
|
"Department",
|
|
"Status",
|
|
"Severity",
|
|
"Priority",
|
|
"Role",
|
|
"Source",
|
|
"Created At",
|
|
"Due At",
|
|
"Resolved At",
|
|
"Description",
|
|
]
|
|
|
|
for col_num, header in enumerate(headers, 1):
|
|
cell = ws.cell(row=1, column=col_num, value=header)
|
|
cell.font = header_font
|
|
cell.fill = header_fill
|
|
cell.alignment = header_alignment
|
|
cell.border = thin_border
|
|
|
|
for row_num, (complaint, role) in enumerate(rows, 2):
|
|
values = [
|
|
complaint.reference_number or "",
|
|
complaint.title,
|
|
complaint.patient.get_full_name() if complaint.patient else complaint.patient_name or "",
|
|
complaint.hospital.name if complaint.hospital else "",
|
|
complaint.department.name if complaint.department else "",
|
|
complaint.get_status_display(),
|
|
complaint.get_severity_display(),
|
|
complaint.get_priority_display(),
|
|
str(role),
|
|
complaint.get_complaint_source_type_display(),
|
|
complaint.created_at.strftime("%Y-%m-%d %H:%M") if complaint.created_at else "",
|
|
complaint.due_at.strftime("%Y-%m-%d %H:%M") if complaint.due_at else "",
|
|
complaint.resolved_at.strftime("%Y-%m-%d %H:%M") if complaint.resolved_at else "",
|
|
complaint.description[:500] if complaint.description else "",
|
|
]
|
|
for col_num, value in enumerate(values, 1):
|
|
cell = ws.cell(row=row_num, column=col_num, value=value)
|
|
cell.border = thin_border
|
|
|
|
for col_num in range(1, len(headers) + 1):
|
|
max_length = max(
|
|
len(str(ws.cell(row=r, column=col_num).value or ""))
|
|
for r in range(1, min(len(rows) + 2, 100))
|
|
)
|
|
ws.column_dimensions[get_column_letter(col_num)].width = min(max_length + 4, 50)
|
|
|
|
response = HttpResponse(
|
|
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
)
|
|
response["Content-Disposition"] = (
|
|
f'attachment; filename="staff_{staff.employee_id or staff.id}_complaints_{timezone.now().strftime("%Y%m%d_%H%M%S")}.xlsx"'
|
|
)
|
|
wb.save(response)
|
|
return response
|
|
|
|
|
|
@block_source_user
|
|
@login_required
|
|
def staff_create(request):
|
|
"""Create staff view"""
|
|
# Only PX Admins and Hospital Admins can create staff
|
|
user = request.user
|
|
if not user.is_px_admin() and not user.is_hospital_admin():
|
|
from django.http import HttpResponseForbidden
|
|
|
|
return HttpResponseForbidden("You don't have permission to create staff")
|
|
|
|
if request.method == "POST":
|
|
form = StaffForm(request.POST, request=request)
|
|
if form.is_valid():
|
|
staff = form.save(commit=False)
|
|
|
|
# Handle user account creation
|
|
create_user = request.POST.get("create_user") == "on"
|
|
if create_user and not staff.user and staff.email:
|
|
from .services import StaffService
|
|
|
|
try:
|
|
user_account, was_created, password = StaffService.create_user_for_staff(
|
|
staff, role='staff', request=request
|
|
)
|
|
if was_created and password:
|
|
try:
|
|
StaffService.send_credentials_email(staff, password, request)
|
|
messages.success(request, "Staff member created and credentials email sent successfully.")
|
|
except Exception as e:
|
|
messages.warning(request, f"Staff member created but email sending failed: {str(e)}")
|
|
elif not was_created:
|
|
messages.success(request, "Existing user account linked successfully.")
|
|
except Exception as e:
|
|
messages.error(request, f"Staff member created but user account creation failed: {str(e)}")
|
|
|
|
staff.save()
|
|
|
|
# Send invitation email if requested
|
|
if create_user and staff.user and request.POST.get("send_email") != "false":
|
|
from .services import StaffService
|
|
|
|
try:
|
|
password = StaffService.generate_password()
|
|
staff.user.set_password(password)
|
|
staff.user.save()
|
|
StaffService.send_credentials_email(staff, password, request)
|
|
messages.success(request, "Credentials email sent successfully.")
|
|
except Exception as e:
|
|
messages.warning(request, f"Email sending failed: {str(e)}")
|
|
|
|
messages.success(request, "Staff member created successfully.")
|
|
return redirect("organizations:staff_detail", pk=staff.id)
|
|
else:
|
|
form = StaffForm(request=request)
|
|
|
|
context = {
|
|
"form": form,
|
|
}
|
|
|
|
return render(request, "organizations/staff_form.html", context)
|
|
|
|
|
|
@block_source_user
|
|
@login_required
|
|
def staff_update(request, pk):
|
|
"""Update staff view"""
|
|
staff = get_object_or_404(Staff.objects.select_related("user"), pk=pk)
|
|
|
|
# Apply RBAC filters
|
|
user = request.user
|
|
if not user.is_px_admin() and not user.is_hospital_admin():
|
|
from django.http import HttpResponseForbidden
|
|
|
|
return HttpResponseForbidden("You don't have permission to update this staff member")
|
|
|
|
if user.is_hospital_admin() and staff.hospital != user.hospital:
|
|
from django.http import HttpResponseForbidden
|
|
|
|
return HttpResponseForbidden("You don't have permission to update this staff member")
|
|
|
|
if request.method == "POST":
|
|
form = StaffForm(request.POST, instance=staff)
|
|
if form.is_valid():
|
|
staff = form.save(commit=False)
|
|
|
|
# Handle user account creation
|
|
create_user = request.POST.get("create_user") == "on"
|
|
if create_user and not staff.user and staff.email:
|
|
from .services import StaffService
|
|
|
|
try:
|
|
user_account, was_created, password = StaffService.create_user_for_staff(
|
|
staff, role='staff', request=request
|
|
)
|
|
if was_created and password:
|
|
try:
|
|
StaffService.send_credentials_email(staff, password, request)
|
|
messages.success(request, "User account created and credentials email sent.")
|
|
except Exception as e:
|
|
messages.warning(request, f"User account created but email sending failed: {str(e)}")
|
|
elif not was_created:
|
|
messages.success(request, "Existing user account linked successfully.")
|
|
except Exception as e:
|
|
messages.error(request, f"User account creation failed: {str(e)}")
|
|
|
|
staff.save()
|
|
|
|
messages.success(request, "Staff member updated successfully.")
|
|
return redirect("organizations:staff_detail", pk=staff.id)
|
|
else:
|
|
form = StaffForm(instance=staff, request=request)
|
|
|
|
context = {
|
|
"form": form,
|
|
"staff": staff,
|
|
}
|
|
|
|
return render(request, "organizations/staff_form.html", context)
|
|
|
|
|
|
@block_source_user
|
|
@login_required
|
|
def staff_hierarchy(request):
|
|
"""
|
|
Staff hierarchy table view
|
|
Shows organizational structure based on report_to relationships
|
|
"""
|
|
queryset = Staff.objects.select_related("hospital", "department", "report_to")
|
|
|
|
user = request.user
|
|
if not user.is_px_admin() and user.hospital:
|
|
queryset = queryset.filter(hospital=user.hospital)
|
|
|
|
hospital_filter = request.GET.get("hospital")
|
|
if hospital_filter:
|
|
queryset = queryset.filter(hospital_id=hospital_filter)
|
|
|
|
department_filter = request.GET.get("department")
|
|
if department_filter:
|
|
queryset = queryset.filter(department_id=department_filter)
|
|
|
|
search_query = request.GET.get("search")
|
|
if search_query:
|
|
queryset = queryset.filter(
|
|
Q(employee_id__icontains=search_query)
|
|
| Q(first_name__icontains=search_query)
|
|
| Q(last_name__icontains=search_query)
|
|
| Q(first_name_ar__icontains=search_query)
|
|
| Q(last_name_ar__icontains=search_query)
|
|
)
|
|
|
|
queryset = queryset.order_by("hospital__name", "department__name", "first_name")
|
|
|
|
total_staff = queryset.count()
|
|
top_managers = queryset.filter(report_to__isnull=True).count()
|
|
|
|
page_size = int(request.GET.get("page_size", 25))
|
|
paginator = Paginator(queryset, page_size)
|
|
page_number = request.GET.get("page", 1)
|
|
page_obj = paginator.get_page(page_number)
|
|
|
|
hospitals = Hospital.objects.filter(status="active")
|
|
if not user.is_px_admin() and user.hospital:
|
|
hospitals = hospitals.filter(id=user.hospital.id)
|
|
|
|
departments = Department.objects.filter(status="active")
|
|
if not user.is_px_admin() and user.hospital:
|
|
departments = departments.filter(hospital=user.hospital)
|
|
|
|
context = {
|
|
"staff": page_obj,
|
|
"hospitals": hospitals,
|
|
"departments": departments,
|
|
"filters": request.GET,
|
|
"total_staff": total_staff,
|
|
"top_managers": top_managers,
|
|
}
|
|
|
|
return render(request, "organizations/staff_hierarchy.html", context)
|
|
|
|
|
|
@block_source_user
|
|
@login_required
|
|
def staff_hierarchy_d3(request):
|
|
"""
|
|
Staff hierarchy D3 visualization view
|
|
Shows interactive organizational chart using D3.js
|
|
"""
|
|
# Get hospitals for filter (used by client-side filters)
|
|
hospitals = Hospital.objects.filter(status="active")
|
|
user = request.user
|
|
if user.is_px_admin() and hasattr(request, "tenant_hospital") and request.tenant_hospital:
|
|
hospitals = hospitals.filter(id=request.tenant_hospital.id)
|
|
elif not user.is_px_admin() and user.hospital:
|
|
hospitals = hospitals.filter(id=user.hospital.id)
|
|
|
|
context = {
|
|
"hospitals": hospitals,
|
|
}
|
|
|
|
return render(request, "organizations/staff_hierarchy_d3.html", context)
|
|
|
|
|
|
# ==================== Department CRUD ====================
|
|
|
|
|
|
@block_source_user
|
|
@login_required
|
|
def department_create(request):
|
|
"""Create department view"""
|
|
user = request.user
|
|
if not user.is_px_admin() and not user.is_hospital_admin():
|
|
from django.http import HttpResponseForbidden
|
|
|
|
return HttpResponseForbidden("You don't have permission to create departments")
|
|
|
|
if request.method == "POST":
|
|
name = request.POST.get("name")
|
|
name_ar = request.POST.get("name_ar", "")
|
|
code = request.POST.get("code")
|
|
hospital_id = request.POST.get("hospital")
|
|
category = request.POST.get("category", "")
|
|
status = request.POST.get("status", "active")
|
|
phone = request.POST.get("phone", "")
|
|
email = request.POST.get("email", "")
|
|
location = request.POST.get("location", "")
|
|
manager_id = request.POST.get("manager", "")
|
|
|
|
if name and code and hospital_id:
|
|
# RBAC: Non-admins can only create in their hospital
|
|
if not user.is_px_admin():
|
|
if str(user.hospital_id) != hospital_id:
|
|
from django.http import HttpResponseForbidden
|
|
|
|
return HttpResponseForbidden("You can only create departments in your hospital")
|
|
|
|
department = Department.objects.create(
|
|
name=name,
|
|
name_ar=name_ar or name,
|
|
code=code,
|
|
hospital_id=hospital_id,
|
|
category=category,
|
|
status=status,
|
|
phone=phone,
|
|
email=email,
|
|
location=location,
|
|
manager_id=manager_id if manager_id else None,
|
|
)
|
|
messages.success(request, "Department created successfully.")
|
|
return redirect("organizations:department_list")
|
|
|
|
# Get hospitals for dropdown
|
|
hospitals = Hospital.objects.filter(status="active")
|
|
if not user.is_px_admin() and user.hospital:
|
|
hospitals = hospitals.filter(id=user.hospital.id)
|
|
|
|
# Get head staff for manager dropdown
|
|
hospital_filter = hospitals.first()
|
|
managers = Staff.objects.none()
|
|
if hospital_filter:
|
|
managers = Staff.objects.filter(
|
|
is_head=True, hospital=hospital_filter, status="active", user__isnull=False
|
|
).select_related("user").order_by("first_name", "last_name")
|
|
|
|
context = {
|
|
"hospitals": hospitals,
|
|
"managers": managers,
|
|
}
|
|
return render(request, "organizations/department_form.html", context)
|
|
|
|
|
|
@block_source_user
|
|
@login_required
|
|
def department_update(request, pk):
|
|
"""Update department view"""
|
|
department = get_object_or_404(Department, pk=pk)
|
|
|
|
user = request.user
|
|
if not user.is_px_admin() and not user.is_hospital_admin():
|
|
from django.http import HttpResponseForbidden
|
|
|
|
return HttpResponseForbidden("You don't have permission to update departments")
|
|
|
|
if not user.is_px_admin() and department.hospital != user.hospital:
|
|
from django.http import HttpResponseForbidden
|
|
|
|
return HttpResponseForbidden("You can only update departments in your hospital")
|
|
|
|
if request.method == "POST":
|
|
department.name = request.POST.get("name", department.name)
|
|
department.name_ar = request.POST.get("name_ar", "")
|
|
department.code = request.POST.get("code", department.code)
|
|
department.category = request.POST.get("category", "")
|
|
department.status = request.POST.get("status", department.status)
|
|
department.phone = request.POST.get("phone", "")
|
|
department.email = request.POST.get("email", "")
|
|
department.location = request.POST.get("location", "")
|
|
manager_id = request.POST.get("manager", "")
|
|
department.manager_id = manager_id if manager_id else None
|
|
department.save()
|
|
|
|
CATEGORY_TO_STAFF_TYPE = {
|
|
"nursing": "nurse",
|
|
"medical": "physician",
|
|
"non_medical": "admin",
|
|
"support_services": "other",
|
|
}
|
|
new_category = department.category
|
|
if new_category in CATEGORY_TO_STAFF_TYPE:
|
|
updated = Staff.objects.filter(
|
|
department=department, status="active"
|
|
).update(department_type=new_category)
|
|
if updated:
|
|
messages.info(
|
|
request,
|
|
f"{updated} staff department_type updated to {new_category}.",
|
|
)
|
|
|
|
messages.success(request, "Department updated successfully.")
|
|
return redirect("organizations:department_list")
|
|
|
|
managers = Staff.objects.filter(
|
|
is_head=True, hospital=department.hospital, status="active", user__isnull=False
|
|
).select_related("user").order_by("first_name", "last_name")
|
|
|
|
context = {
|
|
"department": department,
|
|
"managers": managers,
|
|
}
|
|
return render(request, "organizations/department_form.html", context)
|
|
|
|
|
|
@block_source_user
|
|
@login_required
|
|
def department_delete(request, pk):
|
|
"""Delete department view"""
|
|
department = get_object_or_404(Department, pk=pk)
|
|
|
|
user = request.user
|
|
if not user.is_px_admin() and not user.is_hospital_admin():
|
|
from django.http import HttpResponseForbidden
|
|
|
|
return HttpResponseForbidden("You don't have permission to delete departments")
|
|
|
|
if not user.is_px_admin() and department.hospital != user.hospital:
|
|
from django.http import HttpResponseForbidden
|
|
|
|
return HttpResponseForbidden("You can only delete departments in your hospital")
|
|
|
|
if request.method == "POST":
|
|
# Check for linked staff
|
|
staff_count = department.staff.count()
|
|
if staff_count > 0:
|
|
messages.error(request, f"Cannot delete department. {staff_count} staff members are assigned to it.")
|
|
return redirect("organizations:department_list")
|
|
|
|
department.delete()
|
|
messages.success(request, "Department deleted successfully.")
|
|
return redirect("organizations:department_list")
|
|
|
|
context = {
|
|
"department": department,
|
|
}
|
|
return render(request, "organizations/department_confirm_delete.html", context)
|
|
|
|
|
|
# ==================== Staff Section CRUD ====================
|
|
|
|
|
|
@block_source_user
|
|
@login_required
|
|
def section_list(request):
|
|
"""Sections list view"""
|
|
queryset = StaffSection.objects.select_related("department", "department__hospital", "head")
|
|
|
|
# Apply RBAC filters
|
|
user = request.user
|
|
if not user.is_px_admin() and user.hospital:
|
|
queryset = queryset.filter(department__hospital=user.hospital)
|
|
|
|
# Apply filters
|
|
department_filter = request.GET.get("department")
|
|
if department_filter:
|
|
queryset = queryset.filter(department_id=department_filter)
|
|
|
|
status_filter = request.GET.get("status")
|
|
if status_filter:
|
|
queryset = queryset.filter(status=status_filter)
|
|
|
|
# Search
|
|
search_query = request.GET.get("search")
|
|
if search_query:
|
|
queryset = queryset.filter(
|
|
Q(name__icontains=search_query) | Q(name_ar__icontains=search_query) | Q(code__icontains=search_query)
|
|
)
|
|
|
|
# Ordering
|
|
queryset = queryset.order_by("department__name", "name")
|
|
|
|
# Pagination
|
|
page_size = int(request.GET.get("page_size", 25))
|
|
paginator = Paginator(queryset, page_size)
|
|
page_number = request.GET.get("page", 1)
|
|
page_obj = paginator.get_page(page_number)
|
|
|
|
# Get departments for filter
|
|
departments = Department.objects.filter(status="active")
|
|
if not user.is_px_admin() and user.hospital:
|
|
departments = departments.filter(hospital=user.hospital)
|
|
|
|
context = {
|
|
"page_obj": page_obj,
|
|
"sections": page_obj.object_list,
|
|
"departments": departments,
|
|
"filters": request.GET,
|
|
}
|
|
|
|
return render(request, "organizations/section_list.html", context)
|
|
|
|
|
|
@block_source_user
|
|
@login_required
|
|
def section_create(request):
|
|
"""Create section view"""
|
|
user = request.user
|
|
if not user.is_px_admin() and not user.is_hospital_admin():
|
|
from django.http import HttpResponseForbidden
|
|
|
|
return HttpResponseForbidden("You don't have permission to create sections")
|
|
|
|
if request.method == "POST":
|
|
name = request.POST.get("name")
|
|
name_ar = request.POST.get("name_ar", "")
|
|
code = request.POST.get("code", "")
|
|
department_id = request.POST.get("department")
|
|
status = request.POST.get("status", "active")
|
|
head_id = request.POST.get("head")
|
|
|
|
if name and department_id:
|
|
department = get_object_or_404(Department, pk=department_id)
|
|
|
|
# RBAC check
|
|
if not user.is_px_admin() and department.hospital != user.hospital:
|
|
from django.http import HttpResponseForbidden
|
|
|
|
return HttpResponseForbidden("You can only create sections in your hospital")
|
|
|
|
section = StaffSection.objects.create(
|
|
name=name,
|
|
name_ar=name_ar or name,
|
|
code=code,
|
|
department=department,
|
|
status=status,
|
|
head_id=head_id if head_id else None,
|
|
)
|
|
messages.success(request, "Section created successfully.")
|
|
return redirect("organizations:section_list")
|
|
|
|
# Get departments for dropdown
|
|
departments = Department.objects.filter(status="active")
|
|
if not user.is_px_admin() and user.hospital:
|
|
departments = departments.filter(hospital=user.hospital)
|
|
|
|
context = {
|
|
"departments": departments,
|
|
}
|
|
return render(request, "organizations/section_form.html", context)
|
|
|
|
|
|
@block_source_user
|
|
@login_required
|
|
def section_update(request, pk):
|
|
"""Update section view"""
|
|
section = get_object_or_404(StaffSection, pk=pk)
|
|
|
|
user = request.user
|
|
if not user.is_px_admin() and not user.is_hospital_admin():
|
|
from django.http import HttpResponseForbidden
|
|
|
|
return HttpResponseForbidden("You don't have permission to update sections")
|
|
|
|
if not user.is_px_admin() and section.department.hospital != user.hospital:
|
|
from django.http import HttpResponseForbidden
|
|
|
|
return HttpResponseForbidden("You can only update sections in your hospital")
|
|
|
|
if request.method == "POST":
|
|
section.name = request.POST.get("name", section.name)
|
|
section.name_ar = request.POST.get("name_ar", "")
|
|
section.code = request.POST.get("code", "")
|
|
section.status = request.POST.get("status", section.status)
|
|
head_id = request.POST.get("head")
|
|
section.head_id = head_id if head_id else None
|
|
section.save()
|
|
|
|
messages.success(request, "Section updated successfully.")
|
|
return redirect("organizations:section_list")
|
|
|
|
# Get departments for dropdown
|
|
departments = Department.objects.filter(status="active")
|
|
if not user.is_px_admin() and user.hospital:
|
|
departments = departments.filter(hospital=user.hospital)
|
|
|
|
context = {
|
|
"section": section,
|
|
"departments": departments,
|
|
}
|
|
return render(request, "organizations/section_form.html", context)
|
|
|
|
|
|
@block_source_user
|
|
@login_required
|
|
def section_delete(request, pk):
|
|
"""Delete section view"""
|
|
section = get_object_or_404(StaffSection, pk=pk)
|
|
|
|
user = request.user
|
|
if not user.is_px_admin() and not user.is_hospital_admin():
|
|
from django.http import HttpResponseForbidden
|
|
|
|
return HttpResponseForbidden("You don't have permission to delete sections")
|
|
|
|
if not user.is_px_admin() and section.department.hospital != user.hospital:
|
|
from django.http import HttpResponseForbidden
|
|
|
|
return HttpResponseForbidden("You can only delete sections in your hospital")
|
|
|
|
if request.method == "POST":
|
|
subsection_count = section.subsections.count()
|
|
if subsection_count > 0:
|
|
messages.error(request, f"Cannot delete section. {subsection_count} subsections are linked to it.")
|
|
return redirect("organizations:section_list")
|
|
|
|
section.delete()
|
|
messages.success(request, "Section deleted successfully.")
|
|
return redirect("organizations:section_list")
|
|
|
|
context = {
|
|
"section": section,
|
|
}
|
|
return render(request, "organizations/section_confirm_delete.html", context)
|
|
|
|
|
|
# ==================== Staff Subsection CRUD ====================
|
|
|
|
|
|
@block_source_user
|
|
@login_required
|
|
def subsection_list(request):
|
|
"""Subsections list view"""
|
|
queryset = StaffSubsection.objects.select_related(
|
|
"section", "section__department", "section__department__hospital", "head"
|
|
)
|
|
|
|
# Apply RBAC filters
|
|
user = request.user
|
|
if not user.is_px_admin() and user.hospital:
|
|
queryset = queryset.filter(section__department__hospital=user.hospital)
|
|
|
|
# Apply filters
|
|
section_filter = request.GET.get("section")
|
|
if section_filter:
|
|
queryset = queryset.filter(section_id=section_filter)
|
|
|
|
department_filter = request.GET.get("department")
|
|
if department_filter:
|
|
queryset = queryset.filter(section__department_id=department_filter)
|
|
|
|
status_filter = request.GET.get("status")
|
|
if status_filter:
|
|
queryset = queryset.filter(status=status_filter)
|
|
|
|
# Search
|
|
search_query = request.GET.get("search")
|
|
if search_query:
|
|
queryset = queryset.filter(
|
|
Q(name__icontains=search_query) | Q(name_ar__icontains=search_query) | Q(code__icontains=search_query)
|
|
)
|
|
|
|
# Ordering
|
|
queryset = queryset.order_by("section__name", "name")
|
|
|
|
# Pagination
|
|
page_size = int(request.GET.get("page_size", 25))
|
|
paginator = Paginator(queryset, page_size)
|
|
page_number = request.GET.get("page", 1)
|
|
page_obj = paginator.get_page(page_number)
|
|
|
|
# Get sections and departments for filter
|
|
departments = Department.objects.filter(status="active")
|
|
sections = StaffSection.objects.filter(status="active")
|
|
if not user.is_px_admin() and user.hospital:
|
|
departments = departments.filter(hospital=user.hospital)
|
|
sections = sections.filter(department__hospital=user.hospital)
|
|
|
|
context = {
|
|
"page_obj": page_obj,
|
|
"subsections": page_obj.object_list,
|
|
"sections": sections,
|
|
"departments": departments,
|
|
"filters": request.GET,
|
|
}
|
|
|
|
return render(request, "organizations/subsection_list.html", context)
|
|
|
|
|
|
@block_source_user
|
|
@login_required
|
|
def subsection_create(request):
|
|
"""Create subsection view"""
|
|
user = request.user
|
|
if not user.is_px_admin() and not user.is_hospital_admin():
|
|
from django.http import HttpResponseForbidden
|
|
|
|
return HttpResponseForbidden("You don't have permission to create subsections")
|
|
|
|
if request.method == "POST":
|
|
name = request.POST.get("name")
|
|
name_ar = request.POST.get("name_ar", "")
|
|
code = request.POST.get("code", "")
|
|
section_id = request.POST.get("section")
|
|
status = request.POST.get("status", "active")
|
|
head_id = request.POST.get("head")
|
|
|
|
if name and section_id:
|
|
section = get_object_or_404(StaffSection, pk=section_id)
|
|
|
|
# RBAC check
|
|
if not user.is_px_admin() and section.department.hospital != user.hospital:
|
|
from django.http import HttpResponseForbidden
|
|
|
|
return HttpResponseForbidden("You can only create subsections in your hospital")
|
|
|
|
subsection = StaffSubsection.objects.create(
|
|
name=name,
|
|
name_ar=name_ar or name,
|
|
code=code,
|
|
section=section,
|
|
status=status,
|
|
head_id=head_id if head_id else None,
|
|
)
|
|
messages.success(request, "Subsection created successfully.")
|
|
return redirect("organizations:subsection_list")
|
|
|
|
# Get sections for dropdown
|
|
sections = StaffSection.objects.filter(status="active").select_related("department")
|
|
if not user.is_px_admin() and user.hospital:
|
|
sections = sections.filter(department__hospital=user.hospital)
|
|
|
|
context = {
|
|
"sections": sections,
|
|
}
|
|
return render(request, "organizations/subsection_form.html", context)
|
|
|
|
|
|
@block_source_user
|
|
@login_required
|
|
def subsection_update(request, pk):
|
|
"""Update subsection view"""
|
|
subsection = get_object_or_404(StaffSubsection, pk=pk)
|
|
|
|
user = request.user
|
|
if not user.is_px_admin() and not user.is_hospital_admin():
|
|
from django.http import HttpResponseForbidden
|
|
|
|
return HttpResponseForbidden("You don't have permission to update subsections")
|
|
|
|
if not user.is_px_admin() and subsection.section.department.hospital != user.hospital:
|
|
from django.http import HttpResponseForbidden
|
|
|
|
return HttpResponseForbidden("You can only update subsections in your hospital")
|
|
|
|
if request.method == "POST":
|
|
subsection.name = request.POST.get("name", subsection.name)
|
|
subsection.name_ar = request.POST.get("name_ar", "")
|
|
subsection.code = request.POST.get("code", "")
|
|
subsection.status = request.POST.get("status", subsection.status)
|
|
head_id = request.POST.get("head")
|
|
subsection.head_id = head_id if head_id else None
|
|
subsection.save()
|
|
|
|
messages.success(request, "Subsection updated successfully.")
|
|
return redirect("organizations:subsection_list")
|
|
|
|
# Get sections for dropdown
|
|
sections = StaffSection.objects.filter(status="active").select_related("department")
|
|
if not user.is_px_admin() and user.hospital:
|
|
sections = sections.filter(department__hospital=user.hospital)
|
|
|
|
context = {
|
|
"subsection": subsection,
|
|
"sections": sections,
|
|
}
|
|
return render(request, "organizations/subsection_form.html", context)
|
|
|
|
|
|
@block_source_user
|
|
@login_required
|
|
def subsection_delete(request, pk):
|
|
"""Delete subsection view"""
|
|
subsection = get_object_or_404(StaffSubsection, pk=pk)
|
|
|
|
user = request.user
|
|
if not user.is_px_admin() and not user.is_hospital_admin():
|
|
from django.http import HttpResponseForbidden
|
|
|
|
return HttpResponseForbidden("You don't have permission to delete subsections")
|
|
|
|
if not user.is_px_admin() and subsection.section.department.hospital != user.hospital:
|
|
from django.http import HttpResponseForbidden
|
|
|
|
return HttpResponseForbidden("You can only delete subsections in your hospital")
|
|
|
|
if request.method == "POST":
|
|
subsection.delete()
|
|
messages.success(request, "Subsection deleted successfully.")
|
|
return redirect("organizations:subsection_list")
|
|
|
|
context = {
|
|
"subsection": subsection,
|
|
}
|
|
return render(request, "organizations/subsection_confirm_delete.html", context)
|
|
|
|
|
|
@block_source_user
|
|
@login_required
|
|
def patient_detail(request, pk):
|
|
"""Patient detail view"""
|
|
patient = get_object_or_404(Patient.objects.select_related("primary_hospital"), pk=pk)
|
|
|
|
# Apply RBAC filters
|
|
user = request.user
|
|
if not user.is_px_admin() and patient.primary_hospital != user.hospital:
|
|
from django.http import HttpResponseForbidden
|
|
|
|
return HttpResponseForbidden("You don't have permission to view this patient")
|
|
|
|
from apps.integrations.models import HISPatientVisit
|
|
from apps.surveys.models import SurveyInstance
|
|
|
|
tab = request.GET.get("tab", "visits")
|
|
|
|
his_visits = (
|
|
HISPatientVisit.objects.filter(patient=patient)
|
|
.select_related("hospital", "survey_instance", "primary_doctor_fk", "consultant_fk")
|
|
.order_by("-admit_date")[:20]
|
|
)
|
|
|
|
surveys = (
|
|
SurveyInstance.objects.filter(patient=patient)
|
|
.select_related("survey_template")
|
|
.prefetch_related("responses")
|
|
.order_by("-created_at")[:10]
|
|
)
|
|
|
|
complaints = patient.complaints.select_related("hospital", "department").order_by("-created_at")[:10]
|
|
|
|
inquiries = patient.inquiries.select_related("hospital", "department").order_by("-created_at")[:10]
|
|
|
|
stats = {
|
|
"visits": HISPatientVisit.objects.filter(patient=patient).count(),
|
|
"surveys": SurveyInstance.objects.filter(patient=patient).count(),
|
|
"complaints": patient.complaints.count(),
|
|
"inquiries": patient.inquiries.count(),
|
|
}
|
|
|
|
context = {
|
|
"patient": patient,
|
|
"tab": tab,
|
|
"his_visits": his_visits,
|
|
"surveys": surveys,
|
|
"complaints": complaints,
|
|
"inquiries": inquiries,
|
|
"stats": stats,
|
|
}
|
|
|
|
return render(request, "organizations/patient_detail.html", context)
|
|
|
|
|
|
@block_source_user
|
|
@login_required
|
|
def patient_visit_journey(request, patient_pk, visit_pk):
|
|
"""Patient visit journey timeline view with time analytics"""
|
|
import json
|
|
from apps.integrations.models import HISPatientVisit
|
|
|
|
patient = get_object_or_404(Patient.objects.select_related("primary_hospital"), pk=patient_pk)
|
|
visit = get_object_or_404(
|
|
HISPatientVisit.objects.prefetch_related("visit_events").select_related(
|
|
"hospital", "survey_instance", "primary_doctor_fk", "consultant_fk"
|
|
),
|
|
pk=visit_pk,
|
|
patient=patient,
|
|
)
|
|
|
|
user = request.user
|
|
if not user.is_px_admin() and patient.primary_hospital != user.hospital:
|
|
from django.http import HttpResponseForbidden
|
|
|
|
return HttpResponseForbidden("You don't have permission to view this visit")
|
|
|
|
timeline = list(visit.visit_events.all())
|
|
|
|
EVENT_CATEGORIES = {
|
|
"Registration": ["consultation", "registration", "triage", "admission"],
|
|
"Lab": ["lab", "sample"],
|
|
"Radiology": ["rad", "radiology"],
|
|
"Pharmacy": ["drug", "pharmacy"],
|
|
"Doctor": ["doctor", "procedure", "episode"],
|
|
}
|
|
|
|
def _classify_event(event_type):
|
|
et = event_type.lower()
|
|
for cat, keywords in EVENT_CATEGORIES.items():
|
|
if any(kw in et for kw in keywords):
|
|
return cat
|
|
return "Other"
|
|
|
|
def _fmt_duration(seconds):
|
|
if seconds is None:
|
|
return "-"
|
|
total_min = int(seconds // 60)
|
|
if total_min < 60:
|
|
return f"{total_min}m"
|
|
hours = total_min // 60
|
|
mins = total_min % 60
|
|
if mins == 0:
|
|
return f"{hours}h"
|
|
return f"{hours}h {mins}m"
|
|
|
|
timeline_with_durations = []
|
|
intervals = []
|
|
category_time = {}
|
|
|
|
for i, event in enumerate(timeline):
|
|
duration_seconds = None
|
|
duration_display = "-"
|
|
if i < len(timeline) - 1 and event.parsed_date and timeline[i + 1].parsed_date:
|
|
delta = timeline[i + 1].parsed_date - event.parsed_date
|
|
duration_seconds = delta.total_seconds()
|
|
duration_display = _fmt_duration(duration_seconds)
|
|
|
|
cat = _classify_event(event.event_type)
|
|
category_time[cat] = category_time.get(cat, 0) + max(duration_seconds, 0)
|
|
|
|
intervals.append({
|
|
"label": f"{event.event_type} → {timeline[i + 1].event_type}",
|
|
"short_label": event.event_type,
|
|
"minutes": round(max(duration_seconds, 0) / 60, 1),
|
|
})
|
|
|
|
timeline_with_durations.append({
|
|
"event": event,
|
|
"duration_seconds": duration_seconds,
|
|
"duration_display": duration_display,
|
|
})
|
|
|
|
first_dt = None
|
|
last_dt = None
|
|
for e in timeline:
|
|
if e.parsed_date:
|
|
if first_dt is None or e.parsed_date < first_dt:
|
|
first_dt = e.parsed_date
|
|
if last_dt is None or e.parsed_date > last_dt:
|
|
last_dt = e.parsed_date
|
|
|
|
total_duration_seconds = (last_dt - first_dt).total_seconds() if first_dt and last_dt else 0
|
|
total_duration_display = _fmt_duration(total_duration_seconds)
|
|
|
|
valid_durations = [iv["minutes"] for iv in intervals if iv["minutes"] > 0]
|
|
avg_step = sum(valid_durations) / len(valid_durations) if valid_durations else 0
|
|
longest_wait = max(valid_durations) if valid_durations else 0
|
|
longest_wait_display = _fmt_duration(longest_wait * 60)
|
|
avg_step_display = _fmt_duration(avg_step * 60)
|
|
|
|
bar_chart_data = []
|
|
for iv in intervals[:20]:
|
|
if iv["minutes"] > 0:
|
|
bar_chart_data.append({"x": iv["short_label"], "y": iv["minutes"]})
|
|
if not bar_chart_data:
|
|
bar_chart_data = [{"x": "No intervals", "y": 0.1}]
|
|
|
|
donut_labels = []
|
|
donut_series = []
|
|
donut_color_keys = []
|
|
for cat, seconds in category_time.items():
|
|
minutes = round(seconds / 60, 1)
|
|
if minutes > 0:
|
|
donut_labels.append(cat)
|
|
donut_series.append(minutes)
|
|
donut_color_keys.append(cat)
|
|
if not donut_labels:
|
|
donut_labels = ["No data"]
|
|
donut_series = [1]
|
|
donut_color_keys = ["Other"]
|
|
|
|
donut_chart_data = {
|
|
"labels": donut_labels,
|
|
"series": donut_series,
|
|
}
|
|
|
|
CATEGORY_COLORS = {
|
|
"Registration": "#3b82f6",
|
|
"Lab": "#8b5cf6",
|
|
"Radiology": "#f59e0b",
|
|
"Pharmacy": "#10b981",
|
|
"Doctor": "#06b6d4",
|
|
"Other": "#94a3b8",
|
|
}
|
|
donut_colors = [CATEGORY_COLORS.get(c, "#94a3b8") for c in donut_color_keys]
|
|
|
|
context = {
|
|
"patient": patient,
|
|
"visit": visit,
|
|
"timeline": timeline_with_durations,
|
|
"total_duration_display": total_duration_display,
|
|
"total_duration_seconds": total_duration_seconds,
|
|
"avg_step_display": avg_step_display,
|
|
"longest_wait_display": longest_wait_display,
|
|
"step_count": len(timeline),
|
|
"bar_chart_json": json.dumps(bar_chart_data),
|
|
"donut_chart_json": json.dumps(donut_chart_data),
|
|
"donut_colors_json": json.dumps(donut_colors),
|
|
}
|
|
return render(request, "organizations/patient_visit_journey.html", context)
|
|
|
|
|
|
@block_source_user
|
|
@login_required
|
|
def patient_create(request):
|
|
"""Create patient view"""
|
|
user = request.user
|
|
|
|
# Only PX Admins and Hospital Admins can create patients
|
|
if not user.is_px_admin() and not user.is_hospital_admin():
|
|
from django.http import HttpResponseForbidden
|
|
|
|
return HttpResponseForbidden("You don't have permission to create patients")
|
|
|
|
if request.method == "POST":
|
|
form = PatientForm(user, request.POST)
|
|
if form.is_valid():
|
|
patient = form.save()
|
|
messages.success(request, f"Patient {patient.get_full_name()} created successfully.")
|
|
return redirect("organizations:patient_detail", pk=patient.pk)
|
|
else:
|
|
form = PatientForm(user)
|
|
|
|
context = {
|
|
"form": form,
|
|
"title": _("Create Patient"),
|
|
}
|
|
|
|
return render(request, "organizations/patient_form.html", context)
|
|
|
|
|
|
@block_source_user
|
|
@login_required
|
|
def patient_update(request, pk):
|
|
"""Update patient view"""
|
|
patient = get_object_or_404(Patient, pk=pk)
|
|
user = request.user
|
|
|
|
# Apply RBAC filters
|
|
if not user.is_px_admin() and patient.primary_hospital != user.hospital:
|
|
from django.http import HttpResponseForbidden
|
|
|
|
return HttpResponseForbidden("You don't have permission to edit this patient")
|
|
|
|
# Only PX Admins and Hospital Admins can update patients
|
|
if not user.is_px_admin() and not user.is_hospital_admin():
|
|
from django.http import HttpResponseForbidden
|
|
|
|
return HttpResponseForbidden("You don't have permission to edit patients")
|
|
|
|
if request.method == "POST":
|
|
form = PatientForm(user, request.POST, instance=patient)
|
|
if form.is_valid():
|
|
patient = form.save()
|
|
messages.success(request, f"Patient {patient.get_full_name()} updated successfully.")
|
|
return redirect("organizations:patient_detail", pk=patient.pk)
|
|
else:
|
|
form = PatientForm(user, instance=patient)
|
|
|
|
context = {
|
|
"form": form,
|
|
"patient": patient,
|
|
"title": _("Edit Patient"),
|
|
}
|
|
|
|
return render(request, "organizations/patient_form.html", context)
|
|
|
|
|
|
@block_source_user
|
|
@login_required
|
|
def patient_delete(request, pk):
|
|
"""Delete patient view"""
|
|
patient = get_object_or_404(Patient, pk=pk)
|
|
user = request.user
|
|
|
|
# Apply RBAC filters
|
|
if not user.is_px_admin() and patient.primary_hospital != user.hospital:
|
|
from django.http import HttpResponseForbidden
|
|
|
|
return HttpResponseForbidden("You don't have permission to delete this patient")
|
|
|
|
# Only PX Admins can delete patients
|
|
if not user.is_px_admin():
|
|
from django.http import HttpResponseForbidden
|
|
|
|
return HttpResponseForbidden("Only PX Admins can delete patients")
|
|
|
|
if request.method == "POST":
|
|
patient_name = patient.get_full_name()
|
|
patient.delete()
|
|
messages.success(request, f"Patient {patient_name} deleted successfully.")
|
|
return redirect("organizations:patient_list")
|
|
|
|
context = {
|
|
"patient": patient,
|
|
}
|
|
|
|
return render(request, "organizations/patient_confirm_delete.html", context)
|
|
|
|
|
|
@csrf_exempt
|
|
@login_required
|
|
@require_POST
|
|
def search_his_patient(request):
|
|
"""Search HIS by SSN or MobileNo, return patient demographics as JSON."""
|
|
import json
|
|
from django.http import JsonResponse
|
|
|
|
try:
|
|
data = json.loads(request.body or b"{}")
|
|
if not isinstance(data, dict):
|
|
data = {}
|
|
except (json.JSONDecodeError, TypeError):
|
|
data = {}
|
|
|
|
ssn = (data.get("ssn") or "").strip()
|
|
mobile_no = (data.get("mobile_no") or "").strip()
|
|
|
|
if not ssn and not mobile_no:
|
|
return JsonResponse({"error": "Provide SSN or Mobile number"}, status=400)
|
|
|
|
from apps.integrations.models import IntegrationConfig
|
|
from apps.integrations.services.his_client import HISClient
|
|
from django.conf import settings
|
|
|
|
config = IntegrationConfig.objects.filter(source_system__in=["his", "other"]).first()
|
|
|
|
if not config:
|
|
return JsonResponse({"error": "HIS integration not configured"}, status=400)
|
|
|
|
if settings.DEBUG:
|
|
config.api_url = request.build_absolute_uri("/api/integrations/test-his-data/")
|
|
|
|
client = HISClient(config)
|
|
patients = client.fetch_patient_by_identifier(ssn=ssn or None, mobile_no=mobile_no or None)
|
|
|
|
if patients is None:
|
|
return JsonResponse({"error": "Failed to connect to HIS"}, status=500)
|
|
|
|
if not patients:
|
|
return JsonResponse({"patients": [], "total": 0})
|
|
|
|
result = []
|
|
for p in patients:
|
|
result.append(
|
|
{
|
|
"patient_id": p.get("PatientID"),
|
|
"admission_id": p.get("AdmissionID"),
|
|
"name": p.get("PatientName", ""),
|
|
"ssn": p.get("SSN", ""),
|
|
"mobile_no": p.get("MobileNo", ""),
|
|
"gender": p.get("Gender", ""),
|
|
"nationality": p.get("PatientNationality", ""),
|
|
"dob": p.get("DOB", ""),
|
|
"patient_type": p.get("PatientType", ""),
|
|
"hospital_name": p.get("HospitalName", ""),
|
|
"hospital_id": p.get("HospitalID", ""),
|
|
"admit_date": p.get("AdmitDate", ""),
|
|
"discharge_date": p.get("DischargeDate", ""),
|
|
"reg_code": p.get("RegCode", ""),
|
|
"raw": p,
|
|
}
|
|
)
|
|
|
|
return JsonResponse({"patients": result, "total": len(result)})
|
|
|
|
|
|
@csrf_exempt
|
|
@login_required
|
|
@require_POST
|
|
def save_his_patient(request):
|
|
"""Save a patient from HIS data locally."""
|
|
import json
|
|
from django.http import JsonResponse
|
|
|
|
try:
|
|
data = json.loads(request.body or b"{}")
|
|
if not isinstance(data, dict):
|
|
data = {}
|
|
except (json.JSONDecodeError, TypeError):
|
|
data = {}
|
|
|
|
patient_data = data.get("patient_data")
|
|
if not patient_data:
|
|
return JsonResponse({"error": "No patient data provided"}, status=400)
|
|
|
|
from apps.integrations.models import IntegrationConfig
|
|
from apps.integrations.services.his_adapter import HISAdapter
|
|
|
|
config = IntegrationConfig.objects.filter(source_system__in=["his", "other"]).first()
|
|
|
|
if not config:
|
|
return JsonResponse({"error": "HIS integration not configured"}, status=400)
|
|
|
|
try:
|
|
hospital = HISAdapter.get_or_create_hospital(patient_data)
|
|
patient = HISAdapter.get_or_create_patient(patient_data, hospital)
|
|
visit = HISAdapter.save_patient_visit(
|
|
patient=patient,
|
|
hospital=hospital,
|
|
patient_data=patient_data,
|
|
visit_timeline=[],
|
|
is_visit_complete=False,
|
|
)
|
|
|
|
return JsonResponse(
|
|
{
|
|
"success": True,
|
|
"patient_id": str(patient.id),
|
|
"patient_name": patient.get_full_name(),
|
|
"visit_id": str(visit.id),
|
|
"admission_id": visit.admission_id,
|
|
}
|
|
)
|
|
except Exception as e:
|
|
return JsonResponse({"error": str(e)}, status=500)
|
|
|
|
|
|
@block_source_user
|
|
@login_required
|
|
@require_POST
|
|
def send_complaint_link(request, pk):
|
|
"""Create a complaint session and send SMS link to the patient."""
|
|
from django.http import JsonResponse
|
|
from apps.complaints.models import PatientComplaintSession
|
|
from apps.notifications.services import NotificationService
|
|
|
|
patient = get_object_or_404(Patient.objects.select_related("primary_hospital"), pk=pk)
|
|
|
|
if not patient.phone:
|
|
return JsonResponse({"error": "Patient has no phone number"}, status=400)
|
|
|
|
session = PatientComplaintSession.objects.create(
|
|
patient=patient,
|
|
created_by=request.user,
|
|
)
|
|
|
|
base_url = getattr(settings, "SURVEY_BASE_URL", "")
|
|
link = f"{base_url}/complaints/patient/{session.token}/"
|
|
|
|
message = (
|
|
f"Dear {patient.first_name}, we value your feedback. "
|
|
f"If you have any concerns about your recent visit, please submit them here: {link}"
|
|
)
|
|
|
|
NotificationService.send_sms(phone=patient.phone, message=message)
|
|
|
|
return JsonResponse(
|
|
{
|
|
"success": True,
|
|
"token": session.token,
|
|
"link": link,
|
|
"phone": patient.phone,
|
|
}
|
|
)
|
|
|
|
|
|
|
|
@login_required
|
|
def department_list(request):
|
|
from .models import Department
|
|
from apps.organizations.models import Staff
|
|
from django.db.models import Count, Q
|
|
|
|
user = request.user
|
|
selected_hospital = getattr(request, "tenant_hospital", None)
|
|
|
|
queryset = Department.objects.select_related("hospital", "manager").filter(status="active")
|
|
|
|
if user.is_px_admin():
|
|
if selected_hospital:
|
|
queryset = queryset.filter(hospital=selected_hospital)
|
|
elif user.is_hospital_admin() and user.hospital:
|
|
queryset = queryset.filter(hospital=user.hospital)
|
|
elif user.is_department_manager() and user.department:
|
|
queryset = queryset.filter(
|
|
Q(id=user.department.id) | Q(parent=user.department)
|
|
)
|
|
elif user.is_director():
|
|
directed_depts = user.get_directed_departments()
|
|
if directed_depts.exists():
|
|
queryset = queryset.filter(id__in=directed_depts)
|
|
else:
|
|
queryset = queryset.none()
|
|
elif user.is_champion() and user.department:
|
|
queryset = queryset.filter(id=user.department.id)
|
|
elif user.is_basic_staff() and user.department:
|
|
queryset = queryset.filter(id=user.department.id)
|
|
elif user.hospital:
|
|
queryset = queryset.filter(hospital=user.hospital)
|
|
else:
|
|
queryset = queryset.none()
|
|
|
|
search = request.GET.get("search", "").strip()
|
|
if search:
|
|
queryset = queryset.filter(
|
|
Q(name__icontains=search)
|
|
| Q(name_en__icontains=search)
|
|
| Q(name_ar__icontains=search)
|
|
| Q(code__icontains=search)
|
|
)
|
|
|
|
category_filter = request.GET.get("category")
|
|
if category_filter:
|
|
queryset = queryset.filter(category=category_filter)
|
|
|
|
queryset = queryset.annotate(
|
|
staff_count=Count("staff", filter=Q(staff__status="active"), distinct=True),
|
|
open_complaints=Count(
|
|
"complaints",
|
|
filter=Q(complaints__status__in=["open", "in_progress"]),
|
|
distinct=True,
|
|
),
|
|
pending_inquiries=Count(
|
|
"inquiries",
|
|
filter=Q(inquiries__status__in=["open", "in_progress"]),
|
|
distinct=True,
|
|
),
|
|
open_observations=Count(
|
|
"assigned_observations",
|
|
filter=Q(assigned_observations__status__in=["new", "triaged", "in_progress"]),
|
|
distinct=True,
|
|
),
|
|
).order_by("name")
|
|
|
|
total_count = queryset.count()
|
|
active_count = queryset.filter(status="active").count()
|
|
with_managers = queryset.filter(manager__isnull=False).count()
|
|
total_staff = sum(d.staff_count for d in queryset)
|
|
|
|
hospitals = Hospital.objects.filter(status="active")
|
|
if not user.is_px_admin() and user.hospital:
|
|
hospitals = hospitals.filter(id=user.hospital.id)
|
|
|
|
context = {
|
|
"departments": queryset,
|
|
"total_count": total_count,
|
|
"active_count": active_count,
|
|
"with_managers": with_managers,
|
|
"total_staff": total_staff,
|
|
"hospitals": hospitals,
|
|
"search": search,
|
|
"category_filter": category_filter,
|
|
"categories": Department.DepartmentCategory.choices,
|
|
"can_create": user.is_px_admin() or user.is_hospital_admin(),
|
|
}
|
|
return render(request, "organizations/department_list.html", context)
|
|
|
|
|
|
@login_required
|
|
def department_detail(request, pk):
|
|
from .models import Department, Staff
|
|
from apps.complaints.models import Complaint, ComplaintInvolvedStaff, Inquiry
|
|
from apps.observations.models import Observation
|
|
from django.db.models import Q
|
|
from collections import defaultdict
|
|
|
|
department = get_object_or_404(
|
|
Department.objects.select_related("hospital", "manager", "parent", "respondent"),
|
|
pk=pk,
|
|
)
|
|
|
|
user = request.user
|
|
if not (
|
|
user.is_px_admin()
|
|
or (user.is_hospital_admin() and user.hospital == department.hospital)
|
|
or (
|
|
(user.is_champion() or user.is_department_manager())
|
|
and user.department == department
|
|
)
|
|
or (user.is_basic_staff() and user.department == department)
|
|
or (user.is_director() and user.get_directed_departments().filter(id=department.id).exists())
|
|
or (user.hospital == department.hospital and not user.is_source_user() and not user.is_basic_staff() and not user.is_department_manager() and not user.is_director())
|
|
):
|
|
messages.error(request, _("You don't have permission to view this department."))
|
|
return redirect("organizations:department_list")
|
|
|
|
staff_list = (
|
|
Staff.objects.filter(department=department, status="active")
|
|
.select_related("user", "department")
|
|
.order_by("-is_head", "first_name")
|
|
)
|
|
staff_head = staff_list.filter(is_head=True).first()
|
|
|
|
active_staff_ids = list(staff_list.values_list("pk", flat=True))
|
|
primary_pairs = set(
|
|
Complaint.objects.filter(staff_id__in=active_staff_ids).values_list("staff_id", "pk")
|
|
)
|
|
involved_pairs = set(
|
|
ComplaintInvolvedStaff.objects.filter(staff_id__in=active_staff_ids).values_list("staff_id", "complaint_id")
|
|
)
|
|
staff_complaint_counts = defaultdict(int)
|
|
for staff_id, _complaint_id in primary_pairs | involved_pairs:
|
|
staff_complaint_counts[staff_id] += 1
|
|
|
|
complaints = Complaint.objects.filter(
|
|
Q(department=department) | Q(involved_departments__department=department)
|
|
).select_related(
|
|
"patient", "assigned_to", "category", "domain", "subcategory_obj", "classification_obj", "staff"
|
|
).prefetch_related(
|
|
"involved_staff__staff"
|
|
).distinct().order_by("-created_at")
|
|
|
|
inquiries = Inquiry.objects.filter(
|
|
Q(department=department) | Q(outgoing_department=department)
|
|
).select_related("assigned_to", "patient").order_by("-created_at")
|
|
|
|
observations = Observation.objects.filter(
|
|
assigned_department=department
|
|
).select_related("category", "assigned_to").order_by("-created_at")
|
|
|
|
complaint_status_filter = request.GET.get("complaint_status")
|
|
if complaint_status_filter:
|
|
complaints = complaints.filter(status=complaint_status_filter)
|
|
|
|
inquiry_status_filter = request.GET.get("inquiry_status")
|
|
if inquiry_status_filter:
|
|
inquiries = inquiries.filter(status=inquiry_status_filter)
|
|
|
|
observation_status_filter = request.GET.get("observation_status")
|
|
if observation_status_filter:
|
|
observations = observations.filter(status=observation_status_filter)
|
|
|
|
active_tab = request.GET.get("tab", "analytics")
|
|
|
|
stats = {
|
|
"staff_count": staff_list.count(),
|
|
"open_complaints": complaints.filter(status__in=["open", "in_progress"]).count(),
|
|
"pending_inquiries": inquiries.filter(status__in=["open", "in_progress"]).count(),
|
|
"open_observations": observations.filter(status__in=["new", "triaged", "in_progress"]).count(),
|
|
"total_complaints": complaints.count(),
|
|
"total_inquiries": inquiries.count(),
|
|
"total_observations": observations.count(),
|
|
}
|
|
|
|
search_query = request.GET.get("search", "").strip()
|
|
if search_query:
|
|
staff_list = staff_list.filter(
|
|
Q(first_name__icontains=search_query)
|
|
| Q(last_name__icontains=search_query)
|
|
| Q(employee_id__icontains=search_query)
|
|
)
|
|
complaints = complaints.filter(
|
|
Q(reference_number__icontains=search_query)
|
|
| Q(title__icontains=search_query)
|
|
)
|
|
inquiries = inquiries.filter(
|
|
Q(reference_number__icontains=search_query)
|
|
| Q(subject__icontains=search_query)
|
|
| Q(contact_name__icontains=search_query)
|
|
)
|
|
observations = observations.filter(
|
|
Q(tracking_code__icontains=search_query)
|
|
| Q(title__icontains=search_query)
|
|
)
|
|
|
|
assignable_staff = Staff.objects.filter(
|
|
department=department,
|
|
status="active",
|
|
user__isnull=False,
|
|
).exclude(email="").order_by("first_name", "last_name")
|
|
|
|
managers = Staff.objects.filter(
|
|
hospital=department.hospital, status="active"
|
|
).order_by("first_name", "last_name")
|
|
|
|
pending_actions = []
|
|
from apps.complaints.models import ComplaintExplanation, ComplaintInvolvedDepartment
|
|
from django.utils import timezone as dj_tz
|
|
|
|
# 1. Complaint Department Responses (new)
|
|
pending_complaint_dept_responses = ComplaintInvolvedDepartment.objects.filter(
|
|
department=department,
|
|
forwarded_at__isnull=False,
|
|
response_submitted=False,
|
|
).select_related("complaint").order_by("-forwarded_at")
|
|
for pc in pending_complaint_dept_responses:
|
|
pending_actions.append({
|
|
"type": "complaint_department_response",
|
|
"type_label": _("Complaint Response"),
|
|
"reference": pc.complaint.reference_number or str(pc.complaint.id),
|
|
"subject": pc.complaint.title or _("No title"),
|
|
"sla_due_at": None,
|
|
"is_overdue": False,
|
|
"url": "#",
|
|
"badge_color": "orange",
|
|
"item_id": str(pc.pk),
|
|
"item_type": "complaint_involved_department",
|
|
"complaint_id": str(pc.complaint_id),
|
|
"department_name": pc.department.name,
|
|
})
|
|
|
|
# 2. Complaint Explanations (staff-level)
|
|
pending_explanations = ComplaintExplanation.objects.filter(
|
|
complaint__department=department,
|
|
is_used=False,
|
|
).select_related("complaint", "staff").order_by("sla_due_at")
|
|
for pe in pending_explanations:
|
|
pending_actions.append({
|
|
"type": "complaint_explanation",
|
|
"type_label": _("Complaint Explanation"),
|
|
"reference": pe.complaint.reference_number or str(pe.complaint.id),
|
|
"subject": pe.complaint.title,
|
|
"sla_due_at": pe.sla_due_at,
|
|
"is_overdue": pe.sla_due_at and pe.sla_due_at < dj_tz.now(),
|
|
"url": "/complaints/{}/explain/{}/".format(pe.complaint_id, pe.token),
|
|
"badge_color": "orange",
|
|
})
|
|
|
|
pending_observations = Observation.objects.filter(
|
|
assigned_department=department,
|
|
forwarded_to_dept_at__isnull=False,
|
|
department_responded_at__isnull=True,
|
|
).order_by("dept_response_sla_due_at")
|
|
for obs in pending_observations:
|
|
pending_actions.append({
|
|
"type": "observation_response",
|
|
"type_label": _("Observation Response"),
|
|
"reference": obs.tracking_code or str(obs.id),
|
|
"subject": obs.description[:80] if obs.description else "",
|
|
"sla_due_at": obs.dept_response_sla_due_at,
|
|
"is_overdue": obs.dept_response_sla_due_at and obs.dept_response_sla_due_at < dj_tz.now(),
|
|
"url": "/observations/{}/department-response/".format(obs.pk),
|
|
"badge_color": "purple",
|
|
})
|
|
|
|
pending_inquiries = Inquiry.objects.filter(
|
|
Q(outgoing_department=department) | Q(department=department),
|
|
transferred_at__isnull=False,
|
|
department_responded_at__isnull=True,
|
|
status__in=["open", "in_progress"],
|
|
).order_by("dept_response_sla_due_at")
|
|
for inq in pending_inquiries:
|
|
pending_actions.append({
|
|
"type": "inquiry_response",
|
|
"type_label": _("Inquiry Response"),
|
|
"reference": inq.reference_number or str(inq.id),
|
|
"subject": inq.subject or inq.message[:80] if inq.message else "",
|
|
"sla_due_at": inq.dept_response_sla_due_at,
|
|
"is_overdue": inq.dept_response_sla_due_at and inq.dept_response_sla_due_at < dj_tz.now(),
|
|
"url": "/inquiries/{}/department-response/".format(inq.pk),
|
|
"badge_color": "cyan",
|
|
})
|
|
|
|
# Standards for this department (global + department-specific)
|
|
from apps.standards.models import Standard, StandardCompliance
|
|
from django.db.models import Count
|
|
|
|
dept_standards = (
|
|
Standard.objects.filter(is_active=True, departments=department)
|
|
.select_related("source", "category", "activity_type")
|
|
.order_by("source", "category", "order_within_category", "code")
|
|
)
|
|
standards_data = []
|
|
for std in dept_standards:
|
|
compliance = StandardCompliance.objects.filter(
|
|
department=department, standard=std
|
|
).first()
|
|
standards_data.append({
|
|
"standard": std,
|
|
"compliance": compliance,
|
|
"attachment_count": compliance.attachments.count() if compliance else 0,
|
|
})
|
|
|
|
context = {
|
|
"department": department,
|
|
"staff_list": staff_list,
|
|
"staff_head": staff_head,
|
|
"staff_complaint_counts": staff_complaint_counts,
|
|
"complaints": complaints[:50],
|
|
"inquiries": inquiries[:50],
|
|
"observations": observations[:50],
|
|
"stats": stats,
|
|
"active_tab": active_tab,
|
|
"assignable_staff": assignable_staff,
|
|
"managers": managers,
|
|
"standards_data": standards_data,
|
|
"search_query": search_query,
|
|
"complaint_status_filter": complaint_status_filter,
|
|
"inquiry_status_filter": inquiry_status_filter,
|
|
"observation_status_filter": observation_status_filter,
|
|
"can_assign": user.is_px_admin() or user.is_hospital_admin() or user.is_department_manager(),
|
|
"can_edit": user.is_px_admin() or (user.is_hospital_admin() and user.hospital == department.hospital),
|
|
"can_respond": (
|
|
user.is_px_admin()
|
|
or user.is_hospital_admin()
|
|
or (user.is_department_manager() and user.department == department)
|
|
or (user.is_champion() and user.department == department)
|
|
),
|
|
"pending_actions": pending_actions,
|
|
"pending_actions_count": len(pending_actions),
|
|
}
|
|
return render(request, "organizations/department_detail.html", context)
|
|
|
|
|
|
@login_required
|
|
def department_analytics_api(request, pk):
|
|
from .models import Department
|
|
from apps.complaints.models import Complaint, ComplaintInvolvedStaff, ComplaintUpdate
|
|
from apps.physicians.models import PhysicianMonthlyRating
|
|
from apps.px_action_center.models import PXAction
|
|
from django.db.models import Count, Avg, Sum, Q, F
|
|
from django.db.models.functions import TruncMonth
|
|
from django.utils import timezone
|
|
from django.http import JsonResponse
|
|
from datetime import timedelta
|
|
import traceback
|
|
|
|
try:
|
|
department = get_object_or_404(Department, pk=pk)
|
|
|
|
user = request.user
|
|
if not (
|
|
user.is_px_admin()
|
|
or (user.is_hospital_admin() and user.hospital == department.hospital)
|
|
or (user.is_department_manager() and user.department in [department, department.parent])
|
|
or (
|
|
(user.is_champion() or user.is_department_manager())
|
|
and user.department == department
|
|
)
|
|
or (user.is_basic_staff() and user.department == department)
|
|
or (user.hospital == department.hospital and not user.is_source_user() and not user.is_basic_staff())
|
|
):
|
|
return JsonResponse({"error": "Permission denied"}, status=403)
|
|
|
|
now = timezone.now()
|
|
twelve_months_ago = now - timedelta(days=365)
|
|
six_months_ago = now - timedelta(days=180)
|
|
|
|
complaints_qs = Complaint.objects.filter(
|
|
Q(department=department) | Q(involved_departments__department=department)
|
|
).distinct()
|
|
|
|
# 1. Complaint Trend (last 12 months)
|
|
complaint_trend_raw = (
|
|
complaints_qs.filter(created_at__gte=twelve_months_ago)
|
|
.annotate(month=TruncMonth("created_at"))
|
|
.values("month")
|
|
.annotate(count=Count("pk"))
|
|
.order_by("month")
|
|
)
|
|
complaint_trend = [
|
|
{"period": item["month"].strftime("%Y-%m"), "count": item["count"]}
|
|
for item in complaint_trend_raw
|
|
]
|
|
|
|
# 2. Complaint Status Distribution
|
|
status_raw = complaints_qs.values("status").annotate(count=Count("pk"))
|
|
status_labels = {
|
|
"open": "Open",
|
|
"in_progress": "In Progress",
|
|
"partially_resolved": "Partially Resolved",
|
|
"resolved": "Resolved",
|
|
"closed": "Closed",
|
|
"cancelled": "Cancelled",
|
|
"contacted": "Contacted",
|
|
"contacted_no_response": "No Response",
|
|
}
|
|
complaint_status = [
|
|
{"status": status_labels.get(s["status"], s["status"]), "count": s["count"]}
|
|
for s in status_raw
|
|
if s["count"] > 0
|
|
]
|
|
|
|
# 3. Physician Rating Trend (last 6 months)
|
|
physician_ratings_qs = PhysicianMonthlyRating.objects.filter(
|
|
staff__department=department, staff__physician=True
|
|
)
|
|
rating_trend_raw = []
|
|
for i in range(5, -1, -1):
|
|
m = now.month - i
|
|
y = now.year
|
|
if m <= 0:
|
|
m += 12
|
|
y -= 1
|
|
period_data = physician_ratings_qs.filter(year=y, month=m).aggregate(
|
|
avg=Avg("average_rating"), surveys=Sum("total_surveys")
|
|
)
|
|
rating_trend_raw.append(
|
|
{
|
|
"period": f"{y}-{m:02d}",
|
|
"average_rating": round(float(period_data["avg"] or 0), 1),
|
|
"total_surveys": period_data["surveys"] or 0,
|
|
}
|
|
)
|
|
|
|
# 4. Top Physicians (current month or most recent with data)
|
|
current_month_ratings = physician_ratings_qs.filter(
|
|
year=now.year, month=now.month
|
|
).select_related("staff")
|
|
if not current_month_ratings.exists():
|
|
latest = physician_ratings_qs.order_by("-year", "-month").first()
|
|
if latest:
|
|
current_month_ratings = physician_ratings_qs.filter(
|
|
year=latest.year, month=latest.month
|
|
).select_related("staff")
|
|
|
|
top_physicians_raw = current_month_ratings.order_by("-average_rating", "-total_surveys")[:10]
|
|
top_physicians = [
|
|
{
|
|
"name": r.staff.get_localized_name(),
|
|
"rating": round(float(r.average_rating), 1),
|
|
"surveys": r.total_surveys,
|
|
}
|
|
for r in top_physicians_raw
|
|
]
|
|
|
|
# 5. Actions Overview
|
|
actions_qs = PXAction.objects.filter(department=department)
|
|
actions_status_raw = actions_qs.values("status").annotate(count=Count("pk"))
|
|
actions_status_labels = {
|
|
"open": "Open",
|
|
"in_progress": "In Progress",
|
|
"pending_approval": "Pending Approval",
|
|
"approved": "Approved",
|
|
"closed": "Closed",
|
|
"cancelled": "Cancelled",
|
|
}
|
|
actions_overview = [
|
|
{"status": actions_status_labels.get(a["status"], a["status"]), "count": a["count"]}
|
|
for a in actions_status_raw
|
|
if a["count"] > 0
|
|
]
|
|
|
|
# 6. Satisfaction Distribution
|
|
satisfaction_raw = complaints_qs.exclude(satisfaction__isnull=True).exclude(satisfaction="").values("satisfaction").annotate(count=Count("pk"))
|
|
satisfaction_labels = {
|
|
"satisfied": "Satisfied",
|
|
"neutral": "Neutral",
|
|
"dissatisfied": "Dissatisfied",
|
|
"no_response": "No Response",
|
|
"escalated": "Escalated",
|
|
}
|
|
satisfaction = [
|
|
{"label": satisfaction_labels.get(s["satisfaction"], s["satisfaction"]), "count": s["count"]}
|
|
for s in satisfaction_raw
|
|
if s["count"] > 0
|
|
]
|
|
|
|
# 7. Complaint Severity Distribution
|
|
severity_raw = complaints_qs.values("severity").annotate(count=Count("pk"))
|
|
severity_labels = {
|
|
"low": "Low",
|
|
"medium": "Medium",
|
|
"high": "High",
|
|
"critical": "Critical",
|
|
}
|
|
severity_order = ["low", "medium", "high", "critical"]
|
|
complaint_severity = []
|
|
for sev_key in severity_order:
|
|
count = next((s["count"] for s in severity_raw if s["severity"] == sev_key), 0)
|
|
if count > 0:
|
|
complaint_severity.append(
|
|
{"severity": severity_labels.get(sev_key, sev_key), "count": count}
|
|
)
|
|
|
|
# 8. Top Categories
|
|
categories_raw = (
|
|
complaints_qs.filter(category__isnull=False)
|
|
.values("category__name_en")
|
|
.annotate(count=Count("pk"))
|
|
.order_by("-count")[:5]
|
|
)
|
|
top_categories = [
|
|
{"category": c["category__name_en"] or "Unknown", "count": c["count"]}
|
|
for c in categories_raw
|
|
]
|
|
|
|
# KPIs
|
|
total_complaints = complaints_qs.count()
|
|
resolved_count = complaints_qs.filter(status__in=["resolved", "closed"]).count()
|
|
resolution_rate = round((resolved_count / total_complaints * 100) if total_complaints else 0, 1)
|
|
|
|
current_rating = physician_ratings_qs.filter(year=now.year, month=now.month).aggregate(
|
|
avg=Avg("average_rating")
|
|
)
|
|
avg_physician_rating = round(float(current_rating["avg"] or 0), 1)
|
|
|
|
open_actions = actions_qs.filter(status__in=["open", "in_progress"]).count()
|
|
|
|
total_satisfaction = sum(s["count"] for s in satisfaction)
|
|
satisfied_count = next((s["count"] for s in satisfaction if s["label"] == "Satisfied"), 0)
|
|
satisfaction_rate = round((satisfied_count / total_satisfaction * 100) if total_satisfaction else 0, 1)
|
|
|
|
reopened_count = complaints_qs.filter(reopened_from__isnull=False).count()
|
|
reassigned_count = ComplaintUpdate.objects.filter(
|
|
complaint__in=complaints_qs, update_type="assignment"
|
|
).distinct().count()
|
|
|
|
return JsonResponse(
|
|
{
|
|
"complaint_trend": complaint_trend,
|
|
"complaint_status": complaint_status,
|
|
"physician_rating_trend": rating_trend_raw,
|
|
"top_physicians": top_physicians,
|
|
"actions_overview": actions_overview,
|
|
"satisfaction": satisfaction,
|
|
"complaint_severity": complaint_severity,
|
|
"top_categories": top_categories,
|
|
"kpi": {
|
|
"avg_physician_rating": avg_physician_rating,
|
|
"resolution_rate": resolution_rate,
|
|
"open_actions": open_actions,
|
|
"satisfaction_rate": satisfaction_rate,
|
|
"total_complaints": total_complaints,
|
|
"reopened": reopened_count,
|
|
"reassigned": reassigned_count,
|
|
},
|
|
}
|
|
)
|
|
except Exception as e:
|
|
return JsonResponse({"error": str(e), "traceback": traceback.format_exc()}, status=500)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def set_department_respondent(request, pk):
|
|
"""Set the respondent for a department - Admin or Department Manager only"""
|
|
from .models import Department, Staff
|
|
from django.contrib.auth.models import Group
|
|
|
|
department = get_object_or_404(Department, pk=pk)
|
|
user = request.user
|
|
|
|
if not (
|
|
user.is_px_admin()
|
|
or (user.is_hospital_admin() and user.hospital == department.hospital)
|
|
or (user.is_department_manager() and user.department == department)
|
|
):
|
|
messages.error(request, _("You don't have permission to set the respondent."))
|
|
return redirect("organizations:department_detail", pk=pk)
|
|
|
|
respondent_id = request.POST.get("respondent_id")
|
|
if respondent_id:
|
|
respondent = Staff.objects.filter(pk=respondent_id, department=department, status="active").first()
|
|
if not respondent:
|
|
messages.error(request, _("Invalid staff member selected."))
|
|
return redirect("organizations:department_detail", pk=pk)
|
|
|
|
if not respondent.email:
|
|
messages.error(
|
|
request,
|
|
_(f"{respondent.get_full_name()} does not have an email address. Add an email first."),
|
|
)
|
|
return redirect("organizations:department_detail", pk=pk)
|
|
|
|
if not respondent.user:
|
|
messages.error(
|
|
request,
|
|
_(f"{respondent.get_full_name()} does not have a user account. Create a user account first."),
|
|
)
|
|
return redirect("organizations:department_detail", pk=pk)
|
|
|
|
department.respondent = respondent
|
|
department.save(update_fields=["respondent", "updated_at"])
|
|
|
|
group, _created = Group.objects.get_or_create(name="Champion")
|
|
respondent.user.groups.add(group)
|
|
messages.success(
|
|
request,
|
|
_(f"Champion set to {respondent.get_full_name()}. Champion role assigned."),
|
|
)
|
|
else:
|
|
department.respondent = None
|
|
department.save(update_fields=["respondent", "updated_at"])
|
|
messages.success(request, _("Champion removed."))
|
|
|
|
return redirect("organizations:department_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def set_department_manager(request, pk):
|
|
from .models import Department, Staff
|
|
|
|
department = get_object_or_404(Department, pk=pk)
|
|
user = request.user
|
|
|
|
if not (
|
|
user.is_px_admin()
|
|
or (user.is_hospital_admin() and user.hospital == department.hospital)
|
|
):
|
|
messages.error(request, _("You don't have permission to set the manager."))
|
|
return redirect("organizations:department_detail", pk=pk)
|
|
|
|
manager_id = request.POST.get("manager_id")
|
|
if manager_id:
|
|
manager = Staff.objects.filter(
|
|
pk=manager_id, hospital=department.hospital, status="active"
|
|
).first()
|
|
if not manager:
|
|
messages.error(request, _("Invalid staff member selected."))
|
|
return redirect("organizations:department_detail", pk=pk)
|
|
|
|
if not manager.user:
|
|
from apps.organizations.services import StaffService
|
|
from django.contrib.auth.models import Group
|
|
|
|
if not manager.email:
|
|
messages.warning(
|
|
request,
|
|
_(f"Selected staff {manager.get_full_name()} has no email. Add an email and create a user account first."),
|
|
)
|
|
return redirect("organizations:department_detail", pk=pk)
|
|
|
|
try:
|
|
user_account, _created, _result = StaffService.create_user_for_staff(
|
|
manager, role="department_manager", request=request
|
|
)
|
|
department.manager = user_account
|
|
department.save(update_fields=["manager", "updated_at"])
|
|
messages.success(
|
|
request,
|
|
_(f"Manager set to {manager.get_full_name()}. User account created with Department Manager role."),
|
|
)
|
|
except ValueError as e:
|
|
messages.error(request, _(f"Could not create user account: {e}"))
|
|
return redirect("organizations:department_detail", pk=pk)
|
|
else:
|
|
department.manager = manager.user
|
|
department.save(update_fields=["manager", "updated_at"])
|
|
messages.success(
|
|
request,
|
|
_(f"Manager set to {manager.get_full_name()}."),
|
|
)
|
|
else:
|
|
department.manager = None
|
|
department.save(update_fields=["manager", "updated_at"])
|
|
messages.success(request, _("Manager removed."))
|
|
|
|
return redirect("organizations:department_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
def staff_import(request):
|
|
from django.db import transaction
|
|
import csv
|
|
import io
|
|
|
|
user = request.user
|
|
if not (user.is_px_admin() or user.is_hospital_admin()):
|
|
messages.error(request, _("You don't have permission to import staff."))
|
|
return redirect("organizations:staff_list")
|
|
|
|
hospital = getattr(request, "tenant_hospital", None) or user.hospital
|
|
if not hospital:
|
|
messages.error(request, _("No hospital associated with your account."))
|
|
return redirect("organizations:staff_list")
|
|
|
|
context = {
|
|
"hospital": hospital,
|
|
"results": None,
|
|
}
|
|
|
|
if request.method == "POST":
|
|
csv_file = request.FILES.get("csv_file")
|
|
if not csv_file:
|
|
messages.error(request, _("Please select a CSV file to upload."))
|
|
return render(request, "organizations/staff_import.html", context)
|
|
|
|
if not csv_file.name.endswith(".csv"):
|
|
messages.error(request, _("Please upload a CSV file (.csv extension)."))
|
|
return render(request, "organizations/staff_import.html", context)
|
|
|
|
update_existing = request.POST.get("update_existing") == "on"
|
|
create_departments = request.POST.get("create_departments") == "on"
|
|
deactivate_missing = request.POST.get("deactivate_missing") == "on"
|
|
dry_run = request.POST.get("dry_run") == "on"
|
|
staff_type = request.POST.get("staff_type", "other")
|
|
|
|
try:
|
|
raw = csv_file.read().decode("utf-8-sig")
|
|
except UnicodeDecodeError:
|
|
try:
|
|
csv_file.seek(0)
|
|
raw = csv_file.read().decode("latin-1")
|
|
except Exception:
|
|
messages.error(request, _("Could not decode the file. Please save as UTF-8 CSV."))
|
|
return render(request, "organizations/staff_import.html", context)
|
|
|
|
reader = csv.DictReader(io.StringIO(raw))
|
|
headers = [h.strip() for h in (reader.fieldnames or [])]
|
|
|
|
required_cols = ["Staff ID", "Name"]
|
|
missing = [c for c in required_cols if c not in headers]
|
|
if missing:
|
|
messages.error(request, _(f"Missing required columns: {', '.join(missing)}"))
|
|
return render(request, "organizations/staff_import.html", context)
|
|
|
|
dept_cache = {}
|
|
section_cache = {}
|
|
subsection_cache = {}
|
|
|
|
created_rows = []
|
|
updated_rows = []
|
|
skipped_rows = []
|
|
error_rows = []
|
|
|
|
rows = list(reader)
|
|
|
|
if not dry_run:
|
|
try:
|
|
with transaction.atomic():
|
|
for row_num, row in enumerate(rows, start=2):
|
|
result = _process_staff_row(
|
|
row, row_num, hospital, staff_type,
|
|
update_existing, create_departments,
|
|
dept_cache, section_cache, subsection_cache,
|
|
)
|
|
if result[0] == "created":
|
|
created_rows.append(result[1])
|
|
elif result[0] == "updated":
|
|
updated_rows.append(result[1])
|
|
elif result[0] == "skipped":
|
|
skipped_rows.append(result[1])
|
|
elif result[0] == "error":
|
|
error_rows.append(result[1])
|
|
except Exception as e:
|
|
messages.error(request, _(f"Import failed: {str(e)}"))
|
|
return render(request, "organizations/staff_import.html", context)
|
|
else:
|
|
for row_num, row in enumerate(rows, start=2):
|
|
result = _process_staff_row_dry(
|
|
row, row_num, hospital, staff_type,
|
|
update_existing, create_departments,
|
|
dept_cache, section_cache, subsection_cache,
|
|
)
|
|
if result[0] == "created":
|
|
created_rows.append(result[1])
|
|
elif result[0] == "updated":
|
|
updated_rows.append(result[1])
|
|
elif result[0] == "skipped":
|
|
skipped_rows.append(result[1])
|
|
elif result[0] == "error":
|
|
error_rows.append(result[1])
|
|
|
|
deactivated_rows = []
|
|
if deactivate_missing:
|
|
csv_ids = set()
|
|
for row in rows:
|
|
sid = row.get("Staff ID", "").strip()
|
|
if sid:
|
|
csv_ids.add(sid)
|
|
|
|
missing_staff = Staff.objects.filter(
|
|
hospital=hospital, status="active"
|
|
).exclude(employee_id__in=csv_ids)
|
|
|
|
if dry_run:
|
|
for s in missing_staff:
|
|
deactivated_rows.append({
|
|
"row": "-", "id": s.employee_id, "name": s.get_full_name(),
|
|
"message": "Would deactivate",
|
|
})
|
|
else:
|
|
missing_staff.update(status="inactive")
|
|
for s in missing_staff:
|
|
deactivated_rows.append({
|
|
"row": "-", "id": s.employee_id, "name": s.get_full_name(),
|
|
"message": "Deactivated",
|
|
})
|
|
|
|
context["results"] = {
|
|
"created": created_rows,
|
|
"updated": updated_rows,
|
|
"skipped": skipped_rows,
|
|
"errors": error_rows,
|
|
"deactivated": deactivated_rows,
|
|
"created_count": len(created_rows),
|
|
"updated_count": len(updated_rows),
|
|
"skipped_count": len(skipped_rows),
|
|
"error_count": len(error_rows),
|
|
"deactivated_count": len(deactivated_rows),
|
|
"total_rows": len(rows),
|
|
"dry_run": dry_run,
|
|
}
|
|
|
|
if not dry_run:
|
|
total_ok = len(created_rows) + len(updated_rows)
|
|
msg = f"Import complete: {total_ok} processed ({len(created_rows)} created, {len(updated_rows)} updated), {len(skipped_rows)} skipped, {len(error_rows)} errors."
|
|
if deactivate_missing:
|
|
msg += f" {len(deactivated_rows)} staff deactivated."
|
|
messages.success(request, _(msg))
|
|
|
|
return render(request, "organizations/staff_import.html", context)
|
|
|
|
|
|
def _parse_staff_csv_row(row):
|
|
name = row.get("Name", "").strip()
|
|
parts = name.split(None, 1)
|
|
name_ar = row.get("Name_ar", "").strip()
|
|
parts_ar = name_ar.split(None, 1) if name_ar else ["", ""]
|
|
|
|
manager_id = None
|
|
if row.get("Manager", "").strip():
|
|
manager_parts = row["Manager"].split("-", 1)
|
|
manager_id = manager_parts[0].strip()
|
|
|
|
return {
|
|
"staff_id": row.get("Staff ID", "").strip(),
|
|
"name": name,
|
|
"name_ar": name_ar,
|
|
"first_name": parts[0] if parts else name,
|
|
"last_name": parts[1] if len(parts) > 1 else "",
|
|
"first_name_ar": parts_ar[0] if parts_ar else "",
|
|
"last_name_ar": parts_ar[1] if len(parts_ar) > 1 else "",
|
|
"civil_id": row.get("Civil Identity Number", "").strip(),
|
|
"location": row.get("Location", "").strip(),
|
|
"location_ar": row.get("Location_ar", "").strip(),
|
|
"department": row.get("Department", "").strip(),
|
|
"department_ar": row.get("Department_ar", "").strip(),
|
|
"section": row.get("Section", "").strip(),
|
|
"section_ar": row.get("Section_ar", "").strip(),
|
|
"subsection": row.get("Subsection", "").strip(),
|
|
"subsection_ar": row.get("Subsection_ar", "").strip(),
|
|
"job_title": row.get("AlHammadi Job Title", "").strip(),
|
|
"job_title_ar": row.get("AlHammadi Job Title_ar", "").strip(),
|
|
"country": row.get("Country", "").strip(),
|
|
"country_ar": row.get("Country_ar", "").strip(),
|
|
"gender": (row.get("Gender", "").strip().lower() if row.get("Gender") else ""),
|
|
"manager_id": manager_id,
|
|
}
|
|
|
|
|
|
def _import_get_or_create_dept(hospital, dept_name, dept_name_ar, cache):
|
|
if not dept_name:
|
|
return None
|
|
cache_key = (str(hospital.id), dept_name.lower())
|
|
if cache_key in cache:
|
|
return cache[cache_key]
|
|
dept, _ = Department.objects.get_or_create(
|
|
hospital=hospital,
|
|
name__iexact=dept_name,
|
|
parent__isnull=True,
|
|
defaults={
|
|
"name": dept_name,
|
|
"name_ar": dept_name_ar or "",
|
|
"code": str(uuid.uuid4())[:8],
|
|
"status": "active",
|
|
},
|
|
)
|
|
if dept.name_ar != dept_name_ar and dept_name_ar:
|
|
dept.name_ar = dept_name_ar
|
|
dept.save(update_fields=["name_ar"])
|
|
cache[cache_key] = dept
|
|
return dept
|
|
|
|
|
|
def _import_get_or_create_section(department, section_name, section_name_ar, cache):
|
|
if not section_name or not department:
|
|
return None
|
|
cache_key = (str(department.id), section_name.lower())
|
|
if cache_key in cache:
|
|
return cache[cache_key]
|
|
if section_name.lower() == department.name.lower():
|
|
cache[cache_key] = None
|
|
return None
|
|
section, _ = StaffSection.objects.get_or_create(
|
|
department=department,
|
|
name__iexact=section_name,
|
|
defaults={
|
|
"name": section_name,
|
|
"name_ar": section_name_ar or "",
|
|
"code": str(uuid.uuid4())[:8],
|
|
"status": "active",
|
|
},
|
|
)
|
|
if section.name_ar != section_name_ar and section_name_ar:
|
|
section.name_ar = section_name_ar
|
|
section.save(update_fields=["name_ar"])
|
|
cache[cache_key] = section
|
|
return section
|
|
|
|
|
|
def _import_get_or_create_subsection(section_obj, subsection_name, subsection_name_ar, cache):
|
|
if not subsection_name or not section_obj:
|
|
return None
|
|
cache_key = (str(section_obj.id), subsection_name.lower())
|
|
if cache_key in cache:
|
|
return cache[cache_key]
|
|
if subsection_name.lower() == section_obj.name.lower():
|
|
cache[cache_key] = None
|
|
return None
|
|
subsection, _ = StaffSubsection.objects.get_or_create(
|
|
section=section_obj,
|
|
name__iexact=subsection_name,
|
|
defaults={
|
|
"name": subsection_name,
|
|
"name_ar": subsection_name_ar or "",
|
|
"code": str(uuid.uuid4())[:8],
|
|
"status": "active",
|
|
},
|
|
)
|
|
if subsection.name_ar != subsection_name_ar and subsection_name_ar:
|
|
subsection.name_ar = subsection_name_ar
|
|
subsection.save(update_fields=["name_ar"])
|
|
cache[cache_key] = subsection
|
|
return subsection
|
|
|
|
|
|
def _resolve_staff_org(r, hospital, create_depts, dept_cache, section_cache, subsection_cache):
|
|
department = None
|
|
section_obj = None
|
|
subsection_obj = None
|
|
|
|
if r["department"]:
|
|
if create_depts:
|
|
department = _import_get_or_create_dept(hospital, r["department"], r["department_ar"], dept_cache)
|
|
else:
|
|
department = Department.objects.filter(
|
|
hospital=hospital, name__iexact=r["department"], parent__isnull=True
|
|
).first()
|
|
|
|
if r["section"] and department:
|
|
if create_depts:
|
|
section_obj = _import_get_or_create_section(department, r["section"], r["section_ar"], section_cache)
|
|
else:
|
|
section_obj = StaffSection.objects.filter(
|
|
department=department, name__iexact=r["section"]
|
|
).first()
|
|
|
|
if r["subsection"] and section_obj:
|
|
if create_depts:
|
|
subsection_obj = _import_get_or_create_subsection(section_obj, r["subsection"], r["subsection_ar"], subsection_cache)
|
|
else:
|
|
subsection_obj = StaffSubsection.objects.filter(
|
|
section=section_obj, name__iexact=r["subsection"]
|
|
).first()
|
|
|
|
return department, section_obj, subsection_obj
|
|
|
|
|
|
def _process_staff_row(row, row_num, hospital, staff_type, update_existing, create_depts,
|
|
dept_cache, section_cache, subsection_cache):
|
|
r = _parse_staff_csv_row(row)
|
|
if not r["staff_id"]:
|
|
return ("error", {"row": row_num, "id": "", "name": r["name"], "message": "Missing Staff ID"})
|
|
if not r["first_name"]:
|
|
return ("error", {"row": row_num, "id": r["staff_id"], "name": "", "message": "Missing Name"})
|
|
|
|
department, section_obj, subsection_obj = _resolve_staff_org(
|
|
r, hospital, create_depts, dept_cache, section_cache, subsection_cache
|
|
)
|
|
|
|
existing = Staff.objects.filter(employee_id=r["staff_id"], hospital=hospital).first()
|
|
|
|
if existing:
|
|
if not update_existing:
|
|
return ("skipped", {"row": row_num, "id": r["staff_id"], "name": r["name"], "message": "Already exists"})
|
|
existing.name = r["name"]
|
|
existing.name_ar = r["name_ar"]
|
|
existing.first_name = r["first_name"]
|
|
existing.last_name = r["last_name"]
|
|
existing.first_name_ar = r["first_name_ar"]
|
|
existing.last_name_ar = r["last_name_ar"]
|
|
existing.civil_id = r["civil_id"]
|
|
existing.job_title = r["job_title"]
|
|
existing.job_title_ar = r["job_title_ar"]
|
|
existing.specialization = r["job_title"]
|
|
existing.hospital = hospital
|
|
existing.department = department
|
|
existing.section_fk = section_obj
|
|
existing.subsection_fk = subsection_obj
|
|
existing.department_name = r["department"]
|
|
existing.department_name_ar = r["department_ar"]
|
|
existing.section = r["section"]
|
|
existing.section_ar = r["section_ar"]
|
|
existing.subsection = r["subsection"]
|
|
existing.subsection_ar = r["subsection_ar"]
|
|
existing.location = r["location"]
|
|
existing.location_ar = r["location_ar"]
|
|
existing.country = r["country"]
|
|
existing.country_ar = r["country_ar"]
|
|
if r["gender"]:
|
|
existing.gender = r["gender"]
|
|
existing.save()
|
|
return ("updated", {"row": row_num, "id": r["staff_id"], "name": r["name"], "message": "Updated"})
|
|
|
|
Staff.objects.create(
|
|
employee_id=r["staff_id"],
|
|
name=r["name"],
|
|
name_ar=r["name_ar"],
|
|
first_name=r["first_name"],
|
|
last_name=r["last_name"],
|
|
first_name_ar=r["first_name_ar"],
|
|
last_name_ar=r["last_name_ar"],
|
|
civil_id=r["civil_id"],
|
|
staff_type=staff_type,
|
|
job_title=r["job_title"],
|
|
job_title_ar=r["job_title_ar"],
|
|
specialization=r["job_title"],
|
|
hospital=hospital,
|
|
department=department,
|
|
section_fk=section_obj,
|
|
subsection_fk=subsection_obj,
|
|
department_name=r["department"],
|
|
department_name_ar=r["department_ar"],
|
|
section=r["section"],
|
|
section_ar=r["section_ar"],
|
|
subsection=r["subsection"],
|
|
subsection_ar=r["subsection_ar"],
|
|
location=r["location"],
|
|
location_ar=r["location_ar"],
|
|
country=r["country"],
|
|
country_ar=r["country_ar"],
|
|
gender=r["gender"],
|
|
status="active",
|
|
)
|
|
return ("created", {"row": row_num, "id": r["staff_id"], "name": r["name"], "message": "Created"})
|
|
|
|
|
|
def _process_staff_row_dry(row, row_num, hospital, staff_type, update_existing, create_depts,
|
|
dept_cache, section_cache, subsection_cache):
|
|
r = _parse_staff_csv_row(row)
|
|
if not r["staff_id"]:
|
|
return ("error", {"row": row_num, "id": "", "name": r["name"], "message": "Missing Staff ID"})
|
|
if not r["first_name"]:
|
|
return ("error", {"row": row_num, "id": r["staff_id"], "name": "", "message": "Missing Name"})
|
|
|
|
existing = Staff.objects.filter(employee_id=r["staff_id"], hospital=hospital).first()
|
|
|
|
if existing:
|
|
if not update_existing:
|
|
return ("skipped", {"row": row_num, "id": r["staff_id"], "name": r["name"], "message": "Already exists (would skip)"})
|
|
return ("updated", {"row": row_num, "id": r["staff_id"], "name": r["name"], "message": "Would update"})
|
|
|
|
return ("created", {"row": row_num, "id": r["staff_id"], "name": r["name"], "message": "Would create"})
|
|
|
|
|
|
@login_required
|
|
def staff_import_sample_csv(request):
|
|
from django.http import HttpResponse
|
|
import csv
|
|
|
|
user = request.user
|
|
if not (user.is_px_admin() or user.is_hospital_admin()):
|
|
messages.error(request, _("You don't have permission."))
|
|
return redirect("organizations:staff_list")
|
|
|
|
headers = [
|
|
"Staff ID", "Name", "Name_ar", "Manager", "Manager_ar",
|
|
"Civil Identity Number", "Location", "Location_ar",
|
|
"Department", "Department_ar", "Section", "Section_ar",
|
|
"Subsection", "Subsection_ar", "AlHammadi Job Title",
|
|
"AlHammadi Job Title_ar", "Country", "Country_ar",
|
|
]
|
|
example = [
|
|
"EMP001", "Ahmed Al-Rashid", "أحمد الراشد", "EMP100 - Mohammad Al-Salem", "محمد السالم",
|
|
"12345678", "Building A, Floor 2", "المبنى أ، الطابق ٢",
|
|
"Cardiology", "أمراض القلب", "Cardiac Care Unit", "وحدة العناية القلبية",
|
|
"Catheterization Lab", "مختبر القسطرة", "Senior Cardiologist",
|
|
"استشاري أمراض قلب كبار", "Saudi Arabia", "المملكة العربية السعودية",
|
|
]
|
|
|
|
response = HttpResponse(content_type="text/csv")
|
|
response["Content-Disposition"] = 'attachment; filename="staff_import_template.csv"'
|
|
writer = csv.writer(response)
|
|
writer.writerow(headers)
|
|
writer.writerow(example)
|
|
return response
|