diff --git a/COMPLAINTS_GAP_ANALYSIS.md b/COMPLAINTS_GAP_ANALYSIS.md new file mode 100644 index 0000000..30e29c3 --- /dev/null +++ b/COMPLAINTS_GAP_ANALYSIS.md @@ -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//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 diff --git a/COMPLAINTS_IMPLEMENTATION_STATUS.md b/COMPLAINTS_IMPLEMENTATION_STATUS.md new file mode 100644 index 0000000..28a8f82 --- /dev/null +++ b/COMPLAINTS_IMPLEMENTATION_STATUS.md @@ -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.). diff --git a/apps/complaints/admin.py b/apps/complaints/admin.py index e7ea6ab..f4224ad 100644 --- a/apps/complaints/admin.py +++ b/apps/complaints/admin.py @@ -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' diff --git a/apps/complaints/analytics.py b/apps/complaints/analytics.py new file mode 100644 index 0000000..d6e4d19 --- /dev/null +++ b/apps/complaints/analytics.py @@ -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) + } + } diff --git a/apps/complaints/apps.py b/apps/complaints/apps.py index fe6f188..2f10d8c 100644 --- a/apps/complaints/apps.py +++ b/apps/complaints/apps.py @@ -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 diff --git a/apps/complaints/management/commands/seed_complaint_configs.py b/apps/complaints/management/commands/seed_complaint_configs.py new file mode 100644 index 0000000..8c5595d --- /dev/null +++ b/apps/complaints/management/commands/seed_complaint_configs.py @@ -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") diff --git a/apps/complaints/migrations/0002_complaintcategory_complaintslaconfig_and_more.py b/apps/complaints/migrations/0002_complaintcategory_complaintslaconfig_and_more.py new file mode 100644 index 0000000..0539ea5 --- /dev/null +++ b/apps/complaints/migrations/0002_complaintcategory_complaintslaconfig_and_more.py @@ -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')], + }, + ), + ] diff --git a/apps/complaints/models.py b/apps/complaints/models.py index 23da34d..8c83cd4 100644 --- a/apps/complaints/models.py +++ b/apps/complaints/models.py @@ -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. """ - sla_hours = settings.SLA_DEFAULTS['complaint'].get( - self.severity, - settings.SLA_DEFAULTS['complaint']['medium'] - ) + # 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. diff --git a/apps/complaints/signals.py b/apps/complaints/signals.py new file mode 100644 index 0000000..543160e --- /dev/null +++ b/apps/complaints/signals.py @@ -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}" + ) diff --git a/apps/complaints/tasks.py b/apps/complaints/tasks.py index 4fa3e5a..de0fc56 100644 --- a/apps/complaints/tasks.py +++ b/apps/complaints/tasks.py @@ -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} diff --git a/apps/complaints/ui_views.py b/apps/complaints/ui_views.py index 0593aca..314bac0 100644 --- a/apps/complaints/ui_views.py +++ b/apps/complaints/ui_views.py @@ -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) diff --git a/apps/complaints/urls.py b/apps/complaints/urls.py index 0af470b..5f0e771 100644 --- a/apps/complaints/urls.py +++ b/apps/complaints/urls.py @@ -21,6 +21,15 @@ urlpatterns = [ path('/add-note/', ui_views.complaint_add_note, name='complaint_add_note'), path('/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)), ] diff --git a/apps/complaints/utils.py b/apps/complaints/utils.py new file mode 100644 index 0000000..907c997 --- /dev/null +++ b/apps/complaints/utils.py @@ -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 + }