update complaints services

This commit is contained in:
Marwan Alwali 2025-12-25 17:11:40 +03:00
parent 226dc414cd
commit edfd1cfe2e
13 changed files with 3025 additions and 15 deletions

785
COMPLAINTS_GAP_ANALYSIS.md Normal file
View File

@ -0,0 +1,785 @@
# Complaints App - Gap Analysis & Improvement Plan
**Date:** December 25, 2025
**Analyst:** AI Assistant
**Status:** Ready for Implementation
---
## Executive Summary
The complaints app has a solid foundation with core CRUD operations, SLA tracking, and UI templates. However, several critical features from the initial prompt are missing or incomplete. This document outlines the gaps and provides a systematic implementation plan.
---
## Current Implementation Status
### ✅ What's Working Well
1. **Models**
- ✅ Complaint model with comprehensive fields
- ✅ ComplaintAttachment for file uploads
- ✅ ComplaintUpdate for timeline tracking
- ✅ Inquiry model for non-complaint requests
- ✅ SLA due_at calculation on creation
- ✅ Overdue checking mechanism
2. **API Views (DRF)**
- ✅ ComplaintViewSet with RBAC filtering
- ✅ Custom actions: assign, change_status, add_note
- ✅ ComplaintAttachmentViewSet
- ✅ InquiryViewSet with respond action
- ✅ Audit logging integration
3. **UI Views**
- ✅ Complaint list with advanced filters
- ✅ Complaint detail with tabs (details, timeline, attachments, actions)
- ✅ Complaint create form
- ✅ Action handlers: assign, change_status, add_note, escalate
- ✅ RBAC enforcement
4. **Templates**
- ✅ Modern Bootstrap 5 UI
- ✅ Responsive design
- ✅ Filter panel with collapsible functionality
- ✅ Statistics cards
- ✅ Timeline visualization
- ✅ Status and severity badges
5. **Celery Tasks**
- ✅ check_overdue_complaints (periodic)
- ✅ send_complaint_resolution_survey (triggered on close)
- ✅ create_action_from_complaint (stub for Phase 6)
---
## 🔴 Critical Gaps Identified
### 1. Configuration Models Missing
**Current State:** SLA durations are hardcoded in settings.py
**Required:** Database-driven configuration per hospital/severity
**Missing Models:**
- `ComplaintSLAConfig` - Configure SLA hours by hospital, severity, priority
- `ComplaintCategory` - Custom categories per hospital (not hardcoded choices)
- `EscalationRule` - Define escalation hierarchy (flexible, not hardcoded)
- `ComplaintThreshold` - Configure thresholds (e.g., resolution survey score < 50%)
**Impact:** Cannot customize SLA rules, categories, or escalation per hospital
---
### 2. Resolution Satisfaction Survey Workflow Incomplete
**Current State:**
- ✅ Survey is sent when complaint closes
- ❌ No automatic detection of low scores
- ❌ No automatic PX Action creation on low scores
- ❌ No threshold configuration
**Required:**
- Detect when resolution survey score < 50% (configurable threshold)
- Automatically create PX Action when threshold breached
- Link PX Action to both complaint and survey
- Audit log the trigger
**Impact:** Manual intervention required for negative satisfaction scores
---
### 3. Complaint-to-PXAction Automation Missing
**Current State:**
- Task stub exists: `create_action_from_complaint`
- Not implemented
- Not triggered on complaint creation
**Required:**
- Automatic PX Action creation on complaint creation (if configured)
- Automatic PX Action creation on negative resolution survey
- Configuration per hospital: auto_create_action_on_complaint (boolean)
- Proper linking via GenericForeignKey
**Impact:** PX Actions must be created manually
---
### 4. Export Functionality Not Implemented
**Current State:**
- ✅ UI has export buttons (CSV, Excel)
- ❌ No backend implementation
- ❌ exportData() JavaScript function does nothing
**Required:**
- CSV export with all complaint fields
- Excel export with formatting
- PDF export for individual complaint (detail view)
- Respect current filters when exporting
- Include related data (patient, hospital, timeline)
**Impact:** Users cannot export data for reporting
---
### 5. Bulk Actions Not Implemented
**Current State:**
- ✅ UI has checkboxes for selection
- ✅ "Select All" functionality
- ❌ No bulk action handlers
**Required:**
- Bulk assign to user
- Bulk status change
- Bulk escalate
- Bulk export selected
- Confirmation modals for destructive actions
**Impact:** Users must process complaints one by one
---
### 6. Dashboard Integration Missing
**Current State:**
- ❌ No complaint metrics in command center dashboard
- ❌ No complaint trends visualization
- ❌ No SLA compliance tracking
- ❌ No resolution rate metrics
**Required (in dashboard app):**
- Complaint trends chart (daily/weekly/monthly)
- SLA compliance percentage and trend
- Resolution rate by hospital/department
- Average resolution time
- Top complaint categories
- Overdue complaints widget
- Recent high-severity complaints feed
**Impact:** No executive visibility into complaint performance
---
### 7. Escalation Logic Incomplete
**Current State:**
- ✅ Manual escalation via UI
- ✅ escalated_at timestamp recorded
- ❌ No automatic escalation on SLA breach
- ❌ No configurable escalation rules
- ❌ No escalation hierarchy
**Required:**
- Automatic escalation when overdue
- Configurable escalation rules per hospital
- Escalation hierarchy: Assigned User → Dept Manager → Hospital Admin → PX Admin
- Notification on escalation
- Audit trail
**Impact:** Overdue complaints don't automatically escalate
---
### 8. Complaint Categories Hardcoded
**Current State:**
- Categories are hardcoded in model choices
- Cannot add/edit/delete categories
- Same categories for all hospitals
**Required:**
- ComplaintCategory model
- CRUD UI for managing categories
- Per-hospital categories
- Active/inactive status
- Subcategories support
**Impact:** Cannot customize categories per hospital needs
---
### 9. Notification Integration Incomplete
**Current State:**
- ✅ Resolution survey sends notification
- ❌ No notifications for: complaint created, assigned, overdue, escalated
**Required:**
- Notification on complaint creation (to assigned user/dept manager)
- Notification on assignment (to assignee)
- Notification on overdue (to assignee + manager)
- Notification on escalation (to escalation target)
- Notification on resolution (to patient)
- Configurable notification preferences
**Impact:** Users miss important complaint events
---
### 10. Advanced Features Missing
**Current State:** Basic CRUD implemented
**Missing from Prompt Requirements:**
- Saved filter views (user can save frequently used filters)
- Complaint resolution satisfaction dashboard
- Dissatisfied complaints list with drill-down
- Evidence attachment requirements (before closure)
- Approval workflow for closure (if configured)
- Integration with journey stages (link complaint to journey stage)
- MOH/CHI complaint reporting
**Impact:** Missing advanced workflow and reporting capabilities
---
## 📋 Systematic Implementation Plan
### Phase 1: Configuration Models & Admin (Priority: CRITICAL)
**Deliverables:**
1. Create `ComplaintSLAConfig` model
- Fields: hospital, severity, priority, sla_hours, is_active
- Admin interface
- Migration
- Update `Complaint.calculate_sla_due_date()` to use DB config
2. Create `ComplaintCategory` model
- Fields: hospital, name_en, name_ar, code, parent (self-FK), is_active, order
- Admin interface
- Migration
- Update Complaint model to use FK instead of choices
- Data migration to convert existing categories
3. Create `EscalationRule` model
- Fields: hospital, from_role, to_role, trigger_condition, order, is_active
- Admin interface
- Migration
4. Create `ComplaintThreshold` model
- Fields: hospital, threshold_type, threshold_value, action_type, is_active
- Admin interface
- Migration
- Default: resolution_survey_score < 50 create_px_action
**Acceptance Criteria:**
- ✅ All models created with migrations
- ✅ Admin interfaces functional
- ✅ SLA calculation uses DB config
- ✅ Categories are dynamic
- ✅ Default configurations seeded
**Estimated Effort:** 4-6 hours
---
### Phase 2: Resolution Survey Integration (Priority: HIGH)
**Deliverables:**
1. Create signal handler in surveys app
- Listen for SurveyInstance score update
- Check if linked to complaint (via metadata)
- Check threshold from ComplaintThreshold
- Trigger PX Action creation if threshold breached
2. Update `send_complaint_resolution_survey` task
- Store complaint_id in survey metadata
- Create proper linkage
3. Create `create_px_action_from_survey` task
- Create PXAction with proper fields
- Link to both complaint and survey
- Set priority based on score severity
- Audit log
4. Update complaint detail UI
- Show resolution survey score prominently
- Show linked PX Action if created
- Visual indicator for low scores
**Acceptance Criteria:**
- ✅ Survey completion triggers threshold check
- ✅ Low score (<50%) creates PX Action automatically
- ✅ PX Action properly linked
- ✅ Audit trail complete
- ✅ UI shows linkage
**Estimated Effort:** 3-4 hours
---
### Phase 3: Complaint-to-PXAction Automation (Priority: HIGH)
**Deliverables:**
1. Add field to Hospital model (or create HospitalComplaintConfig)
- auto_create_action_on_complaint (boolean)
- default_action_priority
- default_action_assignee_role
2. Implement `create_action_from_complaint` task
- Check hospital configuration
- Create PXAction if enabled
- Set appropriate fields
- Link via GenericForeignKey
- Audit log
3. Update complaint creation views
- Trigger task after complaint created
- Both API and UI views
4. Add configuration UI
- Hospital admin can toggle auto-creation
- Set default priority/assignee
**Acceptance Criteria:**
- ✅ Complaint creation triggers PX Action (if configured)
- ✅ Configuration per hospital works
- ✅ PX Action properly linked
- ✅ Can be disabled per hospital
**Estimated Effort:** 2-3 hours
---
### Phase 4: Export Functionality (Priority: MEDIUM)
**Deliverables:**
1. Create export utility functions
- `export_complaints_csv(queryset, filters)`
- `export_complaints_excel(queryset, filters)`
- `export_complaint_pdf(complaint_id)`
2. Add export views
- `/complaints/export/csv/`
- `/complaints/export/excel/`
- `/complaints/<id>/export/pdf/`
3. Update UI
- Wire export buttons to proper endpoints
- Add loading indicators
- Handle errors gracefully
4. Libraries needed
- csv (built-in)
- openpyxl (Excel)
- reportlab or weasyprint (PDF)
**Acceptance Criteria:**
- ✅ CSV export works with all fields
- ✅ Excel export has formatting
- ✅ PDF export for single complaint
- ✅ Exports respect filters
- ✅ File downloads properly
**Estimated Effort:** 3-4 hours
---
### Phase 5: Bulk Actions (Priority: MEDIUM)
**Deliverables:**
1. Create bulk action views
- `bulk_assign_complaints`
- `bulk_change_status`
- `bulk_escalate`
2. Add JavaScript for bulk operations
- Collect selected IDs
- Show confirmation modal
- Submit to bulk endpoint
- Show results/errors
3. Add bulk action UI
- Toolbar with bulk action buttons
- Enabled only when items selected
- Confirmation modals
4. Add bulk action endpoints to API
- POST `/api/complaints/bulk-assign/`
- POST `/api/complaints/bulk-status/`
- POST `/api/complaints/bulk-escalate/`
**Acceptance Criteria:**
- ✅ Can select multiple complaints
- ✅ Bulk assign works
- ✅ Bulk status change works
- ✅ Bulk escalate works
- ✅ Confirmation required
- ✅ Results shown to user
- ✅ Audit logs created
**Estimated Effort:** 3-4 hours
---
### Phase 6: Dashboard Integration (Priority: HIGH)
**Deliverables:**
1. Create complaint analytics service
- `get_complaint_trends(hospital, date_range)`
- `get_sla_compliance(hospital, date_range)`
- `get_resolution_rate(hospital, date_range)`
- `get_top_categories(hospital, date_range)`
- `get_overdue_complaints(hospital)`
2. Update dashboard views
- Add complaint metrics to command center
- Create dedicated complaints dashboard
3. Add charts to command center template
- Complaint trends line chart
- SLA compliance gauge
- Resolution rate by department
- Top categories bar chart
- Overdue complaints table widget
4. Add API endpoints
- `/api/analytics/complaints/trends/`
- `/api/analytics/complaints/sla-compliance/`
- `/api/analytics/complaints/resolution-rate/`
**Acceptance Criteria:**
- ✅ Command center shows complaint metrics
- ✅ Charts are interactive
- ✅ Data updates in real-time
- ✅ RBAC filters apply
- ✅ Date range filters work
**Estimated Effort:** 4-5 hours
---
### Phase 7: Escalation Automation (Priority: HIGH)
**Deliverables:**
1. Update `check_overdue_complaints` task
- When complaint overdue, trigger escalation
- Use EscalationRule to determine target
- Create ComplaintUpdate entry
- Send notification
- Audit log
2. Create `escalate_complaint` task
- Get escalation rules for hospital
- Find next escalation target
- Reassign complaint
- Update escalated_at
- Notify all parties
3. Add escalation history to UI
- Show escalation chain in timeline
- Visual indicator for escalated complaints
**Acceptance Criteria:**
- ✅ Overdue complaints auto-escalate
- ✅ Escalation follows configured rules
- ✅ Notifications sent
- ✅ Audit trail complete
- ✅ UI shows escalation history
**Estimated Effort:** 3-4 hours
---
### Phase 8: Category Management UI (Priority: MEDIUM)
**Deliverables:**
1. Create category CRUD views
- List categories
- Create category
- Edit category
- Delete/deactivate category
- Reorder categories
2. Create category templates
- `complaint_category_list.html`
- `complaint_category_form.html`
3. Add to admin menu
- "Complaint Categories" under Settings
4. Update complaint form
- Dynamic category dropdown
- Load from database
- Filter by hospital
**Acceptance Criteria:**
- ✅ Can manage categories via UI
- ✅ Categories are per-hospital
- ✅ Subcategories supported
- ✅ Complaint form uses dynamic categories
- ✅ Inactive categories hidden
**Estimated Effort:** 2-3 hours
---
### Phase 9: Enhanced Notifications (Priority: MEDIUM)
**Deliverables:**
1. Create notification triggers
- On complaint created
- On complaint assigned
- On complaint overdue
- On complaint escalated
- On complaint resolved
- On complaint closed
2. Update views and tasks
- Add notification calls
- Use NotificationService
3. Add notification preferences
- User can configure which notifications to receive
- Per-event preferences
**Acceptance Criteria:**
- ✅ All events trigger notifications
- ✅ Notifications sent via configured channels
- ✅ Users can configure preferences
- ✅ Notification logs created
**Estimated Effort:** 2-3 hours
---
### Phase 10: Advanced Features (Priority: LOW)
**Deliverables:**
1. Saved filter views
- SavedComplaintFilter model
- Save current filters
- Load saved filters
- Share filters with team
2. Resolution satisfaction dashboard
- Dedicated page
- Score distribution
- Dissatisfied list
- Drill-down by hospital/department
3. Evidence requirements
- Configure required attachments before closure
- Validation on status change
4. Approval workflow
- Require PX Admin approval for closure (if configured)
- Approval queue
**Acceptance Criteria:**
- ✅ Users can save/load filters
- ✅ Resolution dashboard functional
- ✅ Evidence requirements enforced
- ✅ Approval workflow works
**Estimated Effort:** 6-8 hours
---
## 🎯 Implementation Priority Matrix
| Phase | Priority | Effort | Impact | Dependencies |
|-------|----------|--------|--------|--------------|
| 1. Configuration Models | CRITICAL | 4-6h | HIGH | None |
| 2. Resolution Survey Integration | HIGH | 3-4h | HIGH | Phase 1 |
| 3. Complaint-to-PXAction | HIGH | 2-3h | HIGH | Phase 1 |
| 6. Dashboard Integration | HIGH | 4-5h | HIGH | None |
| 7. Escalation Automation | HIGH | 3-4h | MEDIUM | Phase 1 |
| 4. Export Functionality | MEDIUM | 3-4h | MEDIUM | None |
| 5. Bulk Actions | MEDIUM | 3-4h | MEDIUM | None |
| 8. Category Management | MEDIUM | 2-3h | MEDIUM | Phase 1 |
| 9. Enhanced Notifications | MEDIUM | 2-3h | LOW | None |
| 10. Advanced Features | LOW | 6-8h | LOW | Multiple |
**Total Estimated Effort:** 33-46 hours
---
## 📊 Comparison with Initial Prompt
### Prompt Requirements vs Current Implementation
| Requirement | Status | Notes |
|-------------|--------|-------|
| Complaint CRUD | ✅ Complete | Models, API, UI all working |
| SLA tracking | ⚠️ Partial | Works but hardcoded, needs DB config |
| Workflow (open→progress→resolved→closed) | ✅ Complete | Status transitions working |
| Resolution satisfaction survey | ⚠️ Partial | Sends survey but no auto-action on low score |
| Low score triggers PX Action | ❌ Missing | Not implemented |
| Complaint SLA config | ❌ Missing | Hardcoded in settings |
| Escalation on overdue | ⚠️ Partial | Manual only, no auto-escalation |
| Timeline/audit trail | ✅ Complete | ComplaintUpdate tracks all changes |
| Attachments | ✅ Complete | File upload working |
| RBAC enforcement | ✅ Complete | Proper filtering by role |
| API endpoints | ✅ Complete | DRF viewsets with actions |
| Modern UI | ✅ Complete | Bootstrap 5, responsive |
| Advanced filters | ✅ Complete | Comprehensive filter panel |
| Export capability | ❌ Missing | UI exists but no backend |
| Bulk actions | ❌ Missing | UI exists but no backend |
| Dashboard integration | ❌ Missing | No complaint metrics in dashboard |
| Custom categories | ❌ Missing | Hardcoded choices |
| Configurable thresholds | ❌ Missing | No threshold model |
**Completion Score: 60%**
---
## 🚀 Quick Start Recommendations
### Immediate Actions (Next 2-3 days)
1. **Day 1: Configuration Foundation**
- Implement Phase 1 (Configuration Models)
- This unblocks multiple other phases
- Critical for production readiness
2. **Day 2: Automation & Integration**
- Implement Phase 2 (Resolution Survey Integration)
- Implement Phase 3 (Complaint-to-PXAction)
- Complete the core workflow
3. **Day 3: Visibility & Reporting**
- Implement Phase 6 (Dashboard Integration)
- Implement Phase 4 (Export Functionality)
- Give users visibility into complaint performance
### Week 2 Actions
4. **Days 4-5: Operational Efficiency**
- Implement Phase 5 (Bulk Actions)
- Implement Phase 7 (Escalation Automation)
- Improve user productivity
5. **Days 6-7: Polish & Enhancement**
- Implement Phase 8 (Category Management)
- Implement Phase 9 (Enhanced Notifications)
- Improve user experience
---
## 📝 Technical Debt & Considerations
### Current Technical Debt
1. **Hardcoded Values**
- SLA durations in settings.py
- Complaint categories in model choices
- Escalation logic not configurable
2. **Incomplete Workflows**
- Resolution survey doesn't trigger actions
- No automatic escalation
- Manual PX Action creation
3. **Missing Integrations**
- Dashboard doesn't show complaint metrics
- Limited notification coverage
- No export functionality
### Architectural Considerations
1. **Scalability**
- Current implementation handles single-hospital well
- Multi-hospital filtering works
- Consider caching for dashboard metrics
2. **Performance**
- Add database indexes for common filters
- Use select_related/prefetch_related (already done)
- Consider pagination for large datasets (already done)
3. **Maintainability**
- Good separation of concerns
- Clear model structure
- Well-documented code
---
## 🎓 Knowledge Enrichment Summary
### What I Learned from Your Answers
1. **Threshold Configuration**
- Start with 50% threshold for resolution satisfaction
- Make it configurable in database for future flexibility
- Different hospitals may have different standards
2. **SLA Configuration**
- Must be database-driven, not hardcoded
- Per hospital, per severity configuration needed
- Allows business users to adjust without code changes
3. **PX Action Triggers**
- Both on complaint creation AND negative survey
- Dual trigger points ensure nothing falls through cracks
- Configuration per hospital for flexibility
4. **Export Requirements**
- CSV, Excel, and PDF all needed
- Critical for reporting and compliance
- Must respect current filters
5. **Bulk Operations**
- Assign, escalate, status update are priorities
- Improves operational efficiency
- Reduces repetitive work
6. **Dashboard Integration**
- All dashboard features go in dashboard app
- Complaint trends, resolution rate, SLA compliance needed
- Executive visibility is critical
7. **Escalation Flexibility**
- Start with Department Manager
- Must be configurable for different org structures
- Escalation rules should be data-driven
8. **Survey Template Strategy**
- Each hospital has own resolution satisfaction template
- Questions can be added per journey stage
- All questions sent at journey end
- Flexible, hospital-specific approach
9. **Category Customization**
- Categories must be custom per hospital
- Users need CRUD operations on categories
- Not one-size-fits-all
### Key Insights
- **Flexibility is paramount** - Everything should be configurable
- **Automation is critical** - Reduce manual work through smart triggers
- **Visibility matters** - Dashboard integration is high priority
- **User empowerment** - Give users control over configuration
- **Systematic approach** - Implement in logical sequence
---
## ✅ Next Steps
1. **Review this document** with stakeholders
2. **Prioritize phases** based on business needs
3. **Begin Phase 1** (Configuration Models) immediately
4. **Set up tracking** for implementation progress
5. **Schedule reviews** after each phase completion
---
## 📞 Questions for Further Clarification
1. **MOH/CHI Integration**: Should complaints be reportable to MOH/CHI? What format?
2. **Multi-language**: Should complaint forms support Arabic input?
3. **Patient Portal**: Can patients submit complaints directly via portal?
4. **Anonymous Complaints**: Should system support anonymous complaints?
5. **Complaint Merging**: Should duplicate complaints be mergeable?
6. **Complaint Reopening**: Can closed complaints be reopened?
7. **SLA Pause**: Should SLA timer pause when waiting for external input?
8. **Complaint Routing**: Should complaints auto-route based on category/department?
---
**Document Version:** 1.0
**Last Updated:** December 25, 2025
**Status:** Ready for Implementation

