983 lines
32 KiB
Python
983 lines
32 KiB
Python
"""
|
|
Complaints admin
|
|
"""
|
|
from django.contrib import admin
|
|
from django.utils.html import format_html
|
|
|
|
from .models import (
|
|
Complaint,
|
|
ComplaintAttachment,
|
|
ComplaintCategory,
|
|
ComplaintMeeting,
|
|
ComplaintPRInteraction,
|
|
ComplaintSLAConfig,
|
|
ComplaintThreshold,
|
|
ComplaintUpdate,
|
|
EscalationRule,
|
|
Inquiry,
|
|
ExplanationSLAConfig,
|
|
ComplaintInvolvedDepartment,
|
|
ComplaintInvolvedStaff,
|
|
OnCallAdminSchedule,
|
|
OnCallAdmin,
|
|
ComplaintAdverseAction,
|
|
ComplaintAdverseActionAttachment,
|
|
)
|
|
|
|
admin.site.register(ExplanationSLAConfig)
|
|
|
|
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', 'response_submitted']
|
|
autocomplete_fields = ['department', 'assigned_to']
|
|
|
|
|
|
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',
|
|
'created_by', 'assigned_to', 'created_at'
|
|
]
|
|
list_filter = [
|
|
'status', 'severity', 'priority', 'category', 'source',
|
|
'location', 'main_section', 'subsection',
|
|
'is_overdue', 'hospital', 'created_by', 'created_at'
|
|
]
|
|
search_fields = [
|
|
'title', 'description', 'patient__mrn',
|
|
'patient__first_name', 'patient__last_name', 'encounter_id'
|
|
]
|
|
ordering = ['-created_at']
|
|
date_hierarchy = 'created_at'
|
|
inlines = [ComplaintUpdateInline, ComplaintAttachmentInline, ComplaintInvolvedDepartmentInline, ComplaintInvolvedStaffInline]
|
|
|
|
fieldsets = (
|
|
('Patient & Encounter', {
|
|
'fields': ('patient', 'encounter_id')
|
|
}),
|
|
('Organization', {
|
|
'fields': ('hospital', 'department', 'staff')
|
|
}),
|
|
('Location Hierarchy', {
|
|
'fields': ('location', 'main_section', 'subsection')
|
|
}),
|
|
('Complaint Details', {
|
|
'fields': ('complaint_type', 'title', 'description', 'category', 'subcategory')
|
|
}),
|
|
('Classification', {
|
|
'fields': ('priority', 'severity', 'source')
|
|
}),
|
|
('Creator Tracking', {
|
|
'fields': ('created_by',)
|
|
}),
|
|
('Status & Assignment', {
|
|
'fields': ('status', 'assigned_to', 'assigned_at')
|
|
}),
|
|
('SLA Tracking', {
|
|
'fields': ('due_at', 'is_overdue', 'reminder_sent_at', 'escalated_at')
|
|
}),
|
|
('Resolution', {
|
|
'fields': ('resolution', 'resolved_at', 'resolved_by')
|
|
}),
|
|
('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', '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)
|
|
if obj.main_section:
|
|
parts.append(obj.main_section.name)
|
|
if obj.subsection:
|
|
parts.append(obj.subsection.name)
|
|
|
|
if not parts:
|
|
return '—'
|
|
|
|
hierarchy = ' → '.join(parts)
|
|
return format_html('<span class="text-muted">{}</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-{}">{}</span>',
|
|
color,
|
|
obj.get_severity_display()
|
|
)
|
|
severity_badge.short_description = 'Severity'
|
|
|
|
def status_badge(self, obj):
|
|
"""Display status with color badge"""
|
|
colors = {
|
|
'open': 'danger',
|
|
'in_progress': 'warning',
|
|
'resolved': 'info',
|
|
'closed': 'success',
|
|
'cancelled': 'secondary',
|
|
}
|
|
color = colors.get(obj.status, 'secondary')
|
|
return format_html(
|
|
'<span class="badge bg-{}">{}</span>',
|
|
color,
|
|
obj.get_status_display()
|
|
)
|
|
status_badge.short_description = 'Status'
|
|
|
|
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-{}">{}</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 format_html('<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 format_html('<span class="badge bg-warning">DUE SOON</span>')
|
|
else:
|
|
return format_html('<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: {}h / {}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: {}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 format_html('<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 format_html('<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']
|