6220 lines
231 KiB
Python
6220 lines
231 KiB
Python
"""
|
|
Complaints UI views - Server-rendered templates for complaints console
|
|
"""
|
|
|
|
import logging
|
|
|
|
from django.contrib import messages
|
|
from django.contrib.auth.decorators import login_required
|
|
from django.core.paginator import Paginator
|
|
from django.db.models import Q, Count, Prefetch
|
|
from django.http import HttpResponseForbidden, JsonResponse
|
|
from django.shortcuts import get_object_or_404, redirect, render
|
|
from django.utils import timezone
|
|
from django.utils.translation import gettext_lazy as _
|
|
from django.views.decorators.http import require_http_methods
|
|
|
|
from apps.accounts.models import User
|
|
from apps.accounts.services import StaffActivityService
|
|
from apps.core.services import AuditService
|
|
from apps.core.decorators import hospital_admin_required, block_source_user, px_admin_required
|
|
from apps.organizations.models import Department, Hospital, Staff
|
|
from apps.observations.models import Observation
|
|
from apps.px_sources.models import SourceUser, PXSource
|
|
|
|
from .models import (
|
|
Complaint,
|
|
ComplaintAttachment,
|
|
ComplaintCategory,
|
|
ComplaintExplanation,
|
|
ComplaintSourceType,
|
|
ComplaintStatus,
|
|
ComplaintType,
|
|
ComplaintUpdate,
|
|
Inquiry,
|
|
InquiryAttachment,
|
|
InquiryUpdate,
|
|
ComplaintAdverseAction,
|
|
ComplaintAdverseActionAttachment,
|
|
GovernmentTicket,
|
|
)
|
|
from .services.complaint_service import ComplaintService, ComplaintServiceError
|
|
from .forms import (
|
|
ComplaintInvolvedDepartmentForm,
|
|
ComplaintInvolvedStaffForm,
|
|
DepartmentResponseForm,
|
|
StaffExplanationForm,
|
|
GovernmentTicketForm,
|
|
)
|
|
|
|
# Set up logger
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _format_duration(start, end):
|
|
"""Format duration between two datetimes as a human-readable string."""
|
|
if not start or not end:
|
|
return None
|
|
duration = end - start
|
|
total_seconds = int(duration.total_seconds())
|
|
if total_seconds < 60:
|
|
return "< 1m"
|
|
days = total_seconds // 86400
|
|
hours = (total_seconds % 86400) // 3600
|
|
minutes = (total_seconds % 3600) // 60
|
|
parts = []
|
|
if days > 0:
|
|
parts.append(f"{days}d")
|
|
if hours > 0:
|
|
parts.append(f"{hours}h")
|
|
if minutes > 0 and days == 0:
|
|
parts.append(f"{minutes}m")
|
|
return " ".join(parts) if parts else "< 1m"
|
|
|
|
|
|
def _build_complaint_stage_timeline(complaint):
|
|
"""Build stage timeline with timestamps and durations for a complaint."""
|
|
stages = []
|
|
|
|
if complaint.created_at:
|
|
stages.append({
|
|
"label": _("Created"),
|
|
"timestamp": complaint.created_at,
|
|
"color": "bg-slate-400",
|
|
})
|
|
|
|
if complaint.activated_at:
|
|
stages.append({
|
|
"label": _("Activated"),
|
|
"timestamp": complaint.activated_at,
|
|
"duration_from_prev": _format_duration(complaint.created_at, complaint.activated_at),
|
|
"color": "bg-blue-500",
|
|
})
|
|
|
|
if complaint.forwarded_to_dept_at:
|
|
stages.append({
|
|
"label": _("Forwarded to Department"),
|
|
"timestamp": complaint.forwarded_to_dept_at,
|
|
"duration_from_prev": _format_duration(complaint.activated_at or complaint.created_at, complaint.forwarded_to_dept_at),
|
|
"color": "bg-purple-500",
|
|
})
|
|
|
|
if complaint.response_date:
|
|
stages.append({
|
|
"label": _("Department Responded"),
|
|
"timestamp": complaint.response_date,
|
|
"duration_from_prev": _format_duration(complaint.forwarded_to_dept_at or complaint.activated_at, complaint.response_date),
|
|
"color": "bg-amber-500",
|
|
})
|
|
|
|
if complaint.resolved_at:
|
|
stages.append({
|
|
"label": _("Resolved"),
|
|
"timestamp": complaint.resolved_at,
|
|
"duration_from_prev": _format_duration(complaint.response_date or complaint.forwarded_to_dept_at, complaint.resolved_at),
|
|
"color": "bg-green-500",
|
|
})
|
|
|
|
if complaint.closed_at:
|
|
stages.append({
|
|
"label": _("Closed"),
|
|
"timestamp": complaint.closed_at,
|
|
"duration_from_prev": _format_duration(complaint.resolved_at or complaint.response_date, complaint.closed_at),
|
|
"color": "bg-emerald-600",
|
|
})
|
|
|
|
return stages
|
|
|
|
|
|
def _build_inquiry_stage_timeline(inquiry):
|
|
"""Build stage timeline with timestamps and durations for an inquiry."""
|
|
stages = []
|
|
|
|
if inquiry.created_at:
|
|
stages.append({
|
|
"label": _("Created"),
|
|
"timestamp": inquiry.created_at,
|
|
"color": "bg-slate-400",
|
|
})
|
|
|
|
if inquiry.activated_at:
|
|
stages.append({
|
|
"label": _("Activated"),
|
|
"timestamp": inquiry.activated_at,
|
|
"duration_from_prev": _format_duration(inquiry.created_at, inquiry.activated_at),
|
|
"color": "bg-blue-500",
|
|
})
|
|
|
|
if inquiry.transferred_at:
|
|
stages.append({
|
|
"label": _("Transferred to Department"),
|
|
"timestamp": inquiry.transferred_at,
|
|
"duration_from_prev": _format_duration(inquiry.activated_at or inquiry.created_at, inquiry.transferred_at),
|
|
"color": "bg-purple-500",
|
|
})
|
|
|
|
if inquiry.department_responded_at:
|
|
stages.append({
|
|
"label": _("Department Responded"),
|
|
"timestamp": inquiry.department_responded_at,
|
|
"duration_from_prev": _format_duration(inquiry.transferred_at or inquiry.activated_at, inquiry.department_responded_at),
|
|
"color": "bg-amber-500",
|
|
})
|
|
|
|
if inquiry.responded_at:
|
|
stages.append({
|
|
"label": _("Response Sent to Inquirer"),
|
|
"timestamp": inquiry.responded_at,
|
|
"duration_from_prev": _format_duration(inquiry.department_responded_at or inquiry.transferred_at, inquiry.responded_at),
|
|
"color": "bg-cyan-500",
|
|
})
|
|
|
|
_resolved_at = getattr(inquiry, 'resolved_at', None)
|
|
if _resolved_at:
|
|
stages.append({
|
|
"label": _("Resolved"),
|
|
"timestamp": _resolved_at,
|
|
"duration_from_prev": _format_duration(inquiry.responded_at or inquiry.department_responded_at, _resolved_at),
|
|
"color": "bg-green-500",
|
|
})
|
|
|
|
_closed_at = getattr(inquiry, 'closed_at', None)
|
|
if _closed_at:
|
|
stages.append({
|
|
"label": _("Closed"),
|
|
"timestamp": _closed_at,
|
|
"duration_from_prev": _format_duration(_resolved_at or inquiry.responded_at, _closed_at),
|
|
"color": "bg-emerald-600",
|
|
})
|
|
|
|
return stages
|
|
|
|
|
|
def can_manage_complaint(user, complaint):
|
|
return ComplaintService.can_manage(user, complaint)
|
|
|
|
|
|
@login_required
|
|
def complaint_list(request):
|
|
"""
|
|
Complaints list view with advanced filters and pagination.
|
|
|
|
Features:
|
|
- Server-side pagination
|
|
- Advanced filters (status, severity, priority, hospital, department, etc.)
|
|
- Search by title, description, patient MRN
|
|
- Bulk actions support
|
|
- Export capability
|
|
"""
|
|
# Base queryset with optimizations
|
|
queryset = Complaint.objects.select_related(
|
|
"patient",
|
|
"hospital",
|
|
"department",
|
|
"staff",
|
|
"assigned_to",
|
|
"resolved_by",
|
|
"closed_by",
|
|
"source",
|
|
"created_by",
|
|
"domain",
|
|
"category",
|
|
"resolution_survey",
|
|
)
|
|
|
|
# Apply RBAC filters
|
|
user = request.user
|
|
# Get selected hospital for PX Admins (from middleware)
|
|
selected_hospital = getattr(request, "tenant_hospital", None)
|
|
|
|
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(department=user.department)
|
|
elif user.is_champion() and user.department:
|
|
queryset = queryset.filter(department=user.department)
|
|
elif user.is_source_user():
|
|
queryset = queryset.filter(created_by=user)
|
|
elif user.hospital:
|
|
queryset = queryset.filter(hospital=user.hospital)
|
|
else:
|
|
queryset = queryset.none()
|
|
|
|
# Apply filters from request
|
|
status_filter = request.GET.get("status")
|
|
if status_filter:
|
|
values = [v.strip() for v in status_filter.split(",")]
|
|
queryset = queryset.filter(status__in=values)
|
|
|
|
# Complaint Type filter (complaint vs appreciation)
|
|
complaint_type_filter = request.GET.get("complaint_type")
|
|
if complaint_type_filter:
|
|
queryset = queryset.filter(complaint_type=complaint_type_filter)
|
|
|
|
# Source Type filter (internal vs external)
|
|
source_type_filter = request.GET.get("complaint_source_type")
|
|
if source_type_filter:
|
|
queryset = queryset.filter(complaint_source_type=source_type_filter)
|
|
|
|
# PX Source filter
|
|
px_source_filter = request.GET.get("px_source")
|
|
if px_source_filter:
|
|
queryset = queryset.filter(source_id=px_source_filter)
|
|
|
|
# Domain filter (taxonomy)
|
|
domain_filter = request.GET.get("domain")
|
|
if domain_filter:
|
|
queryset = queryset.filter(domain_id=domain_filter)
|
|
|
|
# Resolution survey filter
|
|
has_survey_filter = request.GET.get("has_survey")
|
|
if has_survey_filter == "yes":
|
|
queryset = queryset.filter(resolution_survey__isnull=False)
|
|
elif has_survey_filter == "no":
|
|
queryset = queryset.filter(resolution_survey__isnull=True)
|
|
|
|
severity_filter = request.GET.get("severity")
|
|
if severity_filter:
|
|
values = [v.strip() for v in severity_filter.split(",")]
|
|
queryset = queryset.filter(severity__in=values)
|
|
|
|
priority_filter = request.GET.get("priority")
|
|
if priority_filter:
|
|
queryset = queryset.filter(priority=priority_filter)
|
|
|
|
category_filter = request.GET.get("category")
|
|
if category_filter:
|
|
queryset = queryset.filter(category=category_filter)
|
|
|
|
source_filter = request.GET.get("source")
|
|
if source_filter:
|
|
queryset = queryset.filter(source=source_filter)
|
|
|
|
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)
|
|
|
|
staff_filter = request.GET.get("staff")
|
|
if staff_filter:
|
|
queryset = queryset.filter(staff_id=staff_filter)
|
|
|
|
assigned_to_filter = request.GET.get("assigned_to")
|
|
if assigned_to_filter:
|
|
queryset = queryset.filter(assigned_to_id=assigned_to_filter)
|
|
|
|
overdue_filter = request.GET.get("is_overdue")
|
|
if overdue_filter == "true":
|
|
queryset = queryset.filter(is_overdue=True)
|
|
|
|
# Search
|
|
search_query = request.GET.get("search")
|
|
if search_query:
|
|
queryset = queryset.filter(
|
|
Q(title__icontains=search_query)
|
|
| Q(description__icontains=search_query)
|
|
| Q(reference_number__icontains=search_query)
|
|
| Q(patient__mrn__icontains=search_query)
|
|
| Q(patient__first_name__icontains=search_query)
|
|
| Q(patient__last_name__icontains=search_query)
|
|
)
|
|
|
|
# Date range filters (created date)
|
|
date_from = request.GET.get("date_from")
|
|
if not date_from and "date_from" not in request.GET:
|
|
date_from = "2026-01-01"
|
|
if date_from:
|
|
queryset = queryset.filter(created_at__gte=date_from)
|
|
|
|
date_to = request.GET.get("date_to")
|
|
if date_to:
|
|
queryset = queryset.filter(created_at__lte=date_to + " 23:59:59" if len(date_to) == 10 else date_to)
|
|
|
|
# Incident date range filters
|
|
incident_from = request.GET.get("incident_from")
|
|
if incident_from:
|
|
queryset = queryset.filter(incident_date__gte=incident_from)
|
|
|
|
incident_to = request.GET.get("incident_to")
|
|
if incident_to:
|
|
queryset = queryset.filter(incident_date__lte=incident_to)
|
|
|
|
# Ordering
|
|
order_by = request.GET.get("order_by", "-created_at")
|
|
queryset = queryset.order_by(order_by)
|
|
|
|
# Annotate each complaint with can_edit flag
|
|
for complaint in queryset:
|
|
complaint.can_edit = ComplaintService.can_manage(user, complaint)
|
|
|
|
# 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 filter options
|
|
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)
|
|
|
|
# Get assignable users
|
|
from apps.core.utils import get_assignable_users
|
|
assignable_users = get_assignable_users(user.hospital)
|
|
|
|
# Statistics - more comprehensive
|
|
total_count = queryset.count()
|
|
resolved_count = queryset.filter(status=ComplaintStatus.RESOLVED).count()
|
|
pending_count = queryset.filter(status__in=[ComplaintStatus.OPEN, ComplaintStatus.IN_PROGRESS]).count()
|
|
|
|
base_stats = {
|
|
"total": total_count,
|
|
"open": queryset.filter(status=ComplaintStatus.OPEN).count(),
|
|
"in_progress": queryset.filter(status=ComplaintStatus.IN_PROGRESS).count(),
|
|
"resolved": resolved_count,
|
|
"pending": pending_count,
|
|
"resolved_percentage": (resolved_count / total_count * 100) if total_count > 0 else 0,
|
|
"open_percentage": (queryset.filter(status=ComplaintStatus.OPEN).count() / total_count * 100) if total_count > 0 else 0,
|
|
"in_progress_percentage": (queryset.filter(status=ComplaintStatus.IN_PROGRESS).count() / total_count * 100) if total_count > 0 else 0,
|
|
"pending_percentage": (pending_count / total_count * 100) if total_count > 0 else 0,
|
|
"overdue": queryset.filter(is_overdue=True).count(),
|
|
"overdue_percentage": (queryset.filter(is_overdue=True).count() / total_count * 100) if total_count > 0 else 0,
|
|
"complaints": queryset.filter(complaint_type=ComplaintType.COMPLAINT).count(),
|
|
"appreciations": queryset.filter(complaint_type=ComplaintType.APPRECIATION).count(),
|
|
"from_px_sources": queryset.filter(source__isnull=False).count(),
|
|
"internal": queryset.filter(complaint_source_type=ComplaintSourceType.INTERNAL).count(),
|
|
"reopened": queryset.filter(reopened_from__isnull=False).count(),
|
|
"escalated_ovr": queryset.filter(is_escalated_ovr=True).count(),
|
|
}
|
|
|
|
# Get filter options
|
|
from apps.px_sources.models import PXSource
|
|
from apps.complaints.models import ComplaintCategory
|
|
|
|
px_sources = PXSource.objects.filter(is_active=True)
|
|
|
|
# Get domains for taxonomy filter
|
|
domains = ComplaintCategory.objects.filter(level=ComplaintCategory.LevelChoices.DOMAIN, is_active=True)
|
|
|
|
context = {
|
|
"complaints": page_obj,
|
|
"stats": base_stats,
|
|
"hospitals": hospitals,
|
|
"departments": departments,
|
|
"assignable_users": assignable_users,
|
|
"px_sources": px_sources,
|
|
"domains": domains,
|
|
"status_choices": ComplaintStatus.choices,
|
|
"complaint_type_choices": ComplaintType.choices,
|
|
"source_type_choices": ComplaintSourceType.choices,
|
|
"filters": request.GET,
|
|
"status_filter": status_filter,
|
|
"severity_filter": severity_filter,
|
|
"priority_filter": priority_filter,
|
|
"category_filter": category_filter,
|
|
"source_filter": source_filter,
|
|
"hospital_filter": hospital_filter,
|
|
"department_filter": department_filter,
|
|
"assigned_to_filter": assigned_to_filter,
|
|
"overdue_filter": overdue_filter,
|
|
"search_query": search_query,
|
|
"date_from": date_from,
|
|
"date_to": date_to or "",
|
|
}
|
|
|
|
return render(request, "complaints/complaint_list.html", context)
|
|
|
|
|
|
@login_required
|
|
def complaint_detail(request, pk):
|
|
"""
|
|
Complaint detail view with timeline, attachments, and actions.
|
|
|
|
Features:
|
|
- Full complaint details
|
|
- Timeline of all updates
|
|
- Attachments management
|
|
- Related surveys and journey
|
|
- Linked PX actions
|
|
- Workflow actions (assign, status change, add note)
|
|
|
|
PERFORMANCE OPTIMIZATIONS:
|
|
- Added missing select_related fields to main query
|
|
- Annotated counts to avoid N+1 queries in template
|
|
- Used prefetched data instead of re-querying
|
|
- Optimized escalation targets query
|
|
- Prefetched explanations with attachments
|
|
"""
|
|
from apps.px_sources.models import SourceUser
|
|
|
|
source_user = SourceUser.objects.filter(user=request.user).first()
|
|
base_layout = "layouts/source_user_base.html" if source_user else "layouts/base.html"
|
|
|
|
# OPTIMIZED: Added missing select_related fields and annotated counts
|
|
complaint_queryset = (
|
|
Complaint.objects.select_related(
|
|
"patient",
|
|
"hospital",
|
|
"department",
|
|
"staff",
|
|
"assigned_to",
|
|
"resolved_by",
|
|
"closed_by",
|
|
"resolution_survey",
|
|
"source",
|
|
"created_by",
|
|
"domain",
|
|
"category",
|
|
# ADD: Missing foreign keys that are accessed in template
|
|
"subcategory_obj",
|
|
"classification_obj",
|
|
"location",
|
|
"main_section",
|
|
"subsection",
|
|
)
|
|
.prefetch_related(
|
|
"attachments",
|
|
"updates__created_by",
|
|
"involved_departments__department",
|
|
"involved_departments__assigned_to",
|
|
"involved_staff__staff__department",
|
|
# ADD: Prefetch explanations with their attachments
|
|
Prefetch(
|
|
"explanations",
|
|
queryset=ComplaintExplanation.objects.select_related("staff")
|
|
.prefetch_related("attachments")
|
|
.order_by("-created_at"),
|
|
),
|
|
# ADD: Prefetch adverse actions with related data
|
|
Prefetch(
|
|
"adverse_actions",
|
|
queryset=ComplaintAdverseAction.objects.select_related("reported_by").prefetch_related(
|
|
"involved_staff"
|
|
),
|
|
),
|
|
)
|
|
.annotate(
|
|
# ADD: Annotate counts to avoid N+1 queries in template
|
|
updates_count=Count("updates", distinct=True),
|
|
attachments_count=Count("attachments", distinct=True),
|
|
involved_departments_count=Count("involved_departments", distinct=True),
|
|
involved_staff_count=Count("involved_staff", distinct=True),
|
|
explanations_count=Count("explanations", distinct=True),
|
|
adverse_actions_count=Count("adverse_actions", distinct=True),
|
|
)
|
|
)
|
|
|
|
complaint = get_object_or_404(complaint_queryset, pk=pk)
|
|
|
|
# Check access
|
|
user = request.user
|
|
if not user.is_px_admin():
|
|
if user.is_hospital_admin() and complaint.hospital != user.hospital:
|
|
messages.error(request, "You don't have permission to view this complaint.")
|
|
return redirect("complaints:complaint_list")
|
|
elif user.is_department_manager() and complaint.department != user.department:
|
|
messages.error(request, "You don't have permission to view this complaint.")
|
|
return redirect("complaints:complaint_list")
|
|
elif user.hospital and complaint.hospital != user.hospital:
|
|
messages.error(request, "You don't have permission to view this complaint.")
|
|
return redirect("complaints:complaint_list")
|
|
|
|
# OPTIMIZED: Use prefetched data instead of re-querying
|
|
timeline = sorted(complaint.updates.all(), key=lambda x: x.created_at, reverse=True)
|
|
attachments = sorted(complaint.attachments.all(), key=lambda x: x.created_at, reverse=True)
|
|
stage_timeline = _build_complaint_stage_timeline(complaint)
|
|
|
|
# Get related PX actions (using ContentType since PXAction uses GenericForeignKey)
|
|
from django.contrib.contenttypes.models import ContentType
|
|
from apps.px_action_center.models import PXAction
|
|
|
|
complaint_ct = ContentType.objects.get_for_model(Complaint)
|
|
px_actions = PXAction.objects.filter(content_type=complaint_ct, object_id=complaint.id).order_by("-created_at")
|
|
|
|
# Get assignable users
|
|
from apps.core.utils import get_assignable_users
|
|
assignable_users = get_assignable_users(complaint.hospital)
|
|
|
|
# Get departments for the complaint's hospital
|
|
hospital_departments = []
|
|
if complaint.hospital:
|
|
hospital_departments = Department.objects.filter(hospital=complaint.hospital, status="active").order_by("name")
|
|
|
|
# Check if overdue (only update if necessary)
|
|
if complaint.is_active_status and not complaint.is_overdue:
|
|
complaint.check_overdue()
|
|
|
|
# OPTIMIZED: Use prefetched explanations
|
|
explanations = complaint.explanations.all()
|
|
explanation = explanations.first() if explanations else None
|
|
|
|
# OPTIMIZED: Attachments are already prefetched
|
|
explanation_attachments = explanation.attachments.all() if explanation else []
|
|
|
|
# OPTIMIZED: Escalation targets - only query managers and direct reports
|
|
escalation_targets = []
|
|
default_escalation_target = None
|
|
|
|
if complaint.hospital:
|
|
# OPTIMIZED: Only query managers and potential escalation targets
|
|
# instead of ALL staff in the hospital
|
|
from django.db.models import Q
|
|
|
|
# Get potential escalation targets:
|
|
# 1. The staff's direct manager (if exists)
|
|
# 2. Department managers in the hospital
|
|
# 3. Hospital admins in the hospital
|
|
escalation_targets_qs = (
|
|
Staff.objects.filter(hospital=complaint.hospital, status="active", user__isnull=False, user__is_active=True)
|
|
.filter(
|
|
# Either is the staff's manager, or is a manager/admin
|
|
Q(id=complaint.staff.report_to.id if complaint.staff and complaint.staff.report_to else None)
|
|
| Q(user__groups__name__in=["Hospital Admin", "Department Manager"])
|
|
| Q(direct_reports__isnull=False)
|
|
)
|
|
.exclude(id=complaint.staff.id if complaint.staff else None)
|
|
.select_related("user", "department", "report_to")
|
|
.distinct()
|
|
.order_by("first_name", "last_name")
|
|
)
|
|
|
|
# Build list of escalation targets
|
|
for staff in escalation_targets_qs:
|
|
escalation_targets.append(
|
|
{
|
|
"staff": staff,
|
|
"has_user": True, # Already filtered for active user
|
|
"user_id": str(staff.user.id),
|
|
"is_manager": staff.direct_reports.exists(),
|
|
"is_line_manager": complaint.staff and complaint.staff.report_to == staff,
|
|
}
|
|
)
|
|
|
|
# Sort: Line manager first, then other managers, then others
|
|
escalation_targets.sort(
|
|
key=lambda x: (not x["is_line_manager"], not x["is_manager"], x["staff"].get_full_name())
|
|
)
|
|
|
|
# Set default to staff's line manager if exists
|
|
if complaint.staff and complaint.staff.report_to:
|
|
default_escalation_target = str(complaint.staff.report_to.id)
|
|
|
|
# OPTIMIZED: Use prefetched adverse actions
|
|
adverse_actions = complaint.adverse_actions.all()
|
|
|
|
from apps.rca.models import RootCauseAnalysis as RCA
|
|
|
|
complaint_ct = ContentType.objects.get_for_model(Complaint)
|
|
linked_rcas = RCA.objects.filter(
|
|
content_type=complaint_ct, object_id=complaint.pk, is_deleted=False
|
|
).select_related("assigned_to", "created_by")
|
|
|
|
context = {
|
|
"complaint": complaint,
|
|
"timeline": timeline,
|
|
"stage_timeline": stage_timeline,
|
|
"attachments": attachments,
|
|
"px_actions": px_actions,
|
|
"assignable_users": assignable_users,
|
|
"status_choices": ComplaintStatus.choices,
|
|
"base_layout": base_layout,
|
|
"source_user": source_user,
|
|
"can_edit": can_manage_complaint(user, complaint),
|
|
"is_active_status": complaint.is_active_status,
|
|
"ai_department_suggested": (
|
|
bool(complaint.department)
|
|
and not complaint.involved_departments.filter(department=complaint.department).exists()
|
|
),
|
|
"hospital_departments": hospital_departments,
|
|
"explanation": explanation,
|
|
"explanations": explanations,
|
|
"explanation_attachments": explanation_attachments,
|
|
"escalation_targets": escalation_targets,
|
|
"default_escalation_target": default_escalation_target,
|
|
"current_user": user,
|
|
"adverse_actions": adverse_actions,
|
|
"linked_rcas": linked_rcas,
|
|
"show_delay_reason_closure": (
|
|
complaint.delay_reason_closure
|
|
or complaint.is_overdue
|
|
or (timezone.now() - complaint.created_at).total_seconds() / 3600 > 72
|
|
or complaint.status in ("closed", "resolved")
|
|
),
|
|
}
|
|
|
|
_status_label_map = dict(ComplaintStatus.choices)
|
|
_valid_next = ComplaintService.VALID_STATUS_TRANSITIONS.get(complaint.status, [])
|
|
context["available_transitions"] = [(s, _status_label_map.get(s, s)) for s in _valid_next]
|
|
|
|
return render(request, "complaints/complaint_detail.html", context)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["GET", "POST"])
|
|
def complaint_create(request):
|
|
"""Create new complaint with AI-powered classification"""
|
|
from apps.complaints.forms import ComplaintForm
|
|
|
|
# Determine base layout based on user type
|
|
from apps.px_sources.models import SourceUser
|
|
|
|
source_user = SourceUser.objects.filter(user=request.user).first()
|
|
base_layout = "layouts/source_user_base.html" if source_user else "layouts/base.html"
|
|
|
|
if request.method == "POST":
|
|
# Handle form submission
|
|
form = ComplaintForm(request.POST, request=request)
|
|
|
|
if not form.is_valid():
|
|
# Debug: print form errors
|
|
print("Form validation errors:", form.errors)
|
|
messages.error(request, f"Please correct the errors: {form.errors}")
|
|
context = {
|
|
"form": form,
|
|
"base_layout": base_layout,
|
|
"source_user": source_user,
|
|
}
|
|
return render(request, "complaints/complaint_form.html", context)
|
|
|
|
try:
|
|
# Create complaint with AI defaults
|
|
complaint = form.save(commit=False)
|
|
|
|
# Set AI-determined defaults
|
|
complaint.title = "Complaint" # AI will generate title
|
|
# category can be None, AI will determine it
|
|
complaint.subcategory = "" # AI will determine
|
|
|
|
# Set source from form if selected, otherwise from logged-in source user
|
|
if form.cleaned_data.get("source"):
|
|
# User explicitly selected a PX source
|
|
complaint.source = form.cleaned_data["source"]
|
|
elif source_user and source_user.source:
|
|
# Source user is submitting (auto-assign their source)
|
|
complaint.source = source_user.source
|
|
else:
|
|
# Fallback: get or create a 'staff' source
|
|
from apps.px_sources.models import PXSource
|
|
|
|
try:
|
|
source_obj = PXSource.objects.get(code="staff")
|
|
except PXSource.DoesNotExist:
|
|
source_obj = PXSource.objects.create(
|
|
code="staff", name_en="Staff", description="Complaints submitted by staff members"
|
|
)
|
|
complaint.source = source_obj
|
|
|
|
complaint.priority = "medium" # AI will update
|
|
complaint.severity = "medium" # AI will update
|
|
complaint.created_by = request.user
|
|
|
|
# Auto-set priority to high for MOH/CHI complaints
|
|
if complaint.source and complaint.source.name_en:
|
|
source_name = complaint.source.name_en.lower()
|
|
if "moh" in source_name or "chi" in source_name or "council of health" in source_name or "ministry of health" in source_name:
|
|
complaint.priority = "high"
|
|
|
|
# Link to patient if auto-lookup found a match
|
|
patient_id = request.POST.get("patient_id")
|
|
if patient_id:
|
|
try:
|
|
from uuid import UUID
|
|
|
|
UUID(patient_id)
|
|
complaint.patient_id = patient_id
|
|
except (ValueError, AttributeError):
|
|
pass
|
|
|
|
# Generate unique reference number: CMP-YYYYMMDD-XXXXX
|
|
import uuid
|
|
from datetime import datetime
|
|
|
|
today = datetime.now().strftime("%Y%m%d")
|
|
random_suffix = str(uuid.uuid4().int)[:6]
|
|
complaint.reference_number = f"CMP-{today}-{random_suffix}"
|
|
|
|
complaint.save()
|
|
|
|
comm_req_id = request.POST.get("comm_req")
|
|
if comm_req_id:
|
|
try:
|
|
from apps.px_sources.models import CommunicationRequest
|
|
cr = CommunicationRequest.objects.get(pk=comm_req_id)
|
|
cr.link_to_record(complaint)
|
|
except Exception:
|
|
pass
|
|
|
|
ComplaintService.post_create_hooks(complaint, request.user, request=request)
|
|
|
|
StaffActivityService.log_from_request(
|
|
request,
|
|
activity_type="create",
|
|
description=f"Created complaint {complaint.reference_number}",
|
|
content_object=complaint,
|
|
module="complaints",
|
|
)
|
|
|
|
messages.success(
|
|
request,
|
|
f"Complaint #{complaint.id} created successfully. AI is analyzing and classifying the complaint.",
|
|
)
|
|
return redirect("complaints:complaint_detail", pk=complaint.id)
|
|
|
|
except ComplaintCategory.DoesNotExist:
|
|
messages.error(request, "Selected category not found.")
|
|
return redirect("complaints:complaint_create")
|
|
|
|
# GET request - show form
|
|
# Check for hospital parameter from URL (for pre-selection)
|
|
initial_data = {}
|
|
hospital_id = request.GET.get("hospital")
|
|
if hospital_id:
|
|
initial_data["hospital"] = hospital_id
|
|
|
|
communication_request = None
|
|
comm_req_id = request.GET.get("comm_req")
|
|
if comm_req_id:
|
|
try:
|
|
from apps.px_sources.models import CommunicationRequest
|
|
cr_data = CommunicationRequest.get_initial_data(comm_req_id)
|
|
communication_request = cr_data["communication_request"]
|
|
for key in ("hospital", "patient_name", "description"):
|
|
if key in cr_data["initial"] and cr_data["initial"][key]:
|
|
initial_data[key] = cr_data["initial"][key]
|
|
except Exception:
|
|
pass
|
|
|
|
form = ComplaintForm(request=request, initial=initial_data)
|
|
|
|
context = {
|
|
"form": form,
|
|
"base_layout": base_layout,
|
|
"source_user": source_user,
|
|
"communication_request": communication_request,
|
|
}
|
|
|
|
return render(request, "complaints/complaint_form.html", context)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def complaint_assign(request, pk):
|
|
"""Assign complaint to user - Admin or currently assigned user"""
|
|
complaint = get_object_or_404(Complaint, pk=pk)
|
|
|
|
user_id = request.POST.get("user_id")
|
|
if not user_id:
|
|
messages.error(request, "Please select a user to assign.")
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
try:
|
|
assignee = User.objects.get(id=user_id)
|
|
old_status = complaint.status
|
|
ComplaintService.assign(complaint, assignee, request.user, request=request)
|
|
except User.DoesNotExist:
|
|
messages.error(request, "User not found.")
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
except ComplaintServiceError as e:
|
|
messages.error(request, str(e))
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
try:
|
|
from apps.notifications.settings_service import NotificationServiceWithSettings
|
|
NotificationServiceWithSettings.send_complaint_assigned(
|
|
assignee.email if assignee.email else None,
|
|
complaint,
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
messages.success(request, f"Complaint assigned to {assignee.get_full_name()}.")
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def complaint_send_to(request, pk):
|
|
"""
|
|
Unified AJAX endpoint to send complaint to either a person or department.
|
|
"""
|
|
from django.http import JsonResponse
|
|
from .models import ComplaintInvolvedDepartment
|
|
from apps.notifications.services import NotificationService
|
|
|
|
complaint = get_object_or_404(Complaint, pk=pk)
|
|
user = request.user
|
|
|
|
# Check permission
|
|
if not can_manage_complaint(user, complaint):
|
|
return JsonResponse({
|
|
"success": False,
|
|
"error": str(_("You don't have permission to send this complaint.")),
|
|
}, status=403)
|
|
|
|
recipient_type = request.POST.get("recipient_type", "department")
|
|
note = request.POST.get("note", "").strip()
|
|
|
|
try:
|
|
if recipient_type == "person":
|
|
person_id = request.POST.get("person_id")
|
|
if not person_id:
|
|
return JsonResponse({
|
|
"success": False,
|
|
"error": str(_("Please select a person.")),
|
|
}, status=400)
|
|
|
|
try:
|
|
person = User.objects.get(id=person_id)
|
|
except User.DoesNotExist:
|
|
return JsonResponse({
|
|
"success": False,
|
|
"error": str(_("User not found.")),
|
|
}, status=400)
|
|
|
|
# Assign complaint to person
|
|
ComplaintService.assign(complaint, person, user, request=request)
|
|
|
|
# Send notification
|
|
if person.email:
|
|
NotificationService.send_email(
|
|
email=person.email,
|
|
subject=f"Complaint Assigned - {complaint.reference_number}",
|
|
message=f"You have been assigned to complaint #{complaint.reference_number}.",
|
|
html_message=f"""
|
|
<p>You have been assigned to complaint <strong>#{complaint.reference_number}</strong>.</p>
|
|
<p><strong>Title:</strong> {complaint.title or 'N/A'}</p>
|
|
{f'<p><strong>Note:</strong> {note}</p>' if note else ''}
|
|
<p><a href="https://{request.get_host()}/complaints/{complaint.pk}/">View Complaint</a></p>
|
|
""",
|
|
related_object=complaint,
|
|
)
|
|
|
|
message = f"Complaint sent to {person.get_full_name()}."
|
|
|
|
else: # department
|
|
department_id = request.POST.get("department_id")
|
|
if not department_id:
|
|
return JsonResponse({
|
|
"success": False,
|
|
"error": str(_("Please select a department.")),
|
|
}, status=400)
|
|
|
|
try:
|
|
department = Department.objects.get(id=department_id, status="active")
|
|
except Department.DoesNotExist:
|
|
return JsonResponse({
|
|
"success": False,
|
|
"error": str(_("Department not found.")),
|
|
}, status=400)
|
|
|
|
# Create involved department record
|
|
involved_dept, created = ComplaintInvolvedDepartment.objects.get_or_create(
|
|
complaint=complaint,
|
|
department=department,
|
|
defaults={
|
|
"role": "secondary",
|
|
"added_by": user,
|
|
"forwarded_at": timezone.now(),
|
|
}
|
|
)
|
|
|
|
if not created:
|
|
involved_dept.forwarded_at = timezone.now()
|
|
involved_dept.save()
|
|
|
|
# Send notification to department champion
|
|
if department.respondent and department.respondent.user and department.respondent.user.email:
|
|
NotificationService.send_email(
|
|
email=department.respondent.user.email,
|
|
subject=f"Complaint Sent to Department - {complaint.reference_number}",
|
|
message=f"Complaint #{complaint.reference_number} has been sent to your department ({department.name}).",
|
|
html_message=f"""
|
|
<p>Complaint <strong>#{complaint.reference_number}</strong> has been sent to your department <strong>({department.name})</strong>.</p>
|
|
<p><strong>Title:</strong> {complaint.title or 'N/A'}</p>
|
|
{f'<p><strong>Note:</strong> {note}</p>' if note else ''}
|
|
<p><a href="https://{request.get_host()}/organizations/departments/{department.pk}/">View Department Page</a></p>
|
|
""",
|
|
related_object=complaint,
|
|
)
|
|
|
|
message = f"Complaint sent to {department.name}."
|
|
|
|
# Change status to contacted if active
|
|
if complaint.is_active_status and complaint.status in ("open", "in_progress"):
|
|
old_status = complaint.status
|
|
complaint.status = "contacted"
|
|
complaint.save(update_fields=["status"])
|
|
|
|
ComplaintUpdate.objects.create(
|
|
complaint=complaint,
|
|
update_type="status_change",
|
|
message=f"Status changed from {old_status} to contacted - sent to {recipient_type}",
|
|
created_by=user,
|
|
)
|
|
|
|
return JsonResponse({
|
|
"success": True,
|
|
"message": message,
|
|
})
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error in complaint_send_to: {str(e)}")
|
|
return JsonResponse({
|
|
"success": False,
|
|
"error": str(_("An error occurred while sending the complaint.")),
|
|
}, status=500)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def complaint_change_status(request, pk):
|
|
"""Change complaint status"""
|
|
complaint = get_object_or_404(Complaint, pk=pk)
|
|
|
|
new_status = request.POST.get("status")
|
|
note = request.POST.get("note", "")
|
|
resolution = request.POST.get("resolution", "")
|
|
resolution_outcome = request.POST.get("resolution_outcome", "")
|
|
resolution_outcome_other = request.POST.get("resolution_outcome_other", "")
|
|
|
|
try:
|
|
ComplaintService.change_status(
|
|
complaint,
|
|
new_status,
|
|
request.user,
|
|
request=request,
|
|
note=note,
|
|
resolution=resolution,
|
|
resolution_outcome=resolution_outcome,
|
|
resolution_outcome_other=resolution_outcome_other,
|
|
)
|
|
except ComplaintServiceError as e:
|
|
messages.error(request, str(e))
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
try:
|
|
from apps.notifications.settings_service import NotificationServiceWithSettings
|
|
NotificationServiceWithSettings.send_complaint_status_changed(
|
|
complaint.assigned_to.email if complaint.assigned_to and complaint.assigned_to.email else None,
|
|
complaint,
|
|
complaint.status,
|
|
new_status,
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
messages.success(request, f"Complaint status changed to {new_status}.")
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def update_satisfaction(request, pk):
|
|
"""Update complaint satisfaction rating (resolved/closed complaints only)."""
|
|
complaint = get_object_or_404(Complaint, pk=pk)
|
|
|
|
if complaint.status not in ("resolved", "closed"):
|
|
messages.error(request, _("Satisfaction can only be set for resolved or closed complaints."))
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
if not can_manage_complaint(request.user, complaint):
|
|
messages.error(request, _("You don't have permission to update satisfaction."))
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
satisfaction = request.POST.get("satisfaction", "")
|
|
valid_choices = ["satisfied", "neutral", "dissatisfied", "no_response"]
|
|
if satisfaction and satisfaction not in valid_choices:
|
|
messages.error(request, _("Invalid satisfaction value."))
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
from django.utils import timezone
|
|
|
|
complaint.satisfaction = satisfaction
|
|
if satisfaction:
|
|
complaint.satisfaction_set_at = timezone.now()
|
|
else:
|
|
complaint.satisfaction_set_at = None
|
|
complaint.save(update_fields=["satisfaction", "satisfaction_set_at", "updated_at"])
|
|
|
|
if satisfaction:
|
|
messages.success(request, _("Satisfaction updated to: {}").format(complaint.get_satisfaction_display()))
|
|
else:
|
|
messages.success(request, _("Satisfaction cleared."))
|
|
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def toggle_escalated_ovr(request, pk):
|
|
"""Request OVR escalation or cancel pending request."""
|
|
complaint = get_object_or_404(Complaint, pk=pk)
|
|
|
|
if not can_manage_complaint(request.user, complaint):
|
|
messages.error(request, _("You don't have permission to update this complaint."))
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
if complaint.status == ComplaintStatus.OVR_PENDING:
|
|
complaint.status = ComplaintStatus.IN_PROGRESS
|
|
complaint.is_escalated_ovr = False
|
|
complaint.escalated_ovr_by = None
|
|
complaint.escalated_ovr_at = None
|
|
complaint.metadata.pop("ovr_requested_by", None)
|
|
complaint.metadata.pop("ovr_requested_at", None)
|
|
messages.success(request, _("OVR escalation request cancelled."))
|
|
else:
|
|
complaint.status = ComplaintStatus.OVR_PENDING
|
|
complaint.is_escalated_ovr = False
|
|
complaint.metadata["ovr_requested_by"] = str(request.user.pk)
|
|
complaint.metadata["ovr_requested_at"] = timezone.now().isoformat()
|
|
messages.success(request, _("OVR escalation requested. Waiting for admin approval."))
|
|
|
|
# Send email notification to PX admins and management
|
|
_send_ovr_request_notification(complaint, request.user)
|
|
|
|
complaint.save(update_fields=["status", "is_escalated_ovr", "escalated_ovr_by", "escalated_ovr_at", "metadata", "updated_at"])
|
|
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
|
|
def _send_ovr_request_notification(complaint, requested_by):
|
|
"""Send email notification to admins when OVR escalation is requested"""
|
|
from django.conf import settings
|
|
from apps.accounts.models import User
|
|
from apps.notifications.services import NotificationService
|
|
|
|
try:
|
|
admin_users = User.objects.filter(
|
|
is_active=True,
|
|
groups__name__in=["PX Admin", "Hospital Admin", "PX Management"],
|
|
hospital=complaint.hospital
|
|
).distinct()
|
|
|
|
subject = f"OVR Escalation Request - Complaint #{complaint.reference_number}"
|
|
|
|
for admin in admin_users:
|
|
if admin.email:
|
|
message = f"""A new OVR escalation request requires your approval.
|
|
|
|
Complaint: #{complaint.reference_number}
|
|
Title: {complaint.title}
|
|
Requested by: {requested_by.get_full_name()}
|
|
Hospital: {complaint.hospital.name}
|
|
|
|
Please review and approve/reject this request in the complaints system.
|
|
|
|
URL: {settings.SITE_URL.rstrip('/')}/complaints/{complaint.pk}/
|
|
"""
|
|
NotificationService.send_email(
|
|
email=admin.email,
|
|
subject=subject,
|
|
message=message,
|
|
)
|
|
except Exception as e:
|
|
import logging
|
|
logger = logging.getLogger(__name__)
|
|
logger.error(f"Failed to send OVR notification: {e}")
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def approve_ovr_escalation(request, pk):
|
|
"""Approve OVR escalation request."""
|
|
complaint = get_object_or_404(Complaint, pk=pk)
|
|
|
|
if not request.user.is_px_admin() and not request.user.is_hospital_admin():
|
|
messages.error(request, _("You don't have permission to approve OVR escalation."))
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
if complaint.status != ComplaintStatus.OVR_PENDING:
|
|
messages.error(request, _("This complaint does not have a pending OVR request."))
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
complaint.status = ComplaintStatus.IN_PROGRESS
|
|
complaint.is_escalated_ovr = True
|
|
complaint.escalated_ovr_by = request.user
|
|
complaint.escalated_ovr_at = timezone.now()
|
|
requested_by = complaint.metadata.get("ovr_requested_by", "")
|
|
complaint.metadata.pop("ovr_requested_by", None)
|
|
complaint.metadata.pop("ovr_requested_at", None)
|
|
complaint.metadata["ovr_approved_by"] = str(request.user.pk)
|
|
complaint.metadata["ovr_approved_at"] = timezone.now().isoformat()
|
|
complaint.save(update_fields=["status", "is_escalated_ovr", "escalated_ovr_by", "escalated_ovr_at", "metadata", "updated_at"])
|
|
|
|
messages.success(request, _("OVR escalation approved."))
|
|
_send_ovr_decision_notification(complaint, request.user, "approved")
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def reject_ovr_escalation(request, pk):
|
|
"""Reject OVR escalation request."""
|
|
complaint = get_object_or_404(Complaint, pk=pk)
|
|
|
|
if not request.user.is_px_admin() and not request.user.is_hospital_admin():
|
|
messages.error(request, _("You don't have permission to reject OVR escalation."))
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
if complaint.status != ComplaintStatus.OVR_PENDING:
|
|
messages.error(request, _("This complaint does not have a pending OVR request."))
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
complaint.status = ComplaintStatus.IN_PROGRESS
|
|
complaint.metadata.pop("ovr_requested_by", None)
|
|
complaint.metadata.pop("ovr_requested_at", None)
|
|
complaint.metadata["ovr_rejected_by"] = str(request.user.pk)
|
|
complaint.metadata["ovr_rejected_at"] = timezone.now().isoformat()
|
|
complaint.save(update_fields=["status", "metadata", "updated_at"])
|
|
|
|
messages.success(request, _("OVR escalation request rejected."))
|
|
_send_ovr_decision_notification(complaint, request.user, "rejected")
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
|
|
def _send_ovr_decision_notification(complaint, decided_by, decision):
|
|
"""Send email notification to requester when OVR is approved or rejected"""
|
|
from django.conf import settings
|
|
from apps.accounts.models import User
|
|
from apps.notifications.services import NotificationService
|
|
|
|
try:
|
|
requested_by_pk = complaint.metadata.get("ovr_requested_by")
|
|
if not requested_by_pk:
|
|
return
|
|
|
|
requested_by_user = User.objects.filter(pk=requested_by_pk).first()
|
|
if not requested_by_user or not requested_by_user.email:
|
|
return
|
|
|
|
decision_text = "approved" if decision == "approved" else "rejected"
|
|
subject = f"OVR Escalation {decision_text.title()} - Complaint #{complaint.reference_number}"
|
|
|
|
message = f"""Your OVR escalation request for complaint #{complaint.reference_number} has been {decision_text}.
|
|
|
|
Complaint: #{complaint.reference_number}
|
|
Title: {complaint.title}
|
|
Decision: {decision_text.title()} by {decided_by.get_full_name()}
|
|
|
|
{"The complaint is now escalated as OVR." if decision == "approved" else "Please contact the admin for more information."}
|
|
|
|
URL: {settings.SITE_URL.rstrip('/')}/complaints/{complaint.pk}/
|
|
"""
|
|
NotificationService.send_email(
|
|
email=requested_by_user.email,
|
|
subject=subject,
|
|
message=message,
|
|
)
|
|
except Exception as e:
|
|
import logging
|
|
logger = logging.getLogger(__name__)
|
|
logger.error(f"Failed to send OVR decision notification: {e}")
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def reject_ovr_escalation(request, pk):
|
|
"""Reject OVR escalation request."""
|
|
complaint = get_object_or_404(Complaint, pk=pk)
|
|
|
|
if not request.user.is_px_admin() and not request.user.is_hospital_admin():
|
|
messages.error(request, _("You don't have permission to reject OVR escalation."))
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
if complaint.status != ComplaintStatus.OVR_PENDING:
|
|
messages.error(request, _("This complaint does not have a pending OVR request."))
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
complaint.status = ComplaintStatus.IN_PROGRESS
|
|
complaint.metadata.pop("ovr_requested_by", None)
|
|
complaint.metadata.pop("ovr_requested_at", None)
|
|
complaint.metadata["ovr_rejected_by"] = str(request.user.pk)
|
|
complaint.metadata["ovr_rejected_at"] = timezone.now().isoformat()
|
|
complaint.save(update_fields=["status", "metadata", "updated_at"])
|
|
|
|
messages.success(request, _("OVR escalation request rejected."))
|
|
_send_ovr_decision_notification(complaint, request.user, "rejected")
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def complaint_reopen(request, pk):
|
|
"""Reopen a resolved or closed complaint"""
|
|
complaint = get_object_or_404(Complaint, pk=pk)
|
|
|
|
user = request.user
|
|
if not (
|
|
user.is_px_admin()
|
|
or user.is_hospital_admin()
|
|
or complaint.assigned_to == user
|
|
or (user.is_department_manager() and user.department and user.department == complaint.department)
|
|
):
|
|
messages.error(request, "You don't have permission to reopen this complaint.")
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
note = request.POST.get("note", "")
|
|
|
|
if complaint.status not in ("resolved", "closed"):
|
|
messages.error(request, "Only resolved or closed complaints can be reopened.")
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
try:
|
|
ComplaintService.reopen(complaint, request.user, request=request, note=note)
|
|
except ComplaintServiceError as e:
|
|
messages.error(request, str(e))
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
try:
|
|
from apps.notifications.settings_service import NotificationServiceWithSettings
|
|
NotificationServiceWithSettings.send_complaint_status_changed(
|
|
complaint.assigned_to.email if complaint.assigned_to and complaint.assigned_to.email else None,
|
|
complaint,
|
|
complaint.status,
|
|
"in_progress",
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
messages.success(request, "Complaint reopened successfully.")
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def update_explanation_delay_reason(request, pk):
|
|
complaint = get_object_or_404(Complaint, pk=pk)
|
|
|
|
if not can_manage_complaint(request.user, complaint):
|
|
messages.error(request, "You don't have permission to update this complaint.")
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
reason = request.POST.get("explanation_delay_reason", "")
|
|
old_reason = complaint.explanation_delay_reason
|
|
complaint.explanation_delay_reason = reason
|
|
complaint.save(update_fields=["explanation_delay_reason", "updated_at"])
|
|
|
|
ComplaintUpdate.objects.create(
|
|
complaint=complaint,
|
|
update_type="note",
|
|
message=f"Explanation delay reason updated: {reason}" if reason else "Explanation delay reason cleared",
|
|
created_by=request.user,
|
|
)
|
|
|
|
AuditService.log_event(
|
|
event_type="update",
|
|
description="Explanation delay reason updated",
|
|
user=request.user,
|
|
content_object=complaint,
|
|
metadata={"old_value": old_reason, "new_value": reason},
|
|
)
|
|
|
|
messages.success(request, "Explanation delay reason updated.")
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def update_delay_reason_closure(request, pk):
|
|
complaint = get_object_or_404(Complaint, pk=pk)
|
|
|
|
if not can_manage_complaint(request.user, complaint):
|
|
messages.error(request, "You don't have permission to update this complaint.")
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
if complaint.status in ("closed", "resolved"):
|
|
messages.error(request, "Cannot update delay reason for closed/resolved complaints.")
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
from django.utils import timezone
|
|
|
|
hours_since_creation = (timezone.now() - complaint.created_at).total_seconds() / 3600
|
|
if not complaint.is_overdue and hours_since_creation < 72:
|
|
messages.error(request, "Delay reason can only be set when complaint is overdue or past 72 hours.")
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
reason = request.POST.get("delay_reason_closure", "")
|
|
from .models import DelayReasonChoices
|
|
valid_reasons = [choice[0] for choice in DelayReasonChoices.choices]
|
|
if reason and reason not in valid_reasons:
|
|
messages.error(request, "Invalid delay reason.")
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
old_reason = complaint.delay_reason_closure
|
|
complaint.delay_reason_closure = reason
|
|
complaint.save(update_fields=["delay_reason_closure", "updated_at"])
|
|
|
|
ComplaintUpdate.objects.create(
|
|
complaint=complaint,
|
|
update_type="note",
|
|
message=f"Closure delay reason updated: {reason}" if reason else "Closure delay reason cleared",
|
|
created_by=request.user,
|
|
)
|
|
|
|
AuditService.log_event(
|
|
event_type="update",
|
|
description="Closure delay reason updated",
|
|
user=request.user,
|
|
content_object=complaint,
|
|
metadata={"old_value": old_reason, "new_value": reason},
|
|
)
|
|
|
|
messages.success(request, "Closure delay reason updated.")
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def complaint_add_note(request, pk):
|
|
"""Add note to complaint"""
|
|
complaint = get_object_or_404(Complaint, pk=pk)
|
|
note = request.POST.get("note")
|
|
|
|
try:
|
|
ComplaintService.add_note(complaint, note, request.user, request=request)
|
|
except ComplaintServiceError as e:
|
|
messages.error(request, str(e))
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
messages.success(request, "Note added successfully.")
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def complaint_change_department(request, pk):
|
|
"""Change complaint department"""
|
|
complaint = get_object_or_404(Complaint, pk=pk)
|
|
|
|
department_id = request.POST.get("department_id")
|
|
if not department_id:
|
|
messages.error(request, "Please select a department.")
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
try:
|
|
department = Department.objects.get(id=department_id)
|
|
ComplaintService.change_department(complaint, department, request.user, request=request)
|
|
except Department.DoesNotExist:
|
|
messages.error(request, "Department not found.")
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
except ComplaintServiceError as e:
|
|
messages.error(request, str(e))
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
messages.success(request, f"Department changed to {department.name}.")
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def complaint_escalate(request, pk):
|
|
"""Escalate complaint to selected staff (default is staff's manager) - Admin and Dept Manager only"""
|
|
complaint = get_object_or_404(Complaint, pk=pk)
|
|
|
|
# Check if complaint is in active status
|
|
if not complaint.is_active_status:
|
|
messages.error(
|
|
request,
|
|
f"Cannot escalate complaint with status '{complaint.get_status_display()}'. Complaint must be Open, In Progress, or Partially Resolved.",
|
|
)
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
# Check permission
|
|
user = request.user
|
|
if not (user.is_px_admin() or user.is_hospital_admin()):
|
|
messages.error(request, "You don't have permission to escalate complaints.")
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
reason = request.POST.get("reason", "")
|
|
escalate_to_id = request.POST.get("escalate_to", "")
|
|
|
|
# Get the escalation target staff
|
|
escalate_to_staff = None
|
|
escalate_to_user = None
|
|
|
|
if escalate_to_id:
|
|
try:
|
|
escalate_to_staff = Staff.objects.get(id=escalate_to_id)
|
|
if escalate_to_staff.user and escalate_to_staff.user.is_active:
|
|
escalate_to_user = escalate_to_staff.user
|
|
except Staff.DoesNotExist:
|
|
pass
|
|
|
|
# If no staff selected or not found, default to staff's manager
|
|
if not escalate_to_staff and complaint.staff and complaint.staff.report_to:
|
|
escalate_to_staff = complaint.staff.report_to
|
|
if escalate_to_staff.user and escalate_to_staff.user.is_active:
|
|
escalate_to_user = escalate_to_staff.user
|
|
|
|
# Fallback: use escalation target resolver if still no target
|
|
if not escalate_to_user:
|
|
from apps.complaints.services.complaint_service import ComplaintService
|
|
|
|
fallback_user, fallback_path = ComplaintService.get_escalation_target(complaint, staff=complaint.staff)
|
|
if fallback_user:
|
|
escalate_to_user = fallback_user
|
|
reason += f" [fallback via {fallback_path}]"
|
|
|
|
# Mark as escalated and assign to selected user
|
|
complaint.escalated_at = timezone.now()
|
|
if escalate_to_user:
|
|
complaint.assigned_to = escalate_to_user
|
|
complaint.save(update_fields=["escalated_at", "assigned_to"])
|
|
|
|
# Create update with escalation details
|
|
escalation_message = f"Complaint escalated. Reason: {reason}"
|
|
if escalate_to_user:
|
|
escalation_message += f" Escalated to: {escalate_to_user.get_full_name()}"
|
|
|
|
ComplaintUpdate.objects.create(
|
|
complaint=complaint,
|
|
update_type="escalation",
|
|
message=escalation_message,
|
|
created_by=request.user,
|
|
metadata={
|
|
"reason": reason,
|
|
"escalated_to_user_id": str(escalate_to_user.id) if escalate_to_user else None,
|
|
"escalated_to_user_name": escalate_to_user.get_full_name() if escalate_to_user else None,
|
|
},
|
|
)
|
|
|
|
# Log audit
|
|
AuditService.log_event(
|
|
event_type="escalation",
|
|
description=f"Complaint escalated to {escalate_to_user.get_full_name() if escalate_to_user else 'manager'}",
|
|
user=request.user,
|
|
content_object=complaint,
|
|
metadata={
|
|
"reason": reason,
|
|
"escalated_to_user_id": str(escalate_to_user.id) if escalate_to_user else None,
|
|
"escalated_to_user_name": escalate_to_user.get_full_name() if escalate_to_user else None,
|
|
},
|
|
)
|
|
|
|
# Send notification to the escalated user
|
|
if escalate_to_user and escalate_to_user.email:
|
|
from apps.notifications.services import NotificationService
|
|
|
|
try:
|
|
NotificationService.send_email(
|
|
email=escalate_to_user.email,
|
|
subject=f"Complaint Escalated - #{complaint.id}",
|
|
message=f"""
|
|
Dear {escalate_to_user.get_full_name()},
|
|
|
|
A complaint has been escalated to you for attention.
|
|
|
|
COMPLAINT DETAILS:
|
|
------------------
|
|
Reference: #{complaint.id}
|
|
Title: {complaint.title}
|
|
Severity: {complaint.get_severity_display()}
|
|
Priority: {complaint.get_priority_display()}
|
|
|
|
ESCALATION REASON:
|
|
------------------
|
|
{reason}
|
|
|
|
Please review and take necessary action.
|
|
|
|
View complaint: https://{request.get_host()}/complaints/{complaint.id}/
|
|
|
|
---
|
|
This is an automated message from PX360 Complaint Management System.
|
|
""",
|
|
related_object=complaint,
|
|
metadata={
|
|
"notification_type": "complaint_escalated",
|
|
"escalated_by": str(request.user.id),
|
|
"reason": reason,
|
|
},
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Failed to send escalation notification: {e}")
|
|
|
|
messages.success(
|
|
request,
|
|
f"Complaint escalated successfully{f' to {escalate_to_user.get_full_name()}' if escalate_to_user else ''}.",
|
|
)
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def complaint_activate(request, pk):
|
|
"""Activate complaint and assign to current user."""
|
|
complaint = get_object_or_404(Complaint, pk=pk)
|
|
|
|
try:
|
|
ComplaintService.activate(complaint, request.user, request=request)
|
|
except ComplaintServiceError as e:
|
|
messages.error(request, str(e))
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
messages.success(request, "Complaint activated and assigned to you successfully.")
|
|
return redirect("complaints:complaint_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
def complaint_export_csv(request):
|
|
"""Export complaints to CSV"""
|
|
from apps.complaints.utils import export_complaints_csv
|
|
|
|
# Get filtered queryset (reuse list view logic)
|
|
queryset = Complaint.objects.select_related(
|
|
"patient", "hospital", "department", "staff", "assigned_to", "resolved_by", "closed_by"
|
|
)
|
|
|
|
# Apply RBAC filters
|
|
user = request.user
|
|
if user.is_px_admin():
|
|
pass
|
|
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(department=user.department)
|
|
elif user.hospital:
|
|
queryset = queryset.filter(hospital=user.hospital)
|
|
else:
|
|
queryset = queryset.none()
|
|
|
|
# Apply filters from request
|
|
status_filter = request.GET.get("status")
|
|
if status_filter:
|
|
queryset = queryset.filter(status=status_filter)
|
|
|
|
severity_filter = request.GET.get("severity")
|
|
if severity_filter:
|
|
queryset = queryset.filter(severity=severity_filter)
|
|
|
|
priority_filter = request.GET.get("priority")
|
|
if priority_filter:
|
|
queryset = queryset.filter(priority=priority_filter)
|
|
|
|
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)
|
|
|
|
overdue_filter = request.GET.get("is_overdue")
|
|
if overdue_filter == "true":
|
|
queryset = queryset.filter(is_overdue=True)
|
|
|
|
search_query = request.GET.get("search")
|
|
if search_query:
|
|
queryset = queryset.filter(
|
|
Q(title__icontains=search_query)
|
|
| Q(description__icontains=search_query)
|
|
| Q(patient__mrn__icontains=search_query)
|
|
)
|
|
|
|
return export_complaints_csv(queryset, request.GET.dict())
|
|
|
|
|
|
@login_required
|
|
def complaint_export_excel(request):
|
|
"""Export complaints to Excel"""
|
|
from apps.complaints.utils import export_complaints_excel
|
|
|
|
# Get filtered queryset (same as CSV)
|
|
queryset = Complaint.objects.select_related(
|
|
"patient", "hospital", "department", "staff", "assigned_to", "resolved_by", "closed_by"
|
|
)
|
|
|
|
# Apply RBAC filters
|
|
user = request.user
|
|
if user.is_px_admin():
|
|
pass
|
|
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(department=user.department)
|
|
elif user.hospital:
|
|
queryset = queryset.filter(hospital=user.hospital)
|
|
else:
|
|
queryset = queryset.none()
|
|
|
|
# Apply filters from request
|
|
status_filter = request.GET.get("status")
|
|
if status_filter:
|
|
queryset = queryset.filter(status=status_filter)
|
|
|
|
severity_filter = request.GET.get("severity")
|
|
if severity_filter:
|
|
queryset = queryset.filter(severity=severity_filter)
|
|
|
|
priority_filter = request.GET.get("priority")
|
|
if priority_filter:
|
|
queryset = queryset.filter(priority=priority_filter)
|
|
|
|
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)
|
|
|
|
overdue_filter = request.GET.get("is_overdue")
|
|
if overdue_filter == "true":
|
|
queryset = queryset.filter(is_overdue=True)
|
|
|
|
search_query = request.GET.get("search")
|
|
if search_query:
|
|
queryset = queryset.filter(
|
|
Q(title__icontains=search_query)
|
|
| Q(description__icontains=search_query)
|
|
| Q(patient__mrn__icontains=search_query)
|
|
)
|
|
|
|
return export_complaints_excel(queryset, request.GET.dict())
|
|
|
|
|
|
@login_required
|
|
def complaint_export_historical_excel(request):
|
|
"""Export complaints to historical Excel format with Arabic headers."""
|
|
from apps.complaints.utils import export_historical_excel
|
|
|
|
# Get filtered queryset
|
|
queryset = Complaint.objects.select_related(
|
|
"patient",
|
|
"hospital",
|
|
"department",
|
|
"staff",
|
|
"assigned_to",
|
|
"resolved_by",
|
|
"closed_by",
|
|
"created_by",
|
|
"source",
|
|
"domain",
|
|
"category",
|
|
"subcategory_obj",
|
|
"classification_obj",
|
|
)
|
|
|
|
# Apply RBAC filters
|
|
user = request.user
|
|
if user.is_px_admin():
|
|
pass
|
|
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(department=user.department)
|
|
elif user.hospital:
|
|
queryset = queryset.filter(hospital=user.hospital)
|
|
else:
|
|
queryset = queryset.none()
|
|
|
|
# Apply date range filter
|
|
date_start = request.GET.get("date_start")
|
|
date_end = request.GET.get("date_end")
|
|
|
|
if date_start:
|
|
from datetime import datetime
|
|
|
|
try:
|
|
date_start_dt = datetime.strptime(date_start, "%Y-%m-%d")
|
|
queryset = queryset.filter(created_at__date__gte=date_start_dt)
|
|
except ValueError:
|
|
pass
|
|
|
|
if date_end:
|
|
from datetime import datetime
|
|
|
|
try:
|
|
date_end_dt = datetime.strptime(date_end, "%Y-%m-%d")
|
|
queryset = queryset.filter(created_at__date__lte=date_end_dt)
|
|
except ValueError:
|
|
pass
|
|
|
|
# Apply other filters from request
|
|
status_filter = request.GET.get("status")
|
|
if status_filter:
|
|
queryset = queryset.filter(status=status_filter)
|
|
|
|
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(title__icontains=search_query)
|
|
| Q(description__icontains=search_query)
|
|
| Q(patient__mrn__icontains=search_query)
|
|
)
|
|
|
|
return export_historical_excel(queryset, date_start, date_end)
|
|
|
|
|
|
@login_required
|
|
def complaint_export_monthly_calculations(request):
|
|
"""
|
|
Step 1 — Export monthly calculations report to Excel.
|
|
"""
|
|
from apps.complaints.utils import export_monthly_calculations
|
|
from django.core.exceptions import PermissionDenied
|
|
|
|
if not (request.user.is_px_admin() or request.user.is_hospital_admin()):
|
|
raise PermissionDenied("Only PX Admins and Hospital Admins can export.")
|
|
|
|
year = request.GET.get("year")
|
|
month = request.GET.get("month")
|
|
if not year or not month:
|
|
from django.contrib import messages
|
|
|
|
messages.error(request, "Year and month are required for monthly calculations export.")
|
|
return redirect("complaints:complaint_list")
|
|
|
|
queryset = Complaint.objects.filter(
|
|
created_at__year=int(year),
|
|
created_at__month=int(month),
|
|
)
|
|
|
|
if request.user.is_hospital_admin() and request.user.hospital:
|
|
queryset = queryset.filter(hospital=request.user.hospital)
|
|
elif request.user.is_px_admin() and request.tenant_hospital:
|
|
queryset = queryset.filter(hospital=request.tenant_hospital)
|
|
|
|
queryset = queryset.select_related(
|
|
"hospital",
|
|
"department",
|
|
"main_section",
|
|
"subsection",
|
|
"assigned_to",
|
|
"resolved_by",
|
|
"closed_by",
|
|
"created_by",
|
|
"source",
|
|
).prefetch_related("involved_departments__department")
|
|
|
|
return export_monthly_calculations(queryset, int(year), int(month))
|
|
|
|
|
|
@login_required
|
|
def complaint_export_quarterly_calculations(request):
|
|
"""
|
|
Step 2 — Export quarterly calculations report to Excel.
|
|
"""
|
|
from apps.complaints.utils import export_quarterly_calculations
|
|
from django.core.exceptions import PermissionDenied
|
|
|
|
if not (request.user.is_px_admin() or request.user.is_hospital_admin()):
|
|
raise PermissionDenied("Only PX Admins and Hospital Admins can export.")
|
|
|
|
year = request.GET.get("year")
|
|
quarter = request.GET.get("quarter")
|
|
if not year or not quarter:
|
|
from django.contrib import messages
|
|
|
|
messages.error(request, "Year and quarter are required.")
|
|
return redirect("complaints:complaint_list")
|
|
|
|
year, quarter = int(year), int(quarter)
|
|
quarter_months = {1: (1, 3), 2: (4, 6), 3: (7, 9), 4: (10, 12)}
|
|
start_month, end_month = quarter_months[quarter]
|
|
|
|
queryset = Complaint.objects.filter(
|
|
created_at__year=year,
|
|
created_at__month__gte=start_month,
|
|
created_at__month__lte=end_month,
|
|
)
|
|
|
|
if request.user.is_hospital_admin() and request.user.hospital:
|
|
queryset = queryset.filter(hospital=request.user.hospital)
|
|
elif request.user.is_px_admin() and request.tenant_hospital:
|
|
queryset = queryset.filter(hospital=request.tenant_hospital)
|
|
|
|
return export_quarterly_calculations(queryset, year, quarter)
|
|
|
|
|
|
@login_required
|
|
def complaint_export_yearly_calculations(request):
|
|
"""
|
|
Export yearly calculations report to Excel.
|
|
"""
|
|
from apps.complaints.utils import export_yearly_calculations
|
|
from django.core.exceptions import PermissionDenied
|
|
|
|
if not (request.user.is_px_admin() or request.user.is_hospital_admin()):
|
|
raise PermissionDenied("Only PX Admins and Hospital Admins can export.")
|
|
|
|
year = request.GET.get("year")
|
|
if not year:
|
|
from django.contrib import messages
|
|
|
|
messages.error(request, "Year is required.")
|
|
return redirect("complaints:complaint_list")
|
|
|
|
year = int(year)
|
|
queryset = Complaint.objects.filter(created_at__year=year)
|
|
|
|
if request.user.is_hospital_admin() and request.user.hospital:
|
|
queryset = queryset.filter(hospital=request.user.hospital)
|
|
elif request.user.is_px_admin() and request.tenant_hospital:
|
|
queryset = queryset.filter(hospital=request.tenant_hospital)
|
|
|
|
return export_yearly_calculations(queryset, year)
|
|
|
|
|
|
@hospital_admin_required
|
|
@require_http_methods(["POST"])
|
|
def complaint_bulk_assign(request):
|
|
"""Bulk assign complaints - Admin only"""
|
|
from apps.complaints.utils import bulk_assign_complaints
|
|
import json
|
|
|
|
try:
|
|
data = json.loads(request.body)
|
|
complaint_ids = data.get("complaint_ids", [])
|
|
user_id = data.get("user_id")
|
|
|
|
if not complaint_ids or not user_id:
|
|
return JsonResponse({"success": False, "error": "Missing required fields"}, status=400)
|
|
|
|
result = bulk_assign_complaints(complaint_ids, user_id, request.user)
|
|
|
|
if result["success"]:
|
|
messages.success(request, f"Successfully assigned {result['success_count']} complaints.")
|
|
|
|
return JsonResponse(result)
|
|
|
|
except json.JSONDecodeError:
|
|
return JsonResponse({"success": False, "error": "Invalid JSON"}, status=400)
|
|
except Exception as e:
|
|
return JsonResponse({"success": False, "error": str(e)}, status=500)
|
|
|
|
|
|
@hospital_admin_required
|
|
@require_http_methods(["POST"])
|
|
def complaint_bulk_status(request):
|
|
"""Bulk change complaint status - Admin only"""
|
|
from apps.complaints.utils import bulk_change_status
|
|
import json
|
|
|
|
try:
|
|
data = json.loads(request.body)
|
|
complaint_ids = data.get("complaint_ids", [])
|
|
new_status = data.get("status")
|
|
note = data.get("note", "")
|
|
|
|
if not complaint_ids or not new_status:
|
|
return JsonResponse({"success": False, "error": "Missing required fields"}, status=400)
|
|
|
|
result = bulk_change_status(complaint_ids, new_status, request.user, note)
|
|
|
|
if result["success"]:
|
|
messages.success(request, f"Successfully updated {result['success_count']} complaints.")
|
|
|
|
return JsonResponse(result)
|
|
|
|
except json.JSONDecodeError:
|
|
return JsonResponse({"success": False, "error": "Invalid JSON"}, status=400)
|
|
except Exception as e:
|
|
return JsonResponse({"success": False, "error": str(e)}, status=500)
|
|
|
|
|
|
@hospital_admin_required
|
|
@require_http_methods(["POST"])
|
|
def complaint_bulk_escalate(request):
|
|
"""Bulk escalate complaints - Admin only"""
|
|
from apps.complaints.utils import bulk_escalate_complaints
|
|
import json
|
|
|
|
try:
|
|
data = json.loads(request.body)
|
|
complaint_ids = data.get("complaint_ids", [])
|
|
reason = data.get("reason", "")
|
|
|
|
if not complaint_ids:
|
|
return JsonResponse({"success": False, "error": "No complaints selected"}, status=400)
|
|
|
|
result = bulk_escalate_complaints(complaint_ids, request.user, reason)
|
|
|
|
if result["success"]:
|
|
messages.success(request, f"Successfully escalated {result['success_count']} complaints.")
|
|
|
|
return JsonResponse(result)
|
|
|
|
except json.JSONDecodeError:
|
|
return JsonResponse({"success": False, "error": "Invalid JSON"}, status=400)
|
|
except Exception as e:
|
|
return JsonResponse({"success": False, "error": str(e)}, status=500)
|
|
|
|
|
|
# ============================================================================
|
|
# INQUIRIES VIEWS
|
|
# ============================================================================
|
|
|
|
|
|
@login_required
|
|
def inquiry_list(request):
|
|
"""
|
|
Inquiries list view with filters and pagination.
|
|
"""
|
|
from .models import Inquiry
|
|
|
|
# Base queryset with optimizations
|
|
queryset = Inquiry.objects.select_related("patient", "hospital", "department", "assigned_to", "responded_by")
|
|
|
|
# Apply RBAC filters
|
|
user = request.user
|
|
# Get selected hospital for PX Admins (from middleware)
|
|
selected_hospital = getattr(request, "tenant_hospital", None)
|
|
|
|
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(department=user.department) | Q(outgoing_department=user.department)
|
|
)
|
|
elif user.is_champion() and user.department:
|
|
queryset = queryset.filter(
|
|
Q(department=user.department) | Q(outgoing_department=user.department)
|
|
)
|
|
elif user.is_source_user():
|
|
queryset = queryset.filter(created_by=user)
|
|
elif user.hospital:
|
|
queryset = queryset.filter(hospital=user.hospital)
|
|
else:
|
|
queryset = queryset.none()
|
|
|
|
# Apply filters
|
|
status_filter = request.GET.get("status")
|
|
if status_filter:
|
|
queryset = queryset.filter(status=status_filter)
|
|
|
|
category_filter = request.GET.get("category")
|
|
if category_filter:
|
|
queryset = queryset.filter(category=category_filter)
|
|
|
|
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
|
|
search_query = request.GET.get("search")
|
|
if search_query:
|
|
queryset = queryset.filter(
|
|
Q(subject__icontains=search_query)
|
|
| Q(message__icontains=search_query)
|
|
| Q(contact_name__icontains=search_query)
|
|
| Q(contact_email__icontains=search_query)
|
|
)
|
|
|
|
# Ordering
|
|
order_by = request.GET.get("order_by", "-created_at")
|
|
queryset = queryset.order_by(order_by)
|
|
|
|
# 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)
|
|
|
|
# Statistics
|
|
total_count = queryset.count()
|
|
resolved_count = queryset.filter(status="resolved").count()
|
|
|
|
stats = {
|
|
"total": total_count,
|
|
"open": queryset.filter(status="open").count(),
|
|
"in_progress": queryset.filter(status="in_progress").count(),
|
|
"resolved": resolved_count,
|
|
"resolved_percentage": (resolved_count / total_count * 100) if total_count > 0 else 0,
|
|
"overdue": queryset.filter(is_overdue=True).count(),
|
|
}
|
|
|
|
# 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,
|
|
"inquiries": page_obj.object_list,
|
|
"stats": stats,
|
|
"departments": departments,
|
|
"filters": request.GET,
|
|
}
|
|
|
|
return render(request, "complaints/inquiry_list.html", context)
|
|
|
|
|
|
@login_required
|
|
def inquiry_detail(request, pk):
|
|
"""
|
|
Inquiry detail view with timeline and attachments.
|
|
|
|
Features:
|
|
- Full inquiry details
|
|
- Timeline of all updates
|
|
- Attachments management
|
|
- Workflow actions (assign, status change, add note, respond)
|
|
"""
|
|
from apps.px_sources.models import SourceUser
|
|
|
|
source_user = SourceUser.objects.filter(user=request.user).first()
|
|
base_layout = "layouts/source_user_base.html" if source_user else "layouts/base.html"
|
|
|
|
inquiry = get_object_or_404(
|
|
Inquiry.objects.select_related(
|
|
"patient", "hospital", "department", "location", "main_section", "subsection",
|
|
"assigned_to", "responded_by",
|
|
"outgoing_department", "department_responded_by",
|
|
"taxonomy_domain", "taxonomy_category", "taxonomy_subcategory", "taxonomy_classification",
|
|
).prefetch_related("attachments", "updates__created_by"),
|
|
pk=pk,
|
|
)
|
|
|
|
# Check access
|
|
user = request.user
|
|
if not user.is_px_admin():
|
|
if user.is_hospital_admin() and inquiry.hospital != user.hospital:
|
|
messages.error(request, "You don't have permission to view this inquiry.")
|
|
return redirect("inquiries:inquiry_list")
|
|
elif (
|
|
user.is_champion() or user.is_department_manager()
|
|
) and (
|
|
(inquiry.department and inquiry.department == user.department)
|
|
or (inquiry.outgoing_department and inquiry.outgoing_department == user.department)
|
|
):
|
|
pass
|
|
elif (
|
|
user.is_champion() or user.is_department_manager()
|
|
):
|
|
messages.error(request, "You don't have permission to view this inquiry.")
|
|
return redirect("inquiries:inquiry_list")
|
|
elif user.hospital and inquiry.hospital != user.hospital:
|
|
messages.error(request, "You don't have permission to view this inquiry.")
|
|
return redirect("inquiries:inquiry_list")
|
|
|
|
# Get timeline (updates)
|
|
timeline = inquiry.updates.all().order_by("-created_at")
|
|
stage_timeline = _build_inquiry_stage_timeline(inquiry)
|
|
|
|
# Get attachments
|
|
attachments = inquiry.attachments.all().order_by("-created_at")
|
|
|
|
# Get assignable users
|
|
from apps.core.utils import get_assignable_users
|
|
assignable_users = get_assignable_users(inquiry.hospital)
|
|
|
|
# Get departments for the inquiry's hospital
|
|
hospital_departments = []
|
|
if inquiry.hospital:
|
|
hospital_departments = Department.objects.filter(hospital=inquiry.hospital, status="active").order_by("name")
|
|
|
|
# Status choices for the form
|
|
status_choices = [
|
|
("open", "Open"),
|
|
("in_progress", "In Progress"),
|
|
("contacted", "Contacted"),
|
|
("contacted_no_response", "Contacted, No Response"),
|
|
("resolved", "Resolved"),
|
|
("closed", "Closed"),
|
|
]
|
|
|
|
from apps.rca.models import RootCauseAnalysis as RCA
|
|
from django.contrib.contenttypes.models import ContentType as CT
|
|
|
|
inquiry_ct = CT.objects.get_for_model(Inquiry)
|
|
linked_rcas = RCA.objects.filter(content_type=inquiry_ct, object_id=inquiry.pk, is_deleted=False).select_related(
|
|
"assigned_to", "created_by"
|
|
)
|
|
|
|
context = {
|
|
"inquiry": inquiry,
|
|
"timeline": timeline,
|
|
"stage_timeline": stage_timeline,
|
|
"attachments": attachments,
|
|
"assignable_users": assignable_users,
|
|
"hospital_departments": hospital_departments,
|
|
"status_choices": status_choices,
|
|
"can_edit": user.is_px_admin() or user.is_hospital_admin(),
|
|
"can_respond": (
|
|
user.is_px_admin()
|
|
or user.is_hospital_admin()
|
|
or inquiry.assigned_to == user
|
|
or (
|
|
user.is_champion()
|
|
and user.department
|
|
and user.department in [inquiry.department, inquiry.outgoing_department]
|
|
)
|
|
),
|
|
"can_review_dept_response": user.is_px_admin() or user.is_hospital_admin(),
|
|
"can_send_reminder": user.is_px_admin() or user.is_hospital_admin(),
|
|
"base_layout": base_layout,
|
|
"source_user": source_user,
|
|
"linked_rcas": linked_rcas,
|
|
}
|
|
|
|
return render(request, "complaints/inquiry_detail.html", context)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def inquiry_send_to_staff(request, pk):
|
|
"""Send inquiry explanation request to a specific staff member via token link."""
|
|
from .models import InquiryExplanation
|
|
from apps.organizations.models import Staff
|
|
from apps.notifications.services import NotificationService
|
|
from django.contrib.sites.shortcuts import get_current_site
|
|
import secrets
|
|
|
|
inquiry = get_object_or_404(Inquiry, pk=pk)
|
|
|
|
if not (request.user.is_px_admin() or request.user.is_hospital_admin()):
|
|
messages.error(request, _("You don't have permission to perform this action."))
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
staff_id = request.POST.get("staff_id")
|
|
request_message = request.POST.get("request_message", "").strip()
|
|
|
|
if not staff_id:
|
|
messages.error(request, _("Please select a staff member."))
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
staff = get_object_or_404(Staff, pk=staff_id)
|
|
|
|
token = secrets.token_urlsafe(32)
|
|
explanation = InquiryExplanation.objects.create(
|
|
inquiry=inquiry,
|
|
staff=staff,
|
|
token=token,
|
|
requested_by=request.user,
|
|
request_message=request_message,
|
|
email_sent_at=timezone.now(),
|
|
)
|
|
|
|
from apps.complaints.models import InquirySLAConfig
|
|
sla_config = InquirySLAConfig.get_active_config()
|
|
if sla_config and sla_config.dept_response_sla_hours:
|
|
explanation.sla_due_at = timezone.now() + timezone.timedelta(hours=sla_config.dept_response_sla_hours)
|
|
explanation.save(update_fields=["sla_due_at"])
|
|
|
|
site = get_current_site(request)
|
|
explanation_url = f"https://{site.domain}/inquiries/{inquiry.id}/explain/{token}/"
|
|
|
|
if staff.email:
|
|
NotificationService.send_email(
|
|
to_email=staff.email,
|
|
subject=f"Inquiry Response Requested - #{inquiry.reference_number or inquiry.id}",
|
|
template_name="emails/inquiry_explanation_request",
|
|
context={
|
|
"staff_name": f"{staff.first_name} {staff.last_name}",
|
|
"inquiry_subject": inquiry.subject,
|
|
"inquiry_reference": inquiry.reference_number or str(inquiry.id),
|
|
"inquiry_message": inquiry.message[:500],
|
|
"request_message": request_message,
|
|
"explanation_url": explanation_url,
|
|
"site_name": site.name,
|
|
},
|
|
)
|
|
|
|
messages.success(request, _(f"Response request sent to {staff.first_name} {staff.last_name}"))
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["GET", "POST"])
|
|
def inquiry_create(request):
|
|
"""Create new inquiry"""
|
|
from .models import Inquiry
|
|
from .forms import InquiryForm
|
|
from apps.px_sources.models import SourceUser
|
|
|
|
# Determine base layout based on user type
|
|
source_user = SourceUser.objects.filter(user=request.user).first()
|
|
base_layout = "layouts/source_user_base.html" if source_user else "layouts/base.html"
|
|
|
|
if request.method == "POST":
|
|
form = InquiryForm(request.POST, request=request)
|
|
|
|
if form.is_valid():
|
|
try:
|
|
inquiry = form.save(commit=False)
|
|
|
|
inquiry.category = request.POST.get("category")
|
|
inquiry.created_by = request.user
|
|
|
|
is_outgoing = request.POST.get("is_outgoing") in ("true", "True", "on", "1")
|
|
inquiry.is_outgoing = is_outgoing
|
|
|
|
if is_outgoing:
|
|
outgoing_dept_id = request.POST.get("outgoing_department")
|
|
if outgoing_dept_id:
|
|
from apps.organizations.models import Department
|
|
try:
|
|
inquiry.outgoing_department = Department.objects.get(id=outgoing_dept_id)
|
|
except Department.DoesNotExist:
|
|
pass
|
|
|
|
# Link to patient if auto-lookup found a match
|
|
patient_id = request.POST.get("patient_id")
|
|
if patient_id:
|
|
try:
|
|
from uuid import UUID
|
|
|
|
UUID(patient_id)
|
|
inquiry.patient_id = patient_id
|
|
except (ValueError, AttributeError):
|
|
pass
|
|
|
|
inquiry.save()
|
|
|
|
comm_req_id = request.POST.get("comm_req")
|
|
if comm_req_id:
|
|
try:
|
|
from apps.px_sources.models import CommunicationRequest as CR
|
|
cr = CR.objects.get(pk=comm_req_id)
|
|
cr.link_to_record(inquiry)
|
|
except Exception:
|
|
pass
|
|
|
|
from apps.complaints.tasks import analyze_inquiry_with_ai, notify_staff_new_item
|
|
|
|
analyze_inquiry_with_ai.delay(str(inquiry.id))
|
|
notify_staff_new_item.delay("inquiry", str(inquiry.id))
|
|
|
|
InquiryUpdate.objects.create(
|
|
inquiry=inquiry,
|
|
update_type="note",
|
|
message="Inquiry created.",
|
|
created_by=request.user,
|
|
)
|
|
|
|
AuditService.log_event(
|
|
event_type="inquiry_created",
|
|
description=f"Inquiry created: {inquiry.subject}",
|
|
user=request.user,
|
|
content_object=inquiry,
|
|
metadata={"category": inquiry.category},
|
|
)
|
|
|
|
messages.success(request, f"Inquiry #{inquiry.id} created successfully.")
|
|
return redirect("inquiries:inquiry_detail", pk=inquiry.id)
|
|
|
|
except Exception as e:
|
|
messages.error(request, f"Error creating inquiry: {str(e)}")
|
|
else:
|
|
messages.error(request, f"Please correct the errors: {form.errors}")
|
|
else:
|
|
# GET request - show form
|
|
# Check for hospital parameter from URL (for pre-selection)
|
|
initial_data = {}
|
|
hospital_id = request.GET.get("hospital")
|
|
if hospital_id:
|
|
initial_data["hospital"] = hospital_id
|
|
|
|
communication_request = None
|
|
comm_req_id = request.GET.get("comm_req")
|
|
if comm_req_id:
|
|
try:
|
|
from apps.px_sources.models import CommunicationRequest
|
|
cr_data = CommunicationRequest.get_initial_data(comm_req_id)
|
|
communication_request = cr_data["communication_request"]
|
|
for key in ("hospital", "contact_name", "contact_phone", "message"):
|
|
if key in cr_data["initial"] and cr_data["initial"][key]:
|
|
initial_data[key] = cr_data["initial"][key]
|
|
except Exception:
|
|
pass
|
|
|
|
form = InquiryForm(request=request, initial=initial_data)
|
|
|
|
context = {
|
|
"form": form,
|
|
"base_layout": base_layout,
|
|
"source_user": source_user,
|
|
"communication_request": communication_request,
|
|
}
|
|
|
|
return render(request, "complaints/inquiry_form.html", context)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["GET", "POST"])
|
|
def inquiry_edit(request, pk):
|
|
"""Edit existing inquiry"""
|
|
from .forms import InquiryForm
|
|
|
|
inquiry = get_object_or_404(Inquiry, pk=pk)
|
|
|
|
user = request.user
|
|
if not (user.is_px_admin() or user.is_hospital_admin()):
|
|
messages.error(request, _("You don't have permission to edit this inquiry."))
|
|
return redirect("inquiries:inquiry_detail", pk=inquiry.pk)
|
|
|
|
if inquiry.status == "closed":
|
|
messages.error(request, _("Closed inquiries cannot be edited."))
|
|
return redirect("inquiries:inquiry_detail", pk=inquiry.pk)
|
|
|
|
if request.method == "POST":
|
|
form = InquiryForm(request.POST, request=request, instance=inquiry)
|
|
|
|
if form.is_valid():
|
|
try:
|
|
inquiry = form.save(commit=False)
|
|
|
|
inquiry.category = request.POST.get("category", inquiry.category)
|
|
|
|
is_outgoing = request.POST.get("is_outgoing") in ("true", "True", "on", "1")
|
|
inquiry.is_outgoing = is_outgoing
|
|
|
|
if is_outgoing:
|
|
outgoing_dept_id = request.POST.get("outgoing_department")
|
|
if outgoing_dept_id:
|
|
try:
|
|
inquiry.outgoing_department = Department.objects.get(id=outgoing_dept_id)
|
|
except Department.DoesNotExist:
|
|
pass
|
|
else:
|
|
inquiry.outgoing_department = None
|
|
|
|
inquiry.save()
|
|
|
|
InquiryUpdate.objects.create(
|
|
inquiry=inquiry,
|
|
update_type="note",
|
|
message="Inquiry updated.",
|
|
created_by=request.user,
|
|
)
|
|
|
|
AuditService.log_event(
|
|
event_type="inquiry_updated",
|
|
description=f"Inquiry updated: {inquiry.subject}",
|
|
user=request.user,
|
|
content_object=inquiry,
|
|
)
|
|
|
|
messages.success(request, _("Inquiry updated successfully."))
|
|
return redirect("inquiries:inquiry_detail", pk=inquiry.pk)
|
|
|
|
except Exception as e:
|
|
messages.error(request, f"Error updating inquiry: {str(e)}")
|
|
else:
|
|
messages.error(request, f"Please correct the errors: {form.errors}")
|
|
else:
|
|
initial_data = {}
|
|
if inquiry.location:
|
|
initial_data["location"] = inquiry.location_id
|
|
if inquiry.main_section:
|
|
initial_data["main_section"] = inquiry.main_section_id
|
|
if inquiry.subsection:
|
|
initial_data["subsection"] = inquiry.subsection_id
|
|
form = InquiryForm(request=request, instance=inquiry, initial=initial_data)
|
|
|
|
context = {
|
|
"form": form,
|
|
"inquiry": inquiry,
|
|
}
|
|
|
|
return render(request, "complaints/inquiry_form.html", context)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def inquiry_activate(request, pk):
|
|
"""Activate inquiry by assigning it to current logged-in user"""
|
|
from .models import Inquiry
|
|
|
|
inquiry = get_object_or_404(Inquiry, pk=pk)
|
|
|
|
# Check permission
|
|
user = request.user
|
|
if not (user.is_px_admin() or user.is_hospital_admin()):
|
|
messages.error(request, "You don't have permission to activate inquiries.")
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
# Check if already assigned to current user
|
|
if inquiry.assigned_to == user:
|
|
messages.info(request, "This inquiry is already assigned to you.")
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
old_assignee = inquiry.assigned_to
|
|
old_status = inquiry.status
|
|
|
|
# Update inquiry
|
|
inquiry.assigned_to = user
|
|
inquiry.assigned_at = timezone.now()
|
|
|
|
# Only change status to in_progress if it's currently open
|
|
if inquiry.status == "open":
|
|
inquiry.status = "in_progress"
|
|
|
|
inquiry.save(update_fields=["assigned_to", "assigned_at", "status"])
|
|
|
|
# Create update
|
|
roles_display = ", ".join(user.get_role_names())
|
|
InquiryUpdate.objects.create(
|
|
inquiry=inquiry,
|
|
update_type="assignment",
|
|
message=f"Inquiry activated and assigned to {user.get_full_name()} ({roles_display})",
|
|
created_by=request.user,
|
|
metadata={
|
|
"old_assignee_id": str(old_assignee.id) if old_assignee else None,
|
|
"new_assignee_id": str(user.id),
|
|
"assignee_roles": user.get_role_names(),
|
|
"old_status": old_status,
|
|
"new_status": inquiry.status,
|
|
"activated_by_current_user": True,
|
|
},
|
|
)
|
|
|
|
# Log audit
|
|
AuditService.log_event(
|
|
event_type="inquiry_activated",
|
|
description=f"Inquiry activated by {user.get_full_name()}",
|
|
user=request.user,
|
|
content_object=inquiry,
|
|
metadata={
|
|
"old_assignee_id": str(old_assignee.id) if old_assignee else None,
|
|
"new_assignee_id": str(user.id),
|
|
"old_status": old_status,
|
|
"new_status": inquiry.status,
|
|
},
|
|
)
|
|
|
|
messages.success(request, f"Inquiry activated and assigned to you successfully.")
|
|
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def inquiry_assign(request, pk):
|
|
"""Assign inquiry to user"""
|
|
from .models import Inquiry
|
|
|
|
inquiry = get_object_or_404(Inquiry, pk=pk)
|
|
|
|
user = request.user
|
|
can_assign = (
|
|
user.is_px_admin()
|
|
or user.is_hospital_admin()
|
|
or (user.is_department_manager() and user.department and user.department == inquiry.department)
|
|
)
|
|
if not can_assign:
|
|
messages.error(request, "You don't have permission to assign inquiries.")
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
user_id = request.POST.get("user_id")
|
|
if not user_id:
|
|
messages.error(request, "Please select a user to assign.")
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
try:
|
|
assignee = User.objects.get(id=user_id)
|
|
old_assignee = inquiry.assigned_to
|
|
old_status = inquiry.status
|
|
|
|
inquiry.assigned_to = assignee
|
|
inquiry.assigned_at = timezone.now()
|
|
|
|
reopened = False
|
|
if old_status in ("resolved", "closed"):
|
|
inquiry.status = "in_progress"
|
|
reopened = True
|
|
|
|
inquiry.save()
|
|
|
|
msg = f"{'Reopened and a' if reopened else 'A'}ssigned to {assignee.get_full_name()}"
|
|
if old_assignee:
|
|
msg += f" (reassigned from {old_assignee.get_full_name()})"
|
|
|
|
InquiryUpdate.objects.create(
|
|
inquiry=inquiry,
|
|
update_type="assignment",
|
|
message=msg,
|
|
created_by=request.user,
|
|
)
|
|
|
|
AuditService.log_event(
|
|
event_type="assignment",
|
|
description=f"Inquiry {'reopened and ' if reopened else ''}assigned to {assignee.get_full_name()}",
|
|
user=request.user,
|
|
content_object=inquiry,
|
|
metadata={"old_status": old_status, "reopened": reopened},
|
|
)
|
|
|
|
if inquiry.department:
|
|
try:
|
|
from apps.notifications.settings_service import NotificationServiceWithSettings
|
|
NotificationServiceWithSettings.send_inquiry_department_assigned(inquiry.department, inquiry)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to send department assignment notification: {e}")
|
|
|
|
try:
|
|
from apps.notifications.settings_service import NotificationServiceWithSettings
|
|
NotificationServiceWithSettings.send_inquiry_assigned(inquiry)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to send inquiry assignment notification: {e}")
|
|
|
|
messages.success(request, f"Inquiry {'reopened and ' if reopened else ''}assigned to {assignee.get_full_name()}.")
|
|
|
|
except User.DoesNotExist:
|
|
messages.error(request, "User not found.")
|
|
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def inquiry_change_status(request, pk):
|
|
"""Change inquiry status"""
|
|
from .models import Inquiry
|
|
|
|
inquiry = get_object_or_404(Inquiry, pk=pk)
|
|
|
|
# Check permission
|
|
user = request.user
|
|
if not (user.is_px_admin() or user.is_hospital_admin()):
|
|
messages.error(request, "You don't have permission to change inquiry status.")
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
new_status = request.POST.get("status")
|
|
note = request.POST.get("note", "")
|
|
|
|
if not new_status:
|
|
messages.error(request, "Please select a status.")
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
old_status = inquiry.status
|
|
inquiry.status = new_status
|
|
|
|
# Handle status-specific logic
|
|
if new_status == "resolved" and not inquiry.response:
|
|
messages.error(request, "Please add a response before resolving.")
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
inquiry.save()
|
|
|
|
# Create update
|
|
InquiryUpdate.objects.create(
|
|
inquiry=inquiry,
|
|
update_type="status_change",
|
|
message=note or f"Status changed from {old_status} to {new_status}",
|
|
created_by=request.user,
|
|
old_status=old_status,
|
|
new_status=new_status,
|
|
)
|
|
|
|
# Log audit
|
|
AuditService.log_event(
|
|
event_type="status_change",
|
|
description=f"Inquiry status changed from {old_status} to {new_status}",
|
|
user=request.user,
|
|
content_object=inquiry,
|
|
metadata={"old_status": old_status, "new_status": new_status},
|
|
)
|
|
|
|
messages.success(request, f"Inquiry status changed to {new_status}.")
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def inquiry_reopen(request, pk):
|
|
from .models import Inquiry
|
|
|
|
inquiry = get_object_or_404(Inquiry, pk=pk)
|
|
|
|
user = request.user
|
|
if not (
|
|
user.is_px_admin()
|
|
or user.is_hospital_admin()
|
|
or (user.is_department_manager() and user.department == inquiry.department)
|
|
):
|
|
messages.error(request, "You don't have permission to reopen inquiries.")
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
if inquiry.status not in ("resolved", "closed"):
|
|
messages.error(request, "Only resolved or closed inquiries can be reopened.")
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
old_status = inquiry.status
|
|
note = request.POST.get("note", "Inquiry reopened")
|
|
|
|
inquiry.status = "in_progress"
|
|
inquiry.save()
|
|
|
|
InquiryUpdate.objects.create(
|
|
inquiry=inquiry,
|
|
update_type="status_change",
|
|
message=note or f"Inquiry reopened from {old_status}",
|
|
created_by=user,
|
|
)
|
|
|
|
AuditService.log_event(
|
|
event_type="inquiry_reopened",
|
|
description=f"Inquiry reopened from {old_status} by {user.get_full_name()}",
|
|
user=user,
|
|
content_object=inquiry,
|
|
metadata={"old_status": old_status, "new_status": "in_progress"},
|
|
)
|
|
|
|
try:
|
|
from apps.notifications.settings_service import NotificationServiceWithSettings
|
|
NotificationServiceWithSettings.send_inquiry_reopened(inquiry)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to send inquiry reopened notification: {e}")
|
|
|
|
messages.success(request, "Inquiry reopened successfully.")
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def inquiry_add_note(request, pk):
|
|
"""Add note to inquiry"""
|
|
from .models import Inquiry
|
|
|
|
inquiry = get_object_or_404(Inquiry, pk=pk)
|
|
|
|
note = request.POST.get("note")
|
|
if not note:
|
|
messages.error(request, "Please enter a note.")
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
# Create update
|
|
InquiryUpdate.objects.create(inquiry=inquiry, update_type="note", message=note, created_by=request.user)
|
|
|
|
messages.success(request, "Note added successfully.")
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def inquiry_respond(request, pk):
|
|
"""Respond to inquiry with bilingual support and notification"""
|
|
from .models import Inquiry
|
|
|
|
inquiry = get_object_or_404(Inquiry, pk=pk)
|
|
|
|
user = request.user
|
|
can_respond = (
|
|
user.is_px_admin()
|
|
or user.is_hospital_admin()
|
|
or inquiry.assigned_to == user
|
|
or (
|
|
user.is_champion()
|
|
and user.department
|
|
and user.department in [inquiry.department, inquiry.outgoing_department]
|
|
)
|
|
)
|
|
if not can_respond:
|
|
messages.error(request, "You don't have permission to respond to inquiries.")
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
response_en = request.POST.get("response_en", "").strip()
|
|
response_ar = request.POST.get("response_ar", "").strip()
|
|
response = response_en or response_ar
|
|
|
|
if not response:
|
|
messages.error(request, "Please enter a response in at least one language.")
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
inquiry.response = response
|
|
inquiry.response_en = response_en
|
|
inquiry.response_ar = response_ar
|
|
inquiry.responded_at = timezone.now()
|
|
inquiry.responded_by = request.user
|
|
inquiry.status = "resolved"
|
|
inquiry.save()
|
|
|
|
InquiryUpdate.objects.create(
|
|
inquiry=inquiry, update_type="response", message="Response sent", created_by=request.user
|
|
)
|
|
|
|
AuditService.log_event(
|
|
event_type="inquiry_responded",
|
|
description=f"Inquiry responded to: {inquiry.subject}",
|
|
user=request.user,
|
|
content_object=inquiry,
|
|
)
|
|
|
|
try:
|
|
from apps.notifications.settings_service import NotificationServiceWithSettings
|
|
NotificationServiceWithSettings.send_inquiry_resolved(inquiry)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to send inquiry resolved notification: {e}")
|
|
|
|
if inquiry.response_sent_at is None:
|
|
inquiry.response_sent_at = timezone.now()
|
|
inquiry.save(update_fields=["response_sent_at"])
|
|
|
|
try:
|
|
from apps.notifications.services import NotificationService
|
|
|
|
if inquiry.contact_phone:
|
|
sms_text = response_en if response_en else response_ar
|
|
if len(sms_text) > 200:
|
|
sms_text = sms_text[:197] + "..."
|
|
NotificationService.send_sms(
|
|
phone=inquiry.contact_phone,
|
|
message=f"PX360: Your inquiry #{inquiry.reference_number} has been responded to. {sms_text}",
|
|
related_object=inquiry,
|
|
metadata={"notification_type": "inquiry_response_sent"},
|
|
)
|
|
|
|
if inquiry.contact_email:
|
|
email_subject = f"PX360: Response to Your Inquiry #{inquiry.reference_number}"
|
|
email_body_parts = []
|
|
if response_en:
|
|
email_body_parts.append(f"Response (English):\n\n{response_en}")
|
|
if response_ar:
|
|
email_body_parts.append(f"الرد (العربية):\n\n{response_ar}")
|
|
email_body = "\n\n---\n\n".join(email_body_parts)
|
|
email_body += f"\n\n---\nReference: {inquiry.reference_number}\nHospital: {inquiry.hospital.name}\n\nThis is an automated message from PX 360."
|
|
|
|
NotificationService.send_email(
|
|
email=inquiry.contact_email,
|
|
subject=email_subject,
|
|
message=email_body,
|
|
related_object=inquiry,
|
|
metadata={"notification_type": "inquiry_response_email"},
|
|
)
|
|
|
|
InquiryUpdate.objects.create(
|
|
inquiry=inquiry,
|
|
update_type="communication",
|
|
message=f"Response notification sent to inquirer (phone: {bool(inquiry.contact_phone)}, email: {bool(inquiry.contact_email)})",
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Failed to send inquiry response notification: {e}")
|
|
|
|
messages.success(request, "Response sent successfully.")
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def inquiry_transfer_to_department(request, pk):
|
|
from .models import Inquiry, InquiryUpdate
|
|
|
|
inquiry = get_object_or_404(Inquiry, pk=pk)
|
|
|
|
user = request.user
|
|
if not (
|
|
user.is_px_admin()
|
|
or user.is_hospital_admin()
|
|
or user.is_department_manager()
|
|
or user.is_px_management()
|
|
):
|
|
messages.error(request, _("You don't have permission to transfer inquiries to departments."))
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
department_id = request.POST.get("department_id")
|
|
if not department_id:
|
|
messages.error(request, _("Please select a department."))
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
from apps.organizations.models import Department
|
|
try:
|
|
department = Department.objects.get(pk=department_id, status="active")
|
|
except Department.DoesNotExist:
|
|
messages.error(request, _("Department not found."))
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
recipient_type = request.POST.get("recipient_type", "staff")
|
|
|
|
note_en = request.POST.get("note_en", "").strip()
|
|
note_ar = request.POST.get("note_ar", "").strip()
|
|
|
|
note_parts = []
|
|
if note_en:
|
|
note_parts.append(note_en)
|
|
if note_ar:
|
|
note_parts.append(note_ar)
|
|
combined_note = "\n\n".join(note_parts)
|
|
|
|
inquiry.outgoing_department = department
|
|
inquiry.transferred_at = timezone.now()
|
|
inquiry.transferred_by = user
|
|
inquiry.transferred_to_department = department
|
|
inquiry.transfer_count = (inquiry.transfer_count or 0) + 1
|
|
|
|
sla_config = inquiry.get_sla_config()
|
|
if sla_config and sla_config.dept_response_hours:
|
|
from datetime import timedelta
|
|
inquiry.dept_response_sla_due_at = timezone.now() + timedelta(hours=sla_config.dept_response_hours)
|
|
inquiry.dept_response_is_overdue = False
|
|
inquiry.dept_response_reminder_sent_at = None
|
|
inquiry.dept_response_second_reminder_sent_at = None
|
|
inquiry.dept_response_escalated_at = None
|
|
|
|
if inquiry.status in ("open",):
|
|
inquiry.status = "contacted"
|
|
inquiry.save()
|
|
|
|
InquiryUpdate.objects.create(
|
|
inquiry=inquiry,
|
|
update_type="transferred_to_department",
|
|
message=combined_note or f"Inquiry transferred to {department.get_localized_name()} for response",
|
|
created_by=user,
|
|
)
|
|
|
|
try:
|
|
from apps.notifications.settings_service import NotificationServiceWithSettings
|
|
|
|
NotificationServiceWithSettings.send_inquiry_department_assigned(
|
|
department, inquiry, context_note_en=note_en, context_note_ar=note_ar,
|
|
recipient_type=recipient_type,
|
|
)
|
|
|
|
if department.respondent and department.respondent.user and department.respondent.user.email:
|
|
from apps.notifications.services import NotificationService
|
|
NotificationService.send_email(
|
|
email=department.respondent.user.email,
|
|
subject=f"Inquiry #{inquiry.reference_number} - Response Required",
|
|
message=f"An inquiry has been transferred to your department ({department.get_localized_name()}) for response. Subject: {inquiry.subject}. Please submit your response before the deadline: {inquiry.dept_response_sla_due_at}",
|
|
related_object=inquiry,
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to send department notification: {e}")
|
|
|
|
if recipient_type == "department_email":
|
|
messages.success(request, _("Inquiry transferred to %(dept)s department email.") % {"dept": department.get_localized_name()})
|
|
else:
|
|
messages.success(request, _("Inquiry transferred to %(dept)s. Department respondents have been notified.") % {"dept": department.get_localized_name()})
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def inquiry_send_to(request, pk):
|
|
"""
|
|
Unified AJAX endpoint to send inquiry to either a person or department.
|
|
"""
|
|
from django.http import JsonResponse
|
|
from apps.notifications.services import NotificationService
|
|
|
|
inquiry = get_object_or_404(Inquiry, pk=pk)
|
|
user = request.user
|
|
|
|
# Check permission
|
|
if not (
|
|
user.is_px_admin()
|
|
or user.is_hospital_admin()
|
|
or user.is_department_manager()
|
|
or user.is_px_management()
|
|
):
|
|
return JsonResponse({
|
|
"success": False,
|
|
"error": str(_("You don't have permission to send this inquiry.")),
|
|
}, status=403)
|
|
|
|
recipient_type = request.POST.get("recipient_type", "department")
|
|
note = request.POST.get("note", "").strip()
|
|
|
|
try:
|
|
if recipient_type == "person":
|
|
person_id = request.POST.get("person_id")
|
|
if not person_id:
|
|
return JsonResponse({
|
|
"success": False,
|
|
"error": str(_("Please select a person.")),
|
|
}, status=400)
|
|
|
|
try:
|
|
person = User.objects.get(id=person_id)
|
|
except User.DoesNotExist:
|
|
return JsonResponse({
|
|
"success": False,
|
|
"error": str(_("User not found.")),
|
|
}, status=400)
|
|
|
|
# Assign inquiry to person
|
|
inquiry.assigned_to = person
|
|
inquiry.assigned_at = timezone.now()
|
|
inquiry.save(update_fields=["assigned_to", "assigned_at"])
|
|
|
|
# Send notification
|
|
if person.email:
|
|
NotificationService.send_email(
|
|
email=person.email,
|
|
subject=f"Inquiry Assigned - {inquiry.reference_number}",
|
|
message=f"You have been assigned to inquiry #{inquiry.reference_number}.",
|
|
html_message=f"""
|
|
<p>You have been assigned to inquiry <strong>#{inquiry.reference_number}</strong>.</p>
|
|
<p><strong>Subject:</strong> {inquiry.subject or 'N/A'}</p>
|
|
{f'<p><strong>Note:</strong> {note}</p>' if note else ''}
|
|
<p><a href="https://{request.get_host()}/inquiries/{inquiry.pk}/">View Inquiry</a></p>
|
|
""",
|
|
related_object=inquiry,
|
|
)
|
|
|
|
message = f"Inquiry sent to {person.get_full_name()}."
|
|
|
|
else: # department
|
|
department_id = request.POST.get("department_id")
|
|
if not department_id:
|
|
return JsonResponse({
|
|
"success": False,
|
|
"error": str(_("Please select a department.")),
|
|
}, status=400)
|
|
|
|
try:
|
|
department = Department.objects.get(pk=department_id, status="active")
|
|
except Department.DoesNotExist:
|
|
return JsonResponse({
|
|
"success": False,
|
|
"error": str(_("Department not found.")),
|
|
}, status=400)
|
|
|
|
# Transfer to department
|
|
inquiry.outgoing_department = department
|
|
inquiry.transferred_at = timezone.now()
|
|
inquiry.transferred_by = user
|
|
inquiry.transferred_to_department = department
|
|
inquiry.transfer_count = (inquiry.transfer_count or 0) + 1
|
|
|
|
sla_config = inquiry.get_sla_config()
|
|
if sla_config and sla_config.dept_response_hours:
|
|
from datetime import timedelta
|
|
inquiry.dept_response_sla_due_at = timezone.now() + timedelta(hours=sla_config.dept_response_hours)
|
|
inquiry.dept_response_is_overdue = False
|
|
inquiry.dept_response_reminder_sent_at = None
|
|
inquiry.dept_response_second_reminder_sent_at = None
|
|
inquiry.dept_response_escalated_at = None
|
|
|
|
message = f"Inquiry sent to {department.get_localized_name()}."
|
|
|
|
# Change status to contacted if open
|
|
if inquiry.status in ("open",):
|
|
inquiry.status = "contacted"
|
|
|
|
inquiry.save()
|
|
|
|
InquiryUpdate.objects.create(
|
|
inquiry=inquiry,
|
|
update_type="transferred_to_department" if recipient_type == "department" else "assigned",
|
|
message=note or f"Inquiry sent to {recipient_type}",
|
|
created_by=user,
|
|
)
|
|
|
|
# Send department notification if applicable
|
|
if recipient_type == "department":
|
|
try:
|
|
from apps.notifications.settings_service import NotificationServiceWithSettings
|
|
NotificationServiceWithSettings.send_inquiry_department_assigned(
|
|
department, inquiry, context_note_en=note, context_note_ar="",
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to send department notification: {e}")
|
|
|
|
return JsonResponse({
|
|
"success": True,
|
|
"message": message,
|
|
})
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error in inquiry_send_to: {str(e)}")
|
|
return JsonResponse({
|
|
"success": False,
|
|
"error": str(_("An error occurred while sending the inquiry.")),
|
|
}, status=500)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["GET", "POST"])
|
|
def inquiry_department_response(request, pk):
|
|
from .models import Inquiry
|
|
|
|
inquiry = get_object_or_404(Inquiry, pk=pk)
|
|
|
|
user = request.user
|
|
if not (
|
|
user.is_px_admin()
|
|
or user.is_hospital_admin()
|
|
or (user.is_champion() and (
|
|
inquiry.department == user.department
|
|
or inquiry.outgoing_department == user.department
|
|
))
|
|
):
|
|
messages.error(request, "You don't have permission to respond to this inquiry.")
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
if request.method == "POST":
|
|
response_en = request.POST.get("response_en", "").strip()
|
|
response_ar = request.POST.get("response_ar", "").strip()
|
|
response = response_en or response_ar
|
|
|
|
if not response:
|
|
messages.error(request, "Please enter a response in at least one language.")
|
|
return redirect("inquiries:inquiry_department_response", pk=pk)
|
|
|
|
inquiry.department_response_en = response_en
|
|
inquiry.department_response_ar = response_ar
|
|
inquiry.department_responded_at = timezone.now()
|
|
inquiry.department_responded_by = request.user
|
|
inquiry.dept_response_is_overdue = False
|
|
inquiry.dept_response_acceptance_status = "pending"
|
|
inquiry.dept_response_accepted_by = None
|
|
inquiry.dept_response_accepted_at = None
|
|
inquiry.dept_response_acceptance_notes = ""
|
|
inquiry.save()
|
|
|
|
try:
|
|
from apps.core.ai_service import AIService
|
|
import json
|
|
|
|
prompt = f"""You are an AI assistant for a hospital patient experience system. Summarize the following department response to a patient inquiry in 2-3 concise sentences.
|
|
|
|
Inquiry subject: {inquiry.subject}
|
|
Inquiry message: {inquiry.message[:500]}
|
|
Department response: {response[:500]}
|
|
|
|
Generate a JSON response with:
|
|
- "summary_en": A brief summary of the department's response in English (2-3 sentences)
|
|
- "summary_ar": The same summary translated to Modern Standard Arabic"""
|
|
|
|
result = AIService.chat_completion(
|
|
prompt=prompt,
|
|
response_format="json_object",
|
|
)
|
|
parsed = json.loads(result)
|
|
inquiry.department_response_summary_en = parsed.get("summary_en", "")
|
|
inquiry.department_response_summary_ar = parsed.get("summary_ar", "")
|
|
inquiry.save(update_fields=["department_response_summary_en", "department_response_summary_ar"])
|
|
except Exception as e:
|
|
logger.warning(f"AI summary of department response failed: {e}")
|
|
|
|
InquiryUpdate.objects.create(
|
|
inquiry=inquiry,
|
|
update_type="department_response",
|
|
message=f"Department response submitted by {user.get_full_name()}",
|
|
created_by=user,
|
|
)
|
|
|
|
AuditService.log_event(
|
|
event_type="inquiry_department_response",
|
|
description=f"Department response submitted for inquiry: {inquiry.subject}",
|
|
user=user,
|
|
content_object=inquiry,
|
|
)
|
|
|
|
messages.success(request, "Department response submitted successfully.")
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
context = {
|
|
"inquiry": inquiry,
|
|
}
|
|
return render(request, "complaints/inquiry_department_response.html", context)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def inquiry_review_dept_response(request, pk):
|
|
from .models import Inquiry, InquiryUpdate
|
|
|
|
inquiry = get_object_or_404(Inquiry, pk=pk)
|
|
|
|
user = request.user
|
|
if not (user.is_px_admin() or user.is_hospital_admin()):
|
|
messages.error(request, "You don't have permission to review department responses.")
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
if not inquiry.department_responded_at:
|
|
messages.error(request, "No department response to review.")
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
status = request.POST.get("acceptance_status")
|
|
if status not in ("acceptable", "not_acceptable"):
|
|
messages.error(request, "Invalid acceptance status.")
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
notes = request.POST.get("acceptance_notes", "").strip()
|
|
|
|
inquiry.dept_response_acceptance_status = status
|
|
inquiry.dept_response_accepted_by = user
|
|
inquiry.dept_response_accepted_at = timezone.now()
|
|
inquiry.dept_response_acceptance_notes = notes
|
|
inquiry.save(
|
|
update_fields=[
|
|
"dept_response_acceptance_status",
|
|
"dept_response_accepted_by",
|
|
"dept_response_accepted_at",
|
|
"dept_response_acceptance_notes",
|
|
]
|
|
)
|
|
|
|
InquiryUpdate.objects.create(
|
|
inquiry=inquiry,
|
|
update_type="note",
|
|
message=f"Department response marked as {status} by {user.get_full_name()}. {notes}",
|
|
created_by=user,
|
|
)
|
|
|
|
AuditService.log_event(
|
|
event_type="inquiry_dept_response_review",
|
|
description=f"Department response for inquiry {inquiry.reference_number} marked as {status}",
|
|
user=user,
|
|
content_object=inquiry,
|
|
)
|
|
|
|
messages.success(request, f"Department response marked as {status}.")
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def inquiry_send_dept_response_reminder(request, pk):
|
|
from .models import Inquiry, InquiryUpdate
|
|
|
|
inquiry = get_object_or_404(Inquiry, pk=pk)
|
|
|
|
user = request.user
|
|
if not (user.is_px_admin() or user.is_hospital_admin()):
|
|
messages.error(request, "You don't have permission to send reminders.")
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
if inquiry.department_responded_at:
|
|
messages.warning(request, "Department has already responded.")
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
dept = inquiry.outgoing_department or inquiry.transferred_to_department
|
|
if not dept:
|
|
messages.error(request, "No department assigned.")
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
reminder_type = request.POST.get("reminder_type", "first")
|
|
|
|
try:
|
|
from apps.notifications.services import NotificationService
|
|
|
|
recipients = []
|
|
if dept.respondent and dept.respondent.user and dept.respondent.user.email:
|
|
recipients.append(dept.respondent.user)
|
|
|
|
for recipient in recipients:
|
|
NotificationService.send_email(
|
|
email=recipient.email,
|
|
subject=f"Reminder: Inquiry #{inquiry.reference_number} - Response Required",
|
|
message=f"This is a reminder that inquiry #{inquiry.reference_number} is awaiting your department's response. Please submit your response as soon as possible.",
|
|
related_object=inquiry,
|
|
)
|
|
|
|
if reminder_type == "first":
|
|
inquiry.dept_response_reminder_sent_at = timezone.now()
|
|
else:
|
|
inquiry.dept_response_second_reminder_sent_at = timezone.now()
|
|
inquiry.save()
|
|
|
|
InquiryUpdate.objects.create(
|
|
inquiry=inquiry,
|
|
update_type="note",
|
|
message=f"Manual {reminder_type} reminder sent by {user.get_full_name()}",
|
|
created_by=user,
|
|
)
|
|
|
|
messages.success(request, f"Reminder sent to {len(recipients)} recipient(s).")
|
|
except Exception as e:
|
|
logger.error(f"Failed to send reminder: {e}")
|
|
messages.error(request, "Failed to send reminder.")
|
|
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
def inquiry_export_incoming(request):
|
|
"""Export incoming inquiries report."""
|
|
from .utils import export_inquiries_report
|
|
|
|
year = request.GET.get("year")
|
|
month = request.GET.get("month")
|
|
if not year or not month:
|
|
messages.error(request, "Year and month are required.")
|
|
return redirect("inquiries:inquiry_list")
|
|
|
|
queryset = Inquiry.objects.filter(created_at__year=int(year), created_at__month=int(month), is_outgoing=False)
|
|
if request.user.is_hospital_admin() and request.user.hospital:
|
|
queryset = queryset.filter(hospital=request.user.hospital)
|
|
elif request.user.is_px_admin() and request.tenant_hospital:
|
|
queryset = queryset.filter(hospital=request.tenant_hospital)
|
|
|
|
return export_inquiries_report(queryset, int(year), int(month), is_outgoing=False)
|
|
|
|
|
|
@login_required
|
|
def inquiry_export_outgoing(request):
|
|
"""Export outgoing inquiries report."""
|
|
from .utils import export_inquiries_report
|
|
|
|
year = request.GET.get("year")
|
|
month = request.GET.get("month")
|
|
if not year or not month:
|
|
messages.error(request, "Year and month are required.")
|
|
return redirect("inquiries:inquiry_list")
|
|
|
|
queryset = Inquiry.objects.filter(created_at__year=int(year), created_at__month=int(month), is_outgoing=True)
|
|
if request.user.is_hospital_admin() and request.user.hospital:
|
|
queryset = queryset.filter(hospital=request.user.hospital)
|
|
elif request.user.is_px_admin() and request.tenant_hospital:
|
|
queryset = queryset.filter(hospital=request.tenant_hospital)
|
|
|
|
return export_inquiries_report(queryset, int(year), int(month), is_outgoing=True)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def inquiry_update_contact_stage(request, pk):
|
|
"""Update a contact tracking stage on the inquiry."""
|
|
inquiry = get_object_or_404(Inquiry, pk=pk)
|
|
|
|
user = request.user
|
|
if not (user.is_px_admin() or user.is_hospital_admin() or inquiry.assigned_to == user):
|
|
messages.error(request, _("You don't have permission to update contact tracking."))
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
stage = request.POST.get("stage")
|
|
import datetime as _dt
|
|
|
|
if stage == "contacted_nr":
|
|
date_str = request.POST.get("contacted_nr_date")
|
|
time_str = request.POST.get("contacted_nr_time")
|
|
duration_str = request.POST.get("contacted_nr_duration")
|
|
if date_str:
|
|
inquiry.contacted_nr_at = _dt.datetime.strptime(date_str, "%Y-%m-%d")
|
|
if time_str:
|
|
inquiry.contacted_nr_time = _dt.datetime.strptime(time_str, "%H:%M").time()
|
|
if duration_str:
|
|
parts = duration_str.split(":")
|
|
inquiry.contacted_nr_duration = _dt.timedelta(
|
|
hours=int(parts[0]), minutes=int(parts[1] if len(parts) > 1 else 0),
|
|
seconds=int(parts[2] if len(parts) > 2 else 0),
|
|
)
|
|
inquiry.contacted_nr_by = user
|
|
if inquiry.status == "open":
|
|
inquiry.status = "contacted_no_response"
|
|
|
|
elif stage == "under_process":
|
|
date_str = request.POST.get("under_process_date")
|
|
time_str = request.POST.get("under_process_time")
|
|
duration_str = request.POST.get("under_process_duration")
|
|
if date_str:
|
|
inquiry.under_process_at = _dt.datetime.strptime(date_str, "%Y-%m-%d")
|
|
if time_str:
|
|
inquiry.under_process_time = _dt.datetime.strptime(time_str, "%H:%M").time()
|
|
if duration_str:
|
|
parts = duration_str.split(":")
|
|
inquiry.under_process_duration = _dt.timedelta(
|
|
hours=int(parts[0]), minutes=int(parts[1] if len(parts) > 1 else 0),
|
|
seconds=int(parts[2] if len(parts) > 2 else 0),
|
|
)
|
|
inquiry.under_process_by = user
|
|
if inquiry.status in ("open", "contacted_no_response"):
|
|
inquiry.status = "in_progress"
|
|
|
|
elif stage == "contacted":
|
|
date_str = request.POST.get("contacted_date")
|
|
time_str = request.POST.get("contacted_time")
|
|
duration_str = request.POST.get("contacted_duration")
|
|
if date_str:
|
|
inquiry.contacted_at = _dt.datetime.strptime(date_str, "%Y-%m-%d")
|
|
if time_str:
|
|
inquiry.contacted_time = _dt.datetime.strptime(time_str, "%H:%M").time()
|
|
if duration_str:
|
|
parts = duration_str.split(":")
|
|
inquiry.contacted_duration = _dt.timedelta(
|
|
hours=int(parts[0]), minutes=int(parts[1] if len(parts) > 1 else 0),
|
|
seconds=int(parts[2] if len(parts) > 2 else 0),
|
|
)
|
|
inquiry.contacted_by = user
|
|
if inquiry.status in ("open", "in_progress", "contacted_no_response"):
|
|
inquiry.status = "contacted"
|
|
|
|
elif stage == "notes":
|
|
inquiry.staff_notes = request.POST.get("staff_notes", inquiry.staff_notes)
|
|
if user.is_px_admin() or user.is_hospital_admin():
|
|
inquiry.supervisor_notes = request.POST.get("supervisor_notes", inquiry.supervisor_notes)
|
|
|
|
elif stage == "timeline_sla":
|
|
inquiry.timeline_sla = request.POST.get("timeline_sla", inquiry.timeline_sla)
|
|
|
|
inquiry.save()
|
|
|
|
stage_labels = {
|
|
"contacted_nr": "Contacted No Response",
|
|
"under_process": "Under Process",
|
|
"contacted": "Contacted",
|
|
"notes": "Notes",
|
|
"timeline_sla": "Timeline SLA",
|
|
}
|
|
InquiryUpdate.objects.create(
|
|
inquiry=inquiry,
|
|
update_type="note",
|
|
message=f"Contact tracking updated: {stage_labels.get(stage, stage)}",
|
|
created_by=user,
|
|
)
|
|
messages.success(request, _("Contact tracking updated."))
|
|
return redirect("inquiries:inquiry_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
def complaints_analytics(request):
|
|
"""
|
|
Complaints analytics dashboard.
|
|
"""
|
|
from .analytics import ComplaintAnalytics
|
|
|
|
user = request.user
|
|
hospital = None
|
|
|
|
# Apply RBAC
|
|
if user.is_px_admin() and request.tenant_hospital:
|
|
hospital = request.tenant_hospital
|
|
elif not user.is_px_admin() and user.hospital:
|
|
hospital = user.hospital
|
|
|
|
# Get date range from request
|
|
date_range = int(request.GET.get("date_range", 30))
|
|
|
|
# Get analytics data
|
|
dashboard_summary = ComplaintAnalytics.get_dashboard_summary(hospital)
|
|
trends = ComplaintAnalytics.get_complaint_trends(hospital, date_range)
|
|
sla_compliance = ComplaintAnalytics.get_sla_compliance(hospital, date_range)
|
|
resolution_rate = ComplaintAnalytics.get_resolution_rate(hospital, date_range)
|
|
top_categories = ComplaintAnalytics.get_top_categories(hospital, date_range)
|
|
overdue_complaints = ComplaintAnalytics.get_overdue_complaints(hospital)
|
|
|
|
context = {
|
|
"dashboard_summary": dashboard_summary,
|
|
"trends": trends,
|
|
"sla_compliance": sla_compliance,
|
|
"resolution_rate": resolution_rate,
|
|
"top_categories": top_categories,
|
|
"overdue_complaints": overdue_complaints,
|
|
"date_range": date_range,
|
|
}
|
|
|
|
return render(request, "complaints/analytics.html", context)
|
|
|
|
|
|
# ============================================================================
|
|
# PUBLIC COMPLAINT FORM (No Authentication Required)
|
|
# ============================================================================
|
|
|
|
|
|
def public_complaint_submit(request):
|
|
"""
|
|
Public complaint submission form (accessible without login).
|
|
Handles both GET (show form) and POST (submit complaint).
|
|
|
|
Updated to match form structure:
|
|
- Location hierarchy: Location, Main Section, Subsection (3-level dropdowns)
|
|
- Complainant information: name, email, mobile, relation to patient
|
|
- Patient information: name, national ID, incident date
|
|
- Staff reference: staff name
|
|
- Complaint details and expected result
|
|
"""
|
|
if request.method == "POST":
|
|
try:
|
|
# Get form data from public complaint form
|
|
complainant_name = request.POST.get("complainant_name")
|
|
email = request.POST.get("email")
|
|
mobile_number = request.POST.get("mobile_number")
|
|
relation_to_patient = request.POST.get("relation_to_patient")
|
|
hospital_id = request.POST.get("hospital")
|
|
location_id = request.POST.get("location")
|
|
main_section_id = request.POST.get("main_section")
|
|
subsection_id = request.POST.get("subsection")
|
|
patient_name = request.POST.get("patient_name")
|
|
national_id = request.POST.get("national_id")
|
|
incident_date = request.POST.get("incident_date")
|
|
staff_name = request.POST.get("staff_name")
|
|
complaint_details = request.POST.get("complaint_details")
|
|
expected_result = request.POST.get("expected_result")
|
|
|
|
# Validate required fields
|
|
errors = []
|
|
if not complainant_name:
|
|
errors.append(_("Complainant name is required"))
|
|
if not mobile_number:
|
|
errors.append(_("Mobile number is required"))
|
|
if not hospital_id:
|
|
errors.append(_("Hospital is required"))
|
|
if not location_id:
|
|
errors.append(_("Location is required"))
|
|
if not main_section_id:
|
|
errors.append(_("Main section is required"))
|
|
if not complaint_details:
|
|
errors.append(_("Complaint details are required"))
|
|
if incident_date:
|
|
from datetime import datetime as dt, date
|
|
try:
|
|
parsed_date = dt.strptime(incident_date, "%Y-%m-%d").date()
|
|
if parsed_date > date.today():
|
|
errors.append(_("Incident date cannot be in the future"))
|
|
except (ValueError, TypeError):
|
|
errors.append(_("Invalid incident date format"))
|
|
|
|
if errors:
|
|
if request.headers.get("x-requested-with") == "XMLHttpRequest":
|
|
return JsonResponse({"success": False, "errors": errors}, status=400)
|
|
else:
|
|
messages.error(request, _("Please fill in all required fields."))
|
|
return render(
|
|
request,
|
|
"complaints/public_complaint_form.html",
|
|
{
|
|
"hospitals": Hospital.objects.filter(status="active").order_by("name"),
|
|
},
|
|
)
|
|
|
|
# Get hospital
|
|
hospital = Hospital.objects.get(id=hospital_id)
|
|
|
|
# Get location hierarchy objects
|
|
from apps.organizations.models import Location, MainSection, SubSection
|
|
|
|
location = Location.objects.get(id=location_id)
|
|
main_section = MainSection.objects.get(id=main_section_id)
|
|
subsection = None
|
|
if subsection_id:
|
|
subsection = SubSection.objects.get(internal_id=subsection_id)
|
|
|
|
# Generate unique reference number: CMP-YYYYMMDD-XXXXX
|
|
import uuid
|
|
from datetime import datetime
|
|
|
|
today = datetime.now().strftime("%Y%m%d")
|
|
random_suffix = str(uuid.uuid4().int)[:6]
|
|
reference_number = f"CMP-{today}-{random_suffix}"
|
|
|
|
# Create complaint with location hierarchy and all form fields
|
|
complaint = Complaint.objects.create(
|
|
patient=None, # No patient record for public submissions
|
|
hospital=hospital,
|
|
department=None, # AI will determine this
|
|
title="Complaint", # AI will generate title
|
|
description=complaint_details,
|
|
severity="medium", # Default, AI will update
|
|
priority="medium", # Default, AI will update
|
|
status="open", # Start as open
|
|
complaint_source_type=ComplaintSourceType.EXTERNAL, source=PXSource.objects.filter(name_en="Public Form").first(),
|
|
reference_number=reference_number,
|
|
# Location hierarchy (FK relationships)
|
|
location=location,
|
|
main_section=main_section,
|
|
subsection=subsection,
|
|
# Complainant information
|
|
contact_name=complainant_name,
|
|
contact_phone=mobile_number,
|
|
contact_email=email,
|
|
# Store additional information in metadata
|
|
metadata={
|
|
"relation_to_patient": relation_to_patient,
|
|
"patient_name": patient_name,
|
|
"national_id": national_id,
|
|
"incident_date": incident_date,
|
|
"staff_name": staff_name,
|
|
"expected_result": expected_result,
|
|
},
|
|
)
|
|
|
|
# Create initial update
|
|
ComplaintUpdate.objects.create(
|
|
complaint=complaint,
|
|
update_type="note",
|
|
message="Complaint submitted via public form. AI analysis running in background.",
|
|
)
|
|
|
|
# Trigger AI analysis in the background using Celery
|
|
from .tasks import analyze_complaint_with_ai, notify_staff_new_item
|
|
|
|
analyze_complaint_with_ai.delay(str(complaint.id))
|
|
notify_staff_new_item.delay("complaint", str(complaint.id))
|
|
|
|
# If form was submitted via AJAX, return JSON
|
|
if request.headers.get("x-requested-with") == "XMLHttpRequest":
|
|
return JsonResponse(
|
|
{
|
|
"success": True,
|
|
"reference_number": reference_number,
|
|
"message": "Complaint submitted successfully. AI has analyzed and classified your complaint.",
|
|
}
|
|
)
|
|
|
|
# Otherwise, redirect to success page
|
|
return redirect("complaints:public_complaint_success", reference=reference_number)
|
|
|
|
except Hospital.DoesNotExist:
|
|
error_msg = _("Selected hospital not found.")
|
|
if request.headers.get("x-requested-with") == "XMLHttpRequest":
|
|
return JsonResponse({"success": False, "message": error_msg}, status=400)
|
|
messages.error(request, error_msg)
|
|
return render(
|
|
request,
|
|
"complaints/public_complaint_form.html",
|
|
{
|
|
"hospitals": Hospital.objects.filter(status="active").order_by("name"),
|
|
},
|
|
)
|
|
except Exception as e:
|
|
import traceback
|
|
|
|
traceback.print_exc()
|
|
# If AJAX, return error JSON
|
|
if request.headers.get("x-requested-with") == "XMLHttpRequest":
|
|
return JsonResponse({"success": False, "message": str(e)}, status=400)
|
|
|
|
# Otherwise, show error message
|
|
return render(
|
|
request,
|
|
"complaints/public_complaint_form.html",
|
|
{"hospitals": Hospital.objects.filter(status="active").order_by("name"), "error": str(e)},
|
|
)
|
|
|
|
# GET request - show form
|
|
return render(
|
|
request,
|
|
"complaints/public_complaint_form.html",
|
|
{
|
|
"hospitals": Hospital.objects.filter(status="active").order_by("name"),
|
|
},
|
|
)
|
|
|
|
|
|
def public_complaint_track(request):
|
|
"""
|
|
Public complaint tracking page.
|
|
|
|
Allows complainants to check the status of their complaint using the reference number.
|
|
No authentication required.
|
|
|
|
Features:
|
|
- Form to enter reference number
|
|
- Display complaint status when found
|
|
- Show basic information (status, category, submission date, last update)
|
|
- Timeline of public updates (without exposing internal notes)
|
|
- SLA deadline information
|
|
"""
|
|
complaint = None
|
|
error_message = None
|
|
reference_number = request.GET.get("reference", "").strip()
|
|
|
|
if request.method == "POST":
|
|
reference_number = request.POST.get("reference_number", "").strip()
|
|
|
|
if not reference_number:
|
|
error_message = _("Please enter a reference number.")
|
|
else:
|
|
# Try to find complaint by reference number
|
|
try:
|
|
complaint = (
|
|
Complaint.objects.select_related("hospital", "department", "location", "main_section", "subsection")
|
|
.prefetch_related("updates")
|
|
.get(reference_number__iexact=reference_number)
|
|
)
|
|
|
|
# Check overdue status
|
|
complaint.check_overdue()
|
|
|
|
except Complaint.DoesNotExist:
|
|
error_message = _("No complaint found with this reference number. Please check and try again.")
|
|
|
|
elif reference_number:
|
|
# GET request with reference parameter
|
|
try:
|
|
complaint = (
|
|
Complaint.objects.select_related("hospital", "department", "location", "main_section", "subsection")
|
|
.prefetch_related("updates")
|
|
.get(reference_number__iexact=reference_number)
|
|
)
|
|
|
|
# Check overdue status
|
|
complaint.check_overdue()
|
|
|
|
except Complaint.DoesNotExist:
|
|
error_message = _("No complaint found with this reference number. Please check and try again.")
|
|
|
|
public_status = None
|
|
public_updates = []
|
|
if complaint:
|
|
public_status = complaint.public_status
|
|
|
|
public_updates = list(
|
|
complaint.updates.filter(update_type__in=["status_change", "resolution", "communication"]).order_by(
|
|
"-created_at"
|
|
)
|
|
)
|
|
|
|
_status_map = {
|
|
"open": str(_("Received")),
|
|
"in_progress": str(_("In Progress")),
|
|
"partially_resolved": str(_("In Progress")),
|
|
"contacted": str(_("In Progress")),
|
|
"contacted_no_response": str(_("In Progress")),
|
|
"resolved": str(_("Resolved")),
|
|
"closed": str(_("Closed")),
|
|
"cancelled": str(_("Cancelled")),
|
|
}
|
|
for update in public_updates:
|
|
if update.comments:
|
|
for internal, public_label in _status_map.items():
|
|
update.comments = update.comments.replace(internal, public_label)
|
|
if hasattr(update, "old_status") and update.old_status:
|
|
update.old_status = _status_map.get(update.old_status, update.old_status)
|
|
if hasattr(update, "new_status") and update.new_status:
|
|
update.new_status = _status_map.get(update.new_status, update.new_status)
|
|
|
|
context = {
|
|
"complaint": complaint,
|
|
"public_status": public_status,
|
|
"public_updates": public_updates,
|
|
"error_message": error_message,
|
|
"reference_number": reference_number,
|
|
}
|
|
|
|
return render(request, "complaints/public_complaint_track.html", context)
|
|
|
|
|
|
def public_complaint_success(request, reference):
|
|
"""
|
|
Success page after public complaint submission.
|
|
"""
|
|
return render(request, "complaints/public_complaint_success.html", {"reference_number": reference})
|
|
|
|
|
|
def public_inquiry_submit(request):
|
|
"""
|
|
Public inquiry submission form (accessible without login).
|
|
Allows patients/public to submit general inquiries without authentication.
|
|
"""
|
|
if request.method == "POST":
|
|
try:
|
|
name = request.POST.get("name")
|
|
email = request.POST.get("email")
|
|
phone = request.POST.get("phone")
|
|
hospital_id = request.POST.get("hospital")
|
|
category = request.POST.get("category", "general")
|
|
subject = request.POST.get("subject")
|
|
message = request.POST.get("message")
|
|
|
|
errors = []
|
|
if not name:
|
|
errors.append(_("Name is required"))
|
|
if not phone:
|
|
errors.append(_("Phone number is required"))
|
|
if not hospital_id:
|
|
errors.append(_("Hospital is required"))
|
|
if not subject:
|
|
errors.append(_("Subject is required"))
|
|
if not message:
|
|
errors.append(_("Message is required"))
|
|
|
|
if errors:
|
|
if request.headers.get("x-requested-with") == "XMLHttpRequest":
|
|
return JsonResponse({"success": False, "errors": errors}, status=400)
|
|
else:
|
|
messages.error(request, _("Please fill in all required fields."))
|
|
return render(
|
|
request,
|
|
"complaints/public_inquiry_form.html",
|
|
{
|
|
"hospitals": Hospital.objects.filter(status="active").order_by("name"),
|
|
"categories": [
|
|
("appointment", "Appointment"),
|
|
("billing", "Billing"),
|
|
("medical_records", "Medical Records"),
|
|
("general", "General Information"),
|
|
("other", "Other"),
|
|
],
|
|
},
|
|
)
|
|
|
|
hospital = Hospital.objects.get(id=hospital_id)
|
|
|
|
import uuid
|
|
from datetime import datetime
|
|
|
|
today = datetime.now().strftime("%Y%m%d")
|
|
random_suffix = str(uuid.uuid4().int)[:6]
|
|
reference_number = f"INQ-{today}-{random_suffix}"
|
|
|
|
inquiry = Inquiry.objects.create(
|
|
patient=None,
|
|
hospital=hospital,
|
|
subject=subject,
|
|
message=message,
|
|
category=category,
|
|
contact_name=name,
|
|
contact_phone=phone,
|
|
contact_email=email,
|
|
status="open",
|
|
reference_number=reference_number,
|
|
is_outgoing=False,
|
|
)
|
|
|
|
from apps.complaints.tasks import analyze_inquiry_with_ai, notify_staff_new_item
|
|
|
|
analyze_inquiry_with_ai.delay(str(inquiry.id))
|
|
notify_staff_new_item.delay("inquiry", str(inquiry.id))
|
|
|
|
AuditService.log_event(
|
|
event_type="inquiry_created_public",
|
|
description=f"Public inquiry submitted: {subject}",
|
|
content_object=inquiry,
|
|
metadata={"reference": reference_number, "source": "public_form"},
|
|
)
|
|
|
|
if request.headers.get("x-requested-with") == "XMLHttpRequest":
|
|
return JsonResponse(
|
|
{
|
|
"success": True,
|
|
"message": _("Inquiry submitted successfully!"),
|
|
"reference_number": reference_number,
|
|
}
|
|
)
|
|
|
|
return redirect("inquiries:public_inquiry_success", reference=reference_number)
|
|
|
|
except Hospital.DoesNotExist:
|
|
error_msg = _("Selected hospital not found.")
|
|
if request.headers.get("x-requested-with") == "XMLHttpRequest":
|
|
return JsonResponse({"success": False, "message": error_msg}, status=400)
|
|
messages.error(request, error_msg)
|
|
except Exception as e:
|
|
if request.headers.get("x-requested-with") == "XMLHttpRequest":
|
|
return JsonResponse({"success": False, "message": str(e)}, status=400)
|
|
messages.error(request, str(e))
|
|
|
|
return render(
|
|
request,
|
|
"complaints/public_inquiry_form.html",
|
|
{
|
|
"hospitals": Hospital.objects.filter(status="active").order_by("name"),
|
|
"categories": [
|
|
("appointment", "Appointment"),
|
|
("billing", "Billing"),
|
|
("medical_records", "Medical Records"),
|
|
("general", "General Information"),
|
|
("other", "Other"),
|
|
],
|
|
},
|
|
)
|
|
|
|
|
|
def public_inquiry_success(request, reference):
|
|
"""
|
|
Success page after public inquiry submission.
|
|
"""
|
|
return render(request, "complaints/public_inquiry_success.html", {"reference_number": reference})
|
|
|
|
|
|
def public_inquiry_track(request):
|
|
"""
|
|
Public inquiry tracking page. Allows users to check their inquiry status
|
|
using the reference number received after submission.
|
|
"""
|
|
from .models import Inquiry, InquiryUpdate
|
|
|
|
inquiry = None
|
|
public_updates = []
|
|
public_status = None
|
|
error_message = None
|
|
reference = request.GET.get("reference", "").strip() or request.POST.get("reference", "").strip()
|
|
|
|
if request.method == "POST":
|
|
reference = request.POST.get("reference_number", "").strip()
|
|
|
|
if not reference and request.GET.get("reference"):
|
|
reference = request.GET.get("reference").strip()
|
|
|
|
if reference:
|
|
inquiry = (
|
|
Inquiry.objects.filter(reference_number__iexact=reference)
|
|
.select_related("hospital", "department")
|
|
.first()
|
|
)
|
|
if inquiry:
|
|
status_map = {
|
|
"open": {"label": _("Received"), "slug": "received", "progress": 15, "css": "amber"},
|
|
"in_progress": {"label": _("In Progress"), "slug": "in_progress", "progress": 50, "css": "blue"},
|
|
"contacted": {"label": _("In Progress"), "slug": "in_progress", "progress": 50, "css": "blue"},
|
|
"contacted_no_response": {"label": _("In Progress"), "slug": "in_progress", "progress": 50, "css": "blue"},
|
|
"resolved": {"label": _("Resolved"), "slug": "resolved", "progress": 100, "css": "emerald"},
|
|
"closed": {"label": _("Closed"), "slug": "closed", "progress": 100, "css": "slate"},
|
|
}
|
|
sm = status_map.get(inquiry.status, {"label": _("Received"), "slug": "received", "progress": 15, "css": "amber"})
|
|
public_status = {
|
|
"label": str(sm["label"]),
|
|
"slug": sm["slug"],
|
|
"progress": sm["progress"],
|
|
"css": sm["css"],
|
|
}
|
|
|
|
public_updates = list(
|
|
InquiryUpdate.objects.filter(inquiry=inquiry)
|
|
.select_related("created_by")
|
|
.order_by("-created_at")[:20]
|
|
)
|
|
else:
|
|
error_message = _("No inquiry found with this reference number. Please check and try again.")
|
|
|
|
if request.headers.get("x-requested-with") == "XMLHttpRequest":
|
|
if inquiry:
|
|
return JsonResponse({
|
|
"success": True,
|
|
"reference": inquiry.reference_number,
|
|
"status": inquiry.get_status_display(),
|
|
"subject": inquiry.subject,
|
|
"created_at": inquiry.created_at.strftime("%Y-%m-%d %H:%M"),
|
|
"updates": [
|
|
{
|
|
"type": u.update_type,
|
|
"message": u.message,
|
|
"date": u.created_at.strftime("%Y-%m-%d %H:%M"),
|
|
}
|
|
for u in public_updates
|
|
],
|
|
})
|
|
else:
|
|
return JsonResponse({"success": False, "error": "Inquiry not found"})
|
|
|
|
return render(request, "complaints/public_inquiry_track.html", {
|
|
"inquiry": inquiry,
|
|
"public_status": public_status,
|
|
"public_updates": public_updates,
|
|
"error_message": error_message,
|
|
"reference_number": reference,
|
|
})
|
|
|
|
|
|
def api_lookup_patient(request):
|
|
"""
|
|
AJAX endpoint to look up patient by national ID or phone.
|
|
No authentication required for public form.
|
|
"""
|
|
from apps.organizations.models import Patient
|
|
|
|
national_id = request.GET.get("national_id", "").strip()
|
|
phone = request.GET.get("phone", "").strip()
|
|
|
|
if not national_id and not phone:
|
|
return JsonResponse({"found": False, "error": "National ID or phone required"}, status=400)
|
|
|
|
patient = None
|
|
lookup_method = None
|
|
|
|
if national_id:
|
|
from apps.core.encryption import compute_national_id_hash
|
|
|
|
nid_hash = compute_national_id_hash(national_id)
|
|
patient = Patient.objects.filter(national_id_hash=nid_hash, status="active").first()
|
|
lookup_method = "national_id"
|
|
|
|
if not patient and phone:
|
|
patient = Patient.objects.filter(phone=phone, status="active").first()
|
|
lookup_method = "phone"
|
|
|
|
if patient:
|
|
return JsonResponse(
|
|
{
|
|
"found": True,
|
|
"id": str(patient.id),
|
|
"mrn": patient.mrn,
|
|
"name": patient.get_full_name(),
|
|
"phone": patient.phone or "",
|
|
"email": patient.email or "",
|
|
"lookup_method": lookup_method,
|
|
}
|
|
)
|
|
|
|
return JsonResponse({"found": False, "message": "Patient not found"})
|
|
|
|
|
|
def api_load_departments(request):
|
|
"""
|
|
AJAX endpoint to load departments for a hospital.
|
|
No authentication required for public form.
|
|
"""
|
|
hospital_id = request.GET.get("hospital_id")
|
|
|
|
if not hospital_id:
|
|
return JsonResponse({"departments": []})
|
|
|
|
departments = Department.objects.filter(hospital_id=hospital_id, status="active").values("id", "name")
|
|
|
|
return JsonResponse({"departments": list(departments)})
|
|
|
|
|
|
def api_load_categories(request):
|
|
"""
|
|
AJAX endpoint to load complaint categories for a hospital.
|
|
Shows hospital-specific categories first, then system-wide categories.
|
|
Returns both parent categories and their subcategories with parent_id.
|
|
Now includes level field for 4-level hierarchy support (Domain, Category, Subcategory, Classification).
|
|
No authentication required for public form.
|
|
|
|
Updated: Always returns system-wide categories even without hospital_id,
|
|
to support initial form loading.
|
|
"""
|
|
from .models import ComplaintCategory
|
|
|
|
hospital_id = request.GET.get("hospital_id")
|
|
|
|
# Build queryset - always include system-wide categories
|
|
if hospital_id:
|
|
# Return hospital-specific and system-wide categories
|
|
# Empty hospitals list = system-wide
|
|
categories_queryset = (
|
|
ComplaintCategory.objects.filter(Q(hospitals__id=hospital_id) | Q(hospitals__isnull=True), is_active=True)
|
|
.distinct()
|
|
.order_by("level", "order", "name_en")
|
|
)
|
|
else:
|
|
# Return all system-wide categories (empty hospitals list)
|
|
# This allows form to load domains on initial page load
|
|
categories_queryset = ComplaintCategory.objects.filter(Q(hospitals__isnull=True), is_active=True).order_by(
|
|
"level", "order", "name_en"
|
|
)
|
|
|
|
# Get all categories with parent_id, level, domain_type, and descriptions
|
|
categories = categories_queryset.values(
|
|
"id", "name_en", "name_ar", "code", "parent_id", "level", "domain_type", "description_en", "description_ar"
|
|
)
|
|
|
|
return JsonResponse({"categories": list(categories)})
|
|
|
|
|
|
# ============================================================================
|
|
# AJAX/API HELPERS (Authentication Required)
|
|
# ============================================================================
|
|
|
|
|
|
@login_required
|
|
def get_departments_by_hospital(request):
|
|
"""Get departments for a hospital (AJAX)"""
|
|
hospital_id = request.GET.get("hospital_id")
|
|
if not hospital_id:
|
|
return JsonResponse({"departments": []})
|
|
|
|
departments = Department.objects.filter(hospital_id=hospital_id, status="active").values("id", "name", "name_ar")
|
|
|
|
return JsonResponse({"departments": list(departments)})
|
|
|
|
|
|
@login_required
|
|
def get_staff_by_department(request):
|
|
"""Get staff for a department (AJAX)"""
|
|
department_id = request.GET.get("department_id")
|
|
if not department_id:
|
|
return JsonResponse({"staff": []})
|
|
|
|
staff_members = Staff.objects.filter(department_id=department_id, status="active").values(
|
|
"id", "first_name", "last_name", "staff_type", "job_title"
|
|
)
|
|
|
|
return JsonResponse({"staff": list(staff_members)})
|
|
|
|
|
|
@login_required
|
|
def search_patients(request):
|
|
"""Search patients by MRN or name (AJAX)"""
|
|
from apps.organizations.models import Patient
|
|
|
|
query = request.GET.get("q", "")
|
|
if len(query) < 2:
|
|
return JsonResponse({"patients": []})
|
|
|
|
patients = Patient.objects.filter(
|
|
Q(mrn__icontains=query)
|
|
| Q(first_name__icontains=query)
|
|
| Q(last_name__icontains=query)
|
|
| Q(national_id__icontains=query)
|
|
)[:10]
|
|
|
|
results = [
|
|
{
|
|
"id": str(p.id),
|
|
"mrn": p.mrn,
|
|
"name": p.get_full_name(),
|
|
"phone": p.phone,
|
|
"email": p.email,
|
|
}
|
|
for p in patients
|
|
]
|
|
|
|
return JsonResponse({"patients": results})
|
|
|
|
|
|
@px_admin_required
|
|
@login_required
|
|
def sla_management(request):
|
|
"""
|
|
PX Admin-only SLA management page.
|
|
|
|
Automatically uses the hospital from session (selected after login).
|
|
Provides a clean interface to manage complaint SLA configurations.
|
|
"""
|
|
from .models import ComplaintSLAConfig
|
|
from apps.organizations.models import Hospital
|
|
|
|
# Get hospital from session (set after login)
|
|
hospital_id = request.session.get("selected_hospital_id")
|
|
|
|
if not hospital_id:
|
|
messages.error(request, "Please select a hospital first.")
|
|
return redirect("core:select_hospital")
|
|
|
|
# Get the hospital
|
|
try:
|
|
hospital = Hospital.objects.get(id=hospital_id)
|
|
except Hospital.DoesNotExist:
|
|
messages.error(request, "Selected hospital not found.")
|
|
return redirect("core:select_hospital")
|
|
|
|
# Get all SLA configs for this hospital
|
|
sla_configs = (
|
|
ComplaintSLAConfig.objects.filter(hospital=hospital, is_active=True)
|
|
.select_related("source")
|
|
.order_by("source", "severity", "priority")
|
|
)
|
|
|
|
# Group configs by type
|
|
source_based_configs = sla_configs.filter(source__isnull=False)
|
|
severity_based_configs = sla_configs.filter(source__isnull=True)
|
|
|
|
context = {
|
|
"hospital": hospital,
|
|
"source_based_configs": source_based_configs,
|
|
"severity_based_configs": severity_based_configs,
|
|
"total_configs": sla_configs.count(),
|
|
}
|
|
|
|
return render(request, "complaints/sla_management.html", context)
|
|
|
|
|
|
@px_admin_required
|
|
@login_required
|
|
@require_http_methods(["GET", "POST"])
|
|
def sla_management_create(request):
|
|
"""
|
|
PX Admin-only: Create new SLA configuration for selected hospital.
|
|
"""
|
|
from .models import ComplaintSLAConfig
|
|
from .forms import SLAConfigForm
|
|
from apps.organizations.models import Hospital
|
|
|
|
# Get hospital from session
|
|
hospital_id = request.session.get("selected_hospital_id")
|
|
|
|
if not hospital_id:
|
|
messages.error(request, "Please select a hospital first.")
|
|
return redirect("core:select_hospital")
|
|
|
|
try:
|
|
hospital = Hospital.objects.get(id=hospital_id)
|
|
except Hospital.DoesNotExist:
|
|
messages.error(request, "Selected hospital not found.")
|
|
return redirect("core:select_hospital")
|
|
|
|
if request.method == "POST":
|
|
form = SLAConfigForm(request.POST, request=request)
|
|
|
|
if form.is_valid():
|
|
# Force hospital to session hospital
|
|
sla_config = form.save(commit=False)
|
|
sla_config.hospital = hospital
|
|
sla_config.save()
|
|
|
|
# Log audit
|
|
AuditService.log_event(
|
|
event_type="sla_config_created",
|
|
description=f"SLA configuration created: {sla_config}",
|
|
user=request.user,
|
|
content_object=sla_config,
|
|
metadata={
|
|
"hospital": str(sla_config.hospital),
|
|
"severity": sla_config.severity,
|
|
"priority": sla_config.priority,
|
|
"sla_hours": sla_config.sla_hours,
|
|
},
|
|
)
|
|
|
|
messages.success(request, "SLA configuration created successfully.")
|
|
return redirect("complaints:sla_management")
|
|
else:
|
|
messages.error(request, "Please correct the errors below.")
|
|
else:
|
|
form = SLAConfigForm(request=request)
|
|
# Pre-select hospital (hidden field)
|
|
form.fields["hospital"].initial = hospital.id
|
|
|
|
context = {
|
|
"form": form,
|
|
"hospital": hospital,
|
|
"title": "Create SLA Configuration",
|
|
"action": "Create",
|
|
}
|
|
|
|
return render(request, "complaints/sla_management_form.html", context)
|
|
|
|
|
|
@px_admin_required
|
|
@login_required
|
|
@require_http_methods(["GET", "POST"])
|
|
def sla_management_edit(request, pk):
|
|
"""
|
|
PX Admin-only: Edit SLA configuration for selected hospital.
|
|
"""
|
|
from .models import ComplaintSLAConfig
|
|
from .forms import SLAConfigForm
|
|
from apps.organizations.models import Hospital
|
|
|
|
# Get hospital from session
|
|
hospital_id = request.session.get("selected_hospital_id")
|
|
|
|
if not hospital_id:
|
|
messages.error(request, "Please select a hospital first.")
|
|
return redirect("core:select_hospital")
|
|
|
|
try:
|
|
hospital = Hospital.objects.get(id=hospital_id)
|
|
except Hospital.DoesNotExist:
|
|
messages.error(request, "Selected hospital not found.")
|
|
return redirect("core:select_hospital")
|
|
|
|
sla_config = get_object_or_404(ComplaintSLAConfig, pk=pk)
|
|
|
|
if request.method == "POST":
|
|
form = SLAConfigForm(request.POST, request=request, instance=sla_config)
|
|
|
|
if form.is_valid():
|
|
sla_config = form.save()
|
|
|
|
# Log audit
|
|
AuditService.log_event(
|
|
event_type="sla_config_updated",
|
|
description=f"SLA configuration updated: {sla_config}",
|
|
user=request.user,
|
|
content_object=sla_config,
|
|
metadata={
|
|
"hospital": str(sla_config.hospital),
|
|
"severity": sla_config.severity,
|
|
"priority": sla_config.priority,
|
|
"sla_hours": sla_config.sla_hours,
|
|
},
|
|
)
|
|
|
|
messages.success(request, "SLA configuration updated successfully.")
|
|
return redirect("complaints:sla_management")
|
|
else:
|
|
messages.error(request, "Please correct the errors below.")
|
|
else:
|
|
form = SLAConfigForm(request=request, instance=sla_config)
|
|
|
|
context = {
|
|
"form": form,
|
|
"sla_config": sla_config,
|
|
"hospital": hospital,
|
|
"title": "Edit SLA Configuration",
|
|
"action": "Update",
|
|
}
|
|
|
|
return render(request, "complaints/sla_management_form.html", context)
|
|
|
|
|
|
@px_admin_required
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def sla_management_toggle(request, pk):
|
|
"""
|
|
PX Admin-only: Toggle SLA configuration active/inactive status.
|
|
"""
|
|
from .models import ComplaintSLAConfig
|
|
|
|
sla_config = get_object_or_404(ComplaintSLAConfig, pk=pk)
|
|
|
|
# Toggle status
|
|
sla_config.is_active = not sla_config.is_active
|
|
sla_config.save()
|
|
|
|
# Log audit
|
|
AuditService.log_event(
|
|
event_type="sla_config_toggled",
|
|
description=f"SLA configuration {'activated' if sla_config.is_active else 'deactivated'}: {sla_config}",
|
|
user=request.user,
|
|
content_object=sla_config,
|
|
metadata={
|
|
"hospital": str(sla_config.hospital),
|
|
"is_active": sla_config.is_active,
|
|
},
|
|
)
|
|
|
|
status_text = "activated" if sla_config.is_active else "deactivated"
|
|
messages.success(request, f"SLA configuration {status_text} successfully.")
|
|
return redirect("complaints:sla_management")
|
|
|
|
|
|
@login_required
|
|
def escalation_rule_list(request):
|
|
"""
|
|
Escalation Rules list view with filters.
|
|
|
|
Allows Hospital Admins and PX Admins to manage escalation rules.
|
|
"""
|
|
from .models import EscalationRule
|
|
|
|
# Check permission
|
|
user = request.user
|
|
if not (user.is_px_admin() or user.is_hospital_admin()):
|
|
messages.error(request, "You don't have permission to manage escalation rules.")
|
|
return redirect("accounts:settings")
|
|
|
|
# Base queryset
|
|
queryset = EscalationRule.objects.select_related("hospital", "escalate_to_user").all()
|
|
|
|
# Apply hospital filter
|
|
if not user.is_px_admin() and user.hospital:
|
|
queryset = queryset.filter(hospital=user.hospital)
|
|
|
|
# Apply filters from request
|
|
hospital_filter = request.GET.get("hospital")
|
|
if hospital_filter:
|
|
queryset = queryset.filter(hospital_id=hospital_filter)
|
|
|
|
escalation_level_filter = request.GET.get("escalation_level")
|
|
if escalation_level_filter:
|
|
queryset = queryset.filter(escalation_level=escalation_level_filter)
|
|
|
|
is_active_filter = request.GET.get("is_active")
|
|
if is_active_filter:
|
|
queryset = queryset.filter(is_active=(is_active_filter == "true"))
|
|
|
|
# Ordering
|
|
order_by = request.GET.get("order_by", "hospital__name")
|
|
queryset = queryset.order_by(order_by)
|
|
|
|
# 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,
|
|
"escalation_rules": page_obj.object_list,
|
|
"filters": request.GET,
|
|
}
|
|
|
|
return render(request, "complaints/escalation_rule_list.html", context)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["GET", "POST"])
|
|
def escalation_rule_create(request):
|
|
"""
|
|
Create new escalation rule.
|
|
"""
|
|
from .models import EscalationRule
|
|
from .forms import EscalationRuleForm
|
|
|
|
# Check permission
|
|
user = request.user
|
|
if not (user.is_px_admin() or user.is_hospital_admin()):
|
|
messages.error(request, "You don't have permission to create escalation rules.")
|
|
return redirect("accounts:settings")
|
|
|
|
if request.method == "POST":
|
|
form = EscalationRuleForm(request.POST, request=request)
|
|
|
|
if form.is_valid():
|
|
escalation_rule = form.save()
|
|
|
|
# Log audit
|
|
AuditService.log_event(
|
|
event_type="escalation_rule_created",
|
|
description=f"Escalation rule created: {escalation_rule}",
|
|
user=request.user,
|
|
content_object=escalation_rule,
|
|
metadata={
|
|
"hospital": str(escalation_rule.hospital),
|
|
"name": escalation_rule.name,
|
|
"escalation_level": escalation_rule.escalation_level,
|
|
},
|
|
)
|
|
|
|
messages.success(request, "Escalation rule created successfully.")
|
|
return redirect("complaints:escalation_rule_list")
|
|
else:
|
|
messages.error(request, "Please correct the errors below.")
|
|
else:
|
|
form = EscalationRuleForm(request=request)
|
|
|
|
context = {
|
|
"form": form,
|
|
"title": "Create Escalation Rule",
|
|
"action": "Create",
|
|
}
|
|
|
|
return render(request, "complaints/escalation_rule_form.html", context)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["GET", "POST"])
|
|
def escalation_rule_edit(request, pk):
|
|
"""
|
|
Edit existing escalation rule.
|
|
"""
|
|
from .models import EscalationRule
|
|
from .forms import EscalationRuleForm
|
|
|
|
# Check permission
|
|
user = request.user
|
|
if not (user.is_px_admin() or user.is_hospital_admin()):
|
|
messages.error(request, "You don't have permission to edit escalation rules.")
|
|
return redirect("accounts:settings")
|
|
|
|
escalation_rule = get_object_or_404(EscalationRule, pk=pk)
|
|
|
|
# Check if user can edit this rule
|
|
if not user.is_px_admin() and escalation_rule.hospital != user.hospital:
|
|
messages.error(request, "You don't have permission to edit this escalation rule.")
|
|
return redirect("complaints:escalation_rule_list")
|
|
|
|
if request.method == "POST":
|
|
form = EscalationRuleForm(request.POST, request=request, instance=escalation_rule)
|
|
|
|
if form.is_valid():
|
|
escalation_rule = form.save()
|
|
|
|
# Log audit
|
|
AuditService.log_event(
|
|
event_type="escalation_rule_updated",
|
|
description=f"Escalation rule updated: {escalation_rule}",
|
|
user=request.user,
|
|
content_object=escalation_rule,
|
|
metadata={
|
|
"hospital": str(escalation_rule.hospital),
|
|
"name": escalation_rule.name,
|
|
"escalation_level": escalation_rule.escalation_level,
|
|
},
|
|
)
|
|
|
|
messages.success(request, "Escalation rule updated successfully.")
|
|
return redirect("complaints:escalation_rule_list")
|
|
else:
|
|
messages.error(request, "Please correct the errors below.")
|
|
else:
|
|
form = EscalationRuleForm(request=request, instance=escalation_rule)
|
|
|
|
context = {
|
|
"form": form,
|
|
"escalation_rule": escalation_rule,
|
|
"title": "Edit Escalation Rule",
|
|
"action": "Update",
|
|
}
|
|
|
|
return render(request, "complaints/escalation_rule_form.html", context)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def escalation_rule_delete(request, pk):
|
|
"""
|
|
Delete escalation rule.
|
|
"""
|
|
from .models import EscalationRule
|
|
|
|
# Check permission
|
|
user = request.user
|
|
if not (user.is_px_admin() or user.is_hospital_admin()):
|
|
messages.error(request, "You don't have permission to delete escalation rules.")
|
|
return redirect("accounts:settings")
|
|
|
|
escalation_rule = get_object_or_404(EscalationRule, pk=pk)
|
|
|
|
# Check if user can delete this rule
|
|
if not user.is_px_admin() and escalation_rule.hospital != user.hospital:
|
|
messages.error(request, "You don't have permission to delete this escalation rule.")
|
|
return redirect("complaints:escalation_rule_list")
|
|
|
|
escalation_rule.delete()
|
|
|
|
# Log audit
|
|
AuditService.log_event(
|
|
event_type="escalation_rule_deleted",
|
|
description=f"Escalation rule deleted: {escalation_rule}",
|
|
user=request.user,
|
|
metadata={
|
|
"hospital": str(escalation_rule.hospital),
|
|
"name": escalation_rule.name,
|
|
},
|
|
)
|
|
|
|
messages.success(request, "Escalation rule deleted successfully.")
|
|
return redirect("complaints:escalation_rule_list")
|
|
|
|
|
|
@login_required
|
|
def complaint_threshold_list(request):
|
|
"""
|
|
Complaint Threshold list view with filters.
|
|
|
|
Allows Hospital Admins and PX Admins to manage complaint thresholds.
|
|
"""
|
|
from .models import ComplaintThreshold
|
|
|
|
# Check permission
|
|
user = request.user
|
|
if not (user.is_px_admin() or user.is_hospital_admin()):
|
|
messages.error(request, "You don't have permission to manage complaint thresholds.")
|
|
return redirect("accounts:settings")
|
|
|
|
# Base queryset
|
|
queryset = ComplaintThreshold.objects.select_related("hospital").all()
|
|
|
|
# Apply hospital filter
|
|
if not user.is_px_admin() and user.hospital:
|
|
queryset = queryset.filter(hospital=user.hospital)
|
|
|
|
# Apply filters from request
|
|
hospital_filter = request.GET.get("hospital")
|
|
if hospital_filter:
|
|
queryset = queryset.filter(hospital_id=hospital_filter)
|
|
|
|
threshold_type_filter = request.GET.get("threshold_type")
|
|
if threshold_type_filter:
|
|
queryset = queryset.filter(threshold_type=threshold_type_filter)
|
|
|
|
is_active_filter = request.GET.get("is_active")
|
|
if is_active_filter:
|
|
queryset = queryset.filter(is_active=(is_active_filter == "true"))
|
|
|
|
# Ordering
|
|
order_by = request.GET.get("order_by", "hospital__name")
|
|
queryset = queryset.order_by(order_by)
|
|
|
|
# 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,
|
|
"thresholds": page_obj.object_list,
|
|
"filters": request.GET,
|
|
}
|
|
|
|
return render(request, "complaints/complaint_threshold_list.html", context)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["GET", "POST"])
|
|
def complaint_threshold_create(request):
|
|
"""
|
|
Create new complaint threshold.
|
|
"""
|
|
from .models import ComplaintThreshold
|
|
from .forms import ComplaintThresholdForm
|
|
|
|
# Check permission
|
|
user = request.user
|
|
if not (user.is_px_admin() or user.is_hospital_admin()):
|
|
messages.error(request, "You don't have permission to create complaint thresholds.")
|
|
return redirect("accounts:settings")
|
|
|
|
if request.method == "POST":
|
|
form = ComplaintThresholdForm(request.POST, request=request)
|
|
|
|
if form.is_valid():
|
|
threshold = form.save()
|
|
|
|
# Log audit
|
|
AuditService.log_event(
|
|
event_type="complaint_threshold_created",
|
|
description=f"Complaint threshold created: {threshold}",
|
|
user=request.user,
|
|
content_object=threshold,
|
|
metadata={
|
|
"hospital": str(threshold.hospital),
|
|
"threshold_type": threshold.threshold_type,
|
|
"threshold_value": threshold.threshold_value,
|
|
},
|
|
)
|
|
|
|
messages.success(request, "Complaint threshold created successfully.")
|
|
return redirect("complaints:complaint_threshold_list")
|
|
else:
|
|
messages.error(request, "Please correct the errors below.")
|
|
else:
|
|
form = ComplaintThresholdForm(request=request)
|
|
|
|
context = {
|
|
"form": form,
|
|
"title": "Create Complaint Threshold",
|
|
"action": "Create",
|
|
}
|
|
|
|
return render(request, "complaints/complaint_threshold_form.html", context)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["GET", "POST"])
|
|
def complaint_threshold_edit(request, pk):
|
|
"""
|
|
Edit existing complaint threshold.
|
|
"""
|
|
from .models import ComplaintThreshold
|
|
from .forms import ComplaintThresholdForm
|
|
|
|
# Check permission
|
|
user = request.user
|
|
if not (user.is_px_admin() or user.is_hospital_admin()):
|
|
messages.error(request, "You don't have permission to edit complaint thresholds.")
|
|
return redirect("accounts:settings")
|
|
|
|
threshold = get_object_or_404(ComplaintThreshold, pk=pk)
|
|
|
|
# Check if user can edit this threshold
|
|
if not user.is_px_admin() and threshold.hospital != user.hospital:
|
|
messages.error(request, "You don't have permission to edit this complaint threshold.")
|
|
return redirect("complaints:complaint_threshold_list")
|
|
|
|
if request.method == "POST":
|
|
form = ComplaintThresholdForm(request.POST, request=request, instance=threshold)
|
|
|
|
if form.is_valid():
|
|
threshold = form.save()
|
|
|
|
# Log audit
|
|
AuditService.log_event(
|
|
event_type="complaint_threshold_updated",
|
|
description=f"Complaint threshold updated: {threshold}",
|
|
user=request.user,
|
|
content_object=threshold,
|
|
metadata={
|
|
"hospital": str(threshold.hospital),
|
|
"threshold_type": threshold.threshold_type,
|
|
"threshold_value": threshold.threshold_value,
|
|
},
|
|
)
|
|
|
|
messages.success(request, "Complaint threshold updated successfully.")
|
|
return redirect("complaints:complaint_threshold_list")
|
|
else:
|
|
messages.error(request, "Please correct the errors below.")
|
|
else:
|
|
form = ComplaintThresholdForm(request=request, instance=threshold)
|
|
|
|
context = {
|
|
"form": form,
|
|
"threshold": threshold,
|
|
"title": "Edit Complaint Threshold",
|
|
"action": "Update",
|
|
}
|
|
|
|
return render(request, "complaints/complaint_threshold_form.html", context)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def complaint_threshold_delete(request, pk):
|
|
"""
|
|
Delete complaint threshold.
|
|
"""
|
|
from .models import ComplaintThreshold
|
|
|
|
# Check permission
|
|
user = request.user
|
|
if not (user.is_px_admin() or user.is_hospital_admin()):
|
|
messages.error(request, "You don't have permission to delete complaint thresholds.")
|
|
return redirect("accounts:settings")
|
|
|
|
threshold = get_object_or_404(ComplaintThreshold, pk=pk)
|
|
|
|
# Check if user can delete this threshold
|
|
if not user.is_px_admin() and threshold.hospital != user.hospital:
|
|
messages.error(request, "You don't have permission to delete this complaint threshold.")
|
|
return redirect("complaints:complaint_threshold_list")
|
|
|
|
threshold.delete()
|
|
|
|
# Log audit
|
|
AuditService.log_event(
|
|
event_type="complaint_threshold_deleted",
|
|
description=f"Complaint threshold deleted: {threshold}",
|
|
user=request.user,
|
|
metadata={
|
|
"hospital": str(threshold.hospital),
|
|
"threshold_type": threshold.threshold_type,
|
|
},
|
|
)
|
|
|
|
messages.success(request, "Complaint threshold deleted successfully.")
|
|
return redirect("complaints:complaint_threshold_list")
|
|
|
|
|
|
# ============================================================================
|
|
# Involved Departments and Staff Views
|
|
# ============================================================================
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def confirm_ai_department_suggestion(request, complaint_pk):
|
|
"""
|
|
Confirm the AI-suggested department and add it as an involved department.
|
|
"""
|
|
from .models import ComplaintInvolvedDepartment
|
|
|
|
complaint = get_object_or_404(Complaint, pk=complaint_pk)
|
|
|
|
user = request.user
|
|
if not can_manage_complaint(user, complaint):
|
|
messages.error(request, _("You don't have permission to manage this complaint."))
|
|
return redirect("complaints:complaint_detail", pk=complaint.pk)
|
|
|
|
suggested_department = complaint.department
|
|
if not suggested_department:
|
|
messages.warning(request, _("No AI department suggestion found."))
|
|
return redirect("complaints:complaint_detail", pk=complaint.pk)
|
|
|
|
already_involved = complaint.involved_departments.filter(department=suggested_department).exists()
|
|
if already_involved:
|
|
messages.info(request, _("This department is already involved."))
|
|
return redirect("complaints:complaint_detail", pk=complaint.pk)
|
|
|
|
ComplaintInvolvedDepartment.objects.create(
|
|
complaint=complaint,
|
|
department=suggested_department,
|
|
role="primary",
|
|
is_primary=True,
|
|
added_by=user,
|
|
assigned_at=timezone.now(),
|
|
forwarded_at=timezone.now(),
|
|
)
|
|
|
|
ComplaintUpdate.objects.create(
|
|
complaint=complaint,
|
|
update_type="assignment",
|
|
message=f"AI suggested department confirmed: {suggested_department.name} (Primary)",
|
|
created_by=user,
|
|
)
|
|
|
|
AuditService.log_event(
|
|
event_type="complaint_department_added",
|
|
description=f"AI department suggestion confirmed: {suggested_department.name} for complaint {complaint.reference_number}",
|
|
user=user,
|
|
content_object=complaint,
|
|
metadata={
|
|
"department_id": str(suggested_department.pk),
|
|
"department_name": suggested_department.name,
|
|
"role": "primary",
|
|
"is_primary": True,
|
|
"source": "ai_suggestion",
|
|
},
|
|
)
|
|
|
|
messages.success(
|
|
request,
|
|
_("Department '%(dept)s' added as Primary (AI suggestion confirmed).") % {"dept": suggested_department.name},
|
|
)
|
|
return redirect("complaints:complaint_detail", pk=complaint.pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["GET", "POST"])
|
|
def involved_department_add(request, complaint_pk):
|
|
"""
|
|
Add an involved department to a complaint.
|
|
|
|
Allows assigning multiple departments with different roles:
|
|
- Primary: Main responsible department
|
|
- Secondary/Supporting: Assisting departments
|
|
- Coordination: Only for coordination
|
|
- Investigating: Leading the investigation
|
|
"""
|
|
complaint = get_object_or_404(Complaint, pk=complaint_pk)
|
|
|
|
# Check permission
|
|
user = request.user
|
|
if not can_manage_complaint(user, complaint):
|
|
messages.error(request, _("You don't have permission to manage this complaint."))
|
|
return redirect("complaints:complaint_detail", pk=complaint.pk)
|
|
|
|
if request.method == "POST":
|
|
form = ComplaintInvolvedDepartmentForm(request.POST, complaint=complaint, user=user)
|
|
|
|
if form.is_valid():
|
|
involved_dept = form.save(commit=False)
|
|
involved_dept.complaint = complaint
|
|
involved_dept.added_by = user
|
|
|
|
# Set assignment timestamp if assigned
|
|
if involved_dept.assigned_to and not involved_dept.assigned_at:
|
|
involved_dept.assigned_at = timezone.now()
|
|
|
|
# Mark as forwarded to department (for tracking pending responses)
|
|
if not involved_dept.forwarded_at:
|
|
involved_dept.forwarded_at = timezone.now()
|
|
|
|
involved_dept.save()
|
|
|
|
# Log the update
|
|
ComplaintUpdate.objects.create(
|
|
complaint=complaint,
|
|
update_type="assignment",
|
|
message=f"Department added: {involved_dept.department.name} ({involved_dept.get_role_display()})",
|
|
created_by=user,
|
|
)
|
|
|
|
# Audit log
|
|
AuditService.log_event(
|
|
event_type="complaint_department_added",
|
|
description=f"Department {involved_dept.department.name} added to complaint {complaint.reference_number}",
|
|
user=user,
|
|
content_object=complaint,
|
|
metadata={
|
|
"department_id": str(involved_dept.department.pk),
|
|
"department_name": involved_dept.department.name,
|
|
"role": involved_dept.role,
|
|
"is_primary": involved_dept.is_primary,
|
|
},
|
|
)
|
|
|
|
messages.success(
|
|
request,
|
|
_("Department '%(dept)s' added successfully as %(role)s.")
|
|
% {"dept": involved_dept.department.name, "role": involved_dept.get_role_display()},
|
|
)
|
|
return redirect("complaints:complaint_detail", pk=complaint.pk)
|
|
else:
|
|
messages.error(request, _("Please correct the errors below."))
|
|
else:
|
|
form = ComplaintInvolvedDepartmentForm(complaint=complaint, user=user)
|
|
|
|
context = {
|
|
"form": form,
|
|
"complaint": complaint,
|
|
"title": _("Add Involved Department"),
|
|
}
|
|
|
|
return render(request, "complaints/involved_department_form.html", context)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["GET", "POST"])
|
|
def involved_department_edit(request, pk):
|
|
"""
|
|
Edit an involved department's details.
|
|
"""
|
|
from .models import ComplaintInvolvedDepartment
|
|
|
|
involved_dept = get_object_or_404(ComplaintInvolvedDepartment, pk=pk)
|
|
complaint = involved_dept.complaint
|
|
|
|
# Check permission
|
|
user = request.user
|
|
if not can_manage_complaint(user, complaint):
|
|
messages.error(request, _("You don't have permission to manage this complaint."))
|
|
return redirect("complaints:complaint_detail", pk=complaint.pk)
|
|
|
|
if request.method == "POST":
|
|
form = ComplaintInvolvedDepartmentForm(request.POST, instance=involved_dept, complaint=complaint, user=user)
|
|
|
|
if form.is_valid():
|
|
# Check if assignment is being changed
|
|
old_assigned_to = involved_dept.assigned_to
|
|
involved_dept = form.save(commit=False)
|
|
|
|
# Set assignment timestamp if newly assigned
|
|
if involved_dept.assigned_to and not old_assigned_to:
|
|
involved_dept.assigned_at = timezone.now()
|
|
|
|
involved_dept.save()
|
|
|
|
# Log the update
|
|
ComplaintUpdate.objects.create(
|
|
complaint=complaint,
|
|
update_type="assignment",
|
|
message=f"Department updated: {involved_dept.department.name} ({involved_dept.get_role_display()})",
|
|
created_by=user,
|
|
)
|
|
|
|
messages.success(request, _("Department involvement updated successfully."))
|
|
return redirect("complaints:complaint_detail", pk=complaint.pk)
|
|
else:
|
|
messages.error(request, _("Please correct the errors below."))
|
|
else:
|
|
form = ComplaintInvolvedDepartmentForm(instance=involved_dept, complaint=complaint, user=user)
|
|
|
|
context = {
|
|
"form": form,
|
|
"complaint": complaint,
|
|
"involved_dept": involved_dept,
|
|
"title": _("Edit Involved Department"),
|
|
}
|
|
|
|
return render(request, "complaints/involved_department_form.html", context)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def involved_department_remove(request, pk):
|
|
"""
|
|
Remove an involved department from a complaint.
|
|
"""
|
|
from .models import ComplaintInvolvedDepartment
|
|
|
|
involved_dept = get_object_or_404(ComplaintInvolvedDepartment, pk=pk)
|
|
complaint = involved_dept.complaint
|
|
department_name = involved_dept.department.name
|
|
|
|
# Check permission
|
|
user = request.user
|
|
if not can_manage_complaint(user, complaint):
|
|
messages.error(request, _("You don't have permission to manage this complaint."))
|
|
return redirect("complaints:complaint_detail", pk=complaint.pk)
|
|
|
|
involved_dept.delete()
|
|
|
|
# Log the update
|
|
ComplaintUpdate.objects.create(
|
|
complaint=complaint,
|
|
update_type="assignment",
|
|
message=f"Department removed: {department_name}",
|
|
created_by=user,
|
|
)
|
|
|
|
# Audit log
|
|
AuditService.log_event(
|
|
event_type="complaint_department_removed",
|
|
description=f"Department {department_name} removed from complaint {complaint.reference_number}",
|
|
user=user,
|
|
content_object=complaint,
|
|
)
|
|
|
|
messages.success(request, _("Department removed successfully."))
|
|
return redirect("complaints:complaint_detail", pk=complaint.pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def involved_department_response(request, pk):
|
|
"""
|
|
Submit a department's response to the complaint.
|
|
Supports both regular POST and AJAX requests.
|
|
"""
|
|
from .models import ComplaintInvolvedDepartment
|
|
from django.http import JsonResponse
|
|
|
|
involved_dept = get_object_or_404(ComplaintInvolvedDepartment, pk=pk)
|
|
complaint = involved_dept.complaint
|
|
user = request.user
|
|
|
|
# Check permission - must be champion/manager of this department, assigned to it, or have complaint management rights
|
|
is_dept_champion = (
|
|
user.is_champion()
|
|
and user.department == involved_dept.department
|
|
)
|
|
is_dept_manager = (
|
|
user.is_department_manager()
|
|
and user.department == involved_dept.department
|
|
)
|
|
can_respond = (
|
|
is_dept_champion
|
|
or is_dept_manager
|
|
or involved_dept.assigned_to == user
|
|
or can_manage_complaint(user, complaint)
|
|
)
|
|
|
|
if not can_respond:
|
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
|
return JsonResponse({
|
|
"success": False,
|
|
"error": str(_("You don't have permission to submit a response for this department.")),
|
|
}, status=403)
|
|
messages.error(request, _("You don't have permission to submit a response for this department."))
|
|
return redirect("complaints:complaint_detail", pk=complaint.pk)
|
|
|
|
response_notes = request.POST.get("response_notes", "").strip()
|
|
|
|
if not response_notes:
|
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
|
return JsonResponse({
|
|
"success": False,
|
|
"error": str(_("Please provide a valid response.")),
|
|
}, status=400)
|
|
messages.error(request, _("Please provide a valid response."))
|
|
return redirect("complaints:complaint_detail", pk=complaint.pk)
|
|
|
|
involved_dept.response_notes = response_notes
|
|
involved_dept.response_submitted = True
|
|
involved_dept.response_submitted_at = timezone.now()
|
|
involved_dept.save()
|
|
|
|
# Log the update
|
|
ComplaintUpdate.objects.create(
|
|
complaint=complaint,
|
|
update_type="note",
|
|
message=f"Response submitted by {involved_dept.department.name}",
|
|
created_by=user,
|
|
)
|
|
|
|
# Send notification to complaint assigned_to
|
|
if complaint.assigned_to and complaint.assigned_to.email:
|
|
from apps.notifications.services import NotificationService
|
|
NotificationService.send_email(
|
|
complaint.assigned_to.email,
|
|
subject=f"Department Response Received - {complaint.reference_number}",
|
|
message=f"A response has been submitted by {involved_dept.department.name} for complaint {complaint.reference_number}.",
|
|
html_message=f"""
|
|
<p>A response has been submitted by <strong>{involved_dept.department.name}</strong>
|
|
for complaint <strong>{complaint.reference_number}</strong>.</p>
|
|
<p><a href="https://{request.get_host()}/complaints/{complaint.pk}/">View Complaint</a></p>
|
|
""",
|
|
)
|
|
|
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
|
return JsonResponse({
|
|
"success": True,
|
|
"message": str(_("Department response submitted successfully.")),
|
|
})
|
|
|
|
messages.success(request, _("Department response submitted successfully."))
|
|
return redirect("complaints:complaint_detail", pk=complaint.pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["GET", "POST"])
|
|
def involved_staff_add(request, complaint_pk):
|
|
"""
|
|
Add an involved staff member to a complaint.
|
|
|
|
Allows assigning multiple staff members with different roles:
|
|
- Accused/Involved: Staff member involved in the incident
|
|
- Witness: Staff member who witnessed the incident
|
|
- Responsible: Staff responsible for resolution
|
|
- Investigator: Staff investigating the complaint
|
|
- Support: Support staff
|
|
- PX Employee: Coordination role
|
|
"""
|
|
complaint = get_object_or_404(Complaint, pk=complaint_pk)
|
|
|
|
# Check permission
|
|
user = request.user
|
|
if not can_manage_complaint(user, complaint):
|
|
messages.error(request, _("You don't have permission to manage this complaint."))
|
|
return redirect("complaints:complaint_detail", pk=complaint.pk)
|
|
|
|
if request.method == "POST":
|
|
form = ComplaintInvolvedStaffForm(request.POST, complaint=complaint, user=user)
|
|
|
|
if form.is_valid():
|
|
involved_staff = form.save(commit=False)
|
|
involved_staff.complaint = complaint
|
|
involved_staff.added_by = user
|
|
involved_staff.save()
|
|
|
|
# Log the update
|
|
ComplaintUpdate.objects.create(
|
|
complaint=complaint,
|
|
update_type="assignment",
|
|
message=f"Staff added: {involved_staff.staff} ({involved_staff.get_role_display()})",
|
|
created_by=user,
|
|
)
|
|
|
|
# Audit log
|
|
AuditService.log_event(
|
|
event_type="complaint_staff_added",
|
|
description=f"Staff {involved_staff.staff} added to complaint {complaint.reference_number}",
|
|
user=user,
|
|
content_object=complaint,
|
|
metadata={
|
|
"staff_id": str(involved_staff.staff.pk),
|
|
"staff_name": str(involved_staff.staff),
|
|
"role": involved_staff.role,
|
|
},
|
|
)
|
|
|
|
messages.success(
|
|
request,
|
|
_("Staff member '%(staff)s' added successfully as %(role)s.")
|
|
% {"staff": involved_staff.staff, "role": involved_staff.get_role_display()},
|
|
)
|
|
return redirect("complaints:complaint_detail", pk=complaint.pk)
|
|
else:
|
|
messages.error(request, _("Please correct the errors below."))
|
|
else:
|
|
form = ComplaintInvolvedStaffForm(complaint=complaint, user=user)
|
|
|
|
context = {
|
|
"form": form,
|
|
"complaint": complaint,
|
|
"title": _("Add Involved Staff"),
|
|
}
|
|
|
|
return render(request, "complaints/involved_staff_form.html", context)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["GET", "POST"])
|
|
def involved_staff_edit(request, pk):
|
|
"""
|
|
Edit an involved staff member's details.
|
|
"""
|
|
from .models import ComplaintInvolvedStaff
|
|
|
|
involved_staff = get_object_or_404(ComplaintInvolvedStaff, pk=pk)
|
|
complaint = involved_staff.complaint
|
|
|
|
# Check permission
|
|
user = request.user
|
|
if not can_manage_complaint(user, complaint):
|
|
messages.error(request, _("You don't have permission to manage this complaint."))
|
|
return redirect("complaints:complaint_detail", pk=complaint.pk)
|
|
|
|
if request.method == "POST":
|
|
form = ComplaintInvolvedStaffForm(request.POST, instance=involved_staff, complaint=complaint, user=user)
|
|
|
|
if form.is_valid():
|
|
form.save()
|
|
|
|
# Log the update
|
|
ComplaintUpdate.objects.create(
|
|
complaint=complaint,
|
|
update_type="assignment",
|
|
message=f"Staff updated: {involved_staff.staff} ({involved_staff.get_role_display()})",
|
|
created_by=user,
|
|
)
|
|
|
|
messages.success(request, _("Staff involvement updated successfully."))
|
|
return redirect("complaints:complaint_detail", pk=complaint.pk)
|
|
else:
|
|
messages.error(request, _("Please correct the errors below."))
|
|
else:
|
|
form = ComplaintInvolvedStaffForm(instance=involved_staff, complaint=complaint, user=user)
|
|
|
|
context = {
|
|
"form": form,
|
|
"complaint": complaint,
|
|
"involved_staff": involved_staff,
|
|
"title": _("Edit Involved Staff"),
|
|
}
|
|
|
|
return render(request, "complaints/involved_staff_form.html", context)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def involved_staff_remove(request, pk):
|
|
"""
|
|
Remove an involved staff member from a complaint.
|
|
"""
|
|
from .models import ComplaintInvolvedStaff
|
|
|
|
involved_staff = get_object_or_404(ComplaintInvolvedStaff, pk=pk)
|
|
complaint = involved_staff.complaint
|
|
staff_name = str(involved_staff.staff)
|
|
|
|
# Check permission
|
|
user = request.user
|
|
if not can_manage_complaint(user, complaint):
|
|
messages.error(request, _("You don't have permission to manage this complaint."))
|
|
return redirect("complaints:complaint_detail", pk=complaint.pk)
|
|
|
|
involved_staff.delete()
|
|
|
|
# Log the update
|
|
ComplaintUpdate.objects.create(
|
|
complaint=complaint,
|
|
update_type="assignment",
|
|
message=f"Staff removed: {staff_name}",
|
|
created_by=user,
|
|
)
|
|
|
|
# Audit log
|
|
AuditService.log_event(
|
|
event_type="complaint_staff_removed",
|
|
description=f"Staff {staff_name} removed from complaint {complaint.reference_number}",
|
|
user=user,
|
|
content_object=complaint,
|
|
)
|
|
|
|
messages.success(request, _("Staff member removed successfully."))
|
|
return redirect("complaints:complaint_detail", pk=complaint.pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def involved_staff_explanation(request, pk):
|
|
"""
|
|
Submit an explanation as an involved staff member.
|
|
"""
|
|
from .models import ComplaintInvolvedStaff
|
|
|
|
involved_staff = get_object_or_404(ComplaintInvolvedStaff, pk=pk)
|
|
complaint = involved_staff.complaint
|
|
user = request.user
|
|
|
|
# Check permission - must be the staff member themselves or have management rights
|
|
can_submit = (
|
|
involved_staff.staff.user == user if hasattr(involved_staff.staff, "user") else False
|
|
) or can_manage_complaint(user, complaint)
|
|
|
|
if not can_submit:
|
|
messages.error(request, _("You don't have permission to submit an explanation for this staff member."))
|
|
return redirect("complaints:complaint_detail", pk=complaint.pk)
|
|
|
|
form = StaffExplanationForm(request.POST, instance=involved_staff)
|
|
|
|
if form.is_valid():
|
|
involved_staff = form.save(commit=False)
|
|
involved_staff.explanation_received = True
|
|
involved_staff.explanation_received_at = timezone.now()
|
|
involved_staff.save()
|
|
|
|
# Log the update
|
|
ComplaintUpdate.objects.create(
|
|
complaint=complaint,
|
|
update_type="note",
|
|
message=f"Explanation submitted by {involved_staff.staff}",
|
|
created_by=user,
|
|
)
|
|
|
|
messages.success(request, _("Explanation submitted successfully."))
|
|
else:
|
|
messages.error(request, _("Please provide a valid explanation."))
|
|
|
|
return redirect("complaints:complaint_detail", pk=complaint.pk)
|
|
|
|
|
|
# =============================================================================
|
|
# Complaint Adverse Action Views
|
|
# =============================================================================
|
|
|
|
|
|
@login_required
|
|
def adverse_action_list(request):
|
|
"""
|
|
List all adverse actions related to complaints.
|
|
|
|
Features:
|
|
- Filter by status, severity, action type
|
|
- Search by complaint reference or description
|
|
- Paginated results
|
|
"""
|
|
# Check permission
|
|
user = request.user
|
|
if not user.is_px_admin() and not user.is_hospital_admin():
|
|
messages.error(request, _("You don't have permission to view adverse actions."))
|
|
return redirect("complaints:complaint_list")
|
|
|
|
# Base queryset
|
|
queryset = ComplaintAdverseAction.objects.select_related(
|
|
"complaint", "complaint__hospital", "reported_by"
|
|
).prefetch_related("involved_staff")
|
|
|
|
# Apply filters
|
|
status_filter = request.GET.get("status")
|
|
if status_filter:
|
|
queryset = queryset.filter(status=status_filter)
|
|
|
|
severity_filter = request.GET.get("severity")
|
|
if severity_filter:
|
|
queryset = queryset.filter(severity=severity_filter)
|
|
|
|
action_type_filter = request.GET.get("action_type")
|
|
if action_type_filter:
|
|
queryset = queryset.filter(action_type=action_type_filter)
|
|
|
|
# Filter by hospital for hospital admins
|
|
if user.is_hospital_admin() and user.hospital:
|
|
queryset = queryset.filter(complaint__hospital=user.hospital)
|
|
|
|
# Search
|
|
search_query = request.GET.get("search")
|
|
if search_query:
|
|
queryset = queryset.filter(
|
|
Q(complaint__reference_number__icontains=search_query)
|
|
| Q(description__icontains=search_query)
|
|
| Q(patient_impact__icontains=search_query)
|
|
)
|
|
|
|
# Pagination
|
|
paginator = Paginator(queryset.order_by("-incident_date"), 25)
|
|
page_number = request.GET.get("page")
|
|
page_obj = paginator.get_page(page_number)
|
|
|
|
context = {
|
|
"page_obj": page_obj,
|
|
"status_choices": ComplaintAdverseAction.VerificationStatus.choices,
|
|
"severity_choices": ComplaintAdverseAction.SeverityLevel.choices,
|
|
"action_type_choices": ComplaintAdverseAction.ActionType.choices,
|
|
"filters": request.GET,
|
|
}
|
|
|
|
return render(request, "complaints/adverse_action_list.html", context)
|
|
|
|
|
|
@login_required
|
|
def adverse_action_add(request, complaint_pk):
|
|
"""
|
|
Add a new adverse action to a complaint.
|
|
"""
|
|
complaint = get_object_or_404(Complaint, pk=complaint_pk)
|
|
user = request.user
|
|
|
|
# Check permission
|
|
if not can_manage_complaint(user, complaint):
|
|
messages.error(request, _("You don't have permission to add adverse actions to this complaint."))
|
|
return redirect("complaints:complaint_detail", pk=complaint_pk)
|
|
|
|
if request.method == "POST":
|
|
try:
|
|
# Parse form data
|
|
action_type = request.POST.get("action_type")
|
|
severity = request.POST.get("severity")
|
|
description = request.POST.get("description", "").strip()
|
|
patient_impact = request.POST.get("patient_impact", "").strip()
|
|
incident_date = request.POST.get("incident_date")
|
|
location = request.POST.get("location", "").strip()
|
|
involved_staff_ids = request.POST.getlist("involved_staff")
|
|
|
|
if not description:
|
|
messages.error(request, _("Description is required."))
|
|
return render(
|
|
request,
|
|
"complaints/adverse_action_form.html",
|
|
{
|
|
"complaint": complaint,
|
|
"action_type_choices": ComplaintAdverseAction.ActionType.choices,
|
|
"severity_choices": ComplaintAdverseAction.SeverityLevel.choices,
|
|
"staff_list": Staff.objects.filter(hospital=complaint.hospital, is_active=True),
|
|
},
|
|
)
|
|
|
|
# Create adverse action
|
|
adverse_action = ComplaintAdverseAction.objects.create(
|
|
complaint=complaint,
|
|
action_type=action_type or ComplaintAdverseAction.ActionType.OTHER,
|
|
severity=severity or ComplaintAdverseAction.SeverityLevel.MEDIUM,
|
|
description=description,
|
|
patient_impact=patient_impact,
|
|
incident_date=incident_date or timezone.now(),
|
|
location=location,
|
|
reported_by=user,
|
|
status=ComplaintAdverseAction.VerificationStatus.REPORTED,
|
|
)
|
|
|
|
# Add involved staff
|
|
if involved_staff_ids:
|
|
adverse_action.involved_staff.set(involved_staff_ids)
|
|
|
|
# Create timeline entry
|
|
ComplaintUpdate.objects.create(
|
|
complaint=complaint,
|
|
update_type="note",
|
|
message=f"Adverse action reported: {adverse_action.get_action_type_display()}",
|
|
created_by=user,
|
|
)
|
|
|
|
# Log audit
|
|
AuditService.log_event(
|
|
event_type="adverse_action_created",
|
|
description=f"Adverse action added to complaint {complaint.reference_number}: {adverse_action.get_action_type_display()}",
|
|
user=user,
|
|
content_object=adverse_action,
|
|
metadata={
|
|
"complaint_id": str(complaint.id),
|
|
"action_type": action_type,
|
|
"severity": severity,
|
|
},
|
|
)
|
|
|
|
messages.success(request, _("Adverse action reported successfully."))
|
|
return redirect("complaints:complaint_detail", pk=complaint_pk)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error creating adverse action: {str(e)}", exc_info=True)
|
|
messages.error(request, f"Error creating adverse action: {str(e)}")
|
|
|
|
context = {
|
|
"complaint": complaint,
|
|
"action_type_choices": ComplaintAdverseAction.ActionType.choices,
|
|
"severity_choices": ComplaintAdverseAction.SeverityLevel.choices,
|
|
"staff_list": Staff.objects.filter(hospital=complaint.hospital, is_active=True)
|
|
if complaint.hospital
|
|
else Staff.objects.filter(is_active=True),
|
|
}
|
|
|
|
return render(request, "complaints/adverse_action_form.html", context)
|
|
|
|
|
|
@login_required
|
|
def adverse_action_edit(request, pk):
|
|
"""
|
|
Edit an existing adverse action.
|
|
"""
|
|
adverse_action = get_object_or_404(ComplaintAdverseAction.objects.select_related("complaint"), pk=pk)
|
|
complaint = adverse_action.complaint
|
|
user = request.user
|
|
|
|
# Check permission
|
|
if not can_manage_complaint(user, complaint):
|
|
messages.error(request, _("You don't have permission to edit this adverse action."))
|
|
return redirect("complaints:complaint_detail", pk=complaint.id)
|
|
|
|
if request.method == "POST":
|
|
try:
|
|
# Update fields
|
|
adverse_action.action_type = request.POST.get("action_type", adverse_action.action_type)
|
|
adverse_action.severity = request.POST.get("severity", adverse_action.severity)
|
|
adverse_action.description = request.POST.get("description", adverse_action.description).strip()
|
|
adverse_action.patient_impact = request.POST.get("patient_impact", adverse_action.patient_impact).strip()
|
|
adverse_action.location = request.POST.get("location", adverse_action.location).strip()
|
|
|
|
incident_date = request.POST.get("incident_date")
|
|
if incident_date:
|
|
adverse_action.incident_date = incident_date
|
|
|
|
# Update involved staff
|
|
involved_staff_ids = request.POST.getlist("involved_staff")
|
|
if involved_staff_ids:
|
|
adverse_action.involved_staff.set(involved_staff_ids)
|
|
|
|
adverse_action.save()
|
|
|
|
# Log audit
|
|
AuditService.log_event(
|
|
event_type="adverse_action_updated",
|
|
description=f"Adverse action updated for complaint {complaint.reference_number}",
|
|
user=user,
|
|
content_object=adverse_action,
|
|
)
|
|
|
|
messages.success(request, _("Adverse action updated successfully."))
|
|
return redirect("complaints:complaint_detail", pk=complaint.id)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error updating adverse action: {str(e)}", exc_info=True)
|
|
messages.error(request, f"Error updating adverse action: {str(e)}")
|
|
|
|
context = {
|
|
"adverse_action": adverse_action,
|
|
"complaint": complaint,
|
|
"action_type_choices": ComplaintAdverseAction.ActionType.choices,
|
|
"severity_choices": ComplaintAdverseAction.SeverityLevel.choices,
|
|
"staff_list": Staff.objects.filter(hospital=complaint.hospital, is_active=True)
|
|
if complaint.hospital
|
|
else Staff.objects.filter(is_active=True),
|
|
"selected_staff": list(adverse_action.involved_staff.values_list("id", flat=True)),
|
|
}
|
|
|
|
return render(request, "complaints/adverse_action_form.html", context)
|
|
|
|
|
|
@login_required
|
|
def adverse_action_update_status(request, pk):
|
|
"""
|
|
Update the status of an adverse action (investigation, resolution).
|
|
"""
|
|
adverse_action = get_object_or_404(ComplaintAdverseAction.objects.select_related("complaint"), pk=pk)
|
|
complaint = adverse_action.complaint
|
|
user = request.user
|
|
|
|
# Check permission
|
|
if not can_manage_complaint(user, complaint):
|
|
messages.error(request, _("You don't have permission to update this adverse action."))
|
|
return redirect("complaints:complaint_detail", pk=complaint.id)
|
|
|
|
if request.method == "POST":
|
|
try:
|
|
new_status = request.POST.get("status")
|
|
investigation_notes = request.POST.get("investigation_notes", "").strip()
|
|
resolution = request.POST.get("resolution", "").strip()
|
|
|
|
old_status = adverse_action.status
|
|
adverse_action.status = new_status
|
|
|
|
if investigation_notes:
|
|
adverse_action.investigation_notes = investigation_notes
|
|
|
|
if resolution:
|
|
adverse_action.resolution = resolution
|
|
|
|
# Handle status-specific updates
|
|
if new_status == ComplaintAdverseAction.VerificationStatus.UNDER_INVESTIGATION:
|
|
adverse_action.investigated_by = user
|
|
adverse_action.investigated_at = timezone.now()
|
|
|
|
elif new_status == ComplaintAdverseAction.VerificationStatus.RESOLVED:
|
|
adverse_action.resolved_by = user
|
|
adverse_action.resolved_at = timezone.now()
|
|
|
|
elif new_status == ComplaintAdverseAction.VerificationStatus.VERIFIED:
|
|
adverse_action.investigated_by = user
|
|
adverse_action.investigated_at = timezone.now()
|
|
|
|
adverse_action.save()
|
|
|
|
# Create timeline entry
|
|
ComplaintUpdate.objects.create(
|
|
complaint=complaint,
|
|
update_type="note",
|
|
message=f"Adverse action status changed from {adverse_action.get_status_display()} to {dict(ComplaintAdverseAction.VerificationStatus.choices)[new_status]}",
|
|
created_by=user,
|
|
)
|
|
|
|
# Log audit
|
|
AuditService.log_event(
|
|
event_type="adverse_action_status_changed",
|
|
description=f"Adverse action status changed from {old_status} to {new_status} for complaint {complaint.reference_number}",
|
|
user=user,
|
|
content_object=adverse_action,
|
|
metadata={
|
|
"old_status": old_status,
|
|
"new_status": new_status,
|
|
},
|
|
)
|
|
|
|
messages.success(request, _("Adverse action status updated successfully."))
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error updating adverse action status: {str(e)}", exc_info=True)
|
|
messages.error(request, f"Error updating status: {str(e)}")
|
|
|
|
return redirect("complaints:complaint_detail", pk=complaint.id)
|
|
|
|
|
|
@login_required
|
|
def adverse_action_escalate(request, pk):
|
|
"""
|
|
Escalate an adverse action to management.
|
|
"""
|
|
adverse_action = get_object_or_404(ComplaintAdverseAction.objects.select_related("complaint"), pk=pk)
|
|
complaint = adverse_action.complaint
|
|
user = request.user
|
|
|
|
# Check permission
|
|
if not can_manage_complaint(user, complaint):
|
|
messages.error(request, _("You don't have permission to escalate this adverse action."))
|
|
return redirect("complaints:complaint_detail", pk=complaint.id)
|
|
|
|
if request.method == "POST":
|
|
try:
|
|
adverse_action.is_escalated = True
|
|
adverse_action.escalated_at = timezone.now()
|
|
adverse_action.save()
|
|
|
|
# Create timeline entry
|
|
ComplaintUpdate.objects.create(
|
|
complaint=complaint,
|
|
update_type="escalation",
|
|
message=f"Adverse action escalated: {adverse_action.get_action_type_display()}",
|
|
created_by=user,
|
|
)
|
|
|
|
# Log audit
|
|
AuditService.log_event(
|
|
event_type="adverse_action_escalated",
|
|
description=f"Adverse action escalated for complaint {complaint.reference_number}",
|
|
user=user,
|
|
content_object=adverse_action,
|
|
)
|
|
|
|
messages.success(request, _("Adverse action escalated successfully."))
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error escalating adverse action: {str(e)}", exc_info=True)
|
|
messages.error(request, f"Error escalating: {str(e)}")
|
|
|
|
return redirect("complaints:complaint_detail", pk=complaint.id)
|
|
|
|
|
|
@login_required
|
|
def adverse_action_delete(request, pk):
|
|
"""
|
|
Delete an adverse action.
|
|
"""
|
|
adverse_action = get_object_or_404(ComplaintAdverseAction.objects.select_related("complaint"), pk=pk)
|
|
complaint = adverse_action.complaint
|
|
user = request.user
|
|
|
|
# Check permission - only PX Admin or Hospital Admin can delete
|
|
if not (user.is_px_admin() or (user.is_hospital_admin() and user.hospital == complaint.hospital)):
|
|
messages.error(request, _("You don't have permission to delete adverse actions."))
|
|
return redirect("complaints:complaint_detail", pk=complaint.id)
|
|
|
|
if request.method == "POST":
|
|
try:
|
|
complaint_pk = complaint.id
|
|
|
|
# Log before deletion
|
|
AuditService.log_event(
|
|
event_type="adverse_action_deleted",
|
|
description=f"Adverse action deleted from complaint {complaint.reference_number}: {adverse_action.get_action_type_display()}",
|
|
user=user,
|
|
metadata={
|
|
"complaint_id": str(complaint.id),
|
|
"action_type": adverse_action.action_type,
|
|
"description": adverse_action.description[:100],
|
|
},
|
|
)
|
|
|
|
adverse_action.delete()
|
|
messages.success(request, _("Adverse action deleted successfully."))
|
|
return redirect("complaints:complaint_detail", pk=complaint_pk)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error deleting adverse action: {str(e)}", exc_info=True)
|
|
messages.error(request, f"Error deleting: {str(e)}")
|
|
|
|
return redirect("complaints:complaint_detail", pk=complaint.id)
|
|
|
|
|
|
def patient_complaint_portal(request, token):
|
|
"""Hospital selection page - first step of patient complaint flow."""
|
|
from apps.complaints.models import PatientComplaintSession
|
|
from apps.integrations.models import HISPatientVisit
|
|
from django.http import Http404
|
|
from django.db.models import Count
|
|
|
|
try:
|
|
session = PatientComplaintSession.objects.get(token=token, is_active=True)
|
|
except PatientComplaintSession.DoesNotExist:
|
|
raise Http404("Invalid or expired link")
|
|
|
|
if session.is_expired():
|
|
return render(request, "complaints/patient_complaint_expired.html")
|
|
|
|
patient = session.patient
|
|
|
|
hospitals = (
|
|
HISPatientVisit.objects.filter(patient=patient)
|
|
.values("hospital__id", "hospital__name", "hospital__name_ar")
|
|
.annotate(visit_count=Count("id"))
|
|
.order_by("-visit_count")
|
|
)
|
|
|
|
context = {
|
|
"patient": patient,
|
|
"session": session,
|
|
"hospitals": hospitals,
|
|
}
|
|
return render(request, "complaints/patient_complaint_portal.html", context)
|
|
|
|
|
|
def patient_complaint_hospital_visits(request, token, hospital_id):
|
|
"""Visit list for a selected hospital - second step."""
|
|
from apps.complaints.models import PatientComplaintSession
|
|
from apps.integrations.models import HISPatientVisit
|
|
from django.http import Http404
|
|
|
|
try:
|
|
session = PatientComplaintSession.objects.get(token=token, is_active=True)
|
|
except PatientComplaintSession.DoesNotExist:
|
|
raise Http404("Invalid or expired link")
|
|
|
|
if session.is_expired():
|
|
return render(request, "complaints/patient_complaint_expired.html")
|
|
|
|
patient = session.patient
|
|
|
|
visits = HISPatientVisit.objects.filter(patient=patient, hospital_id=hospital_id).order_by("-admit_date")[:50]
|
|
|
|
hospital = None
|
|
if visits:
|
|
hospital = visits[0].hospital
|
|
|
|
context = {
|
|
"patient": patient,
|
|
"session": session,
|
|
"hospital": hospital,
|
|
"visits": visits,
|
|
}
|
|
return render(request, "complaints/patient_complaint_visits.html", context)
|
|
|
|
|
|
def patient_complaint_visit_form(request, token, visit_id):
|
|
"""Complaint form for a selected visit - third step."""
|
|
from apps.complaints.models import PatientComplaintSession, Complaint
|
|
from apps.integrations.models import HISPatientVisit
|
|
from django.http import Http404
|
|
|
|
try:
|
|
session = PatientComplaintSession.objects.get(token=token, is_active=True)
|
|
except PatientComplaintSession.DoesNotExist:
|
|
raise Http404("Invalid or expired link")
|
|
|
|
if session.is_expired():
|
|
return render(request, "complaints/patient_complaint_expired.html")
|
|
|
|
patient = session.patient
|
|
visit = get_object_or_404(HISPatientVisit, id=visit_id, patient=patient)
|
|
|
|
if request.method == "POST":
|
|
description = request.POST.get("description", "").strip()
|
|
if not description:
|
|
return render(
|
|
request,
|
|
"complaints/patient_complaint_visit_form.html",
|
|
{
|
|
"patient": patient,
|
|
"session": session,
|
|
"visit": visit,
|
|
"error": "Please enter a description of your complaint.",
|
|
},
|
|
)
|
|
|
|
title = f"Complaint - {visit.patient_type} Visit ({visit.admission_id})"
|
|
complaint = Complaint.objects.create(
|
|
patient=patient,
|
|
hospital=visit.hospital,
|
|
title=title,
|
|
description=description,
|
|
encounter_id=visit.admission_id,
|
|
complaint_source_type="external",
|
|
priority="medium",
|
|
severity="medium",
|
|
status="open",
|
|
metadata={
|
|
"session_id": str(session.id),
|
|
"visit_id": str(visit.id),
|
|
"visit_type": visit.patient_type,
|
|
"submitted_via": "patient_link",
|
|
},
|
|
)
|
|
|
|
session.is_active = False
|
|
session.save()
|
|
|
|
return render(
|
|
request,
|
|
"complaints/patient_complaint_success.html",
|
|
{
|
|
"complaint": complaint,
|
|
"patient": patient,
|
|
},
|
|
)
|
|
|
|
context = {
|
|
"patient": patient,
|
|
"session": session,
|
|
"visit": visit,
|
|
}
|
|
return render(request, "complaints/patient_complaint_visit_form.html", context)
|
|
|
|
|
|
# ==================== Government Ticket Views ====================
|
|
|
|
|
|
@login_required
|
|
def government_ticket_list(request):
|
|
"""List government tickets with filters"""
|
|
# Permission check: PX Admin or PX Employee only
|
|
if not (request.user.is_px_admin() or request.user.is_px_management()):
|
|
messages.error(request, _("You don't have permission to view government tickets."))
|
|
return redirect("dashboard:index")
|
|
|
|
# Base queryset
|
|
queryset = GovernmentTicket.objects.select_related("source", "location", "main_section", "assigned_to").all()
|
|
|
|
# Filters
|
|
source_filter = request.GET.get("source")
|
|
status_filter = request.GET.get("status")
|
|
department_filter = request.GET.get("department")
|
|
search_query = request.GET.get("q", "").strip()
|
|
converted_filter = request.GET.get("converted")
|
|
|
|
if source_filter:
|
|
queryset = queryset.filter(source_id=source_filter)
|
|
if status_filter:
|
|
queryset = queryset.filter(status=status_filter)
|
|
if department_filter:
|
|
queryset = queryset.filter(main_section_id=department_filter)
|
|
if converted_filter:
|
|
is_converted = converted_filter == "yes"
|
|
queryset = queryset.filter(converted_to_complaint=is_converted)
|
|
if search_query:
|
|
queryset = queryset.filter(
|
|
Q(ticket_number__icontains=search_query)
|
|
| Q(complainant_name__icontains=search_query)
|
|
| Q(national_id__icontains=search_query)
|
|
| Q(content__icontains=search_query)
|
|
)
|
|
|
|
# Pagination
|
|
paginator = Paginator(queryset.order_by("-received_date"), 25)
|
|
page_number = request.GET.get("page")
|
|
page_obj = paginator.get_page(page_number)
|
|
|
|
# Context
|
|
context = {
|
|
"page_obj": page_obj,
|
|
"sources": PXSource.objects.filter(source_type="government", is_active=True),
|
|
"departments": Department.objects.filter(status="active"),
|
|
"status_choices": GovernmentTicket.TicketStatus.choices,
|
|
"source_filter": source_filter,
|
|
"status_filter": status_filter,
|
|
"department_filter": department_filter,
|
|
"converted_filter": converted_filter,
|
|
"search_query": search_query,
|
|
"total_count": queryset.count(),
|
|
}
|
|
|
|
return render(request, "complaints/government_ticket_list.html", context)
|
|
|
|
|
|
@login_required
|
|
def government_ticket_detail(request, pk):
|
|
"""Detail view for a government ticket"""
|
|
ticket = get_object_or_404(
|
|
GovernmentTicket.objects.select_related("source", "location", "main_section", "assigned_to", "complaint"),
|
|
pk=pk,
|
|
)
|
|
|
|
# Permission check
|
|
if not (request.user.is_px_admin() or request.user.is_px_management()):
|
|
messages.error(request, _("You don't have permission to view this ticket."))
|
|
return redirect("complaints:government_ticket_list")
|
|
|
|
context = {
|
|
"ticket": ticket,
|
|
}
|
|
return render(request, "complaints/government_ticket_detail.html", context)
|
|
|
|
|
|
@login_required
|
|
def government_ticket_create(request):
|
|
"""Create a new government ticket"""
|
|
# Permission check
|
|
if not (request.user.is_px_admin() or request.user.is_px_management()):
|
|
messages.error(request, _("You don't have permission to create government tickets."))
|
|
return redirect("complaints:government_ticket_list")
|
|
|
|
if request.method == "POST":
|
|
form = GovernmentTicketForm(request.POST)
|
|
if form.is_valid():
|
|
ticket = form.save()
|
|
messages.success(request, _("Government ticket created successfully."))
|
|
return redirect("complaints:government_ticket_detail", pk=ticket.pk)
|
|
else:
|
|
form = GovernmentTicketForm()
|
|
|
|
context = {
|
|
"form": form,
|
|
"is_create": True,
|
|
}
|
|
return render(request, "complaints/government_ticket_form.html", context)
|
|
|
|
|
|
@login_required
|
|
def government_ticket_update(request, pk):
|
|
"""Update an existing government ticket"""
|
|
ticket = get_object_or_404(GovernmentTicket, pk=pk)
|
|
|
|
# Permission check
|
|
if not (request.user.is_px_admin() or request.user.is_px_management()):
|
|
messages.error(request, _("You don't have permission to update this ticket."))
|
|
return redirect("complaints:government_ticket_list")
|
|
|
|
if request.method == "POST":
|
|
form = GovernmentTicketForm(request.POST, instance=ticket)
|
|
if form.is_valid():
|
|
form.save()
|
|
messages.success(request, _("Government ticket updated successfully."))
|
|
return redirect("complaints:government_ticket_detail", pk=ticket.pk)
|
|
else:
|
|
form = GovernmentTicketForm(instance=ticket)
|
|
|
|
context = {
|
|
"form": form,
|
|
"ticket": ticket,
|
|
"is_create": False,
|
|
}
|
|
return render(request, "complaints/government_ticket_form.html", context)
|
|
|
|
|
|
@login_required
|
|
def convert_to_complaint(request, pk):
|
|
"""Convert a government ticket to a complaint"""
|
|
ticket = get_object_or_404(GovernmentTicket, pk=pk)
|
|
|
|
# Permission check
|
|
if not (request.user.is_px_admin() or request.user.is_px_management()):
|
|
messages.error(request, _("You don't have permission to convert this ticket."))
|
|
return redirect("complaints:government_ticket_list")
|
|
|
|
if ticket.converted_to_complaint:
|
|
messages.warning(request, _("This ticket has already been converted to a complaint."))
|
|
return redirect("complaints:government_ticket_detail", pk=ticket.pk)
|
|
|
|
# Build redirect URL to complaint create form with pre-filled data
|
|
from django.urls import reverse
|
|
import urllib.parse
|
|
|
|
params = {
|
|
"source": ticket.source_id,
|
|
"complaint_source_type": "external",
|
|
"title": f"[{ticket.ticket_number}] {ticket.classification or 'Government Ticket'}",
|
|
"description": ticket.content,
|
|
"main_section": ticket.main_section_id or "",
|
|
"subsection": ticket.subsection_id or "",
|
|
"patient_name": ticket.complainant_name,
|
|
"contact_phone": ticket.contact_number or "",
|
|
"moh_reference": ticket.ticket_number if "moh" in ticket.source.code.lower() else "",
|
|
"chi_reference": ticket.ticket_number if "chi" in ticket.source.code.lower() else "",
|
|
}
|
|
|
|
query_string = urllib.parse.urlencode({k: v for k, v in params.items() if v})
|
|
url = f"{reverse('complaints:complaint_create')}?{query_string}"
|
|
|
|
return redirect(url)
|
|
|
|
|
|
@login_required
|
|
def government_ticket_import(request):
|
|
"""Import government tickets from Excel file"""
|
|
if not (request.user.is_px_admin() or request.user.is_px_management()):
|
|
messages.error(request, _("You don't have permission to import government tickets."))
|
|
return redirect("complaints:government_ticket_list")
|
|
|
|
preview_data = None
|
|
import_count = 0
|
|
error_count = 0
|
|
errors = []
|
|
|
|
if request.method == "POST":
|
|
action = request.POST.get("action")
|
|
|
|
if action == "preview":
|
|
# Handle file upload and show preview
|
|
file = request.FILES.get("file")
|
|
if not file:
|
|
messages.error(request, _("Please select a file to import."))
|
|
return redirect("complaints:government_ticket_import")
|
|
|
|
try:
|
|
import pandas as pd
|
|
|
|
# Read Excel file
|
|
df = pd.read_excel(file, header=1) # Header is on row 2 (index 1)
|
|
|
|
# Column mapping (Arabic to English)
|
|
column_map = {
|
|
"رقم التذكرة": "ticket_number",
|
|
"اسم المشتكي": "complainant_name",
|
|
"رقم الهوية": "national_id",
|
|
"رقم التواصل": "contact_number",
|
|
"الموقع": "location",
|
|
"القسم الرئيسي": "main_section",
|
|
"القسم الفرعي": "subsection",
|
|
"تاريخ إنشاء التذكرة": "received_date",
|
|
"وقت إنشاء التذكرة": "received_time",
|
|
"تصنيف الشكوى": "classification",
|
|
"محتوى الشكوى": "content",
|
|
"حالة الشكوى": "status",
|
|
"اسم الموظف": "assigned_to",
|
|
}
|
|
|
|
# Rename columns
|
|
df.rename(columns=column_map, inplace=True)
|
|
|
|
# Take first 5 rows for preview
|
|
preview_df = df.head(5)
|
|
preview_data = preview_df.fillna("").to_dict("records")
|
|
|
|
# Store full dataframe in session for actual import
|
|
request.session["import_df"] = df.to_json()
|
|
request.session["import_filename"] = file.name
|
|
|
|
messages.info(request, _("Preview ready. Review the data below and confirm to import."))
|
|
|
|
except Exception as e:
|
|
messages.error(request, f"Error reading file: {str(e)}")
|
|
return redirect("complaints:government_ticket_import")
|
|
|
|
elif action == "import":
|
|
# Handle actual import
|
|
json_data = request.session.get("import_df")
|
|
if not json_data:
|
|
messages.error(request, _("No data to import. Please upload a file first."))
|
|
return redirect("complaints:government_ticket_import")
|
|
|
|
try:
|
|
import pandas as pd
|
|
from apps.px_sources.models import PXSource
|
|
from apps.organizations.models import Location, MainSection, SubSection
|
|
from apps.accounts.models import User
|
|
|
|
df = pd.read_json(json_data)
|
|
|
|
# Get MOH source (default for import)
|
|
moh_source = PXSource.objects.filter(code__icontains="MOH", source_type="government").first()
|
|
if not moh_source:
|
|
messages.error(request, _("MOH source not found in system. Please configure it first."))
|
|
return redirect("complaints:government_ticket_import")
|
|
|
|
# Status mapping
|
|
status_map = {
|
|
"مفتوحة": "pending",
|
|
"قيد المعالجة": "in_progress",
|
|
"تم الحل": "resolved",
|
|
"مغلقة": "closed",
|
|
"pending": "pending",
|
|
"in_progress": "in_progress",
|
|
"resolved": "resolved",
|
|
"closed": "closed",
|
|
}
|
|
|
|
for idx, row in df.iterrows():
|
|
try:
|
|
# Skip if ticket number is missing
|
|
if pd.isna(row.get("ticket_number")):
|
|
errors.append(f"Row {idx + 1}: Missing ticket number")
|
|
error_count += 1
|
|
continue
|
|
|
|
# Check for duplicate
|
|
if GovernmentTicket.objects.filter(ticket_number=str(row["ticket_number"])).exists():
|
|
errors.append(f"Row {idx + 1}: Ticket {row['ticket_number']} already exists")
|
|
error_count += 1
|
|
continue
|
|
|
|
# Parse received date
|
|
received_date = None
|
|
if pd.notna(row.get("received_date")):
|
|
received_date = pd.to_datetime(row["received_date"])
|
|
if pd.notna(row.get("received_time")):
|
|
time_part = pd.to_datetime(row["received_time"])
|
|
received_date = received_date.replace(
|
|
hour=time_part.hour,
|
|
minute=time_part.minute,
|
|
second=time_part.second,
|
|
)
|
|
else:
|
|
received_date = timezone.now()
|
|
|
|
# Lookup location
|
|
ticket_location = None
|
|
if pd.notna(row.get("location")):
|
|
loc_name = str(row["location"]).strip()
|
|
ticket_location = Location.objects.filter(
|
|
models.Q(name_en__icontains=loc_name) | models.Q(name_ar__icontains=loc_name)
|
|
).first()
|
|
|
|
# Lookup main section
|
|
ticket_main_section = None
|
|
if pd.notna(row.get("main_section")):
|
|
sec_name = str(row["main_section"]).strip()
|
|
ticket_main_section = MainSection.objects.filter(
|
|
models.Q(name_en__icontains=sec_name) | models.Q(name_ar__icontains=sec_name)
|
|
).first()
|
|
|
|
# Lookup subsection
|
|
ticket_subsection = None
|
|
if pd.notna(row.get("subsection")):
|
|
sub_name = str(row["subsection"]).strip()
|
|
qs = SubSection.objects.filter(
|
|
models.Q(name_en__icontains=sub_name) | models.Q(name_ar__icontains=sub_name)
|
|
)
|
|
if ticket_location:
|
|
qs = qs.filter(location=ticket_location)
|
|
if ticket_main_section:
|
|
qs = qs.filter(main_section=ticket_main_section)
|
|
ticket_subsection = qs.first()
|
|
|
|
# Lookup assigned user
|
|
assigned_user = None
|
|
if pd.notna(row.get("assigned_to")):
|
|
assigned_user = User.objects.filter(
|
|
models.Q(first_name__icontains=str(row["assigned_to"]).strip()) |
|
|
models.Q(last_name__icontains=str(row["assigned_to"]).strip()) |
|
|
models.Q(get_full_name__icontains=str(row["assigned_to"]).strip())
|
|
).first()
|
|
|
|
# Map status
|
|
status = "pending"
|
|
if pd.notna(row.get("status")):
|
|
status = status_map.get(str(row["status"]).strip().lower(), "pending")
|
|
|
|
# Create ticket
|
|
GovernmentTicket.objects.create(
|
|
source=moh_source,
|
|
ticket_number=str(row["ticket_number"]).strip(),
|
|
complainant_name=str(row.get("complainant_name", "")).strip() or "Unknown",
|
|
national_id=str(row.get("national_id", "")).strip() if pd.notna(row.get("national_id")) else "",
|
|
contact_number=str(row.get("contact_number", "")).strip() if pd.notna(row.get("contact_number")) else "",
|
|
location=ticket_location,
|
|
main_section=ticket_main_section,
|
|
subsection=ticket_subsection,
|
|
received_date=received_date,
|
|
classification=str(row.get("classification", "")).strip() if pd.notna(row.get("classification")) else "",
|
|
content=str(row.get("content", "")).strip() if pd.notna(row.get("content")) else "",
|
|
status=status,
|
|
assigned_to=assigned_user,
|
|
)
|
|
import_count += 1
|
|
|
|
except Exception as e:
|
|
errors.append(f"Row {idx + 1}: {str(e)}")
|
|
error_count += 1
|
|
|
|
# Clear session data
|
|
del request.session["import_df"]
|
|
del request.session["import_filename"]
|
|
|
|
if import_count > 0:
|
|
messages.success(request, f"Successfully imported {import_count} tickets.")
|
|
if error_count > 0:
|
|
messages.warning(request, f"{error_count} rows had errors.")
|
|
|
|
return redirect("complaints:government_ticket_list")
|
|
|
|
except Exception as e:
|
|
messages.error(request, f"Import failed: {str(e)}")
|
|
return redirect("complaints:government_ticket_import")
|
|
|
|
context = {
|
|
"preview_data": preview_data,
|
|
"errors": errors,
|
|
"import_count": import_count,
|
|
"error_count": error_count,
|
|
}
|
|
return render(request, "complaints/government_ticket_import.html", context)
|
|
|
|
|
|
@login_required
|
|
def government_ticket_export(request):
|
|
"""Export government tickets to Excel"""
|
|
if not (request.user.is_px_admin() or request.user.is_px_management()):
|
|
messages.error(request, _("You don't have permission to export government tickets."))
|
|
return redirect("complaints:government_ticket_list")
|
|
|
|
import pandas as pd
|
|
from django.http import HttpResponse
|
|
|
|
# Get filtered queryset (same filters as list view)
|
|
queryset = GovernmentTicket.objects.select_related("source", "location", "main_section", "assigned_to").all()
|
|
|
|
source_filter = request.GET.get("source")
|
|
status_filter = request.GET.get("status")
|
|
department_filter = request.GET.get("department")
|
|
search_query = request.GET.get("q", "").strip()
|
|
|
|
if source_filter:
|
|
queryset = queryset.filter(source_id=source_filter)
|
|
if status_filter:
|
|
queryset = queryset.filter(status=status_filter)
|
|
if department_filter:
|
|
queryset = queryset.filter(main_section_id=department_filter)
|
|
if search_query:
|
|
queryset = queryset.filter(
|
|
models.Q(ticket_number__icontains=search_query)
|
|
| models.Q(complainant_name__icontains=search_query)
|
|
| models.Q(national_id__icontains=search_query)
|
|
| models.Q(content__icontains=search_query)
|
|
)
|
|
|
|
# Prepare data
|
|
data = []
|
|
for ticket in queryset.order_by("-received_date"):
|
|
data.append({
|
|
"رقم التذكرة": ticket.ticket_number,
|
|
"اسم المشتكي": ticket.complainant_name,
|
|
"رقم الهوية": ticket.national_id or "",
|
|
"رقم التواصل": ticket.contact_number or "",
|
|
"الموقع": ticket.location.name_en if ticket.location else "",
|
|
"القسم الرئيسي": ticket.main_section.name_en if ticket.main_section else "",
|
|
"القسم الفرعي": ticket.subsection.name_en if ticket.subsection else "",
|
|
"تاريخ إنشاء التذكرة": ticket.received_date.strftime("%Y-%m-%d") if ticket.received_date else "",
|
|
"وقت إنشاء التذكرة": ticket.received_date.strftime("%H:%M:%S") if ticket.received_date else "",
|
|
"تصنيف الشكوى": ticket.classification or "",
|
|
"محتوى الشكوى": ticket.content or "",
|
|
"حالة الشكوى": ticket.get_status_display(),
|
|
"اسم الموظف": ticket.assigned_to.get_full_name() if ticket.assigned_to else "",
|
|
"تم التحويل": "نعم" if ticket.converted_to_complaint else "لا",
|
|
})
|
|
|
|
df = pd.DataFrame(data)
|
|
|
|
# Create response
|
|
response = HttpResponse(content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
|
response["Content-Disposition"] = 'attachment; filename="government_tickets.xlsx"'
|
|
|
|
with pd.ExcelWriter(response, engine="openpyxl") as writer:
|
|
df.to_excel(writer, index=False, sheet_name="Government Tickets")
|
|
|
|
return response
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def complaint_soft_delete(request, pk):
|
|
complaint = get_object_or_404(Complaint, pk=pk)
|
|
if not (request.user.is_px_admin() or request.user.is_hospital_admin()):
|
|
return HttpResponseForbidden(_("You don't have permission to delete complaints."))
|
|
complaint.soft_delete(user=request.user)
|
|
messages.success(request, _("Complaint moved to trash."))
|
|
return redirect("complaints:complaint_list")
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def complaint_restore(request, pk):
|
|
complaint = get_object_or_404(Complaint.all_objects, pk=pk, is_deleted=True)
|
|
if not (request.user.is_px_admin() or request.user.is_hospital_admin()):
|
|
return HttpResponseForbidden(_("You don't have permission to restore complaints."))
|
|
complaint.restore()
|
|
messages.success(request, _("Complaint restored successfully."))
|
|
return redirect("config:deleted_items")
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def inquiry_restore(request, pk):
|
|
inquiry = get_object_or_404(Inquiry.all_objects, pk=pk, is_deleted=True)
|
|
if not (request.user.is_px_admin() or request.user.is_hospital_admin()):
|
|
return HttpResponseForbidden(_("You don't have permission to restore inquiries."))
|
|
inquiry.restore()
|
|
messages.success(request, _("Inquiry restored successfully."))
|
|
return redirect("config:deleted_items")
|
|
|
|
|
|
@login_required
|
|
def trash_list(request):
|
|
if not (request.user.is_px_admin() or request.user.is_hospital_admin()):
|
|
return HttpResponseForbidden(_("You don't have permission to view trash."))
|
|
|
|
deleted_complaints = Complaint.all_objects.filter(is_deleted=True).select_related(
|
|
"hospital", "department", "created_by"
|
|
).order_by("-deleted_at")
|
|
|
|
deleted_observations = Observation.all_objects.filter(is_deleted=True).select_related(
|
|
"hospital", "assigned_department"
|
|
).order_by("-deleted_at")
|
|
|
|
return render(request, "complaints/trash_list.html", {
|
|
"deleted_complaints": deleted_complaints,
|
|
"deleted_observations": deleted_observations,
|
|
})
|