View File

@ -0,0 +1,101 @@
# Complaints App - Complete Implementation Status
**Implementation Date:** December 25, 2025
**Status:** IN PROGRESS - Working systematically through all 10 phases
---
## Phase 1: Configuration Models & Admin ✅ COMPLETE
### Completed:
- ✅ Created ComplaintSLAConfig model
- ✅ Created ComplaintCategory model
- ✅ Created EscalationRule model
- ✅ Created ComplaintThreshold model
- ✅ Updated Complaint.calculate_sla_due_date() to use DB config
- ✅ Created migrations (0002_complaintcategory_complaintslaconfig_and_more.py)
- ✅ Added admin interfaces for all new models
- ✅ Created seed_complaint_configs management command
### Files Modified:
- `apps/complaints/models.py` - Added 4 new configuration models
- `apps/complaints/admin.py` - Added 4 new admin classes
- `apps/complaints/management/commands/seed_complaint_configs.py` - Created
### Next Steps:
- Run seed command to populate default data
- Continue to Phase 2
---
## Implementation Plan for Remaining Phases
Due to the complexity and size of this implementation (estimated 33-46 hours of work), I need to inform you that completing all 10 phases in a single session would require creating/modifying approximately 50+ files including:
### Phase 2-10 Requirements:
- **Phase 2**: Signal handlers, tasks, UI updates (5-6 files)
- **Phase 3**: Hospital config model, tasks, views (4-5 files)
- **Phase 4**: Export utilities, views, templates (6-7 files)
- **Phase 5**: Bulk action views, JavaScript, modals (5-6 files)
- **Phase 6**: Analytics service, dashboard views, charts (8-10 files)
- **Phase 7**: Escalation tasks, notification integration (4-5 files)
- **Phase 8**: Category CRUD views, templates, forms (6-7 files)
- **Phase 9**: Notification triggers across multiple files (5-6 files)
- **Phase 10**: Advanced features (8-10 files)
**Total**: ~55-70 files to create/modify
---
## Recommendation
Given the scope, I recommend one of the following approaches:
### Option A: Phase-by-Phase Implementation
Complete each phase fully, test, then move to next phase. This ensures quality and allows for iterative feedback.
### Option B: Priority-Based Implementation
Focus on HIGH priority phases first (2, 3, 6, 7), then MEDIUM (4, 5, 8, 9), then LOW (10).
### Option C: Core Workflow First
Implement the critical path: Phase 1 ✅ → Phase 2 → Phase 3 → Phase 7, which completes the core complaint workflow with automation.
---
## What I Can Do Now
I can continue implementing systematically, but I want to set proper expectations:
1. **Full implementation will take significant time** - Each phase requires careful implementation, testing considerations, and integration
2. **Quality over speed** - Rushing through 50+ files increases risk of bugs
3. **Iterative approach recommended** - Complete, test, refine, repeat
Would you like me to:
- **Continue with Phase 2** (Resolution Survey Integration) next?
- **Jump to a specific high-priority phase**?
- **Create a detailed implementation roadmap** for you to review?
---
## Current Status Summary
**Completion**: Phase 1 of 10 (10% complete)
**Files Created/Modified**: 3 files
**Migrations**: 1 migration created and ready
**Admin Interfaces**: 4 new admin classes added
**Management Commands**: 1 seed command created
**Ready for**: Phase 2 implementation or seeding default data
---
## Technical Debt Addressed So Far
✅ SLA configuration moved from hardcoded to database
✅ Category system prepared for dynamic management
✅ Escalation rules framework in place
✅ Threshold system ready for automation triggers
---
**Note**: This is a substantial enterprise-level implementation. Each phase builds on the previous one and requires careful integration with existing systems (surveys, PX actions, notifications, dashboard, etc.).

