1000 lines
34 KiB
Python
1000 lines
34 KiB
Python
"""
|
|
Complaints admin
|
|
"""
|
|
|
|
from django.contrib import admin
|
|
from django.utils.html import format_html, mark_safe
|
|
|
|
from .models import (
|
|
Complaint,
|
|
ComplaintAttachment,
|
|
ComplaintCategory,
|
|
ComplaintMeeting,
|
|
ComplaintPRInteraction,
|
|
ComplaintSLAConfig,
|
|
ComplaintThreshold,
|
|
ComplaintUpdate,
|
|
EscalationRule,
|
|
Inquiry,
|
|
ExplanationSLAConfig,
|
|
ComplaintInvolvedDepartment,
|
|
ComplaintInvolvedStaff,
|
|
OnCallAdminSchedule,
|
|
OnCallAdmin,
|
|
ComplaintAdverseAction,
|
|
ComplaintAdverseActionAttachment,
|
|
)
|
|
|
|
|
|
class ExplanationSLAConfigAdmin(admin.ModelAdmin):
|
|
list_display = (
|
|
"hospital",
|
|
"response_hours",
|
|
"reminder_hours_before",
|
|
"second_reminder_enabled",
|
|
"second_reminder_hours_before",
|
|
"auto_escalate_enabled",
|
|
"is_active",
|
|
)
|
|
list_filter = ("is_active", "second_reminder_enabled", "auto_escalate_enabled")
|
|
fieldsets = (
|
|
(
|
|
None,
|
|
{
|
|
"fields": (
|
|
"hospital",
|
|
"response_hours",
|
|
"reminder_hours_before",
|
|
"second_reminder_enabled",
|
|
"second_reminder_hours_before",
|
|
"is_active",
|
|
)
|
|
},
|
|
),
|
|
("Escalation", {"fields": ("auto_escalate_enabled", "escalation_hours_overdue", "max_escalation_levels")}),
|
|
)
|
|
|
|
|
|
admin.site.register(ExplanationSLAConfig, ExplanationSLAConfigAdmin)
|
|
|
|
|
|
class ComplaintAttachmentInline(admin.TabularInline):
|
|
"""Inline admin for complaint attachments"""
|
|
|
|
model = ComplaintAttachment
|
|
extra = 0
|
|
fields = ["file", "filename", "file_size", "uploaded_by", "description"]
|
|
readonly_fields = ["file_size"]
|
|
|
|
|
|
class ComplaintUpdateInline(admin.TabularInline):
|
|
"""Inline admin for complaint updates"""
|
|
|
|
model = ComplaintUpdate
|
|
extra = 1
|
|
fields = ["update_type", "message", "created_by", "created_at"]
|
|
readonly_fields = ["created_at"]
|
|
ordering = ["-created_at"]
|
|
|
|
|
|
class ComplaintInvolvedDepartmentInline(admin.TabularInline):
|
|
"""Inline admin for involved departments"""
|
|
|
|
model = ComplaintInvolvedDepartment
|
|
extra = 0
|
|
fields = [
|
|
"department",
|
|
"role",
|
|
"is_primary",
|
|
"assigned_to",
|
|
"forwarded_at",
|
|
"first_reminder_sent_at",
|
|
"second_reminder_sent_at",
|
|
"response_submitted",
|
|
"response_submitted_at",
|
|
"delay_reason",
|
|
"delayed_person",
|
|
]
|
|
autocomplete_fields = ["department", "assigned_to"]
|
|
readonly_fields = ["response_submitted_at"]
|
|
|
|
|
|
class ComplaintInvolvedStaffInline(admin.TabularInline):
|
|
"""Inline admin for involved staff"""
|
|
|
|
model = ComplaintInvolvedStaff
|
|
extra = 0
|
|
fields = ["staff", "role", "explanation_requested", "explanation_received"]
|
|
autocomplete_fields = ["staff"]
|
|
|
|
|
|
@admin.register(Complaint)
|
|
class ComplaintAdmin(admin.ModelAdmin):
|
|
"""Complaint admin"""
|
|
|
|
list_display = [
|
|
"title_preview",
|
|
"complaint_type_badge",
|
|
"patient",
|
|
"hospital",
|
|
"location_hierarchy",
|
|
"category",
|
|
"severity_badge",
|
|
"status_badge",
|
|
"sla_indicator",
|
|
"satisfaction_badge",
|
|
"created_by",
|
|
"assigned_to",
|
|
"created_at",
|
|
]
|
|
list_filter = [
|
|
"status",
|
|
"severity",
|
|
"priority",
|
|
"category",
|
|
"source",
|
|
"location",
|
|
"main_section",
|
|
"subsection",
|
|
"is_overdue",
|
|
"hospital",
|
|
"created_by",
|
|
"created_at",
|
|
"satisfaction",
|
|
]
|
|
search_fields = [
|
|
"title",
|
|
"description",
|
|
"patient__mrn",
|
|
"patient__first_name",
|
|
"patient__last_name",
|
|
"encounter_id",
|
|
"file_number",
|
|
"moh_reference",
|
|
"chi_reference",
|
|
"complaint_subject",
|
|
]
|
|
ordering = ["-created_at"]
|
|
date_hierarchy = "created_at"
|
|
inlines = [
|
|
ComplaintUpdateInline,
|
|
ComplaintAttachmentInline,
|
|
ComplaintInvolvedDepartmentInline,
|
|
ComplaintInvolvedStaffInline,
|
|
]
|
|
|
|
fieldsets = (
|
|
(
|
|
"Patient & Encounter",
|
|
{"fields": ("patient", "patient_name", "file_number", "encounter_id", "incident_date", "contact_phone")},
|
|
),
|
|
("Organization", {"fields": ("hospital", "department", "staff")}),
|
|
("Location Hierarchy", {"fields": ("location", "main_section", "subsection")}),
|
|
(
|
|
"Complaint Details",
|
|
{
|
|
"fields": (
|
|
"complaint_type",
|
|
"title",
|
|
"ai_brief_en",
|
|
"ai_brief_ar",
|
|
"description",
|
|
"complaint_subject",
|
|
"category",
|
|
"subcategory",
|
|
)
|
|
},
|
|
),
|
|
("Classification", {"fields": ("priority", "severity", "source")}),
|
|
(
|
|
"External References",
|
|
{"fields": ("moh_reference", "moh_reference_date", "chi_reference", "chi_reference_date")},
|
|
),
|
|
("Creator Tracking", {"fields": ("created_by",)}),
|
|
("Status & Assignment", {"fields": ("status", "assigned_to", "assigned_at", "activated_at")}),
|
|
("Workflow Timeline", {"fields": ("form_sent_at", "forwarded_to_dept_at", "response_date")}),
|
|
(
|
|
"SLA Tracking",
|
|
{
|
|
"fields": (
|
|
"due_at",
|
|
"is_overdue",
|
|
"reminder_sent_at",
|
|
"second_reminder_sent_at",
|
|
"breached_at",
|
|
"escalated_at",
|
|
)
|
|
},
|
|
),
|
|
(
|
|
"Resolution",
|
|
{
|
|
"fields": (
|
|
"resolution",
|
|
"resolution_category",
|
|
"resolution_outcome",
|
|
"resolution_outcome_other",
|
|
"resolved_at",
|
|
"resolved_by",
|
|
)
|
|
},
|
|
),
|
|
(
|
|
"Satisfaction",
|
|
{
|
|
"fields": (
|
|
"satisfaction",
|
|
"action_taken_by_dept",
|
|
"action_result",
|
|
"recommendation_action_plan",
|
|
"delay_reason_closure",
|
|
"explanation_delay_reason",
|
|
)
|
|
},
|
|
),
|
|
("Closure", {"fields": ("closed_at", "closed_by", "resolution_survey", "resolution_survey_sent_at")}),
|
|
("Metadata", {"fields": ("metadata", "created_at", "updated_at"), "classes": ("collapse",)}),
|
|
)
|
|
|
|
readonly_fields = [
|
|
"assigned_at",
|
|
"activated_at",
|
|
"reminder_sent_at",
|
|
"escalated_at",
|
|
"resolved_at",
|
|
"closed_at",
|
|
"resolution_survey_sent_at",
|
|
"created_at",
|
|
"updated_at",
|
|
]
|
|
|
|
def get_queryset(self, request):
|
|
qs = super().get_queryset(request)
|
|
return qs.select_related(
|
|
"patient",
|
|
"hospital",
|
|
"department",
|
|
"staff",
|
|
"location",
|
|
"main_section",
|
|
"subsection",
|
|
"assigned_to",
|
|
"resolved_by",
|
|
"closed_by",
|
|
"resolution_survey",
|
|
"created_by",
|
|
)
|
|
|
|
def title_preview(self, obj):
|
|
"""Show preview of title"""
|
|
return obj.title[:60] + "..." if len(obj.title) > 60 else obj.title
|
|
|
|
title_preview.short_description = "Title"
|
|
|
|
def location_hierarchy(self, obj):
|
|
"""Display location hierarchy in admin"""
|
|
parts = []
|
|
if obj.location:
|
|
parts.append(obj.location.name_en or obj.location.name_ar or str(obj.location))
|
|
if obj.main_section:
|
|
parts.append(obj.main_section.name_en or obj.main_section.name_ar or str(obj.main_section))
|
|
if obj.subsection:
|
|
parts.append(obj.subsection.name_en or obj.subsection.name_ar or str(obj.subsection))
|
|
|
|
if not parts:
|
|
return "—"
|
|
|
|
hierarchy = " → ".join(parts)
|
|
return format_html('<span class="text-muted">{0}</span>', hierarchy)
|
|
|
|
location_hierarchy.short_description = "Location"
|
|
|
|
def severity_badge(self, obj):
|
|
"""Display severity with color badge"""
|
|
colors = {
|
|
"low": "info",
|
|
"medium": "warning",
|
|
"high": "danger",
|
|
"critical": "danger",
|
|
}
|
|
color = colors.get(obj.severity, "secondary")
|
|
return format_html('<span class="badge bg-{0}">{1}</span>', color, obj.get_severity_display())
|
|
|
|
severity_badge.short_description = "Severity"
|
|
|
|
def status_badge(self, obj):
|
|
"""Display status with color badge"""
|
|
colors = {
|
|
"open": "warning",
|
|
"in_progress": "info",
|
|
"partially_resolved": "info",
|
|
"resolved": "success",
|
|
"closed": "secondary",
|
|
"cancelled": "secondary",
|
|
"contacted": "info",
|
|
"contacted_no_response": "danger",
|
|
}
|
|
color = colors.get(obj.status, "secondary")
|
|
return format_html('<span class="badge bg-{0}">{1}</span>', color, obj.get_status_display())
|
|
|
|
status_badge.short_description = "Status"
|
|
|
|
def satisfaction_badge(self, obj):
|
|
"""Display satisfaction with color badge"""
|
|
if not obj.satisfaction:
|
|
return "—"
|
|
colors = {
|
|
"satisfied": "success",
|
|
"neutral": "warning",
|
|
"dissatisfied": "danger",
|
|
"no_response": "secondary",
|
|
"escalated": "danger",
|
|
}
|
|
color = colors.get(obj.satisfaction, "secondary")
|
|
return format_html('<span class="badge bg-{0}">{1}</span>', color, obj.get_satisfaction_display())
|
|
|
|
satisfaction_badge.short_description = "Satisfaction"
|
|
|
|
def complaint_type_badge(self, obj):
|
|
"""Display complaint type with color badge"""
|
|
colors = {
|
|
"complaint": "danger",
|
|
"appreciation": "success",
|
|
}
|
|
color = colors.get(obj.complaint_type, "secondary")
|
|
return format_html('<span class="badge bg-{0}">{1}</span>', color, obj.get_complaint_type_display())
|
|
|
|
complaint_type_badge.short_description = "Type"
|
|
|
|
def sla_indicator(self, obj):
|
|
"""Display SLA status"""
|
|
if obj.is_overdue:
|
|
return mark_safe('<span class="badge bg-danger">OVERDUE</span>')
|
|
|
|
from django.utils import timezone
|
|
|
|
time_remaining = obj.due_at - timezone.now()
|
|
hours_remaining = time_remaining.total_seconds() / 3600
|
|
|
|
if hours_remaining < 4:
|
|
return mark_safe('<span class="badge bg-warning">DUE SOON</span>')
|
|
else:
|
|
return mark_safe('<span class="badge bg-success">ON TIME</span>')
|
|
|
|
sla_indicator.short_description = "SLA"
|
|
|
|
|
|
@admin.register(ComplaintAttachment)
|
|
class ComplaintAttachmentAdmin(admin.ModelAdmin):
|
|
"""Complaint attachment admin"""
|
|
|
|
list_display = ["complaint", "filename", "file_type", "file_size", "uploaded_by", "created_at"]
|
|
list_filter = ["file_type", "created_at"]
|
|
search_fields = ["filename", "description", "complaint__title"]
|
|
ordering = ["-created_at"]
|
|
|
|
fieldsets = (
|
|
(None, {"fields": ("complaint", "file", "filename", "file_type", "file_size")}),
|
|
("Details", {"fields": ("uploaded_by", "description")}),
|
|
("Metadata", {"fields": ("created_at", "updated_at")}),
|
|
)
|
|
|
|
readonly_fields = ["file_size", "created_at", "updated_at"]
|
|
|
|
def get_queryset(self, request):
|
|
qs = super().get_queryset(request)
|
|
return qs.select_related("complaint", "uploaded_by")
|
|
|
|
|
|
@admin.register(ComplaintUpdate)
|
|
class ComplaintUpdateAdmin(admin.ModelAdmin):
|
|
"""Complaint update admin"""
|
|
|
|
list_display = ["complaint", "update_type", "message_preview", "created_by", "created_at"]
|
|
list_filter = ["update_type", "created_at"]
|
|
search_fields = ["message", "complaint__title"]
|
|
ordering = ["-created_at"]
|
|
|
|
fieldsets = (
|
|
(None, {"fields": ("complaint", "update_type", "message")}),
|
|
("Status Change", {"fields": ("old_status", "new_status"), "classes": ("collapse",)}),
|
|
("Details", {"fields": ("created_by", "metadata")}),
|
|
("Metadata", {"fields": ("created_at", "updated_at")}),
|
|
)
|
|
|
|
readonly_fields = ["created_at", "updated_at"]
|
|
|
|
def get_queryset(self, request):
|
|
qs = super().get_queryset(request)
|
|
return qs.select_related("complaint", "created_by")
|
|
|
|
def message_preview(self, obj):
|
|
"""Show preview of message"""
|
|
return obj.message[:100] + "..." if len(obj.message) > 100 else obj.message
|
|
|
|
message_preview.short_description = "Message"
|
|
|
|
|
|
@admin.register(Inquiry)
|
|
class InquiryAdmin(admin.ModelAdmin):
|
|
"""Inquiry admin"""
|
|
|
|
list_display = [
|
|
"subject_preview",
|
|
"patient",
|
|
"contact_name",
|
|
"hospital",
|
|
"category",
|
|
"status",
|
|
"created_by",
|
|
"assigned_to",
|
|
"created_at",
|
|
]
|
|
list_filter = ["status", "category", "source", "hospital", "created_by", "created_at"]
|
|
search_fields = [
|
|
"subject",
|
|
"message",
|
|
"contact_name",
|
|
"contact_phone",
|
|
"patient__mrn",
|
|
"patient__first_name",
|
|
"patient__last_name",
|
|
]
|
|
ordering = ["-created_at"]
|
|
|
|
fieldsets = (
|
|
("Patient Information", {"fields": ("patient",)}),
|
|
(
|
|
"Contact Information (if no patient)",
|
|
{"fields": ("contact_name", "contact_phone", "contact_email"), "classes": ("collapse",)},
|
|
),
|
|
("Organization", {"fields": ("hospital", "department")}),
|
|
("Inquiry Details", {"fields": ("subject", "message", "category", "source")}),
|
|
("Creator Tracking", {"fields": ("created_by",)}),
|
|
("Status & Assignment", {"fields": ("status", "assigned_to")}),
|
|
("Response", {"fields": ("response", "responded_at", "responded_by")}),
|
|
("Metadata", {"fields": ("created_at", "updated_at")}),
|
|
)
|
|
|
|
readonly_fields = ["responded_at", "created_at", "updated_at"]
|
|
|
|
def get_queryset(self, request):
|
|
qs = super().get_queryset(request)
|
|
return qs.select_related("patient", "hospital", "department", "assigned_to", "responded_by", "created_by")
|
|
|
|
def subject_preview(self, obj):
|
|
"""Show preview of subject"""
|
|
return obj.subject[:60] + "..." if len(obj.subject) > 60 else obj.subject
|
|
|
|
subject_preview.short_description = "Subject"
|
|
|
|
|
|
@admin.register(ComplaintSLAConfig)
|
|
class ComplaintSLAConfigAdmin(admin.ModelAdmin):
|
|
"""Complaint SLA Configuration admin"""
|
|
|
|
list_display = ["hospital", "source", "severity", "priority", "sla_hours", "reminder_timing_display", "is_active"]
|
|
list_filter = ["hospital", "source", "severity", "priority", "is_active"]
|
|
search_fields = ["hospital__name_en", "hospital__name_ar", "source__name_en"]
|
|
ordering = ["hospital", "source", "severity", "priority"]
|
|
|
|
fieldsets = (
|
|
("Hospital", {"fields": ("hospital",)}),
|
|
("Source & Classification", {"fields": ("source", "severity", "priority")}),
|
|
("SLA Configuration", {"fields": ("sla_hours", "reminder_hours_before")}),
|
|
(
|
|
"Source-Based Timing (Hours After Creation)",
|
|
{
|
|
"fields": ("first_reminder_hours_after", "second_reminder_hours_after", "escalation_hours_after"),
|
|
"description": 'When set, these override the "Hours Before Deadline" timing. Used for source-based SLAs (e.g., MOH, CCHI).',
|
|
},
|
|
),
|
|
("Status", {"fields": ("is_active",)}),
|
|
("Metadata", {"fields": ("created_at", "updated_at"), "classes": ("collapse",)}),
|
|
)
|
|
|
|
readonly_fields = ["created_at", "updated_at"]
|
|
|
|
def get_queryset(self, request):
|
|
qs = super().get_queryset(request)
|
|
return qs.select_related("hospital", "source")
|
|
|
|
def reminder_timing_display(self, obj):
|
|
"""Display reminder timing method"""
|
|
if obj.source and obj.first_reminder_hours_after:
|
|
return format_html(
|
|
'<span class="badge bg-primary">Source-based: {0}h / {1}h</span>',
|
|
obj.first_reminder_hours_after,
|
|
obj.second_reminder_hours_after or "N/A",
|
|
)
|
|
elif obj.reminder_hours_before:
|
|
return format_html(
|
|
'<span class="badge bg-info">Deadline-based: {0}h before</span>', obj.reminder_hours_before
|
|
)
|
|
else:
|
|
return "—"
|
|
|
|
reminder_timing_display.short_description = "Reminder Timing"
|
|
|
|
|
|
@admin.register(ComplaintCategory)
|
|
class ComplaintCategoryAdmin(admin.ModelAdmin):
|
|
"""Complaint Category admin"""
|
|
|
|
list_display = ["name_en", "code", "hospitals_display", "parent", "order", "is_active"]
|
|
list_filter = ["is_active", "parent"]
|
|
search_fields = ["name_en", "name_ar", "code", "description_en"]
|
|
ordering = ["order", "name_en"]
|
|
|
|
fieldsets = (
|
|
("Hospitals", {"fields": ("hospitals",)}),
|
|
("Category Details", {"fields": ("code", "name_en", "name_ar")}),
|
|
("Description", {"fields": ("description_en", "description_ar"), "classes": ("collapse",)}),
|
|
("Hierarchy", {"fields": ("parent", "order")}),
|
|
("Status", {"fields": ("is_active",)}),
|
|
("Metadata", {"fields": ("created_at", "updated_at"), "classes": ("collapse",)}),
|
|
)
|
|
|
|
readonly_fields = ["created_at", "updated_at"]
|
|
filter_horizontal = ["hospitals"]
|
|
|
|
def get_queryset(self, request):
|
|
qs = super().get_queryset(request)
|
|
return qs.select_related("parent").prefetch_related("hospitals")
|
|
|
|
def hospitals_display(self, obj):
|
|
"""Display hospitals for category"""
|
|
hospital_count = obj.hospitals.count()
|
|
if hospital_count == 0:
|
|
return "System-wide"
|
|
elif hospital_count == 1:
|
|
return obj.hospitals.first().name
|
|
else:
|
|
return f"{hospital_count} hospitals"
|
|
|
|
hospitals_display.short_description = "Hospitals"
|
|
|
|
|
|
@admin.register(EscalationRule)
|
|
class EscalationRuleAdmin(admin.ModelAdmin):
|
|
"""Escalation Rule admin"""
|
|
|
|
list_display = ["name", "hospital", "escalate_to_role", "trigger_on_overdue", "order", "is_active"]
|
|
list_filter = [
|
|
"hospital",
|
|
"escalate_to_role",
|
|
"trigger_on_overdue",
|
|
"severity_filter",
|
|
"priority_filter",
|
|
"is_active",
|
|
]
|
|
search_fields = ["name", "description", "hospital__name_en"]
|
|
ordering = ["hospital", "order"]
|
|
|
|
fieldsets = (
|
|
("Hospital", {"fields": ("hospital",)}),
|
|
("Rule Details", {"fields": ("name", "description")}),
|
|
("Trigger Conditions", {"fields": ("trigger_on_overdue", "trigger_hours_overdue")}),
|
|
("Escalation Target", {"fields": ("escalate_to_role", "escalate_to_user")}),
|
|
("Filters", {"fields": ("severity_filter", "priority_filter"), "classes": ("collapse",)}),
|
|
("Order & Status", {"fields": ("order", "is_active")}),
|
|
("Metadata", {"fields": ("created_at", "updated_at"), "classes": ("collapse",)}),
|
|
)
|
|
|
|
readonly_fields = ["created_at", "updated_at"]
|
|
|
|
def get_queryset(self, request):
|
|
qs = super().get_queryset(request)
|
|
return qs.select_related("hospital", "escalate_to_user")
|
|
|
|
|
|
@admin.register(ComplaintThreshold)
|
|
class ComplaintThresholdAdmin(admin.ModelAdmin):
|
|
"""Complaint Threshold admin"""
|
|
|
|
list_display = ["hospital", "threshold_type", "comparison_display", "threshold_value", "action_type", "is_active"]
|
|
list_filter = ["hospital", "threshold_type", "comparison_operator", "action_type", "is_active"]
|
|
search_fields = ["hospital__name_en", "hospital__name_ar"]
|
|
ordering = ["hospital", "threshold_type"]
|
|
|
|
fieldsets = (
|
|
("Hospital", {"fields": ("hospital",)}),
|
|
("Threshold Configuration", {"fields": ("threshold_type", "threshold_value", "comparison_operator")}),
|
|
("Action", {"fields": ("action_type",)}),
|
|
("Status", {"fields": ("is_active",)}),
|
|
("Metadata", {"fields": ("created_at", "updated_at"), "classes": ("collapse",)}),
|
|
)
|
|
|
|
readonly_fields = ["created_at", "updated_at"]
|
|
|
|
def get_queryset(self, request):
|
|
qs = super().get_queryset(request)
|
|
return qs.select_related("hospital")
|
|
|
|
def comparison_display(self, obj):
|
|
"""Display comparison operator"""
|
|
return f"{obj.get_comparison_operator_display()}"
|
|
|
|
comparison_display.short_description = "Comparison"
|
|
|
|
|
|
@admin.register(ComplaintPRInteraction)
|
|
class ComplaintPRInteractionAdmin(admin.ModelAdmin):
|
|
"""PR Interaction admin"""
|
|
|
|
list_display = [
|
|
"complaint",
|
|
"contact_date",
|
|
"contact_method_display",
|
|
"pr_staff",
|
|
"procedure_explained",
|
|
"created_at",
|
|
]
|
|
list_filter = ["contact_method", "procedure_explained", "created_at"]
|
|
search_fields = ["complaint__title", "statement_text", "notes", "pr_staff__first_name", "pr_staff__last_name"]
|
|
ordering = ["-contact_date"]
|
|
|
|
fieldsets = (
|
|
("Complaint", {"fields": ("complaint",)}),
|
|
("Contact Details", {"fields": ("contact_date", "contact_method", "pr_staff")}),
|
|
("Interaction Details", {"fields": ("statement_text", "procedure_explained", "notes")}),
|
|
("Metadata", {"fields": ("created_by", "created_at", "updated_at")}),
|
|
)
|
|
|
|
readonly_fields = ["created_at", "updated_at"]
|
|
|
|
def get_queryset(self, request):
|
|
qs = super().get_queryset(request)
|
|
return qs.select_related("complaint", "pr_staff", "created_by")
|
|
|
|
def contact_method_display(self, obj):
|
|
"""Display contact method"""
|
|
return obj.get_contact_method_display()
|
|
|
|
contact_method_display.short_description = "Method"
|
|
|
|
|
|
@admin.register(ComplaintMeeting)
|
|
class ComplaintMeetingAdmin(admin.ModelAdmin):
|
|
"""Complaint Meeting admin"""
|
|
|
|
list_display = ["complaint", "meeting_date", "meeting_type_display", "outcome_preview", "created_by", "created_at"]
|
|
list_filter = ["meeting_type", "created_at"]
|
|
search_fields = ["complaint__title", "outcome", "notes"]
|
|
ordering = ["-meeting_date"]
|
|
|
|
fieldsets = (
|
|
("Complaint", {"fields": ("complaint",)}),
|
|
("Meeting Details", {"fields": ("meeting_date", "meeting_type")}),
|
|
("Meeting Outcome", {"fields": ("outcome", "notes")}),
|
|
("Metadata", {"fields": ("created_by", "created_at", "updated_at")}),
|
|
)
|
|
|
|
readonly_fields = ["created_at", "updated_at"]
|
|
|
|
def get_queryset(self, request):
|
|
qs = super().get_queryset(request)
|
|
return qs.select_related("complaint", "created_by")
|
|
|
|
def meeting_type_display(self, obj):
|
|
"""Display meeting type"""
|
|
return obj.get_meeting_type_display()
|
|
|
|
meeting_type_display.short_description = "Type"
|
|
|
|
def outcome_preview(self, obj):
|
|
"""Show preview of outcome"""
|
|
return obj.outcome[:100] + "..." if len(obj.outcome) > 100 else obj.outcome
|
|
|
|
outcome_preview.short_description = "Outcome"
|
|
|
|
|
|
@admin.register(ComplaintInvolvedDepartment)
|
|
class ComplaintInvolvedDepartmentAdmin(admin.ModelAdmin):
|
|
"""Complaint Involved Department admin"""
|
|
|
|
list_display = ["complaint", "department", "role", "is_primary", "assigned_to", "response_submitted", "created_at"]
|
|
list_filter = ["role", "is_primary", "response_submitted", "created_at"]
|
|
search_fields = ["complaint__title", "complaint__reference_number", "department__name", "notes"]
|
|
ordering = ["-is_primary", "-created_at"]
|
|
autocomplete_fields = ["complaint", "department", "assigned_to", "added_by"]
|
|
|
|
fieldsets = (
|
|
("Complaint", {"fields": ("complaint",)}),
|
|
("Department & Role", {"fields": ("department", "role", "is_primary")}),
|
|
("Assignment", {"fields": ("assigned_to", "assigned_at")}),
|
|
("Response", {"fields": ("response_submitted", "response_submitted_at", "response_notes")}),
|
|
("Notes", {"fields": ("notes",)}),
|
|
("Metadata", {"fields": ("added_by", "created_at", "updated_at"), "classes": ("collapse",)}),
|
|
)
|
|
|
|
readonly_fields = ["assigned_at", "response_submitted_at", "created_at", "updated_at"]
|
|
|
|
def get_queryset(self, request):
|
|
qs = super().get_queryset(request)
|
|
return qs.select_related("complaint", "department", "assigned_to", "added_by")
|
|
|
|
|
|
@admin.register(ComplaintInvolvedStaff)
|
|
class ComplaintInvolvedStaffAdmin(admin.ModelAdmin):
|
|
"""Complaint Involved Staff admin"""
|
|
|
|
list_display = ["complaint", "staff", "role", "explanation_requested", "explanation_received", "created_at"]
|
|
list_filter = ["role", "explanation_requested", "explanation_received", "created_at"]
|
|
search_fields = [
|
|
"complaint__title",
|
|
"complaint__reference_number",
|
|
"staff__first_name",
|
|
"staff__last_name",
|
|
"notes",
|
|
]
|
|
ordering = ["role", "-created_at"]
|
|
autocomplete_fields = ["complaint", "staff", "added_by"]
|
|
|
|
fieldsets = (
|
|
("Complaint", {"fields": ("complaint",)}),
|
|
("Staff & Role", {"fields": ("staff", "role")}),
|
|
(
|
|
"Explanation",
|
|
{
|
|
"fields": (
|
|
"explanation_requested",
|
|
"explanation_requested_at",
|
|
"explanation_received",
|
|
"explanation_received_at",
|
|
"explanation",
|
|
)
|
|
},
|
|
),
|
|
("Notes", {"fields": ("notes",)}),
|
|
("Metadata", {"fields": ("added_by", "created_at", "updated_at"), "classes": ("collapse",)}),
|
|
)
|
|
|
|
readonly_fields = ["explanation_requested_at", "explanation_received_at", "created_at", "updated_at"]
|
|
|
|
def get_queryset(self, request):
|
|
qs = super().get_queryset(request)
|
|
return qs.select_related("complaint", "staff", "added_by")
|
|
|
|
|
|
class OnCallAdminInline(admin.TabularInline):
|
|
"""Inline admin for on-call admins"""
|
|
|
|
model = OnCallAdmin
|
|
extra = 1
|
|
fields = [
|
|
"admin_user",
|
|
"start_date",
|
|
"end_date",
|
|
"notification_priority",
|
|
"is_active",
|
|
"notify_email",
|
|
"notify_sms",
|
|
"sms_phone",
|
|
]
|
|
autocomplete_fields = ["admin_user"]
|
|
|
|
def get_queryset(self, request):
|
|
qs = super().get_queryset(request)
|
|
return qs.select_related("admin_user")
|
|
|
|
|
|
@admin.register(OnCallAdminSchedule)
|
|
class OnCallAdminScheduleAdmin(admin.ModelAdmin):
|
|
"""On-Call Admin Schedule admin"""
|
|
|
|
list_display = [
|
|
"hospital_or_system",
|
|
"working_hours_display",
|
|
"working_days_display",
|
|
"timezone",
|
|
"is_active",
|
|
"created_at",
|
|
]
|
|
list_filter = ["is_active", "timezone", "created_at"]
|
|
search_fields = ["hospital__name"]
|
|
inlines = [OnCallAdminInline]
|
|
|
|
fieldsets = (
|
|
("Scope", {"fields": ("hospital", "is_active")}),
|
|
(
|
|
"Working Hours Configuration",
|
|
{
|
|
"fields": ("work_start_time", "work_end_time", "timezone", "working_days"),
|
|
"description": "Configure working hours. Outside these hours, only on-call admins will be notified.",
|
|
},
|
|
),
|
|
)
|
|
|
|
readonly_fields = ["created_at", "updated_at"]
|
|
|
|
def hospital_or_system(self, obj):
|
|
"""Display hospital name or 'System-wide'"""
|
|
if obj.hospital:
|
|
return obj.hospital.name
|
|
return mark_safe('<span style="color: #007bbd; font-weight: bold;">System-wide</span>')
|
|
|
|
hospital_or_system.short_description = "Scope"
|
|
hospital_or_system.admin_order_field = "hospital__name"
|
|
|
|
def working_hours_display(self, obj):
|
|
"""Display working hours"""
|
|
return f"{obj.work_start_time.strftime('%H:%M')} - {obj.work_end_time.strftime('%H:%M')}"
|
|
|
|
working_hours_display.short_description = "Working Hours"
|
|
|
|
def working_days_display(self, obj):
|
|
"""Display working days as abbreviated day names"""
|
|
days = obj.get_working_days_list()
|
|
day_names = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
|
selected_days = [day_names[d] for d in days if 0 <= d <= 6]
|
|
return ", ".join(selected_days) if selected_days else "None"
|
|
|
|
working_days_display.short_description = "Working Days"
|
|
|
|
def get_queryset(self, request):
|
|
qs = super().get_queryset(request)
|
|
return qs.select_related("hospital")
|
|
|
|
|
|
@admin.register(OnCallAdmin)
|
|
class OnCallAdminAdmin(admin.ModelAdmin):
|
|
"""On-Call Admin admin"""
|
|
|
|
list_display = ["admin_user", "schedule", "notification_priority", "date_range", "contact_preferences", "is_active"]
|
|
list_filter = ["is_active", "notify_email", "notify_sms", "schedule__hospital", "created_at"]
|
|
search_fields = ["admin_user__email", "admin_user__first_name", "admin_user__last_name", "sms_phone"]
|
|
autocomplete_fields = ["admin_user", "schedule"]
|
|
|
|
fieldsets = (
|
|
("Assignment", {"fields": ("schedule", "admin_user", "is_active")}),
|
|
(
|
|
"Active Period (Optional)",
|
|
{"fields": ("start_date", "end_date"), "description": "Leave empty for permanent assignment"},
|
|
),
|
|
(
|
|
"Notification Settings",
|
|
{
|
|
"fields": ("notification_priority", "notify_email", "notify_sms", "sms_phone"),
|
|
"description": "Configure how this admin should be notified for after-hours complaints",
|
|
},
|
|
),
|
|
)
|
|
|
|
readonly_fields = ["created_at", "updated_at"]
|
|
|
|
def date_range(self, obj):
|
|
"""Display date range"""
|
|
if obj.start_date and obj.end_date:
|
|
return f"{obj.start_date} to {obj.end_date}"
|
|
elif obj.start_date:
|
|
return f"From {obj.start_date}"
|
|
elif obj.end_date:
|
|
return f"Until {obj.end_date}"
|
|
return mark_safe('<span style="color: green;">Permanent</span>')
|
|
|
|
date_range.short_description = "Active Period"
|
|
|
|
def contact_preferences(self, obj):
|
|
"""Display contact preferences"""
|
|
prefs = []
|
|
if obj.notify_email:
|
|
prefs.append("📧 Email")
|
|
if obj.notify_sms:
|
|
prefs.append(f"📱 SMS ({obj.sms_phone or 'user phone'})")
|
|
return ", ".join(prefs) if prefs else "None"
|
|
|
|
contact_preferences.short_description = "Contact"
|
|
|
|
def get_queryset(self, request):
|
|
qs = super().get_queryset(request)
|
|
return qs.select_related("admin_user", "schedule", "schedule__hospital")
|
|
|
|
|
|
class ComplaintAdverseActionAttachmentInline(admin.TabularInline):
|
|
"""Inline admin for adverse action attachments"""
|
|
|
|
model = ComplaintAdverseActionAttachment
|
|
extra = 0
|
|
fields = ["file", "filename", "description", "uploaded_by"]
|
|
readonly_fields = ["filename", "file_size"]
|
|
|
|
|
|
@admin.register(ComplaintAdverseAction)
|
|
class ComplaintAdverseActionAdmin(admin.ModelAdmin):
|
|
"""Admin for complaint adverse actions"""
|
|
|
|
list_display = [
|
|
"complaint_reference",
|
|
"action_type_display",
|
|
"severity_badge",
|
|
"incident_date",
|
|
"status_badge",
|
|
"is_escalated",
|
|
"created_at",
|
|
]
|
|
list_filter = ["action_type", "severity", "status", "is_escalated", "incident_date", "created_at"]
|
|
search_fields = ["complaint__reference_number", "complaint__title", "description", "patient_impact"]
|
|
date_hierarchy = "incident_date"
|
|
inlines = [ComplaintAdverseActionAttachmentInline]
|
|
|
|
fieldsets = (
|
|
("Complaint Information", {"fields": ("complaint",)}),
|
|
("Adverse Action Details", {"fields": ("action_type", "severity", "description", "incident_date", "location")}),
|
|
("Impact & Staff", {"fields": ("patient_impact", "involved_staff")}),
|
|
(
|
|
"Verification & Investigation",
|
|
{
|
|
"fields": ("status", "reported_by", "investigation_notes", "investigated_by", "investigated_at"),
|
|
"classes": ("collapse",),
|
|
},
|
|
),
|
|
("Resolution", {"fields": ("resolution", "resolved_by", "resolved_at"), "classes": ("collapse",)}),
|
|
("Escalation", {"fields": ("is_escalated", "escalated_at")}),
|
|
)
|
|
|
|
readonly_fields = ["created_at", "updated_at"]
|
|
|
|
def complaint_reference(self, obj):
|
|
"""Display complaint reference"""
|
|
return format_html(
|
|
'<a href="/admin/complaints/complaint/{}/change/">{}</a>', obj.complaint.id, obj.complaint.reference_number
|
|
)
|
|
|
|
complaint_reference.short_description = "Complaint"
|
|
|
|
def action_type_display(self, obj):
|
|
"""Display action type with formatting"""
|
|
return obj.get_action_type_display()
|
|
|
|
action_type_display.short_description = "Action Type"
|
|
|
|
def severity_badge(self, obj):
|
|
"""Display severity as colored badge"""
|
|
colors = {
|
|
"low": "#22c55e", # green
|
|
"medium": "#f59e0b", # amber
|
|
"high": "#ef4444", # red
|
|
"critical": "#7f1d1d", # dark red
|
|
}
|
|
color = colors.get(obj.severity, "#64748b")
|
|
return format_html(
|
|
'<span style="background-color: {}; color: white; padding: 2px 8px; border-radius: 4px; font-size: 11px;">{}</span>',
|
|
color,
|
|
obj.get_severity_display(),
|
|
)
|
|
|
|
severity_badge.short_description = "Severity"
|
|
|
|
def status_badge(self, obj):
|
|
"""Display status as colored badge"""
|
|
colors = {
|
|
"reported": "#f59e0b",
|
|
"under_investigation": "#3b82f6",
|
|
"verified": "#22c55e",
|
|
"unfounded": "#64748b",
|
|
"resolved": "#10b981",
|
|
}
|
|
color = colors.get(obj.status, "#64748b")
|
|
return format_html(
|
|
'<span style="background-color: {}; color: white; padding: 2px 8px; border-radius: 4px; font-size: 11px;">{}</span>',
|
|
color,
|
|
obj.get_status_display(),
|
|
)
|
|
|
|
status_badge.short_description = "Status"
|
|
|
|
def get_queryset(self, request):
|
|
qs = super().get_queryset(request)
|
|
return qs.select_related("complaint", "reported_by", "investigated_by", "resolved_by")
|
|
|
|
|
|
@admin.register(ComplaintAdverseActionAttachment)
|
|
class ComplaintAdverseActionAttachmentAdmin(admin.ModelAdmin):
|
|
"""Admin for adverse action attachments"""
|
|
|
|
list_display = ["adverse_action", "filename", "file_type", "uploaded_by", "created_at"]
|
|
list_filter = ["file_type", "created_at"]
|
|
search_fields = ["filename", "description", "adverse_action__complaint__reference_number"]
|
|
ordering = ["-created_at"]
|