update complaints services
This commit is contained in:
parent
226dc414cd
commit
edfd1cfe2e
785
COMPLAINTS_GAP_ANALYSIS.md
Normal file
785
COMPLAINTS_GAP_ANALYSIS.md
Normal 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
|
||||
101
COMPLAINTS_IMPLEMENTATION_STATUS.md
Normal file
101
COMPLAINTS_IMPLEMENTATION_STATUS.md
Normal 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.).
|
||||
@ -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'
|
||||
|
||||
306
apps/complaints/analytics.py
Normal file
306
apps/complaints/analytics.py
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
253
apps/complaints/management/commands/seed_complaint_configs.py
Normal file
253
apps/complaints/management/commands/seed_complaint_configs.py
Normal 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")
|
||||
@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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.
|
||||
|
||||
66
apps/complaints/signals.py
Normal file
66
apps/complaints/signals.py
Normal 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}"
|
||||
)
|
||||
@ -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}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
312
apps/complaints/utils.py
Normal 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
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user