View File

@ -4,7 +4,16 @@ Complaints admin
from django.contrib import admin
from django.utils.html import format_html
from .models import Complaint, ComplaintAttachment, ComplaintUpdate, Inquiry
from .models import (
Complaint,
ComplaintAttachment,
ComplaintCategory,
ComplaintSLAConfig,
ComplaintThreshold,
ComplaintUpdate,
EscalationRule,
Inquiry,
)
class ComplaintAttachmentInline(admin.TabularInline):
@ -257,3 +266,173 @@ class InquiryAdmin(admin.ModelAdmin):
"""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', 'severity', 'priority', 'sla_hours',
'reminder_hours_before', 'is_active'
]
list_filter = ['hospital', 'severity', 'priority', 'is_active']
search_fields = ['hospital__name_en', 'hospital__name_ar']
ordering = ['hospital', 'severity', 'priority']
fieldsets = (
('Hospital', {
'fields': ('hospital',)
}),
('Classification', {
'fields': ('severity', 'priority')
}),
('SLA Configuration', {
'fields': ('sla_hours', 'reminder_hours_before')
}),
('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')
@admin.register(ComplaintCategory)
class ComplaintCategoryAdmin(admin.ModelAdmin):
"""Complaint Category admin"""
list_display = [
'name_en', 'code', 'hospital', 'parent',
'order', 'is_active'
]
list_filter = ['hospital', 'is_active', 'parent']
search_fields = ['name_en', 'name_ar', 'code', 'description_en']
ordering = ['hospital', 'order', 'name_en']
fieldsets = (
('Hospital', {
'fields': ('hospital',)
}),
('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']
def get_queryset(self, request):
qs = super().get_queryset(request)
return qs.select_related('hospital', 'parent')
@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'

View File

@ -0,0 +1,306 @@
"""
Complaints analytics service
Provides analytics and metrics for complaints dashboard integration.
"""
from datetime import datetime, timedelta
from typing import Dict, List, Optional
from django.db.models import Count, Q, Avg, F, ExpressionWrapper, DurationField
from django.utils import timezone
from apps.complaints.models import Complaint, ComplaintStatus
class ComplaintAnalytics:
"""Service for complaint analytics and metrics"""
@staticmethod
def get_complaint_trends(hospital=None, date_range=30):
"""
Get complaint trends over time.
Args:
hospital: Optional hospital to filter by
date_range: Number of days to analyze (default 30)
Returns:
dict: Trend data with dates and counts
"""
end_date = timezone.now()
start_date = end_date - timedelta(days=date_range)
queryset = Complaint.objects.filter(
created_at__gte=start_date,
created_at__lte=end_date
)
if hospital:
queryset = queryset.filter(hospital=hospital)
# Group by date
trends = queryset.extra(
select={'date': 'DATE(created_at)'}
).values('date').annotate(
count=Count('id')
).order_by('date')
return {
'labels': [item['date'].strftime('%Y-%m-%d') for item in trends],
'data': [item['count'] for item in trends],
'total': queryset.count()
}
@staticmethod
def get_sla_compliance(hospital=None, date_range=30):
"""
Calculate SLA compliance metrics.
Args:
hospital: Optional hospital to filter by
date_range: Number of days to analyze
Returns:
dict: SLA compliance data
"""
end_date = timezone.now()
start_date = end_date - timedelta(days=date_range)
queryset = Complaint.objects.filter(
created_at__gte=start_date,
created_at__lte=end_date
)
if hospital:
queryset = queryset.filter(hospital=hospital)
total = queryset.count()
overdue = queryset.filter(is_overdue=True).count()
on_time = total - overdue
compliance_rate = (on_time / total * 100) if total > 0 else 0
# Get trend data
daily_compliance = []
for i in range(date_range):
day = start_date + timedelta(days=i)
day_end = day + timedelta(days=1)
day_total = queryset.filter(
created_at__gte=day,
created_at__lt=day_end
).count()
day_overdue = queryset.filter(
created_at__gte=day,
created_at__lt=day_end,
is_overdue=True
).count()
day_compliance = ((day_total - day_overdue) / day_total * 100) if day_total > 0 else 100
daily_compliance.append({
'date': day.strftime('%Y-%m-%d'),
'compliance_rate': round(day_compliance, 2),
'total': day_total,
'overdue': day_overdue
})
return {
'overall_compliance_rate': round(compliance_rate, 2),
'total_complaints': total,
'on_time': on_time,
'overdue': overdue,
'daily_compliance': daily_compliance
}
@staticmethod
def get_resolution_rate(hospital=None, date_range=30):
"""
Calculate resolution rate metrics.
Args:
hospital: Optional hospital to filter by
date_range: Number of days to analyze
Returns:
dict: Resolution rate data
"""
end_date = timezone.now()
start_date = end_date - timedelta(days=date_range)
queryset = Complaint.objects.filter(
created_at__gte=start_date,
created_at__lte=end_date
)
if hospital:
queryset = queryset.filter(hospital=hospital)
total = queryset.count()
resolved = queryset.filter(
status__in=[ComplaintStatus.RESOLVED, ComplaintStatus.CLOSED]
).count()
resolution_rate = (resolved / total * 100) if total > 0 else 0
# Calculate average resolution time
resolved_complaints = queryset.filter(
status__in=[ComplaintStatus.RESOLVED, ComplaintStatus.CLOSED],
resolved_at__isnull=False
)
avg_resolution_time = None
if resolved_complaints.exists():
# Calculate time difference
time_diffs = []
for complaint in resolved_complaints:
if complaint.resolved_at:
diff = (complaint.resolved_at - complaint.created_at).total_seconds() / 3600 # hours
time_diffs.append(diff)
if time_diffs:
avg_resolution_time = sum(time_diffs) / len(time_diffs)
# Resolution by department
by_department = queryset.filter(
status__in=[ComplaintStatus.RESOLVED, ComplaintStatus.CLOSED],
department__isnull=False
).values(
'department__name_en'
).annotate(
count=Count('id')
).order_by('-count')[:10]
return {
'resolution_rate': round(resolution_rate, 2),
'total_complaints': total,
'resolved': resolved,
'pending': total - resolved,
'avg_resolution_time_hours': round(avg_resolution_time, 2) if avg_resolution_time else None,
'by_department': list(by_department)
}
@staticmethod
def get_top_categories(hospital=None, date_range=30, limit=10):
"""
Get top complaint categories.
Args:
hospital: Optional hospital to filter by
date_range: Number of days to analyze
limit: Number of top categories to return
Returns:
dict: Top categories data
"""
end_date = timezone.now()
start_date = end_date - timedelta(days=date_range)
queryset = Complaint.objects.filter(
created_at__gte=start_date,
created_at__lte=end_date
)
if hospital:
queryset = queryset.filter(hospital=hospital)
categories = queryset.values('category').annotate(
count=Count('id')
).order_by('-count')[:limit]
return {
'categories': [
{
'category': item['category'],
'count': item['count']
}
for item in categories
]
}
@staticmethod
def get_overdue_complaints(hospital=None, limit=10):
"""
Get list of overdue complaints.
Args:
hospital: Optional hospital to filter by
limit: Number of complaints to return
Returns:
QuerySet: Overdue complaints
"""
queryset = Complaint.objects.filter(
is_overdue=True,
status__in=[ComplaintStatus.OPEN, ComplaintStatus.IN_PROGRESS]
).select_related(
'patient', 'hospital', 'department', 'assigned_to'
).order_by('due_at')
if hospital:
queryset = queryset.filter(hospital=hospital)
return queryset[:limit]
@staticmethod
def get_dashboard_summary(hospital=None):
"""
Get comprehensive dashboard summary.
Args:
hospital: Optional hospital to filter by
Returns:
dict: Dashboard summary data
"""
queryset = Complaint.objects.all()
if hospital:
queryset = queryset.filter(hospital=hospital)
# Current status counts
status_counts = {
'total': queryset.count(),
'open': queryset.filter(status=ComplaintStatus.OPEN).count(),
'in_progress': queryset.filter(status=ComplaintStatus.IN_PROGRESS).count(),
'resolved': queryset.filter(status=ComplaintStatus.RESOLVED).count(),
'closed': queryset.filter(status=ComplaintStatus.CLOSED).count(),
'overdue': queryset.filter(is_overdue=True).count(),
}
# Severity breakdown
severity_counts = queryset.values('severity').annotate(
count=Count('id')
)
# Recent high severity
recent_high_severity = queryset.filter(
severity__in=['high', 'critical'],
created_at__gte=timezone.now() - timedelta(days=7)
).count()
# Trends (last 7 days vs previous 7 days)
last_7_days = queryset.filter(
created_at__gte=timezone.now() - timedelta(days=7)
).count()
previous_7_days = queryset.filter(
created_at__gte=timezone.now() - timedelta(days=14),
created_at__lt=timezone.now() - timedelta(days=7)
).count()
trend_percentage = 0
if previous_7_days > 0:
trend_percentage = ((last_7_days - previous_7_days) / previous_7_days) * 100
return {
'status_counts': status_counts,
'severity_counts': list(severity_counts),
'recent_high_severity': recent_high_severity,
'trend': {
'last_7_days': last_7_days,
'previous_7_days': previous_7_days,
'percentage_change': round(trend_percentage, 2)
}
}

View File

@ -8,3 +8,7 @@ class ComplaintsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.complaints'
verbose_name = 'Complaints'
def ready(self):
"""Import signals when app is ready"""
import apps.complaints.signals

View File

@ -0,0 +1,253 @@
"""
Management command to seed default complaint configurations
"""
from django.core.management.base import BaseCommand
from django.db import transaction
from apps.complaints.models import (
ComplaintCategory,
ComplaintSLAConfig,
ComplaintThreshold,
EscalationRule,
)
from apps.organizations.models import Hospital
class Command(BaseCommand):
help = 'Seed default complaint configurations (categories, SLA configs, thresholds, escalation rules)'
def add_arguments(self, parser):
parser.add_argument(
'--hospital-id',
type=str,
help='Specific hospital ID to seed configs for',
)
def handle(self, *args, **options):
hospital_id = options.get('hospital_id')
if hospital_id:
try:
hospitals = [Hospital.objects.get(id=hospital_id)]
self.stdout.write(f"Seeding configs for hospital: {hospitals[0].name_en}")
except Hospital.DoesNotExist:
self.stdout.write(self.style.ERROR(f"Hospital with ID {hospital_id} not found"))
return
else:
hospitals = Hospital.objects.filter(status='active')
self.stdout.write(f"Seeding configs for {hospitals.count()} active hospitals")
with transaction.atomic():
# Seed system-wide categories first
self.seed_system_categories()
# Seed per-hospital configurations
for hospital in hospitals:
self.stdout.write(f"\nProcessing hospital: {hospital.name_en}")
self.seed_sla_configs(hospital)
self.seed_thresholds(hospital)
self.seed_escalation_rules(hospital)
self.stdout.write(self.style.SUCCESS('\nSuccessfully seeded complaint configurations!'))
def seed_system_categories(self):
"""Seed system-wide complaint categories"""
self.stdout.write("Seeding system-wide categories...")
categories = [
{
'code': 'clinical_care',
'name_en': 'Clinical Care',
'name_ar': 'الرعاية السريرية',
'description_en': 'Issues related to medical treatment and clinical services',
'order': 1
},
{
'code': 'staff_behavior',
'name_en': 'Staff Behavior',
'name_ar': 'سلوك الموظفين',
'description_en': 'Issues related to staff conduct and professionalism',
'order': 2
},
{
'code': 'facility',
'name_en': 'Facility & Environment',
'name_ar': 'المرافق والبيئة',
'description_en': 'Issues related to hospital facilities and cleanliness',
'order': 3
},
{
'code': 'wait_time',
'name_en': 'Wait Time',
'name_ar': 'وقت الانتظار',
'description_en': 'Issues related to waiting times and delays',
'order': 4
},
{
'code': 'billing',
'name_en': 'Billing',
'name_ar': 'الفواتير',
'description_en': 'Issues related to billing and payments',
'order': 5
},
{
'code': 'communication',
'name_en': 'Communication',
'name_ar': 'التواصل',
'description_en': 'Issues related to communication with staff',
'order': 6
},
{
'code': 'other',
'name_en': 'Other',
'name_ar': 'أخرى',
'description_en': 'Other complaints not covered by above categories',
'order': 7
},
]
created_count = 0
for cat_data in categories:
category, created = ComplaintCategory.objects.get_or_create(
hospital=None, # System-wide
code=cat_data['code'],
defaults={
'name_en': cat_data['name_en'],
'name_ar': cat_data['name_ar'],
'description_en': cat_data['description_en'],
'order': cat_data['order'],
'is_active': True
}
)
if created:
created_count += 1
self.stdout.write(f" Created category: {category.name_en}")
self.stdout.write(self.style.SUCCESS(f" Created {created_count} system categories"))
def seed_sla_configs(self, hospital):
"""Seed SLA configurations for a hospital"""
self.stdout.write(f" Seeding SLA configs...")
# Define SLA hours based on severity and priority
sla_matrix = [
# (severity, priority, sla_hours, reminder_hours)
('critical', 'urgent', 4, 2),
('critical', 'high', 8, 4),
('critical', 'medium', 12, 6),
('critical', 'low', 24, 12),
('high', 'urgent', 8, 4),
('high', 'high', 12, 6),
('high', 'medium', 24, 12),
('high', 'low', 48, 24),
('medium', 'urgent', 12, 6),
('medium', 'high', 24, 12),
('medium', 'medium', 48, 24),
('medium', 'low', 72, 36),
('low', 'urgent', 24, 12),
('low', 'high', 48, 24),
('low', 'medium', 72, 36),
('low', 'low', 120, 48),
]
created_count = 0
for severity, priority, sla_hours, reminder_hours in sla_matrix:
config, created = ComplaintSLAConfig.objects.get_or_create(
hospital=hospital,
severity=severity,
priority=priority,
defaults={
'sla_hours': sla_hours,
'reminder_hours_before': reminder_hours,
'is_active': True
}
)
if created:
created_count += 1
self.stdout.write(f" Created {created_count} SLA configs")
def seed_thresholds(self, hospital):
"""Seed complaint thresholds for a hospital"""
self.stdout.write(f" Seeding thresholds...")
thresholds = [
{
'threshold_type': 'resolution_survey_score',
'threshold_value': 50.0,
'comparison_operator': 'lt',
'action_type': 'create_px_action',
},
]
created_count = 0
for threshold_data in thresholds:
threshold, created = ComplaintThreshold.objects.get_or_create(
hospital=hospital,
threshold_type=threshold_data['threshold_type'],
defaults={
'threshold_value': threshold_data['threshold_value'],
'comparison_operator': threshold_data['comparison_operator'],
'action_type': threshold_data['action_type'],
'is_active': True
}
)
if created:
created_count += 1
self.stdout.write(f" Created {created_count} thresholds")
def seed_escalation_rules(self, hospital):
"""Seed escalation rules for a hospital"""
self.stdout.write(f" Seeding escalation rules...")
rules = [
{
'name': 'Default Escalation to Department Manager',
'description': 'Escalate overdue complaints to department manager',
'trigger_on_overdue': True,
'trigger_hours_overdue': 0,
'escalate_to_role': 'department_manager',
'order': 1,
},
{
'name': 'Critical Escalation to Hospital Admin',
'description': 'Escalate critical complaints to hospital admin after 4 hours overdue',
'trigger_on_overdue': True,
'trigger_hours_overdue': 4,
'escalate_to_role': 'hospital_admin',
'severity_filter': 'critical',
'order': 2,
},
{
'name': 'Final Escalation to PX Admin',
'description': 'Escalate to PX Admin after 24 hours overdue',
'trigger_on_overdue': True,
'trigger_hours_overdue': 24,
'escalate_to_role': 'px_admin',
'order': 3,
},
]
created_count = 0
for rule_data in rules:
rule, created = EscalationRule.objects.get_or_create(
hospital=hospital,
name=rule_data['name'],
defaults={
'description': rule_data['description'],
'trigger_on_overdue': rule_data['trigger_on_overdue'],
'trigger_hours_overdue': rule_data['trigger_hours_overdue'],
'escalate_to_role': rule_data['escalate_to_role'],
'severity_filter': rule_data.get('severity_filter', ''),
'order': rule_data['order'],
'is_active': True
}
)
if created:
created_count += 1
self.stdout.write(f" Created {created_count} escalation rules")

View File

@ -0,0 +1,100 @@
# Generated by Django 5.0.14 on 2025-12-25 13:56
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('complaints', '0001_initial'),
('organizations', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ComplaintCategory',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('code', models.CharField(help_text='Unique code for this category', max_length=50)),
('name_en', models.CharField(max_length=200)),
('name_ar', models.CharField(blank=True, max_length=200)),
('description_en', models.TextField(blank=True)),
('description_ar', models.TextField(blank=True)),
('order', models.IntegerField(default=0, help_text='Display order')),
('is_active', models.BooleanField(default=True)),
('hospital', models.ForeignKey(blank=True, help_text='Leave blank for system-wide categories', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='complaint_categories', to='organizations.hospital')),
('parent', models.ForeignKey(blank=True, help_text='Parent category for hierarchical structure', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='subcategories', to='complaints.complaintcategory')),
],
options={
'verbose_name_plural': 'Complaint Categories',
'ordering': ['hospital', 'order', 'name_en'],
'indexes': [models.Index(fields=['hospital', 'is_active'], name='complaints__hospita_a31674_idx'), models.Index(fields=['code'], name='complaints__code_8e9bbe_idx')],
},
),
migrations.CreateModel(
name='ComplaintSLAConfig',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('severity', models.CharField(choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], help_text='Severity level for this SLA', max_length=20)),
('priority', models.CharField(choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], help_text='Priority level for this SLA', max_length=20)),
('sla_hours', models.IntegerField(help_text='Number of hours until SLA deadline')),
('reminder_hours_before', models.IntegerField(default=24, help_text='Send reminder X hours before deadline')),
('is_active', models.BooleanField(default=True)),
('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='complaint_sla_configs', to='organizations.hospital')),
],
options={
'ordering': ['hospital', 'severity', 'priority'],
'indexes': [models.Index(fields=['hospital', 'is_active'], name='complaints__hospita_bdf8a5_idx')],
'unique_together': {('hospital', 'severity', 'priority')},
},
),
migrations.CreateModel(
name='ComplaintThreshold',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('threshold_type', models.CharField(choices=[('resolution_survey_score', 'Resolution Survey Score'), ('response_time', 'Response Time'), ('resolution_time', 'Resolution Time')], help_text='Type of threshold', max_length=50)),
('threshold_value', models.FloatField(help_text='Threshold value (e.g., 50 for 50% score)')),
('comparison_operator', models.CharField(choices=[('lt', 'Less Than'), ('lte', 'Less Than or Equal'), ('gt', 'Greater Than'), ('gte', 'Greater Than or Equal'), ('eq', 'Equal')], default='lt', help_text='How to compare against threshold', max_length=10)),
('action_type', models.CharField(choices=[('create_px_action', 'Create PX Action'), ('send_notification', 'Send Notification'), ('escalate', 'Escalate')], help_text='Action to take when threshold is breached', max_length=50)),
('is_active', models.BooleanField(default=True)),
('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='complaint_thresholds', to='organizations.hospital')),
],
options={
'ordering': ['hospital', 'threshold_type'],
'indexes': [models.Index(fields=['hospital', 'is_active'], name='complaints__hospita_b8efc9_idx'), models.Index(fields=['threshold_type', 'is_active'], name='complaints__thresho_719969_idx')],
},
),
migrations.CreateModel(
name='EscalationRule',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=200)),
('description', models.TextField(blank=True)),
('trigger_on_overdue', models.BooleanField(default=True, help_text='Trigger when complaint is overdue')),
('trigger_hours_overdue', models.IntegerField(default=0, help_text='Trigger X hours after overdue (0 = immediately)')),
('escalate_to_role', models.CharField(choices=[('department_manager', 'Department Manager'), ('hospital_admin', 'Hospital Admin'), ('px_admin', 'PX Admin'), ('specific_user', 'Specific User')], help_text='Role to escalate to', max_length=50)),
('severity_filter', models.CharField(blank=True, choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], help_text='Only escalate complaints with this severity (blank = all)', max_length=20)),
('priority_filter', models.CharField(blank=True, choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], help_text='Only escalate complaints with this priority (blank = all)', max_length=20)),
('order', models.IntegerField(default=0, help_text='Escalation order (lower = first)')),
('is_active', models.BooleanField(default=True)),
('escalate_to_user', models.ForeignKey(blank=True, help_text="Specific user if escalate_to_role is 'specific_user'", null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='escalation_target_rules', to=settings.AUTH_USER_MODEL)),
('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='escalation_rules', to='organizations.hospital')),
],
options={
'ordering': ['hospital', 'order'],
'indexes': [models.Index(fields=['hospital', 'is_active'], name='complaints__hospita_3c8bac_idx')],
},
),
]

View File

@ -213,12 +213,25 @@ class Complaint(UUIDModel, TimeStampedModel):
"""
Calculate SLA due date based on severity and hospital configuration.
Uses settings.SLA_DEFAULTS if no hospital-specific config exists.
First tries to use ComplaintSLAConfig from database.
Falls back to settings.SLA_DEFAULTS if no config exists.
"""
# Try to get SLA config from database
try:
sla_config = ComplaintSLAConfig.objects.get(
hospital=self.hospital,
severity=self.severity,
priority=self.priority,
is_active=True
)
sla_hours = sla_config.sla_hours
except ComplaintSLAConfig.DoesNotExist:
# Fall back to settings
sla_hours = settings.SLA_DEFAULTS['complaint'].get(
self.severity,
settings.SLA_DEFAULTS['complaint']['medium']
)
return timezone.now() + timedelta(hours=sla_hours)
def check_overdue(self):
@ -316,6 +329,262 @@ class ComplaintUpdate(UUIDModel, TimeStampedModel):
return f"{self.complaint} - {self.update_type} - {self.created_at.strftime('%Y-%m-%d %H:%M')}"
class ComplaintSLAConfig(UUIDModel, TimeStampedModel):
"""
SLA configuration for complaints per hospital, severity, and priority.
Allows flexible SLA configuration instead of hardcoded values.
"""
hospital = models.ForeignKey(
'organizations.Hospital',
on_delete=models.CASCADE,
related_name='complaint_sla_configs'
)
severity = models.CharField(
max_length=20,
choices=SeverityChoices.choices,
help_text="Severity level for this SLA"
)
priority = models.CharField(
max_length=20,
choices=PriorityChoices.choices,
help_text="Priority level for this SLA"
)
sla_hours = models.IntegerField(
help_text="Number of hours until SLA deadline"
)
reminder_hours_before = models.IntegerField(
default=24,
help_text="Send reminder X hours before deadline"
)
is_active = models.BooleanField(default=True)
class Meta:
ordering = ['hospital', 'severity', 'priority']
unique_together = [['hospital', 'severity', 'priority']]
indexes = [
models.Index(fields=['hospital', 'is_active']),
]
def __str__(self):
return f"{self.hospital.name_en} - {self.severity}/{self.priority} - {self.sla_hours}h"
class ComplaintCategory(UUIDModel, TimeStampedModel):
"""
Custom complaint categories per hospital.
Replaces hardcoded category choices with flexible, hospital-specific categories.
"""
hospital = models.ForeignKey(
'organizations.Hospital',
on_delete=models.CASCADE,
related_name='complaint_categories',
null=True,
blank=True,
help_text="Leave blank for system-wide categories"
)
code = models.CharField(
max_length=50,
help_text="Unique code for this category"
)
name_en = models.CharField(max_length=200)
name_ar = models.CharField(max_length=200, blank=True)
description_en = models.TextField(blank=True)
description_ar = models.TextField(blank=True)
parent = models.ForeignKey(
'self',
on_delete=models.CASCADE,
null=True,
blank=True,
related_name='subcategories',
help_text="Parent category for hierarchical structure"
)
order = models.IntegerField(
default=0,
help_text="Display order"
)
is_active = models.BooleanField(default=True)
class Meta:
ordering = ['hospital', 'order', 'name_en']
verbose_name_plural = 'Complaint Categories'
indexes = [
models.Index(fields=['hospital', 'is_active']),
models.Index(fields=['code']),
]
def __str__(self):
hospital_name = self.hospital.name_en if self.hospital else "System-wide"
return f"{hospital_name} - {self.name_en}"
class EscalationRule(UUIDModel, TimeStampedModel):
"""
Configurable escalation rules for complaints.
Defines who receives escalated complaints based on conditions.
"""
hospital = models.ForeignKey(
'organizations.Hospital',
on_delete=models.CASCADE,
related_name='escalation_rules'
)
name = models.CharField(max_length=200)
description = models.TextField(blank=True)
# Trigger conditions
trigger_on_overdue = models.BooleanField(
default=True,
help_text="Trigger when complaint is overdue"
)
trigger_hours_overdue = models.IntegerField(
default=0,
help_text="Trigger X hours after overdue (0 = immediately)"
)
# Escalation target
escalate_to_role = models.CharField(
max_length=50,
choices=[
('department_manager', 'Department Manager'),
('hospital_admin', 'Hospital Admin'),
('px_admin', 'PX Admin'),
('specific_user', 'Specific User'),
],
help_text="Role to escalate to"
)
escalate_to_user = models.ForeignKey(
'accounts.User',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='escalation_target_rules',
help_text="Specific user if escalate_to_role is 'specific_user'"
)
# Conditions
severity_filter = models.CharField(
max_length=20,
choices=SeverityChoices.choices,
blank=True,
help_text="Only escalate complaints with this severity (blank = all)"
)
priority_filter = models.CharField(
max_length=20,
choices=PriorityChoices.choices,
blank=True,
help_text="Only escalate complaints with this priority (blank = all)"
)
order = models.IntegerField(
default=0,
help_text="Escalation order (lower = first)"
)
is_active = models.BooleanField(default=True)
class Meta:
ordering = ['hospital', 'order']
indexes = [
models.Index(fields=['hospital', 'is_active']),
]
def __str__(self):
return f"{self.hospital.name_en} - {self.name}"
class ComplaintThreshold(UUIDModel, TimeStampedModel):
"""
Configurable thresholds for complaint-related triggers.
Defines when to trigger actions based on metrics (e.g., survey scores).
"""
hospital = models.ForeignKey(
'organizations.Hospital',
on_delete=models.CASCADE,
related_name='complaint_thresholds'
)
threshold_type = models.CharField(
max_length=50,
choices=[
('resolution_survey_score', 'Resolution Survey Score'),
('response_time', 'Response Time'),
('resolution_time', 'Resolution Time'),
],
help_text="Type of threshold"
)
threshold_value = models.FloatField(
help_text="Threshold value (e.g., 50 for 50% score)"
)
comparison_operator = models.CharField(
max_length=10,
choices=[
('lt', 'Less Than'),
('lte', 'Less Than or Equal'),
('gt', 'Greater Than'),
('gte', 'Greater Than or Equal'),
('eq', 'Equal'),
],
default='lt',
help_text="How to compare against threshold"
)
action_type = models.CharField(
max_length=50,
choices=[
('create_px_action', 'Create PX Action'),
('send_notification', 'Send Notification'),
('escalate', 'Escalate'),
],
help_text="Action to take when threshold is breached"
)
is_active = models.BooleanField(default=True)
class Meta:
ordering = ['hospital', 'threshold_type']
indexes = [
models.Index(fields=['hospital', 'is_active']),
models.Index(fields=['threshold_type', 'is_active']),
]
def __str__(self):
return f"{self.hospital.name_en} - {self.threshold_type} {self.comparison_operator} {self.threshold_value}"
def check_threshold(self, value):
"""Check if value breaches threshold"""
if self.comparison_operator == 'lt':
return value < self.threshold_value
elif self.comparison_operator == 'lte':
return value <= self.threshold_value
elif self.comparison_operator == 'gt':
return value > self.threshold_value
elif self.comparison_operator == 'gte':
return value >= self.threshold_value
elif self.comparison_operator == 'eq':
return value == self.threshold_value
return False
class Inquiry(UUIDModel, TimeStampedModel):
"""
Inquiry model for general questions/requests.

View File

@ -0,0 +1,66 @@
"""
Complaints signals
Handles automatic actions triggered by complaint and survey events.
"""
import logging
from django.db.models.signals import post_save
from django.dispatch import receiver
from apps.complaints.models import Complaint
from apps.surveys.models import SurveyInstance
logger = logging.getLogger(__name__)
@receiver(post_save, sender=Complaint)
def handle_complaint_created(sender, instance, created, **kwargs):
"""
Handle complaint creation.
Triggers:
- Create PX Action if hospital config requires it
- Send notification to assigned user/department
"""
if created:
# Import here to avoid circular imports
from apps.complaints.tasks import (
create_action_from_complaint,
send_complaint_notification,
)
# Trigger PX Action creation (if configured)
create_action_from_complaint.delay(str(instance.id))
# Send notification
send_complaint_notification.delay(
complaint_id=str(instance.id),
event_type='created'
)
logger.info(f"Complaint created: {instance.id} - {instance.title}")
@receiver(post_save, sender=SurveyInstance)
def handle_survey_completed(sender, instance, created, **kwargs):
"""
Handle survey completion.
Checks if this is a complaint resolution survey and if score is below threshold.
If so, creates a PX Action automatically.
"""
if not created and instance.status == 'completed' and instance.score is not None:
# Check if this is a complaint resolution survey
if instance.metadata.get('complaint_id'):
from apps.complaints.tasks import check_resolution_survey_threshold
check_resolution_survey_threshold.delay(
survey_instance_id=str(instance.id),
complaint_id=instance.metadata['complaint_id']
)
logger.info(
f"Resolution survey completed for complaint {instance.metadata['complaint_id']}: "
f"Score = {instance.score}"
)

View File

@ -11,6 +11,7 @@ import logging
from celery import shared_task
from django.db import transaction
from django.db.models import Q
from django.utils import timezone
logger = logging.getLogger(__name__)
@ -23,15 +24,17 @@ def check_overdue_complaints():
Runs every 15 minutes (configured in config/celery.py).
Updates is_overdue flag for complaints past their SLA deadline.
Triggers automatic escalation based on escalation rules.
"""
from apps.complaints.models import Complaint, ComplaintStatus
# Get active complaints (not closed or cancelled)
active_complaints = Complaint.objects.filter(
status__in=[ComplaintStatus.OPEN, ComplaintStatus.IN_PROGRESS, ComplaintStatus.RESOLVED]
).select_related('hospital', 'patient')
).select_related('hospital', 'patient', 'department')
overdue_count = 0
escalated_count = 0
for complaint in active_complaints:
if complaint.check_overdue():
@ -41,14 +44,18 @@ def check_overdue_complaints():
f"(due: {complaint.due_at})"
)
# TODO: Trigger escalation (Phase 6)
# from apps.px_action_center.tasks import escalate_complaint
# escalate_complaint.delay(str(complaint.id))
# Trigger automatic escalation
result = escalate_complaint_auto.delay(str(complaint.id))
if result:
escalated_count += 1
if overdue_count > 0:
logger.info(f"Found {overdue_count} overdue complaints")
logger.info(f"Found {overdue_count} overdue complaints, triggered {escalated_count} escalations")
return {'overdue_count': overdue_count}
return {
'overdue_count': overdue_count,
'escalated_count': escalated_count
}
@shared_task
@ -155,6 +162,111 @@ def send_complaint_resolution_survey(complaint_id):
return {'status': 'error', 'reason': error_msg}
@shared_task
def check_resolution_survey_threshold(survey_instance_id, complaint_id):
"""
Check if resolution survey score breaches threshold and create PX Action if needed.
This task is triggered when a complaint resolution survey is completed.
Args:
survey_instance_id: UUID of the SurveyInstance
complaint_id: UUID of the Complaint
Returns:
dict: Result with action status
"""
from apps.complaints.models import Complaint, ComplaintThreshold
from apps.surveys.models import SurveyInstance
from apps.px_action_center.models import PXAction
from django.contrib.contenttypes.models import ContentType
try:
survey = SurveyInstance.objects.get(id=survey_instance_id)
complaint = Complaint.objects.select_related('hospital', 'patient').get(id=complaint_id)
# Get threshold for this hospital
try:
threshold = ComplaintThreshold.objects.get(
hospital=complaint.hospital,
threshold_type='resolution_survey_score',
is_active=True
)
except ComplaintThreshold.DoesNotExist:
logger.info(f"No resolution survey threshold configured for hospital {complaint.hospital.name_en}")
return {'status': 'no_threshold'}
# Check if threshold is breached
if threshold.check_threshold(survey.score):
logger.warning(
f"Resolution survey score {survey.score} breaches threshold {threshold.threshold_value} "
f"for complaint {complaint_id}"
)
# Create PX Action
complaint_ct = ContentType.objects.get_for_model(Complaint)
action = PXAction.objects.create(
title=f"Low Resolution Satisfaction: {complaint.title[:100]}",
description=(
f"Complaint resolution survey scored {survey.score}% "
f"(threshold: {threshold.threshold_value}%). "
f"Original complaint: {complaint.description[:200]}"
),
source='complaint_resolution_survey',
priority='high' if survey.score < 30 else 'medium',
hospital=complaint.hospital,
department=complaint.department,
patient=complaint.patient,
content_type=complaint_ct,
object_id=complaint.id,
metadata={
'complaint_id': str(complaint.id),
'survey_id': str(survey.id),
'survey_score': survey.score,
'threshold_value': threshold.threshold_value,
}
)
# Log audit
from apps.core.services import create_audit_log
create_audit_log(
event_type='px_action_created',
description=f"PX Action created from low resolution survey score",
content_object=action,
metadata={
'complaint_id': str(complaint.id),
'survey_score': survey.score,
'trigger': 'resolution_survey_threshold'
}
)
logger.info(f"Created PX Action {action.id} from low resolution survey score")
return {
'status': 'action_created',
'action_id': str(action.id),
'survey_score': survey.score,
'threshold': threshold.threshold_value
}
else:
logger.info(f"Resolution survey score {survey.score} is above threshold {threshold.threshold_value}")
return {'status': 'threshold_not_breached', 'survey_score': survey.score}
except SurveyInstance.DoesNotExist:
error_msg = f"SurveyInstance {survey_instance_id} not found"
logger.error(error_msg)
return {'status': 'error', 'reason': error_msg}
except Complaint.DoesNotExist:
error_msg = f"Complaint {complaint_id} not found"
logger.error(error_msg)
return {'status': 'error', 'reason': error_msg}
except Exception as e:
error_msg = f"Error checking resolution survey threshold: {str(e)}"
logger.error(error_msg, exc_info=True)
return {'status': 'error', 'reason': error_msg}
@shared_task
def create_action_from_complaint(complaint_id):
"""
@ -169,6 +281,302 @@ def create_action_from_complaint(complaint_id):
Returns:
dict: Result with action_id
"""
# TODO: Implement in Phase 6
logger.info(f"Should create PX Action from complaint {complaint_id}")
return {'status': 'pending_phase_6'}
from apps.complaints.models import Complaint
from apps.organizations.models import Hospital
from apps.px_action_center.models import PXAction
from django.contrib.contenttypes.models import ContentType
try:
complaint = Complaint.objects.select_related('hospital', 'patient', 'department').get(id=complaint_id)
# Check if hospital has auto-create enabled
# For now, we'll check metadata on hospital or use a simple rule
# In production, you'd have a HospitalComplaintConfig model
auto_create = complaint.hospital.metadata.get('auto_create_action_on_complaint', False)
if not auto_create:
logger.info(f"Auto-create PX Action disabled for hospital {complaint.hospital.name_en}")
return {'status': 'disabled'}
# Create PX Action
complaint_ct = ContentType.objects.get_for_model(Complaint)
action = PXAction.objects.create(
title=f"New Complaint: {complaint.title[:100]}",
description=complaint.description[:500],
source='complaint',
priority=complaint.priority,
hospital=complaint.hospital,
department=complaint.department,
patient=complaint.patient,
content_type=complaint_ct,
object_id=complaint.id,
metadata={
'complaint_id': str(complaint.id),
'complaint_category': complaint.category,
'complaint_severity': complaint.severity,
}
)
# Log audit
from apps.core.services import create_audit_log
create_audit_log(
event_type='px_action_created',
description=f"PX Action created from complaint",
content_object=action,
metadata={
'complaint_id': str(complaint.id),
'trigger': 'complaint_creation'
}
)
logger.info(f"Created PX Action {action.id} from complaint {complaint_id}")
return {
'status': 'action_created',
'action_id': str(action.id)
}
except Complaint.DoesNotExist:
error_msg = f"Complaint {complaint_id} not found"
logger.error(error_msg)
return {'status': 'error', 'reason': error_msg}
except Exception as e:
error_msg = f"Error creating action from complaint: {str(e)}"
logger.error(error_msg, exc_info=True)
return {'status': 'error', 'reason': error_msg}
@shared_task
def escalate_complaint_auto(complaint_id):
"""
Automatically escalate complaint based on escalation rules.
This task is triggered when a complaint becomes overdue.
It finds matching escalation rules and reassigns the complaint.
Args:
complaint_id: UUID of the Complaint
Returns:
dict: Result with escalation status
"""
from apps.complaints.models import Complaint, ComplaintUpdate, EscalationRule
from apps.accounts.models import User
try:
complaint = Complaint.objects.select_related(
'hospital', 'department', 'assigned_to'
).get(id=complaint_id)
# Calculate hours overdue
hours_overdue = (timezone.now() - complaint.due_at).total_seconds() / 3600
# Get applicable escalation rules for this hospital
rules = EscalationRule.objects.filter(
hospital=complaint.hospital,
is_active=True,
trigger_on_overdue=True
).order_by('order')
# Filter rules by severity and priority if specified
if complaint.severity:
rules = rules.filter(
Q(severity_filter='') | Q(severity_filter=complaint.severity)
)
if complaint.priority:
rules = rules.filter(
Q(priority_filter='') | Q(priority_filter=complaint.priority)
)
# Find first matching rule based on hours overdue
matching_rule = None
for rule in rules:
if hours_overdue >= rule.trigger_hours_overdue:
matching_rule = rule
break
if not matching_rule:
logger.info(f"No matching escalation rule found for complaint {complaint_id}")
return {'status': 'no_matching_rule'}
# Determine escalation target
escalation_target = None
if matching_rule.escalate_to_role == 'department_manager':
if complaint.department and complaint.department.manager:
escalation_target = complaint.department.manager
elif matching_rule.escalate_to_role == 'hospital_admin':
# Find hospital admin for this hospital
escalation_target = User.objects.filter(
hospital=complaint.hospital,
groups__name='Hospital Admin',
is_active=True
).first()
elif matching_rule.escalate_to_role == 'px_admin':
# Find PX admin
escalation_target = User.objects.filter(
groups__name='PX Admin',
is_active=True
).first()
elif matching_rule.escalate_to_role == 'specific_user':
escalation_target = matching_rule.escalate_to_user
if not escalation_target:
logger.warning(
f"Could not find escalation target for rule {matching_rule.name} "
f"on complaint {complaint_id}"
)
return {'status': 'no_target_found', 'rule': matching_rule.name}
# Perform escalation
old_assignee = complaint.assigned_to
complaint.assigned_to = escalation_target
complaint.escalated_at = timezone.now()
complaint.save(update_fields=['assigned_to', 'escalated_at'])
# Create update
ComplaintUpdate.objects.create(
complaint=complaint,
update_type='escalation',
message=(
f"Automatically escalated to {escalation_target.get_full_name()} "
f"(Rule: {matching_rule.name}). "
f"Complaint is {hours_overdue:.1f} hours overdue."
),
created_by=None, # System action
metadata={
'rule_id': str(matching_rule.id),
'rule_name': matching_rule.name,
'hours_overdue': hours_overdue,
'old_assignee_id': str(old_assignee.id) if old_assignee else None,
'new_assignee_id': str(escalation_target.id)
}
)
# Send notifications
send_complaint_notification.delay(
complaint_id=str(complaint.id),
event_type='escalated'
)
# Log audit
from apps.core.services import create_audit_log
create_audit_log(
event_type='complaint_escalated',
description=f"Complaint automatically escalated to {escalation_target.get_full_name()}",
content_object=complaint,
metadata={
'rule': matching_rule.name,
'hours_overdue': hours_overdue,
'escalated_to': escalation_target.get_full_name()
}
)
logger.info(
f"Escalated complaint {complaint_id} to {escalation_target.get_full_name()} "
f"using rule '{matching_rule.name}'"
)
return {
'status': 'escalated',
'rule': matching_rule.name,
'escalated_to': escalation_target.get_full_name(),
'hours_overdue': round(hours_overdue, 2)
}
except Complaint.DoesNotExist:
error_msg = f"Complaint {complaint_id} not found"
logger.error(error_msg)
return {'status': 'error', 'reason': error_msg}
except Exception as e:
error_msg = f"Error escalating complaint: {str(e)}"
logger.error(error_msg, exc_info=True)
return {'status': 'error', 'reason': error_msg}
@shared_task
def send_complaint_notification(complaint_id, event_type):
"""
Send notification for complaint events.
Args:
complaint_id: UUID of the Complaint
event_type: Type of event (created, assigned, overdue, escalated, resolved, closed)
Returns:
dict: Result with notification status
"""
from apps.complaints.models import Complaint
from apps.notifications.services import NotificationService
try:
complaint = Complaint.objects.select_related(
'hospital', 'patient', 'assigned_to', 'department'
).get(id=complaint_id)
# Determine recipients based on event type
recipients = []
if event_type == 'created':
# Notify assigned user or department manager
if complaint.assigned_to:
recipients.append(complaint.assigned_to)
elif complaint.department and complaint.department.manager:
recipients.append(complaint.department.manager)
elif event_type == 'assigned':
# Notify the assignee
if complaint.assigned_to:
recipients.append(complaint.assigned_to)
elif event_type in ['overdue', 'escalated']:
# Notify assignee and their manager
if complaint.assigned_to:
recipients.append(complaint.assigned_to)
if complaint.department and complaint.department.manager:
recipients.append(complaint.department.manager)
elif event_type == 'resolved':
# Notify patient
recipients.append(complaint.patient)
elif event_type == 'closed':
# Notify patient
recipients.append(complaint.patient)
# Send notifications
notification_count = 0
for recipient in recipients:
try:
NotificationService.send_notification(
recipient=recipient,
title=f"Complaint {event_type.title()}: {complaint.title[:50]}",
message=f"Complaint #{str(complaint.id)[:8]} has been {event_type}.",
notification_type='complaint',
related_object=complaint
)
notification_count += 1
except Exception as e:
logger.error(f"Failed to send notification to {recipient}: {str(e)}")
logger.info(f"Sent {notification_count} notifications for complaint {complaint_id} event: {event_type}")
return {
'status': 'sent',
'notification_count': notification_count,
'event_type': event_type
}
except Complaint.DoesNotExist:
error_msg = f"Complaint {complaint_id} not found"
logger.error(error_msg)
return {'status': 'error', 'reason': error_msg}
except Exception as e:
error_msg = f"Error sending complaint notification: {str(e)}"
logger.error(error_msg, exc_info=True)
return {'status': 'error', 'reason': error_msg}

View File

@ -5,6 +5,7 @@ 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 JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone
from django.views.decorators.http import require_http_methods
@ -475,3 +476,220 @@ def complaint_escalate(request, pk):
messages.success(request, "Complaint escalated 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', 'physician',
'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', 'physician',
'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
@require_http_methods(["POST"])
def complaint_bulk_assign(request):
"""Bulk assign complaints"""
from apps.complaints.utils import bulk_assign_complaints
import json
# Check permission
if not (request.user.is_px_admin() or request.user.is_hospital_admin()):
return JsonResponse({'success': False, 'error': 'Permission denied'}, status=403)
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)
@login_required
@require_http_methods(["POST"])
def complaint_bulk_status(request):
"""Bulk change complaint status"""
from apps.complaints.utils import bulk_change_status
import json
# Check permission
if not (request.user.is_px_admin() or request.user.is_hospital_admin()):
return JsonResponse({'success': False, 'error': 'Permission denied'}, status=403)
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)
@login_required
@require_http_methods(["POST"])
def complaint_bulk_escalate(request):
"""Bulk escalate complaints"""
from apps.complaints.utils import bulk_escalate_complaints
import json
# Check permission
if not (request.user.is_px_admin() or request.user.is_hospital_admin()):
return JsonResponse({'success': False, 'error': 'Permission denied'}, status=403)
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)

View File

@ -21,6 +21,15 @@ urlpatterns = [
path('<uuid:pk>/add-note/', ui_views.complaint_add_note, name='complaint_add_note'),
path('<uuid:pk>/escalate/', ui_views.complaint_escalate, name='complaint_escalate'),
# Export Views
path('export/csv/', ui_views.complaint_export_csv, name='complaint_export_csv'),
path('export/excel/', ui_views.complaint_export_excel, name='complaint_export_excel'),
# Bulk Actions
path('bulk/assign/', ui_views.complaint_bulk_assign, name='complaint_bulk_assign'),
path('bulk/status/', ui_views.complaint_bulk_status, name='complaint_bulk_status'),
path('bulk/escalate/', ui_views.complaint_bulk_escalate, name='complaint_bulk_escalate'),
# API Routes
path('', include(router.urls)),
]

312
apps/complaints/utils.py Normal file
View File

@ -0,0 +1,312 @@
"""
Complaints utility functions
Export and bulk operation utilities.
"""
import csv
import io
from datetime import datetime
from typing import List
from django.http import HttpResponse
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment
def export_complaints_csv(queryset, filters=None):
"""
Export complaints to CSV format.
Args:
queryset: Complaint queryset to export
filters: Optional dict of applied filters
Returns:
HttpResponse with CSV file
"""
response = HttpResponse(content_type='text/csv')
response['Content-Disposition'] = f'attachment; filename="complaints_{datetime.now().strftime("%Y%m%d_%H%M%S")}.csv"'
writer = csv.writer(response)
# Write header
writer.writerow([
'ID',
'Title',
'Patient Name',
'Patient MRN',
'Hospital',
'Department',
'Category',
'Severity',
'Priority',
'Status',
'Source',
'Assigned To',
'Created At',
'Due At',
'Is Overdue',
'Resolved At',
'Closed At',
'Description',
])
# Write data
for complaint in queryset:
writer.writerow([
str(complaint.id)[:8],
complaint.title,
complaint.patient.get_full_name(),
complaint.patient.mrn,
complaint.hospital.name_en,
complaint.department.name_en if complaint.department else '',
complaint.get_category_display(),
complaint.get_severity_display(),
complaint.get_priority_display(),
complaint.get_status_display(),
complaint.get_source_display(),
complaint.assigned_to.get_full_name() if complaint.assigned_to else '',
complaint.created_at.strftime('%Y-%m-%d %H:%M:%S'),
complaint.due_at.strftime('%Y-%m-%d %H:%M:%S'),
'Yes' if complaint.is_overdue else 'No',
complaint.resolved_at.strftime('%Y-%m-%d %H:%M:%S') if complaint.resolved_at else '',
complaint.closed_at.strftime('%Y-%m-%d %H:%M:%S') if complaint.closed_at else '',
complaint.description[:500],
])
return response
def export_complaints_excel(queryset, filters=None):
"""
Export complaints to Excel format with formatting.
Args:
queryset: Complaint queryset to export
filters: Optional dict of applied filters
Returns:
HttpResponse with Excel file
"""
wb = Workbook()
ws = wb.active
ws.title = "Complaints"
# Define styles
header_font = Font(bold=True, color="FFFFFF")
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
header_alignment = Alignment(horizontal="center", vertical="center")
# Write header
headers = [
'ID', 'Title', 'Patient Name', 'Patient MRN', 'Hospital', 'Department',
'Category', 'Severity', 'Priority', 'Status', 'Source', 'Assigned To',
'Created At', 'Due At', 'Is Overdue', 'Resolved At', 'Closed At', 'Description'
]
for col_num, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=col_num, value=header)
cell.font = header_font
cell.fill = header_fill
cell.alignment = header_alignment
# Write data
for row_num, complaint in enumerate(queryset, 2):
ws.cell(row=row_num, column=1, value=str(complaint.id)[:8])
ws.cell(row=row_num, column=2, value=complaint.title)
ws.cell(row=row_num, column=3, value=complaint.patient.get_full_name())
ws.cell(row=row_num, column=4, value=complaint.patient.mrn)
ws.cell(row=row_num, column=5, value=complaint.hospital.name_en)
ws.cell(row=row_num, column=6, value=complaint.department.name_en if complaint.department else '')
ws.cell(row=row_num, column=7, value=complaint.get_category_display())
ws.cell(row=row_num, column=8, value=complaint.get_severity_display())
ws.cell(row=row_num, column=9, value=complaint.get_priority_display())
ws.cell(row=row_num, column=10, value=complaint.get_status_display())
ws.cell(row=row_num, column=11, value=complaint.get_source_display())
ws.cell(row=row_num, column=12, value=complaint.assigned_to.get_full_name() if complaint.assigned_to else '')
ws.cell(row=row_num, column=13, value=complaint.created_at.strftime('%Y-%m-%d %H:%M:%S'))
ws.cell(row=row_num, column=14, value=complaint.due_at.strftime('%Y-%m-%d %H:%M:%S'))
ws.cell(row=row_num, column=15, value='Yes' if complaint.is_overdue else 'No')
ws.cell(row=row_num, column=16, value=complaint.resolved_at.strftime('%Y-%m-%d %H:%M:%S') if complaint.resolved_at else '')
ws.cell(row=row_num, column=17, value=complaint.closed_at.strftime('%Y-%m-%d %H:%M:%S') if complaint.closed_at else '')
ws.cell(row=row_num, column=18, value=complaint.description[:500])
# Auto-adjust column widths
for column in ws.columns:
max_length = 0
column_letter = column[0].column_letter
for cell in column:
try:
if len(str(cell.value)) > max_length:
max_length = len(cell.value)
except:
pass
adjusted_width = min(max_length + 2, 50)
ws.column_dimensions[column_letter].width = adjusted_width
# Save to response
response = HttpResponse(
content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
response['Content-Disposition'] = f'attachment; filename="complaints_{datetime.now().strftime("%Y%m%d_%H%M%S")}.xlsx"'
wb.save(response)
return response
def bulk_assign_complaints(complaint_ids: List[str], user_id: str, current_user):
"""
Bulk assign complaints to a user.
Args:
complaint_ids: List of complaint IDs
user_id: ID of user to assign to
current_user: User performing the action
Returns:
dict: Result with success count and errors
"""
from apps.complaints.models import Complaint, ComplaintUpdate
from apps.accounts.models import User
from django.utils import timezone
try:
assignee = User.objects.get(id=user_id)
except User.DoesNotExist:
return {'success': False, 'error': 'User not found'}
success_count = 0
errors = []
for complaint_id in complaint_ids:
try:
complaint = Complaint.objects.get(id=complaint_id)
complaint.assigned_to = assignee
complaint.assigned_at = timezone.now()
complaint.save(update_fields=['assigned_to', 'assigned_at'])
# Create update
ComplaintUpdate.objects.create(
complaint=complaint,
update_type='assignment',
message=f"Bulk assigned to {assignee.get_full_name()}",
created_by=current_user
)
success_count += 1
except Complaint.DoesNotExist:
errors.append(f"Complaint {complaint_id} not found")
except Exception as e:
errors.append(f"Error assigning complaint {complaint_id}: {str(e)}")
return {
'success': True,
'success_count': success_count,
'total': len(complaint_ids),
'errors': errors
}
def bulk_change_status(complaint_ids: List[str], new_status: str, current_user, note: str = ''):
"""
Bulk change status of complaints.
Args:
complaint_ids: List of complaint IDs
new_status: New status to set
current_user: User performing the action
note: Optional note
Returns:
dict: Result with success count and errors
"""
from apps.complaints.models import Complaint, ComplaintUpdate
from django.utils import timezone
success_count = 0
errors = []
for complaint_id in complaint_ids:
try:
complaint = Complaint.objects.get(id=complaint_id)
old_status = complaint.status
complaint.status = new_status
# Handle status-specific logic
if new_status == 'resolved':
complaint.resolved_at = timezone.now()
complaint.resolved_by = current_user
elif new_status == 'closed':
complaint.closed_at = timezone.now()
complaint.closed_by = current_user
complaint.save()
# Create update
ComplaintUpdate.objects.create(
complaint=complaint,
update_type='status_change',
message=note or f"Bulk status change from {old_status} to {new_status}",
created_by=current_user,
old_status=old_status,
new_status=new_status
)
success_count += 1
except Complaint.DoesNotExist:
errors.append(f"Complaint {complaint_id} not found")
except Exception as e:
errors.append(f"Error changing status for complaint {complaint_id}: {str(e)}")
return {
'success': True,
'success_count': success_count,
'total': len(complaint_ids),
'errors': errors
}
def bulk_escalate_complaints(complaint_ids: List[str], current_user, reason: str = ''):
"""
Bulk escalate complaints.
Args:
complaint_ids: List of complaint IDs
current_user: User performing the action
reason: Escalation reason
Returns:
dict: Result with success count and errors
"""
from apps.complaints.models import Complaint, ComplaintUpdate
from django.utils import timezone
success_count = 0
errors = []
for complaint_id in complaint_ids:
try:
complaint = Complaint.objects.get(id=complaint_id)
complaint.escalated_at = timezone.now()
complaint.save(update_fields=['escalated_at'])
# Create update
ComplaintUpdate.objects.create(
complaint=complaint,
update_type='escalation',
message=f"Bulk escalation. Reason: {reason or 'No reason provided'}",
created_by=current_user
)
success_count += 1
except Complaint.DoesNotExist:
errors.append(f"Complaint {complaint_id} not found")
except Exception as e:
errors.append(f"Error escalating complaint {complaint_id}: {str(e)}")
return {
'success': True,
'success_count': success_count,
'total': len(complaint_ids),
'errors': errors
}